Skip to content

Cuts text at the end or middle and unfolds it in place when cut.

Text & content@base-ui/react

01Preview

Q3 planning — shared with Finance, Design and Leadership

4 files

Forecasts, brand assets and offsite photos for the planning cycle. Finance owns the spreadsheets, so ask Maya before moving anything.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install @base-ui/react

03Usage

import { Truncate, TruncateGroup } from "@/components/ui/truncate";

<Truncate>{project.name}</Truncate>

// File names and paths: cut in the middle so the extension or last folder survives.
<TruncateGroup>
  {files.map((f) => (
    <button key={f.id} className="flex min-w-0 flex-col">
      <Truncate position="middle">{f.name}</Truncate>
      <Truncate position="middle" className="text-fg-3">{f.path}</Truncate>
    </button>
  ))}
</TruncateGroup>

04Source

"use client";
import { Tooltip } from "@base-ui/react/tooltip";
import { createContext, use, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";

/* -------------------------------------------------------------------------------------------------
 * Where to cut
 * -----------------------------------------------------------------------------------------------*/

const segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter() : null;
// Cut between graphemes, never inside an emoji or an accented letter.
const graphemes = (s: string) => (segmenter ? Array.from(segmenter.segment(s), (g) => g.segment) : Array.from(s));

/**
 * How many characters stay whole after the ellipsis in middle truncation, when you don't say.
 * Paths keep their last segment (the file), file names keep their extension plus a few
 * characters (where "v2" and "final" live), anything else keeps its last few characters
 * (enough to tell two IDs or hashes apart).
 */
export function autoTail(text: string) {
  const n = graphemes(text).length;
  const slash = Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\"));
  if (slash > 0 && slash < text.length - 1) return Math.min(n, graphemes(text.slice(slash)).length);
  const ext = /\.[a-z0-9]{1,8}$/i.exec(text);
  // Short names leave at least four characters in front, so there's always a head to cut.
  if (ext && ext.index > 0) return Math.max(0, Math.min(ext[0].length + 6, n - 4));
  return Math.min(8, Math.floor(n / 3));
}

/** Splits text into the part that shrinks and the part that stays. Pure, so it renders the same on the server. */
export function splitTail(text: string, tail: number | "auto" = "auto") {
  const g = graphemes(text);
  const keep = Math.max(0, Math.min(g.length, tail === "auto" ? autoTail(text) : tail));
  return { head: g.slice(0, g.length - keep).join(""), tail: g.slice(g.length - keep).join("") };
}

/* -------------------------------------------------------------------------------------------------
 * Measuring
 * -----------------------------------------------------------------------------------------------*/

const overflows = (el: HTMLElement) => el.scrollWidth - el.clientWidth > 0 || el.scrollHeight - el.clientHeight > 1;

/** Watches an element's size, and web fonts arriving, and calls `measure` for each. */
function useMeasure(ref: React.RefObject<HTMLElement | null>, measure: () => void, deps: unknown[]) {
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    // Observing fires once straight away, which is also the first measurement.
    const ro = new ResizeObserver(() => measure());
    ro.observe(el);
    let live = true;
    document.fonts?.ready.then(() => live && measure());
    return () => {
      live = false;
      ro.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- re-measure when the caller's content changes
  }, [ref, measure, ...deps]);
}

/**
 * Whether the text inside `ref` is actually cut off right now. Re-measures on resize, when the
 * text changes and once web fonts have loaded, so it never reports on a fallback font's width.
 */
export function useIsTruncated<T extends HTMLElement>(deps: unknown[] = []) {
  const ref = useRef<T>(null);
  const [truncated, setTruncated] = useState(false);

  const measure = useCallback(() => {
    if (ref.current) setTruncated(overflows(ref.current));
  }, []);

  useMeasure(ref, measure, deps);
  return { ref, truncated };
}

/* -------------------------------------------------------------------------------------------------
 * Group
 * -----------------------------------------------------------------------------------------------*/

const GroupContext = createContext<{ delay: number } | null>(null);

export type TruncateGroupProps = {
  /** Milliseconds of hover before the first reveal in the group. */
  delay?: number;
  /** Once one reveal closes, neighbors hovered within this many milliseconds open instantly. */
  timeout?: number;
  children?: React.ReactNode;
};

/**
 * Wrap a list or table. The first row waits, then moving down the list reveals each
 * truncated row on contact, the way a file browser does.
 */
