UI Patterns

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.

Default CSS truncate (end):
2024-Q4-financial-report-final-v3.xlsx
Middle truncation (extension kept):
2024-Q4-financial-report-final-v3.xlsx
components/ui/middle-truncate.tsx
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 span is a flex container with min-w-0 so it can shrink below its content. The head child has min-w-0 + the usual overflow / text-overflow / nowrap triplet; it uses the flex default flex: 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 has shrink-0 so it always renders in full, no matter how narrow the container gets.
  • min-w-0 is not optional. Flex items default to min-width: auto, which is their content min-width. Without min-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 .
  • title and aria-label expose the full value on hover and to assistive tech. The visual ellipsis is decorative.
  • splitOnExtension vs tailLength. Use splitOnExtension for filenames — it keeps the .xlsx / .tar.gz extension visible regardless of length. Use a fixed tailLength (default 7) for IDs, hashes, wallet addresses, URLs — anything where the last N characters are what distinguishes one value from another.
  • whitespace-pre on 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.measureText inside a ResizeObserver. 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.

Click the row to expand / collapse:
components/ui/expandable-text.tsx
"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 with block w-full text-left so it lays out as a row, not a pill.
  • aria-expanded tells 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], not break-all? break-all forces every line to break at any character, including where a natural word boundary would have worked — giving you quic / k brow / n fox. overflow-wrap: anywhere only breaks inside a word when no opportunity between words is available on the current line, so The quick brown fox still 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 only height changes when the whitespace-normal branch 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.

On this page