Expandable Text
alphav0.2.0Truncating text block that only shows its toggle when text actually overflows — configurable line clamp, controlled or uncontrolled.
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
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/expandable-textAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/expandable-text-fixturesPreview
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
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
ReactNodeascontent— measurement requires a stable text node. Userich-text-editorfor 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-expandedon the toggle reflects state;aria-controlslinks to the content'sid(computed viauseId).- Custom
renderToggleis the consumer's responsibility — preservearia-expanded+aria-labelon 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