export function TruncateGroup({ delay = 400, timeout = 400, children }: TruncateGroupProps) {
  return (
    <GroupContext value={{ delay }}>
      <Tooltip.Provider delay={delay} timeout={timeout}>
        {children}
      </Tooltip.Provider>
    </GroupContext>
  );
}

/* -------------------------------------------------------------------------------------------------
 * Truncate
 * -----------------------------------------------------------------------------------------------*/

// The nearest thing a keyboard user can land on, so tabbing onto a row reveals its name.
const HOST = "a[href],button,input,select,textarea,summary,[tabindex],[role=row],[role=option],[role=menuitem],[role=treeitem],[role=gridcell]";

// Surface padding and border, in px. The reveal is offset by exactly this so its text lands on the original.
const PAD_X = 6;
const PAD_Y = 2;
const BORDER = 1;

type FontMatch = Pick<
  React.CSSProperties,
  "fontFamily" | "fontSize" | "fontWeight" | "letterSpacing" | "lineHeight" | "color" | "fontFeatureSettings" | "fontVariantNumeric"
>;

function readFont(el: HTMLElement): FontMatch {
  const s = getComputedStyle(el);
  return {
    fontFamily: s.fontFamily,
    fontSize: s.fontSize,
    fontWeight: s.fontWeight,
    letterSpacing: s.letterSpacing,
    lineHeight: s.lineHeight,
    color: s.color,
    fontFeatureSettings: s.fontFeatureSettings,
    fontVariantNumeric: s.fontVariantNumeric,
  };
}

export type TruncateProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** The full text. Always what screen readers read and what copying gives you. */
  children: string;
  /** Cut at the end, or in the middle so the end (an extension, a file, the last digits of an ID) survives. */
  position?: "end" | "middle";
  /**
   * Middle only: characters kept whole after the ellipsis. "auto" keeps a path's last
   * segment, a file's extension plus a few characters, or an ID's last few characters.
   */
  tail?: number | "auto";
  /** End only: clamp to this many lines instead of one. */
  lines?: number;
  /**
   * How the full text appears when it's cut off. "inline" unfolds it in place, over the
   * original, in the same font; "tooltip" shows it above; "none" never shows it.
   */
  reveal?: "inline" | "tooltip" | "none";
  /** Milliseconds of hover before revealing. Inside a TruncateGroup, the group's delay. */
  delay?: number;
  /** Where to portal the reveal. Defaults to document.body. */
  container?: Tooltip.Portal.Props["container"];
  /** Called when the text starts or stops being cut off. */
  onTruncatedChange?: (truncated: boolean) => void;
};

/**
 * One line (or a few) of text that cuts itself off cleanly, at the end or in the middle,
 * and offers the full text only when something was actually cut.
 */
