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…

"use client"

import * as React from "react"
import { Check, Copy, X } from "lucide-react"
import { cn } from "@/lib/utils"

export type TruncateMiddleMode = "chars" | "fit"

type CopyStatus = "idle" | "copied" | "error"

const COPY_RESET_DELAY = 2000

/**
 * Grapheme clusters, not code units. `"👨‍👩‍👧‍👦".length` is 11 and

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/truncate-middle.json

Prompt

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: ellipsis can 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 charactersIntl.Segmenter counts 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 raw slice() 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 measureText search 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 copy event 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.

On This Page