Line-clamp that tells the truth: the fold control states the exact measured word count it hides ('+ 42 words'), the cut edge is a dashed selvage rule with a soft fade, and unfolding is an in-place height ease.
npx shadcn add https://design.helpmarq.com /r/truncation-word-count.jsonregistry/core/truncation-word-count/component.tsx"use client";
import { useEffect, useMemo, useRef, useState } from "react";
export interface SelvageFoldProps {
/** Plain text. Words are individually measured, so the fold count is exact, not estimated. */
children: string;
/** Visible lines when folded. */
lines?: number;
className?: string;
}
/**
* Truncation that tells the truth. "…" says something was cut but not how
* much; line-clamp says nothing at all. This clamp measures where the fold
* actually lands (every word is a span, the first one pushed past the fold
* line is found by offsetTop) and the control states exactly what's hidden:
* "+ 42 words". Unfolding is an in-place height ease, not a jump; folding
* back re-measures. The full text stays in the DOM throughout, so assistive
* tech and find-in-page always see everything — the fold is visual, not
* informational.
*/
export function SelvageFold({ children, lines = 3, className = "" }: SelvageFoldProps) {
const words = useMemo(() => children.split(/\s+/).filter(Boolean), [children]);
const innerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [metrics, setMetrics] = useState<{ clampH: number; fullH: number; hidden: number } | null>(null);
useEffect(() => {
const inner = innerRef.current;
if (!inner) return;
const measure = () => {
const cs = getComputedStyle(inner);
let lineH = parseFloat(cs.lineHeight);
if (!Number.isFinite(lineH)) lineH = parseFloat(cs.fontSize) * 1.5;
const clampH = Math.round(lineH * lines);
const fullH = inner.scrollHeight;
let hidden = 0;
if (fullH > clampH + 2) {
const spans = inner.querySelectorAll<HTMLElement>("[data-word]");
// first word whose box starts at or past the fold line is the first casualty
for (let i = 0; i < spans.length; i++) {
if (spans[i].offsetTop + 1 >= clampH) {
hidden = spans.length - i;
break;
}
}
}
setMetrics({ clampH, fullH, hidden });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(inner);
return () => ro.disconnect();
}, [lines, children]);
const folded = metrics !== null && metrics.hidden > 0 && !open;
const needsFold = metrics !== null && metrics.hidden > 0;
return (
<div className={className}>
<div
className="overflow-hidden transition-[max-height] ease-out motion-reduce:transition-none"
style={{
maxHeight: metrics ? (folded ? metrics.clampH : metrics.fullH) : undefined,
transitionDuration: "400ms",
maskImage: folded
? "linear-gradient(to bottom, black calc(100% - 1.5em), rgba(0,0,0,0.15))"
: undefined,
WebkitMaskImage: folded
? "linear-gradient(to bottom, black calc(100% - 1.5em), rgba(0,0,0,0.15))"
: undefined,
}}
>
<div ref={innerRef}>
{words.map((w, i) => (
<span key={i} data-word>
{w}
{i < words.length - 1 ? " " : ""}
</span>
))}
</div>
</div>
{needsFold && (
<div className="mt-1 flex items-center gap-3">
{/* the selvage: the finished edge of the cut, dashes like thread ends */}
<span aria-hidden className="h-px flex-1 border-t border-dashed border-border" />
<button
type="button"
aria-expanded={open}
aria-label={open ? "Fold text back" : `Unfold ${metrics.hidden} more ${metrics.hidden === 1 ? "word" : "words"}`}
onClick={() => setOpen((o) => !o)}
className="flex cursor-pointer items-center gap-1.5 rounded-sm px-1.5 py-0.5 font-mono text-[11px] tabular-nums text-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<svg
aria-hidden
viewBox="0 0 10 10"
className={`h-2.5 w-2.5 transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 3.5 L5 6.5 L8 3.5" />
</svg>
{open ? "fold" : `+ ${metrics.hidden} words`}
</button>
</div>
)}
</div>
);
}
A truncation primitive for prose where the cut is measured, not guessed. Every word renders in its own span, and after layout the component finds the first span whose offsetTop lands at or past the fold line (lines × computed line-height, with a fontSize×1.5 fallback when line-height is 'normal') — everything from that word on is the hidden remainder, so the control can state exactly '+ 42 words' instead of an ellipsis that admits something was cut but not how much. The measurement re-runs through a ResizeObserver, so reflowing the container (resize, font swap) keeps the count honest rather than stale. THE FOLD EDGE: when folded, the clip is a max-height with a mask-image fade over the last 1.5em (alpha-only mask, theme-independent) so the final visible line visibly runs out rather than guillotines, and the control row draws a dashed hairline rule — the selvage, the finished edge of the cut, thread-ends showing — leading into a mono '+ N words' button with a chevron. UNFOLD: activating the button eases max-height from the clamp height to the measured full height over 400ms ease-out (an in-place reflow of the same paragraph, never a jump or a remount), flips the chevron, and swaps the label to 'fold'; folding back is the same motion reversed. The button carries aria-expanded and a full-sentence accessible name ('Unfold 42 more words' / 'Fold text back'). HONESTY GUARANTEES: text that fits its clamp renders with no mask, no rule, and no control at all — a read-more that appears on nothing-to-read is decoration; and the full text stays in the DOM in both states, so assistive tech, find-in-page and copy always see everything — the fold is visual, never informational. REDUCED MOTION: the height ease and chevron rotation drop via motion-reduce, folding and unfolding become instant, nothing else changes. Colors are tokens only (--border for the selvage rule, --muted/--foreground for the control, --accent only as the focus ring). Pure DOM/CSS, zero dependencies, no canvas.