export function Truncate({
  children: text,
  position = "end",
  tail = "auto",
  lines = 1,
  reveal = "inline",
  delay,
  container,
  onTruncatedChange,
  className,
  ...rest
}: TruncateProps) {
  const group = use(GroupContext);
  const ref = useRef<HTMLSpanElement>(null);
  const headRef = useRef<HTMLSpanElement>(null);
  const tailRef = useRef<HTMLSpanElement>(null);
  const dotsRef = useRef<HTMLSpanElement>(null);
  // `cut` is the head's width in px when it's been cut, null when everything fits.
  const [fit, setFit] = useState<{ truncated: boolean; cut: number | null }>({ truncated: false, cut: null });
  const [open, setOpen] = useState(false);
  const [font, setFont] = useState<FontMatch>();
  const middle = position === "middle";
  const multiline = !middle && lines > 1;
  const { head, tail: end } = middle ? splitTail(text, tail) : { head: text, tail: "" };
  const truncated = fit.truncated;

  const measure = useCallback(() => {
    const el = ref.current;
    if (!el) return;
    const commit = (next: { truncated: boolean; cut: number | null }) =>
      setFit((prev) => (prev.truncated === next.truncated && prev.cut === next.cut ? prev : next));
    if (!middle) return commit({ truncated: overflows(el), cut: null });

    // Middle: find the longest run of the head that fits beside the ellipsis and the tail,
    // measured on the rendered glyphs (a Range, no DOM writes), and clip the head to exactly
    // that width. CSS ellipsis would stop at a glyph and leave a gap before the tail.
    const node = headRef.current?.firstChild;
    const tailWidth = tailRef.current?.getBoundingClientRect().width ?? 0;
    const available = el.clientWidth;
    if (!(node instanceof Text)) return commit({ truncated: tailWidth - available > 0.5, cut: null });
    const range = document.createRange();
    const widthTo = (i: number) => {
      range.setStart(node, 0);
      range.setEnd(node, i);
      return range.getBoundingClientRect().width;
    };
    if (widthTo(node.length) + tailWidth <= available + 0.5) return commit({ truncated: false, cut: null });

    const room = available - tailWidth - (dotsRef.current?.getBoundingClientRect().width ?? 0);
    const stops = [0];
    for (const g of graphemes(node.data)) stops.push(stops[stops.length - 1] + g.length);
    let lo = 0;
    let hi = stops.length - 1;
    while (lo < hi) {
      const mid = Math.ceil((lo + hi) / 2);
      if (widthTo(stops[mid]) <= room) lo = mid;
      else hi = mid - 1;
    }
    // Never leave a space hanging before the ellipsis.
    let at = stops[lo];
    while (at > 0 && /\s/.test(node.data[at - 1])) at--;
    commit({ truncated: true, cut: at > 0 ? widthTo(at) : 0 });
  }, [middle]);

  useMeasure(ref, measure, [text, tail, lines]);

  const onChange = useRef(onTruncatedChange);
  useEffect(() => {
    onChange.current = onTruncatedChange;
  });
  useEffect(() => {
    onChange.current?.(truncated);
  }, [truncated]);


  const show = useCallback(() => {
    if (ref.current) setFont(readFont(ref.current));
    setOpen(true);
  }, [ref]);

  // Keyboard: reveal when focus lands on the row, link or option this text lives in.
  useEffect(() => {
    const host = ref.current?.closest<HTMLElement>(HOST);
    if (!host || reveal === "none") return;
    const onIn = (e: FocusEvent) => {
      if (e.target !== host || !host.matches(":focus-visible")) return;
      // A row can hold several (a name and its path): the first one that's cut is the one to unfold.
      if (host.querySelector("[data-slot=truncate][data-truncated]") === ref.current) show();
    };
    const onOut = (e: FocusEvent) => {
      if (e.target === host) setOpen(false);
    };
    host.addEventListener("focusin", onIn);
    host.addEventListener("focusout", onOut);
    return () => {
      host.removeEventListener("focusin", onIn);
      host.removeEventListener("focusout", onOut);
    };
  }, [ref, reveal, show]);

  const inline = reveal === "inline";

  return (
    <Tooltip.Root
      open={open && truncated}
      onOpenChange={(next) => (next ? show() : setOpen(false))}
      disabled={!truncated || reveal === "none"}
      disableHoverablePopup
    >
      <Tooltip.Trigger
        delay={delay ?? group?.delay ?? 400}
        closeOnClick={false}
        render={
          <span
            ref={ref}
            data-slot="truncate"
            data-position={position}
            data-truncated={truncated || undefined}
            className={cn(
              "min-w-0 max-w-full align-top",
              // Middle fills the width it's given, so the measurement reads the space, not its own content.
              middle ? "block w-full truncate" : multiline ? "inline-block [overflow-wrap:anywhere]" : "inline-block truncate",
              className,
            )}
            style={multiline ? { display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: lines, overflow: "hidden" } : undefined}
            {...rest}
          />
        }
      >
        {middle ? (
          <>
            {/* The head and tail hold the whole text between them, inline so selecting and copying gives the real name. */}
            <span aria-hidden>
              {head && (
                <span ref={headRef} className={cn(fit.cut !== null && "inline-block overflow-hidden align-top")} style={fit.cut !== null ? { width: fit.cut } : undefined}>
                  {head}
                </span>
              )}
              <span ref={dotsRef} className={cn("select-none", fit.cut === null && "invisible absolute")}>

              </span>
              {end && (
                <span ref={tailRef}>
                  {end}
                </span>
              )}
            </span>
            <span className="sr-only select-none">{text}</span>
          </>
        ) : (
          text
        )}
      </Tooltip.Trigger>

      <Tooltip.Portal container={container}>
        <Tooltip.Positioner
          side={inline ? "bottom" : "top"}
          align={inline ? "start" : "center"}
          // Inline sits exactly over the original: up by the text's height plus the surface's padding and border.
          sideOffset={inline ? ({ anchor }) => -anchor.height - PAD_Y - BORDER : 8}
          alignOffset={inline ? -PAD_X - BORDER : 0}
          collisionAvoidance={inline ? { side: "none", align: "shift", fallbackAxisSide: "none" } : undefined}
          collisionPadding={8}
          className="z-(--z-tooltip)"
        >
          <Tooltip.Popup
            aria-hidden
            style={inline ? font : undefined}
            className={cn(
              "rounded-md border border-line-2 bg-raised shadow-pop outline-none [overflow-wrap:anywhere]",
              "data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none",
              inline
                ? cn(
                    "pointer-events-none px-[6px] py-[2px]",
                    multiline
                      ? "w-[calc(var(--anchor-width)+14px)] max-w-(--available-width)"
                      : "w-max max-w-[min(32rem,var(--available-width))]",
                    // Unfolds from the original's own box: the clip starts where the text was cut and opens to full size.
                    "transition-[opacity,clip-path] [transition-duration:120ms,240ms] ease-out-expo",
                    "data-starting-style:opacity-0",
                    multiline
                      ? "data-starting-style:[clip-path:inset(0_0_calc(100%-var(--anchor-height)-3px)_0_round_6px)]"
                      : "data-starting-style:[clip-path:inset(0_calc(100%-var(--anchor-width)-7px)_0_0_round_6px)]",
                    "[clip-path:inset(0_0_0_0_round_6px)]",
                  )
                : cn(
                    "max-w-[min(20rem,var(--available-width))] px-2 py-1 text-[12px] leading-4 text-fg",
                    "origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo",
                    "data-starting-style:scale-96 data-starting-style:translate-y-0.5 data-starting-style:opacity-0",
                    "motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0",
                  ),
            )}
          >
            {text}
          </Tooltip.Popup>
        </Tooltip.Positioner>
      </Tooltip.Portal>
    </Tooltip.Root>
  );
}

