A line-clamped paragraph with a Show more control that expands via grid-template-rows, fades the last visible line, and hides itself when the text already fits.
npx shadcn@latest add @paragon/truncate-expand"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface TruncateExpandProps extends React.ComponentProps<"div"> {
/** Number of lines visible while clamped. */
lines?: number;
/** Control label while clamped. */
moreLabel?: string;
/** Control label while expanded. */
lessLabel?: string;
}
/**
* A clamped paragraph with a "Show more" disclosure that never jumps.
*
* Expansion animates `grid-template-rows: 0fr → 1fr` (the house-sanctioned
* height exception); a `min-height` on the clipped row holds the collapsed
* window at N lines, and a gradient fade sits over the last visible line
* while clamped. The component measures its own content (ResizeObserver) —
* if the text already fits, the control and fade never render. Collapse runs
* faster than expand; reduced motion toggles instantly.
*
* The fade color defaults to the page background; set `--truncate-fade` on
* a card or tinted surface to match.
*/
export function TruncateExpand({
lines = 3,
moreLabel = "Show more",
lessLabel = "Show less",
className,
children,
...props
}: TruncateExpandProps) {
const contentRef = React.useRef<HTMLDivElement>(null);
const contentId = React.useId();
const reducedMotion = useReducedMotion() ?? false;
const [expanded, setExpanded] = React.useState(false);
const [clampNeeded, setClampNeeded] = React.useState(true);
const [collapsedHeight, setCollapsedHeight] = React.useState<number | null>(
null,
);
React.useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const measure = () => {
const target =
node.firstElementChild instanceof HTMLElement
? node.firstElementChild
: node;
const styles = getComputedStyle(target);
let lineHeight = parseFloat(styles.lineHeight);
if (Number.isNaN(lineHeight)) {
lineHeight = parseFloat(styles.fontSize) * 1.5;
}
const collapsed = lineHeight * lines;
setCollapsedHeight(collapsed);
// Half-line tolerance: don't ship a control that reveals 3px.
setClampNeeded(node.scrollHeight > collapsed + lineHeight / 2);
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, [lines]);
const open = expanded || !clampNeeded;
return (
<div className={className} {...props}>
<div className="relative">
<div
className="grid transition-[grid-template-rows]"
style={{
gridTemplateRows: open ? "1fr" : "0fr",
transitionDuration: reducedMotion
? "0ms"
: expanded
? "300ms"
: "200ms",
transitionTimingFunction: "var(--ease-out)",
}}
>
<div
id={contentId}
className="overflow-hidden"
style={{
minHeight:
collapsedHeight !== null
? collapsedHeight
: `calc(${lines} * 1lh)`,
}}
>
<div ref={contentRef}>{children}</div>
</div>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-10"
style={{
backgroundImage:
"linear-gradient(to top, var(--truncate-fade, var(--color-background)), transparent)",
opacity: open ? 0 : 1,
transitionProperty: "opacity",
transitionDuration: "200ms",
transitionTimingFunction: "var(--ease-out)",
}}
/>
</div>
{clampNeeded && (
<button
type="button"
aria-expanded={expanded}
aria-controls={contentId}
onClick={() => setExpanded((value) => !value)}
className="relative mt-1.5 inline-flex items-center gap-1 text-[13px] font-medium text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground after:absolute after:-inset-x-2 after:-inset-y-2.5"
>
{expanded ? lessLabel : moreLabel}
<ChevronDown
aria-hidden
className={cn(
"size-3.5 transition-transform duration-200 ease-out motion-reduce:transition-none",
expanded && "rotate-180",
)}
/>
</button>
)}
</div>
);
}