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
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

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

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
"use client"; import { useState } from "react";import { ChevronDown, ChevronUp } from "lucide-react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { ExpandableText } from "./expandable-text";import { LONG_EN, LONG_TR, SHORT_EN, SHORT_TR } from "./dummy-data"; const TR_LABELS = {  showMore: "Daha fazla göster",  showLess: "Daha az göster",}; function CustomToggleDemo() {  const [expanded, setExpanded] = useState(false);  return (    <ExpandableText      content={LONG_EN}      maxLines={3}      expanded={expanded}      onExpandedChange={setExpanded}      renderToggle={({ isExpanded, setExpanded }) => (        <button          type="button"          onClick={() => setExpanded(!isExpanded)}          aria-label={isExpanded ? "Collapse" : "Expand"}          aria-expanded={isExpanded}          className="mt-2 inline-flex items-center gap-1 rounded-full border border-border bg-card px-2 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"        >          {isExpanded ? (            <>              <ChevronUp className="h-3 w-3" aria-hidden="true" /> Less            </>          ) : (            <>              <ChevronDown className="h-3 w-3" aria-hidden="true" /> More            </>          )}        </button>      )}    />  );} export default function ExpandableTextDemo() {  return (    <Tabs defaultValue="default">      <SwipeTabsList>        <TabsTrigger value="default">Default</TabsTrigger>        <TabsTrigger value="custom-lines">Custom maxLines</TabsTrigger>        <TabsTrigger value="localized">Localized (TR)</TabsTrigger>        <TabsTrigger value="custom-toggle">Custom toggle</TabsTrigger>      </SwipeTabsList>       <TabsContent value="default" className="mt-6 space-y-6">        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            Long content (toggle appears)          </p>          <ExpandableText content={LONG_EN} />        </div>        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            Short content (no toggle — content fits)          </p>          <ExpandableText content={SHORT_EN} />        </div>      </TabsContent>       <TabsContent value="custom-lines" className="mt-6 space-y-6">        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            maxLines=2          </p>          <ExpandableText content={LONG_EN} maxLines={2} />        </div>        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            maxLines=6          </p>          <ExpandableText content={LONG_EN} maxLines={6} />        </div>      </TabsContent>       <TabsContent value="localized" className="mt-6 space-y-6">        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            Long content + Turkish labels          </p>          <ExpandableText content={LONG_TR} labels={TR_LABELS} />        </div>        <div className="space-y-2">          <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">            Short content + Turkish labels (no toggle rendered)          </p>          <ExpandableText content={SHORT_TR} labels={TR_LABELS} />        </div>      </TabsContent>       <TabsContent value="custom-toggle" className="mt-6 space-y-2">        <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">          Chevron-icon toggle via renderToggle slot (controlled mode)        </p>        <CustomToggleDemo />      </TabsContent>    </Tabs>  );} 

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