Truncate
Patterns for truncating long strings — middle ellipsis (filenames, addresses) and click-to-expand (end ellipsis).
Examples
Filename with extension
CSS text-overflow: ellipsis only truncates at the end, which is the wrong
default for filenames, file paths, hashes, wallet addresses — anywhere the
tail of the string is as distinguishing as the head. macOS Finder solves
this by keeping the extension visible and chopping the middle of the name:
2024-Q4-financial-repo…final-v3.xlsx.
The web has no built-in way to do this (see csswg-drafts#3937), so
we split the string into a head and a tail, truncate the head with normal
overflow: hidden; text-overflow: ellipsis, and let the tail stay fixed.
Two flex children, no JavaScript measurement loop.
import * as React from "react";
import { cn } from "@/lib/utils";
export interface MiddleTruncateProps extends Omit<
React.HTMLAttributes<HTMLSpanElement>,
"children"
> {
value: string;
/**
* Number of trailing characters kept on the right side of the ellipsis.
* Ignored when `splitOnExtension` finds a match.
* @default 7
*/
tailLength?: number;
/**
* Split on the last `.` so the file extension is kept as the tail.
* Falls back to `tailLength` when the value has no dot.
* @default false
*/
splitOnExtension?: boolean;
}
function splitValue(
value: string,
tailLength: number,
splitOnExtension: boolean,
): { head: string; tail: string } {
if (splitOnExtension) {
const dot = value.lastIndexOf(".");
if (dot > 0 && dot < value.length - 1) {
return { head: value.slice(0, dot), tail: value.slice(dot) };
}
}
const split = Math.max(0, value.length - tailLength);
return { head: value.slice(0, split), tail: value.slice(split) };
}
function MiddleTruncate({
value,
tailLength = 7,
splitOnExtension = false,
className,
...props
}: MiddleTruncateProps) {
const { head, tail } = splitValue(value, tailLength, splitOnExtension);
return (
<span
data-slot="middle-truncate"
className={cn("flex max-w-full min-w-0", className)}
title={value}
aria-label={value}
{...props}
>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{head}
</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</span>
);
}
export { MiddleTruncate };Notes:
- How the flex trick works. The outer
spanis a flex container withmin-w-0so it can shrink below its content. The head child hasmin-w-0+ the usualoverflow / text-overflow / nowraptriplet; it uses the flex defaultflex: 0 1 auto, so it doesn't grow past its content (important — otherwise the head stretches to fill leftover width and leaves a gap before the tail) but does shrink when the container is narrower than the combined content. The tail hasshrink-0so it always renders in full, no matter how narrow the container gets. min-w-0is not optional. Flex items default tomin-width: auto, which is their content min-width. Withoutmin-w-0, the head refuses to shrink past its text and the container overflows horizontally. This is the most common reason people's "truncate inside a flex row" breaks.- Copy/paste gets the full string. The rendered DOM contains both the
head and the tail in order, so selecting the element and copying yields
the original value — not the ellipsised one. That's the main reason to
prefer the flex-children approach over splitting the string with
…. titleandaria-labelexpose the full value on hover and to assistive tech. The visual ellipsis is decorative.splitOnExtensionvstailLength. UsesplitOnExtensionfor filenames — it keeps the.xlsx/.tar.gzextension visible regardless of length. Use a fixedtailLength(default 7) for IDs, hashes, wallet addresses, URLs — anything where the last N characters are what distinguishes one value from another.whitespace-preon the tail is important if your values can contain trailing spaces, or if the tail begins with a.or-you don't want collapsed.- Font-metric accuracy. This is a character-split, not a
pixel-measurement. With a proportional font you might see the ellipsis
appear slightly early for wide-character heads ("WWW…") or late for
narrow-character heads ("iii…"). If you need pixel-accurate truncation
(e.g. the head must end exactly at the ellipsis glyph), measure with
canvas.measureTextinside aResizeObserver. For 95% of UI, character split is good enough and costs nothing. - CSS direction tricks (
direction: rtl; text-align: left;) give you start-truncation, not middle-truncation. Useful for paths where you want…/deep/file.tsx, not quite the same pattern. - There's an open CSS Working Group discussion to add a native
text-overflow: ellipsis-word/ middle-truncation token. As of writing it's still a draft issue; no browser ships it.
Click to expand
Sometimes you don't want to lose characters at all — you just need the collapsed state to fit one row. This pattern keeps the default end-ellipsis (so it's obvious there's more), but clicking the row expands it in place: width stays the same, height grows to accommodate the full wrapped text. Click again to collapse.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ExpandableTextProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"children"
> {
value: string;
defaultOpen?: boolean;
}
function ExpandableText({
value,
defaultOpen = false,
className,
onClick,
...props
}: ExpandableTextProps) {
const [open, setOpen] = React.useState(defaultOpen);
return (
<button
type="button"
data-slot="expandable-text"
aria-expanded={open}
onClick={(event) => {
setOpen((v) => !v);
onClick?.(event);
}}
className={cn(
"focus-visible:ring-ring/50 block w-full cursor-pointer rounded text-left outline-none focus-visible:ring-[3px]",
open
? "[overflow-wrap:anywhere] whitespace-normal"
: "overflow-hidden text-ellipsis whitespace-nowrap",
className,
)}
{...props}
>
{value}
</button>
);
}
export { ExpandableText };Notes:
- Real
<button>, not a clickable<div>. You get keyboard focus, Enter/Space activation, and the right disabled/hover behavior for free. Override the default button styling withblock w-full text-leftso it lays out as a row, not a pill. aria-expandedtells assistive tech the current state. Screen readers announce "expanded" or "collapsed" as the user toggles it, without needing any additional visible affordance.- Why
[overflow-wrap: anywhere], notbreak-all?break-allforces every line to break at any character, including where a natural word boundary would have worked — giving youquic/k brow/n fox.overflow-wrap: anywhereonly breaks inside a word when no opportunity between words is available on the current line, soThe quick brown foxstill wraps at spaces, but a 40-character token with no spaces (URL, wallet address) still wraps mid-token when it has to. - Width stays fixed. The button inherits the container's width
(
block w-full), and onlyheightchanges when thewhitespace-normalbranch kicks in. Don't put this inside a flex row that auto-stretches or you'll fight the parent's layout. - Trade-off: click-to-select is gone. Wrapping the row in a
<button>means double-click selects the entire value (good for copying) but drag-to-select across the text doesn't behave like a normal paragraph. If partial selection matters more than tap-to-expand, swap the pattern for a sibling "Show more" button and leave the text as plain<p>. - Collapsed-state row height is fixed too. In the collapsed branch
the text is
whitespace-nowrap+overflow-hidden, so the row is always exactly one line — useful when this component lives inside a virtualised list where every row needs a predictable initial height.