The simplest pattern can still frustrate users. A “Read More” button sits at the intersection of content design, affordance, and motion. Done well, it reduces cognitive load, surfaces key information, and invites exploration without overwhelming the layout. In this tutorial you will build a polished, accessible “Read More” interaction with semantic HTML, clean CSS, and micro-interactions that guide the eye. You will also learn how to craft a purely CSS caret icon and wire it into a responsive, keyboard-friendly control.
Why a “Read More” Button Matters
Long text blocks can intimidate. A “Read More” control lets you present a clear headline and a short teaser, then reveal secondary details only when the reader asks for them. That is progressive disclosure. It creates a cleaner first impression and gives the reader choice without hiding critical information. From a product point of view, you shape reading flow, reduce scroll fatigue, and help users finish tasks faster because the path is obvious: skim, then expand when needed. Small details like a rotating caret, a clear “Read more / Read less” label, and strong focus states add up to a control that feels responsive, deliberate, and trustworthy.
Prerequisites
You do not need a framework here. We will rely on native elements and CSS. If you know how to style pseudo-elements, you will feel right at home.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The component uses a native <details> with a <summary> as the toggle. A short teaser sits above the control, and extra content lives inside the details element. The summary contains two labels for text swapping and an empty span for the caret icon. Screen readers get a proper button-like control out of the box, and keyboard users can toggle with Enter or Space.
<!-- HTML -->
<article class="card">
<h1 class="card__title">Designing a Readable Product Page</h1>
<p class="card__teaser">
Clarity beats cleverness. Lead with a focused pitch, then reveal details
on demand. This pattern improves scanning and supports deeper reading when
the user opts in.
</p>
<details class="readmore" data-id="product-page">
<summary class="readmore__summary">
<span class="readmore__label">
<span class="readmore__label--more">Read more</span>
<span class="readmore__label--less">Read less</span>
</span>
<span class="readmore__icon" aria-hidden="true"></span>
</summary>
<div class="readmore__content">
<p>
Use progressive disclosure to keep the first screen clean. Group
secondary information , dimensions, care instructions, expanded specs ,
behind a clear control. Pair this with a visual anchor like a caret so
the control reads as interactive.
</p>
<p>
A "Read More" control benefits from sensible defaults: readable line
length, generous line height, and a tap target that works on touch and
keyboard. Micro-interactions like a rotating caret and label swap set
user expectations and improve comprehension.
</p>
<p>
Keep the content structure simple. Users expect the button to sit near
the text it reveals. Avoid nesting other controls inside the summary,
and keep the label language consistent across your site.
</p>
</div>
</details>
</article>
Step 2: The Basic CSS & Styling
Start with CSS variables for color and spacing so you can theme the component later. The summary is styled to look and behave like a button: large hit area, visible focus, and clear affordance. The browser’s default marker is removed to make room for our custom caret.
/* CSS */
:root {
--rm-bg: #0b0c10;
--rm-surface: #16181d;
--rm-border: #2a2f36;
--rm-text: #e8eef5;
--rm-muted: #a9b3bf;
--rm-accent: #4aa3ff;
--rm-focus: #9ec9ff;
--rm-radius: 12px;
--rm-gap: 1rem;
--rm-pad: 1rem;
--rm-font: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, sans-serif;
}
*,
*::before,
*::after { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--rm-font);
color: var(--rm-text);
background: radial-gradient(1200px 700px at 70% -200px, #1a2230, var(--rm-bg));
line-height: 1.6;
}
.card {
max-width: 68ch;
margin: 4rem auto;
background: var(--rm-surface);
border: 1px solid var(--rm-border);
border-radius: var(--rm-radius);
padding: calc(var(--rm-pad) * 1.25);
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
.card__title {
margin: 0 0 .25rem 0;
font-size: 1.25rem;
}
.card__teaser {
color: var(--rm-muted);
margin: 0 0 var(--rm-gap) 0;
}
.readmore {
border-top: 1px solid var(--rm-border);
padding-top: var(--rm-gap);
}
/* Remove default marker so we can draw our own caret */
.readmore__summary {
list-style: none;
}
.readmore__summary::-webkit-details-marker {
display: none;
}
/* Make the summary feel like a button */
.readmore__summary {
display: inline-flex;
align-items: center;
gap: .5rem;
cursor: pointer;
user-select: none;
color: var(--rm-accent);
font-weight: 600;
border-radius: 999px;
padding: .5rem .875rem;
transition: background-color .15s ease-out, color .15s ease-out;
}
.readmore__summary:hover {
background: color-mix(in srgb, var(--rm-accent) 12%, transparent);
color: color-mix(in srgb, var(--rm-accent) 82%, white 18%);
}
/* Strong focus outline that does not get lost on dark UIs */
.readmore__summary:focus-visible {
outline: 3px solid var(--rm-focus);
outline-offset: 2px;
}
/* Content area spacing */
.readmore__content {
padding-top: .75rem;
color: var(--rm-text);
}
Advanced Tip: CSS variables make theming trivial. Expose only the tokens you want teams to change, like –rm-accent and –rm-radius, and keep structural values private to the component.
Step 3: Building the Caret Icon
A disclosure control needs a clear directional cue. You can draw a caret with borders in pure CSS. We will start with a right-pointing triangle and rotate it when the content is open. If you want a refresher on the border trick, this guide to a CSS triangle covers the core technique. If you prefer a chevron style, here is a reference for a CSS chevron.
/* CSS */
/* 1) Reserve space for the icon next to the label */
.readmore__icon {
position: relative;
width: 1em;
height: 1em;
display: inline-block;
}
/* 2) Draw a right-pointing triangle using borders */
.readmore__icon::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
/* Triangles with borders: only the colored side shows.
This builds a right-pointing caret. */
border-top: .32em solid transparent;
border-bottom: .32em solid transparent;
border-left: .50em solid currentColor;
transform: translate(-50%, -50%);
transition: transform .18s ease-out;
}
/* 3) Rotate it when open so right ➜ down */
.readmore[open] .readmore__icon::before {
transform: translate(-50%, -50%) rotate(90deg);
}
/* 4) Swap labels: "Read more" when closed, "Read less" when open */
.readmore__label--less { display: none; }
.readmore[open] .readmore__label--more { display: none; }
.readmore[open] .readmore__label--less { display: inline; }
How This Works (Code Breakdown)
The caret is drawn with the border trick. Only one side of the box has a visible border, which creates a triangle. Setting the top and bottom borders to transparent and coloring the left border yields a right-pointing arrow. The icon sits in a 1em square so it scales with the text size, which keeps the hit area and the visual weight in sync when users change zoom.
The triangle lives in a pseudo-element so the markup stays lean. By positioning the pseudo-element at the center of the icon box and using translate to center it precisely, we avoid layout jumps when we rotate it. The rotation occurs only on the pseudo-element, not on the summary container, so we avoid unintended shifts in baseline alignment.
The label swap uses two spans. The open state of <details> gives us a clean hook: .readmore[open]. We do not need JavaScript to toggle text. If you want a chevron rather than a triangle, swap the pseudo-element with a chevron shape using borders as shown in the CSS chevron tutorial. If you prefer a pill-shaped button, set the radius high, which mirrors the technique in an oval.
Step 4: Building the Reveal Behavior
The native <details> element handles expansion and collapse without scripting. The browser hides the content when closed and exposes it when open. You cannot animate height smoothly from closed to open because the content is not in the layout while closed. You can still guide perception with subtle motion: rotate the caret and fade the content in. That gives the eye a clear cue without fighting the element’s default behavior.
/* CSS */
/* Fade the content in on open */
@keyframes rm-fade-in {
from { opacity: 0; translate: 0 -2px; }
to { opacity: 1; translate: 0 0; }
}
/* Apply the animation only when opening */
.readmore[open] .readmore__content {
animation: rm-fade-in .18s ease-out both;
}
/* Slight color nudge on open so state change reads even without motion */
.readmore[open] .readmore__summary {
color: color-mix(in srgb, var(--rm-accent) 90%, white 10%);
}
How This Works (Code Breakdown)
When the details element gains the open attribute, the content becomes visible. The fade-in softens the pop-in effect and draws attention to the newly revealed text. The translation by two pixels adds just enough movement to feel intentional without distracting from the words.
The caret rotation and label swap already signal state. The color change on the summary reinforces that signal for users who focus on the control rather than the body text. All of these cues work together: the button changes wording, the icon rotates toward the content, and the content appears with a brief fade. The control feels alive and responsive without heavy animation.
Advanced Techniques: Animations & Hover Effects
Small touches improve perceived quality. The right treatments are subtle and fast. You can add a hover background that adjusts in low-contrast themes, and you can respect motion preferences to avoid unwanted movement. The code below adds a reduced-motion variant and a pressed state for click feedback.
/* CSS */
/* Respect user motion preference */
@media (prefers-reduced-motion: reduce) {
.readmore__icon::before { transition: none; }
.readmore[open] .readmore__content { animation: none; }
}
/* Pressed feedback without shifting layout */
.readmore__summary:active {
filter: brightness(0.96);
transform: translateY(1px);
}
/* Elevate the summary on hover within the card for a bit more affordance */
.card:hover .readmore__summary:not(:focus-visible) {
box-shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 1px 8px rgba(0,0,0,0.25);
}
/* Optional: an inline variant that matches running text better */
.readmore--inline .readmore__summary {
padding: .25rem .5rem;
border-radius: 6px;
font-weight: 500;
}
Accessibility & Performance
Accessible patterns reduce friction across the board. The <details> and <summary> combo provides built-in semantics, keyboard support, and state management. Focus treatment, hit area, and color contrast complete the picture. Performance considerations are straightforward here, since the control is small and the CSS is light.
Accessibility
Use <details> for disclosure unless you need custom animation of height or complex sequencing. It presents a focusable summary that behaves like a button, exposes the open state to assistive tech, and supports keyboard toggling. Keep the summary concise and avoid nesting other interactive elements inside it. Place the summary as the first child of <details>, which matches the HTML specification and user expectations.
Provide a visible focus ring that meets contrast requirements. The snippet uses a 3px outline with a small offset to avoid clipping on rounded corners. Keep the hit target generous; the padding makes the summary easy to tap on touch screens. For icons that are purely decorative, use aria-hidden="true" so screen readers do not announce them. The label swap uses visible text, which is better than switching aria-label silently because all users see the change.
Motion should be subtle and fast. Respect prefers-reduced-motion by turning off rotations and content animation. The component remains clear because the label swap and layout change still communicate the state change.
Performance
This approach is fast. The browser handles toggle logic natively. The caret is a few borders on a pseudo-element, which is trivial for the rendering engine. The fade-in runs on opacity and transform, which modern browsers handle on the compositor thread. Avoid animating layout-affecting properties like height or top in this case. If you need a sliding height animation, you will need a wrapper and some JavaScript to measure and animate, which adds complexity and can introduce jank if not handled carefully.
Ship Small, Readable Interactions
You built a focused “Read More” control with a semantic toggle, a CSS triangle caret, a clear label swap, and motion that respects user preferences. The pattern scales across cards, product pages, and blog excerpts without a framework or icon library.
Now you can refine the look to match your brand, swap the caret for a chevron, or style the control as a compact inline link. With these tools, your next disclosure pattern will be calm, accessible, and easy to maintain.