Skip to content
ilinxa/pro-ui

Expandable Text

alphav0.2.0

Truncating text block that only shows its toggle when text actually overflows — configurable line clamp, controlled or uncontrolled.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

Context

Use for any user-authored multi-line text where the surface budget is bounded — post bodies, comment bodies, event descriptions, news excerpts, product descriptions, profile bios. Pure CSS line-clamp clips silently; this component measures scrollHeight against lineHeight × maxLines after mount + on resize, so the 'show more' toggle only appears when content actually exceeds the budget. Migration origin: kasder kas-social-front-v0 PostContent.tsx; first ship in the 8-component social-posts-system arc.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/expandable-text

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/expandable-text-fixtures

Preview

Long content (toggle appears)

Spent the afternoon walking through the new market by the harbor. The vendors had set up under bright canopies, and the smell of grilled fish and fresh bread filled the air. I bought a small jar of olive paste from a woman who told me her family has been making it the same way for four generations. The texture was somewhere between butter and tapenade — earthy, salty, with a faint heat at the finish. I'll pick up another jar before we leave.

Short content (no toggle — content fits)

Just shipped a small update. Quick fix for the auth flow.

Demo source

demo.tsxtsx

Usage

When to use

Reach for ExpandableText for any user-authored multi-line text where the surface budget is bounded — post bodies, comment bodies, event descriptions, news excerpts in feeds, profile bios. Pure CSS line-clamp clips silently; this component measures scrollHeight against lineHeight × maxLinesafter mount and on resize, so the "show more" toggle only appears when content actually exceeds the budget. For HTML / Plate JSON / Markdown bodies, use rich-text-editor instead.

Basic example

import { ExpandableText } from "@/components/expandable-text";

export function Example() {
  return <ExpandableText content={post.body} />;
}

Custom maxLines

<ExpandableText content={comment.body} maxLines={4} />

Controlled mode (feed virtualization)

For virtualized feeds where posts unmount on scroll, persist the expand state in a host-level map so it survives re-mount.

const expandedSet = useExpandedSet(); // host's per-post state map

<ExpandableText
  content={post.body}
  expanded={expandedSet.has(post.id)}
  onExpandedChange={(next) => expandedSet.toggle(post.id, next)}
/>

Localized labels

<ExpandableText
  content={post.body}
  labels={{
    showMore: "Daha fazla göster",
    showLess: "Daha az göster",
  }}
/>

Custom toggle (chevron icon)

The renderToggle slot replaces the default text button. Receives isExpanded + setExpanded; consumers wire their own UI but should preserve aria-expanded + aria-label for accessibility.

import { ChevronDown, ChevronUp } from "lucide-react";

<ExpandableText
  content={comment.body}
  maxLines={2}
  renderToggle={({ isExpanded, setExpanded }) => (
    <button
      type="button"
      onClick={() => setExpanded(!isExpanded)}
      aria-expanded={isExpanded}
      aria-label={isExpanded ? "Collapse" : "Expand"}
    >
      {isExpanded ? <ChevronUp /> : <ChevronDown />}
    </button>
  )}
/>

Standalone hook (advanced)

useLineClampDetectis exported for hosts that want to detect truncation without using our component (e.g., to show a small "..." indicator in a different style). The content parameter is typed unknown — pass any primitive or stable identity that changes when re-measurement is needed.

import { useLineClampDetect } from "@/components/expandable-text";

function MyTextBlock({ text }: { text: string }) {
  const { ref, isTruncated } = useLineClampDetect({
    maxLines: 3,
    content: text,
  });
  return (
    <div>
      <p ref={ref} className="line-clamp-3">{text}</p>
      {isTruncated && <span>...</span>}
    </div>
  );
}

Anti-patterns

  • Don't pass ReactNode as content — measurement requires a stable text node. Use rich-text-editor for rich content (Markdown / Plate JSON / HTML).
  • Don't expect auto-collapse on click outside — one-shot expand; user collapses by clicking the toggle.
  • Don't expect animated height transition on expand/collapse in v0.1 — the swap is instant. Animation requires extra browser support and lands in v0.2.
  • Don't define inline labels objects if you care about React.memo — the inline object identity changes every render, busting memo. Hoist the labels object to module scope.

Accessibility

  • Default toggle is a real <button type="button"> — keyboard activation via Enter / Space comes free.
  • aria-expanded on the toggle reflects state; aria-controls links to the content's id (computed via useId).
  • Custom renderToggleis the consumer's responsibility — preserve aria-expanded + aria-label on icon-only triggers.

Features

  • Measure-based truncation detection — toggle hidden when content fits
  • Configurable maxLines (default 3)
  • Controlled-or-uncontrolled expand state via expanded / defaultExpanded / onExpandedChange (mirrors React form-input convention)
  • Re-measure on content + maxLines change AND on container resize (ResizeObserver)
  • i18n via labels object (English defaults: 'Show more' / 'Show less')
  • renderToggle slot for full toggle takeover
  • Public useLineClampDetect hook export for advanced consumers
  • a11y: real <button> with aria-expanded + aria-controls; <p> id from useId; focus-visible ring
  • Empty content guard — renders nothing when content is empty/null
  • No peer deps beyond React

Tags

expandable-texttexttruncateline-clampexpand

Dependencies