Text
Truncate Middle
Middle-ellipsis text that keeps both ends readable — a fixed head/tail count, or as many grapheme clusters as the measured width actually fits.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/truncate-middle.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TruncateMiddle" component (one
ResizeObserver, one offscreen canvas for text metrics, lucide-react icons for
the optional copy button, plus the shared cn() class-merge helper).
Contract
- Named export `TruncateMiddle`, forwardRef<HTMLSpanElement>, props extend
native span attributes minus `children`:
- `text: string` — the whole value. Nothing else is ever the source of truth.
- `mode?: "chars" | "fit"` — default `"fit"`.
- `start?: number` — default `8`. In chars mode: clusters kept at the head.
In fit mode: the head's share of the measured budget.
- `end?: number` — default `8`. Same, for the tail.
- `ellipsis?: string` — default `"…"`.
- `copyable?: boolean` — default `false`; renders a copy button.
- `copyLabel?: string` — default `"Copy full text"`, the button's aria-label.
Behavior
- Split `text` into grapheme clusters with `new Intl.Segmenter("en", {
granularity: "grapheme" })` (a module-level singleton, explicit locale, never
`Intl.Segmenter(undefined)`), and count in clusters everywhere. Cutting by
code unit splits a surrogate pair; cutting by code point still splits a ZWJ
family emoji, a flag, and a skin-tone modifier. Fall back to
`Array.from(text)` (code points) when Segmenter is missing.
- chars mode: keep exactly `start` clusters at the head and `end` at the tail.
No measurement, no browser API, identical output on server and client.
- fit mode: measure the real available width and keep as many clusters as fit,
distributing them over head and tail in the `start:end` ratio (50/50 when
both are 0). Same two props, two meanings — document it, since `start={0}`
then still means "no head at all".
- Measurement: a ResizeObserver watches the inner text span, which is
`flex-1 min-w-0` so its width comes from the container and never from its own
content — that is what stops the measure → setState → re-render cycle from
feeding itself and throwing "ResizeObserver loop completed with undelivered
notifications". The callback schedules a `requestAnimationFrame`; the actual
read + setState happen there, one frame later, never synchronously in the
effect body.
- Inside the frame: read `getBoundingClientRect().width`, build the canvas font
shorthand from `getComputedStyle` (`fontStyle fontWeight fontSize
fontFamily`, plus `ctx.letterSpacing` — written as `"0px"` when the computed
value is the keyword `normal`, since the canvas setter rejects the keyword
and would otherwise keep the previous instance's tracking on the shared
context: measured, an untracked value next to a `tracking-[0.35em]` one kept
25 clusters instead of the 39 that fit), then binary-search the largest number
of kept clusters whose composed string measures <= width - 0.5px. Width grows
monotonically with the kept count, so ~10 `measureText` calls settle an
80-character path and no layout pass is triggered. Offscreen canvas metrics
cost nothing compared with a per-candidate DOM reflow.
- The observer is re-created whenever `text`, `mode`, `start`, `end` or
`ellipsis` changes: swapping the text does not resize the box, so the
observer would otherwise never fire again and the old cut would stick.
`observe()` always fires once on its own, which doubles as the first measure.
- Also re-measure after `document.fonts.ready` — a web font swaps in without
resizing the observed box, and the fallback face has different metrics.
- SSR: fit mode has no width on the server, so the first paint (server and the
matching hydration render) uses the chars-mode result from `start`/`end`.
The measured value only replaces it after mount, so there is never a blank
first frame and never a hydration mismatch. Bail out of the measurement (keep
the fallback) when the width is 0 — a hidden or detached subtree must not
collapse the text to a lone ellipsis.
- Boundaries: `start`/`end` are floored and clamped to >= 0; NaN/Infinity fall
back to the default 8. When `head + tail >= cluster count`, render `text`
verbatim — no ellipsis, no tooltip, no duplicate node. `end={0}` renders a
head-only "9f2c1ab…". `start={0} end={0}` collapses to the ellipsis alone
(predictable rather than silently clamped up to 1).
- The full value stays reachable three ways: a `title` tooltip (unless the
caller passes their own `title`), an `sr-only` copy for assistive tech (the
visible run is `aria-hidden`, because "abc…xyz" read aloud is useless), and
the clipboard.
- Clipboard, selection path: `::selection` cannot reach text that was never
rendered, so the component intercepts the `copy` event and calls
`clipboardData.setData("text/plain", text)` + `preventDefault()`. Guard it:
rewrite only when the selection's `commonAncestorContainer` is inside the
root AND the range covers the whole visible run (compare boundary points
against a range over the run). A partial highlight or a paragraph-wide
selection keeps its native result. The alternative — keeping the full text in
the DOM and hiding the middle with CSS — cannot work: `display:none` and
`visibility:hidden` text is excluded from a copy anyway, so the "real text is
still there" trick would need a zero-width, still-selectable run that fights
the width budget it is supposed to respect. Known limitation of the chosen
route: a selection that starts outside the component copies the elided form,
because the copy event then targets an ancestor and never reaches this
handler.
- The `sr-only` duplicate is `select-none`, so a page-wide selection does not
pick the value up twice (measured: two visible rows produce two occurrences,
not four).
- Clipboard, button path: `navigator.clipboard.writeText(text)` inside
try/catch, awaiting the promise. Missing API, insecure context or a denied
permission flips the button to a destructive-tinted X plus a "Copy failed"
polite live-region message — never a check mark for a write that did not
happen. Success shows a check for 2s. A `mountedRef` set to `true` in the
effect body (not only cleared in cleanup, which StrictMode's
mount→cleanup→mount would leave permanently false) gates the post-await
setState, and the reset timer is cleared on unmount.
Rendering & styling
- Root: `inline-flex min-w-0 max-w-full items-center gap-1.5`, plus
`flex w-full` in fit mode so the box takes a definite width from its parent
instead of shrinking to its content. No vertical-align override — the
default baseline lines an inline value up with the sentence around it
(align-middle measurably drops it ~1.8px).
- Text span: `min-w-0 flex-1 overflow-hidden whitespace-nowrap`. The clip is a
safety net; the string is already short enough not to need it.
- Copy button: `size-6 rounded-md border` with `text-muted-foreground`,
`hover:bg-muted hover:text-foreground`, `focus-visible:ring-2
focus-visible:ring-ring`, and `text-destructive` in the failure state.
Semantic tokens only, no hex, no oklch.
- No animation beyond `transition-colors`, so there is nothing for
prefers-reduced-motion to switch off.
Customization levers
- `mode` — `"chars"` for a stable badge-like abbreviation (wallet, short SHA,
license key) that must not shift as the layout moves; `"fit"` for a column
that should use every pixel it is given.
- `start` / `end` — the domain's information density: `6/4` for a wallet,
`7/0` for a short commit, `0/12` to keep only a filename tail. In fit mode
the same pair biases the split (`10/4` keeps the origin, `4/10` keeps the
object key).
- `ellipsis` — `"..."`, `" … "`, or `" ⋯ "` if the font's single-glyph
ellipsis is too tight in a monospace grid.
- `copyable` — drop it when the row already has its own copy affordance; the
selection-copy rewrite works with or without the button.
- Fit mode needs a parent with a definite width (a flex/grid item with
`min-w-0`, a fixed-width box, `table-layout: fixed`). Inside a shrink-to-fit
parent (auto table cell, inline-block) there is no budget to fit into and the
text renders in full — use chars mode there.
- To expose the current cut (a "showing 41 of 83 characters" hint, or tests),
add an `onFitChange?: (kept: number) => void` fired from the same rAF that
sets state, kept in a latest-ref so an inline arrow function does not
re-create the observer on every render.
- To truncate at a semantic boundary instead of a character one (never cut
inside a path segment), replace the composed candidate in the binary search
with a join over `text.split("/")` and search over segment counts; the
measure/monotonicity logic is unchanged.Concepts
- Middle ellipsis — CSS
text-overflow: ellipsiscan only cut the tail, which is exactly where a path's filename, a hash's checksum and a URL's object key live. Dropping the middle keeps both identifying ends and costs the same one line. - Grapheme clusters, not characters —
Intl.Segmentercounts what a reader calls a character, so a ZWJ family emoji, a regional-indicator flag or a combining accent is kept or dropped whole; a rawslice()on the same string cuts a family in half. - Chars fallback as the SSR frame — fit mode has no width to measure on the server, so it paints the chars-mode result first and refines it after mount. That keeps the first frame non-empty and byte-identical across hydration, instead of flashing empty or logging a mismatch.
- Content-independent measurement box — the observed span is
flex-1 min-w-0, so its width comes from the container, never from the text inside it. Without that, every re-render would resize the observed box and the ResizeObserver would notify itself in a loop. - Binary search over canvas metrics — width grows monotonically with the number of kept clusters, so the largest fitting count is a ~10-call
measureTextsearch on an offscreen canvas: no DOM writes, no layout thrash, no measurement node in the tree. - Clipboard payload rewrite — the elided middle was never rendered, so a native copy cannot contain it. The
copyevent is intercepted and re-filled with the full value, but only when the selection is inside the component and covers the whole run — a partial highlight still copies exactly what was highlighted.
Redacted Text
Blur, black-bar or character-mask a sensitive string with hover / click / press-and-hold reveal — where mask actually removes the plaintext from the DOM.
Glitch Text
Cyberpunk glitch headline — two pseudo-element copies tear the text into slices and slide, in pure CSS, dead still under reduced motion.