@apollosuny/react-truncate
v0.3.0
Published
A modern, pixel-accurate React text truncation component with show more/less toggle
Maintainers
Readme
@apollosuny/react-truncate
A pixel-accurate, responsive React text truncation component with inline "see more / see less" toggle.
Unlike CSS -webkit-line-clamp, this library uses canvas.measureText() and binary search to find the exact character cutoff at any container width, font, or letter-spacing — then re-runs automatically on resize.
Features
- Pixel-accurate —
canvas.measureText()+ binary search, not CSS hacks - Responsive —
ResizeObserverrecalculates on every container resize - Inline toggle — place "see more" and "see less" at the end of the last line, like Facebook
- Unstyled — zero CSS shipped; style with
className, Tailwind, CSS Modules, anything - Composable — compound component API (
<Truncate.Content>,<Truncate.Toggle>) asChild— render the toggle as any element (e.g.<a>, a custom button) via a built-in slot- Emoji-safe — cuts on grapheme clusters (
Intl.Segmenter), so emoji, flags, and combining marks are never split - Zero dependencies — no runtime dependencies; only a
reactpeer - Controlled + uncontrolled —
expanded/defaultExpanded/onExpandedChange - Accessible —
aria-expanded/aria-controlswired automatically, and the full text stays readable by screen readers while visually clipped - Font-aware — re-measures after web fonts load, so the cutoff doesn't drift
- RSC-ready — ships a
"use client"directive, so it drops straight into a Next.js App Router server component with no wrapper, and still SSRs to HTML - TypeScript — full type definitions included
Installation
npm install @apollosuny/react-truncate
# or
pnpm add @apollosuny/react-truncate
# or
yarn add @apollosuny/react-truncatePeer dependencies: react@^18 || ^19
Quick Start
The simplest form — a string child plus moreLabel / lessLabel. The library
renders accessible inline buttons for you (wired with aria-expanded /
aria-controls):
import { Truncate } from "@apollosuny/react-truncate";
export function Post({ body }: { body: string }) {
return (
<Truncate lines={3} moreLabel="See more" lessLabel="See less">
{body}
</Truncate>
);
}Style the generated buttons via the [data-truncate="toggle"] attribute (the
library ships zero CSS otherwise).
Need full control over the markup and placement? Use the compound API:
import { Truncate } from "@apollosuny/react-truncate";
export function Post({ body }: { body: string }) {
return (
<Truncate lines={3}>
<Truncate.Content
ellipsis="... "
more={(toggle) => (
<button onClick={toggle} className="font-semibold text-blue-600">
See more
</button>
)}
less={(toggle) => (
<button onClick={toggle} className="font-semibold text-blue-600">
See less
</button>
)}
>
{body}
</Truncate.Content>
</Truncate>
);
}
moresits inline at the end of the last clipped line;lesssits inline at the end of the full text once expanded. Prefer these over<Truncate.Toggle>when you want the control on the same line as the text. Use<Truncate.Toggle>when you want a separate, block-level control instead.
API
<Truncate>
Root provider. Renders a <div> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
| lines | number | 3 | Maximum lines before truncation |
| expanded | boolean | — | Controlled expanded state |
| defaultExpanded | boolean | false | Initial state (uncontrolled) |
| onExpandedChange | (expanded: boolean) => void | — | Fired on every toggle |
| ellipsis | ReactNode | "... " | Shorthand mode only — forwarded to the auto-rendered Content |
| moreLabel | ReactNode | — | Shorthand mode only — see <Truncate.Content> |
| lessLabel | ReactNode | — | Shorthand mode only — see <Truncate.Content> |
Accepts all <div> props. Exposes data-state="truncated" or data-state="expanded" for CSS targeting.
Shorthand mode. When
childrenis a plain string, the root renders a<Truncate.Content>for you and forwardsellipsis/moreLabel/lessLabel. Pass<Truncate.Content>/<Truncate.Toggle>children explicitly to opt into the full compound API instead (these props are ignored then).
<Truncate.Content>
The text container. Renders a block <span>.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | string | required | Plain string to truncate (see note below) |
| ellipsis | ReactNode | "... " | Rendered before more at the cutoff point |
| more | (toggle: () => void) => ReactNode | — | Inline element placed at the end of the last truncated line |
| less | (toggle: () => void) => ReactNode | — | Inline element placed at the end of the full text once expanded |
| moreLabel | ReactNode | — | Shorthand for more: renders a default accessible inline <button>. Ignored when more is set |
| lessLabel | ReactNode | — | Shorthand for less. Ignored when less is set |
Accepts all <span> props.
Labels vs render-props.
moreLabel/lessLabelrender a default<button data-truncate="toggle">withonClick,aria-expanded, andaria-controlswired automatically — the zero-config path. Usemore/lesswhen you need custom markup; they take precedence over the labels.
Plain text only.
childrenmust be a string — measurement is done withcanvas.measureText(), which can't measure arbitrary JSX. For rich content (links, mentions, emoji rendered as nodes), this library is not the right fit.
<Truncate.Toggle>
A separate, block-level control rendered outside the truncated text. Hidden automatically when the text is not truncated and not expanded. For an inline control, use the more / less props on <Truncate.Content> instead.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode \| (state: { expanded: boolean }) => ReactNode | required | Label or render-prop |
| asChild | boolean | false | Merges props onto the child element instead of rendering a <button> |
| contentId | string | — | Override the aria-controls target. Defaults to the <Truncate.Content> id automatically |
Sets aria-expanded and aria-controls automatically — aria-controls points at the <Truncate.Content> region by default, with no wiring required.
useTruncate()
Access the truncation context from any component nested inside <Truncate>.
import { useTruncate } from "@apollosuny/react-truncate";
function CustomBadge() {
const { isTruncated, expanded } = useTruncate();
if (!isTruncated || expanded) return null;
return <span className="text-xs text-gray-400">truncated</span>;
}| Field | Type | Description |
|---|---|---|
| expanded | boolean | Current expanded state |
| isTruncated | boolean | Whether the text is actually clipped |
| lines | number | The configured line limit |
| toggle | () => void | Toggle expanded state |
Patterns
Facebook-style — inline "See more" / "See less"
The more prop places a clickable element at the end of the last clipped line; less places one at the end of the full text once expanded. Both stay on the same line as the text.
<Truncate lines={3}>
<Truncate.Content
ellipsis="... "
more={(toggle) => (
<button onClick={toggle} className="font-semibold text-blue-600">
See more
</button>
)}
less={(toggle) => (
<button onClick={toggle} className="font-semibold text-blue-600">
See less
</button>
)}
>
{text}
</Truncate.Content>
</Truncate>Block-level toggle below the text
Use <Truncate.Toggle> when you want the control on its own line instead of inline. It renders a <button> (or any element via asChild) and wires up aria-expanded / aria-controls automatically.
<Truncate lines={3}>
<Truncate.Content
ellipsis="... "
more={(toggle) => (
<button onClick={toggle} className="font-semibold text-blue-600">
See more
</button>
)}
>
{text}
</Truncate.Content>
<Truncate.Toggle className="mt-1 font-semibold text-blue-600">
{({ expanded }) => (expanded ? "See less" : null)}
</Truncate.Toggle>
</Truncate>Expand-only (no collapse)
Omit Truncate.Toggle entirely. Once expanded, the text stays expanded.
<Truncate lines={3}>
<Truncate.Content
ellipsis="... "
more={(toggle) => (
<button onClick={toggle} className="text-blue-600">
Show more
</button>
)}
>
{text}
</Truncate.Content>
</Truncate>Controlled state
Drive the expanded state from outside the component.
const [open, setOpen] = useState(false);
<Truncate lines={3} expanded={open} onExpandedChange={setOpen}>
<Truncate.Content
more={(toggle) => <button onClick={toggle}>See more</button>}
>
{text}
</Truncate.Content>
<Truncate.Toggle>
{({ expanded }) => (expanded ? "See less" : null)}
</Truncate.Toggle>
</Truncate>Custom ellipsis
<Truncate lines={2}>
<Truncate.Content
ellipsis=" "
more={(toggle) => <button onClick={toggle}>[read more]</button>}
>
{text}
</Truncate.Content>
</Truncate>Toggle as a custom element (asChild)
<Truncate.Toggle asChild>
{({ expanded }) =>
expanded ? <a href="#">See less</a> : null
}
</Truncate.Toggle>CSS data-state targeting
<Truncate lines={3} className="post-body">
{/* ... */}
</Truncate>.post-body[data-state="truncated"] { border-left: 3px solid orange; }
.post-body[data-state="expanded"] { border-left: 3px solid green; }With Tailwind CSS
<Truncate lines={4}>
<Truncate.Content
className="text-gray-700 leading-relaxed"
more={(toggle) => (
<button
onClick={toggle}
className="font-semibold text-blue-600 hover:underline"
>
see more
</button>
)}
>
{article.body}
</Truncate.Content>
<Truncate.Toggle className="mt-2 text-sm font-semibold text-blue-600 hover:underline">
{({ expanded }) => (expanded ? "see less" : null)}
</Truncate.Toggle>
</Truncate>Comparison
vs CSS line-clamp / -webkit-line-clamp
CSS line clamping is the right default when you only need to cap lines with a
trailing …. Reach for this library when that isn't enough:
| | CSS line-clamp | This library |
|---|---|---|
| Standardization | Unprefixed form still a Working Draft; only -webkit-line-clamp resolves everywhere (not Baseline) | JS, works wherever Canvas + ResizeObserver do (≈ every browser since 2020) |
| Inline "see more" at the exact cutoff | ✗ (clamps text only; no room for a trailing control on the same line) | ✓ |
| Cutoff accuracy | Approximate near the boundary | Pixel-accurate (measureText + binary search) |
| isTruncated detection | ✗ | ✓ via useTruncate() |
| Custom ellipsis node | ✗ | ✓ |
| Cost | Zero JS | Measurement runs on the client |
If you don't need the inline toggle, exact cutoff, or truncation detection, plain CSS is lighter — use it.
vs JSX-truncating libraries (react-truncate-markup, @re-dev/react-truncate)
Those measure rendered DOM, so they can truncate arbitrary JSX (links,
mentions, badges). This library measures with canvas.measureText(), which is
faster and pixel-exact but plain-string only. Choose based on content:
- Rich/JSX content (inline links, formatted spans) → use a DOM-measuring library.
- Plain text (post bodies, descriptions, comments) with an exact inline toggle and zero-CSS styling → this library.
How it works
- A
ResizeObserverwatches the container and reads its exact pixel width viagetBoundingClientRect(). window.getComputedStyle()captures the element's font (family, size, weight, style) andletter-spacing.- A hidden
<canvas>runsmeasureText()to determine character widths, with a manual correction forletter-spacing(which the Canvas API ignores). - For each line up to
lines - 1, a binary search over words finds the last word that fits. - On the final line, a binary search over characters finds the exact cutoff point, leaving room for
ellipsisandmore. - When the container resizes — or when web fonts finish loading — steps 1–5 repeat automatically.
This approach is accurate across any font, size, or container width — unlike -webkit-line-clamp, which produces slightly wrong results near the breakpoint and cannot accommodate an inline toggle element.
Accessibility
- Full text for screen readers. While collapsed, the visible text is a clipped fragment of a sentence. That fragment is hidden from assistive tech (
aria-hidden), and the complete text is exposed via a visually-hidden copy — so screen-reader users get the whole content, not a cut-off phrase. - Focusable controls stay announced. The inline
more/lesstoggle is rendered outside thearia-hiddenregion, so it remains reachable by keyboard and screen readers. - Wired disclosure semantics.
<Truncate.Toggle>setsaria-expandedand pointsaria-controlsat the<Truncate.Content>region automatically. - Zero CSS dependency. The visually-hidden styles are inlined; you don't need a global
.sr-onlyutility.
If you wire up an inline
more/lessbutton yourself, addaria-expanded={expanded}to it for full disclosure semantics.<Truncate.Toggle>does this for you.
Browser support
All modern browsers (Chrome ≥ 79, Firefox ≥ 69, Safari ≥ 13.1, Edge ≥ 79).
Requires ResizeObserver and HTMLCanvasElement — both universally supported since 2020.
License
MIT © apollosuny
