Text
Read More
A line-clamped text block with an expand/collapse toggle that only appears when content actually overflows.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/read-more.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ReadMore" component (one
ResizeObserver, no animation library, no dependency beyond the shared cn()
class-merge helper).
Contract
- Named export `ReadMore`, forwardRef<HTMLDivElement>, typed with
`ReadMoreProps` extending native div props (children required):
- `lines?: 2 | 3 | 4 | 5 | 6` — clamp height while collapsed, default `3`.
- `moreLabel?: string` — default `"Read more"`.
- `lessLabel?: string` — default `"Show less"`.
- `defaultExpanded?: boolean` — default `false`.
- `children: ReactNode` — the long-form text.
Behavior
- Render two overlapping copies of `children`: a visible text div (clamp
class applied only while collapsed) and an invisible, absolutely
positioned "probe" div that always keeps the collapsed clamp class,
regardless of the visible node's expanded state.
- Why the probe exists: measuring the visible node directly goes blind the
moment it's expanded (no clamp → scrollHeight === clientHeight always),
so `defaultExpanded` would never be able to report "yes, this would
overflow if collapsed." The probe always carries the clamp class, so it
always reports true collapsed geometry.
- Overflow check: `probe.scrollHeight > probe.clientHeight + 1` (a +1px
epsilon absorbs subpixel rounding). With `-webkit-line-clamp` +
`overflow: hidden`, the browser still lays out the full content
(`scrollHeight`) inside a visually clipped box (`clientHeight`) — no
special API needed to detect the cutoff.
- A single `ResizeObserver` watches the probe and re-runs the check in its
callback whenever the probe's box or content changes size (container
resize, font swap, prop change). The observer's first callback fires
asynchronously right after `observe()` is called, so the initial
measurement lands there too — never call the check synchronously inside
the effect body.
- The expand/collapse button renders only when the overflow check is true.
Short content that never hits the clamp produces zero button — no dead
"Read more" that reveals nothing.
- Clicking the button flips `expanded`, which toggles the clamp class on
the visible node between "line-clamp-N" and none. This is a hard cut, not
an animated reveal (see levers for the animated alternative).
- `lines` resolves through a fixed lookup object (`{2: "line-clamp-2", ...,
6: "line-clamp-6"}`), never a template string like `line-clamp-${n}` —
Tailwind's build-time scanner only sees literal class names.
Rendering & styling
- Text: `text-sm leading-relaxed text-foreground` — inherits size/color from
context by default, only these two utilities are fixed.
- Toggle button: `text-sm font-medium text-primary hover:underline`, plus a
`focus-visible:ring-2 focus-visible:ring-ring` focus ring. No icon, no
background — reads as an inline text action, not a button chrome.
- `aria-expanded` on the button mirrors `expanded`; `aria-controls` points
at the visible text div's id (`React.useId()`), not the invisible probe.
- Semantic tokens only: `text-foreground`, `text-primary`, `ring-ring`. No
hex, no oklch literals.
Customization levers
- `lines` (2-6) — how much collapsed density a comment feed vs. a product
page needs.
- `moreLabel` / `lessLabel` — swap copy per domain ("Read full review" /
"Collapse", "…more" / "…less").
- Animated expand/collapse: line-clamp toggling can't be transitioned
(`-webkit-line-clamp` isn't an interpolatable CSS property) — for a smooth
height animation, replace the clamp swap with a `grid-template-rows: 0fr`
→ `1fr` wrapper trick instead. Trade-off: you lose the exact N-line clamp
and instead animate toward the content's natural height.
- Fade-out affordance: to hint "there's more" without or alongside the
button, add a bottom gradient mask over the collapsed text — an absolutely
positioned `h-6 bg-gradient-to-t from-card to-transparent` strip, rendered
only while `overflows && !expanded`.
- Character-count truncation: swap the line-clamp + probe measurement for a
plain `text.slice(n) + "…"` when a fixed character budget (e.g. an SEO
snippet) matters more than visual line count.
- Controlled mode: lift `expanded` out to `expanded` / `onExpandedChange`
props instead of internal state, if a parent needs to drive several
`ReadMore` instances together (an "expand all" control).
- This component intentionally does not call `scrollIntoView` on collapse —
collapsing a long expanded block can shift the viewport since the content
shrinks above the fold. Add it at the call site if that jump matters for
a given layout.Concepts
- Invisible measurement probe — a duplicate,
aria-hiddenclone that always keeps the collapsed clamp class, so overflow detection stays accurate even when the visible node starts already expanded (defaultExpanded) and has no clamp of its own to measure. - scrollHeight vs. clientHeight — with
-webkit-line-clampplusoverflow: hidden, the browser still lays out the full, un-clamped content (scrollHeight) inside a visually clipped box (clientHeight); comparing the two is the dependency-free way to detect clamp overflow. - Static clamp class map —
line-clamp-2..6come from a fixed lookup object, not a template string likeline-clamp-${n}, so Tailwind's build-time scanner can see every class that might render. - Async-first measurement — the first overflow check runs inside
ResizeObserver's always-fires-once callback rather than synchronously in the effect body, so state never gets set directly in the effect itself. - Smart toggle visibility — the expand/collapse control only mounts once
overflowsis true, so short content never grows a button that would reveal nothing new. - Clamp toggling isn't animatable — flipping
-webkit-line-clampon or off is a hard cut, not a transitionable property; a smoothly animated version needs a different technique (agrid-template-rowscollapse), trading away the exact line-count clamp.