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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

type ReadMoreLines = 2 | 3 | 4 | 5 | 6

// Tailwind extracts utility classes by scanning literal strings in source, so
// clamp line counts are looked up in a fixed table rather than built at
// runtime with `line-clamp-${n}` — that string would never be seen by the build.
const LINE_CLAMP_CLASS: Record<ReadMoreLines, string> = {
  2: "line-clamp-2",
  3: "line-clamp-3",
  4: "line-clamp-4",

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/read-more.json

Prompt

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-hidden clone 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-clamp plus overflow: 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 mapline-clamp-2..6 come from a fixed lookup object, not a template string like line-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 overflows is true, so short content never grows a button that would reveal nothing new.
  • Clamp toggling isn't animatable — flipping -webkit-line-clamp on or off is a hard cut, not a transitionable property; a smoothly animated version needs a different technique (a grid-template-rows collapse), trading away the exact line-count clamp.

On This Page