05Props

Truncate

PropTypeDefaultDescription
children*stringThe full text. It's what screen readers read and what copying gives you, however it's cut.
position"end" | "middle""end"Where the ellipsis goes. Middle keeps the end whole: the extension, the file in a path, the last characters of an ID.
tailnumber | "auto""auto"Middle only: characters kept after the ellipsis. Auto keeps a path's last segment, an extension plus six characters, or an ID's last few.
linesnumber1End only: clamp to this many lines.
reveal"inline" | "tooltip" | "none""inline"How the full text appears when cut: unfolded in place over the original, a tooltip above, or not at all.
delaynumber400Milliseconds of hover before revealing. Inside a TruncateGroup, the group's delay.
containerHTMLElement | RefObject<HTMLElement>Where to portal the reveal. Defaults to document.body.
onTruncatedChange(truncated: boolean) => voidCalled when the text starts or stops being cut off.

TruncateGroup

PropTypeDefaultDescription
delaynumber400Hover time before the first reveal in the group.
timeoutnumber400Once a reveal closes, neighbors hovered within this many milliseconds open instantly.

useIsTruncated

PropTypeDefaultDescription
depsunknown[][]Re-measure when these change. Returns { ref, truncated } for any element with overflow hidden.

06Notes

Behavior

  • Measured, never guessed: a ResizeObserver and the web-font load both re-measure, and the reveal is disabled whenever everything fits, so short names never get a tooltip.
  • Middle cuts are placed on the rendered glyphs with a Range, not by CSS: the head is clipped at a grapheme boundary right against the ellipsis, trailing spaces are dropped, and emoji are never split.
  • The DOM keeps the whole string, so selecting and copying a cut name gives the real name; the ellipsis itself isn't selectable.
  • Middle truncation fills the width it's given. Its flex parent needs min-w-0, as any truncation does. If the tail alone is wider than the space, the tail is cut at its end too.
  • Touch has no hover, so on phones the full text is for screen readers only. Put anything people must read in full somewhere that wraps.

Motion

  • The inline reveal sits exactly over the original, offset by its own padding and border, in the original's font, color and tracking. It unfolds with a clip-path that starts at the original's width and opens to full size over 240ms on the expo ease-out, with opacity in 120ms, so the text appears to continue past its edge.
  • Clamped paragraphs unfold downward the same way. Leaving is a 100ms fade.
  • Inside a TruncateGroup the first reveal waits 400ms, then neighbors open instantly and without animation, like a file browser.
  • Reduced motion drops the unfold and the tooltip's scale; the text still appears in place.

Accessibility

  • Screen readers get the full text from a visually hidden copy; the cut version is aria-hidden, and the reveal itself is aria-hidden so nothing is read twice.
  • Tabbing onto the row, link or option that holds the text unfolds its first cut text (the name, not the path) while it has focus-visible, and closes on blur or Escape.
  • The reveal ignores the pointer, so clicks go straight to the row underneath.