← ui

middle-truncate

ui

text cut in the middle, so the end that tells addresses, hashes and paths apart stays

npx shadcn add @ag/middle-truncate
Already installed? Preview an update
npx shadcn add @ag/middle-truncate --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-19
  • New: text cut in the middle, never at the end, so the part that tells two values apart stays (two wallet addresses no longer both read 0x71C7656E…). <MiddleTruncate text={address} tail={6} /> keeps six characters whole; tail="/" keeps a path's file, tail="." an extension. Pure CSS, follows any width with no measuring, and shows the whole text on hover only when something is cut.
  • Inside a tooltip's trigger (a Copy with tip, a Tip) or under a title of yours, it no longer shows its own: one hover, one tooltip.
Updated 2026-10-19 · release notes

also called ellipsis, truncate, text overflow

shelf text & code

The same column twice: cut at the end, three wallets read alike; cut in the middle, the end you check stays.

  • 0x71C7656EC7ab88b098defB751B7401B5f6d8976F
  • 0x71C7656EC7ab88b098defB751B7401B5f6d8A21c
  • 0x71C7656EC7ab88b098defB751B7401B5f6d8C03E
  • 0x71C7656EC7ab88b098defB751B7401B5f6d8976F
  • 0x71C7656EC7ab88b098defB751B7401B5f6d8A21c
  • 0x71C7656EC7ab88b098defB751B7401B5f6d8C03E

Drag the corner. A count keeps a hash's end, a separator keeps a file or an extension whole.

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/Users/someone/Projects/garden/assets/photos/2025/greenhouse-tomatoes.jpgquarterly-report-for-the-board-meeting-second-draft.pdf

Copy round it: the text is the button, the mark shows under the hand, its tip the whole address.

the demo above, verbatim

middle-truncate.demo.tsx
import { Sample } from "@/app/samples"
import { Copy } from "@/registry/base-nova/ui/copy"
import { MiddleTruncate } from "@/registry/base-nova/ui/middle-truncate"

// #region wallets
const wallets = [
  "0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
  "0x71C7656EC7ab88b098defB751B7401B5f6d8A21c",
  "0x71C7656EC7ab88b098defB751B7401B5f6d8C03E",
]
// #endregion

export default function Demo() {
  return (
    <div className="space-y-8">
      <Sample
        name="end"
        glance
        title="the end tells them apart"
        description="The same column twice: cut at the end, three wallets read alike; cut in the middle, the end you check stays."
        with="wallets"
      >
        <div className="grid w-72 grid-cols-2 gap-4 font-mono text-sm">
          <ul className="space-y-1">
            {wallets.map((w) => (
              <li key={w} className="truncate">
                {w}
              </li>
            ))}
          </ul>
          <ul className="space-y-1">
            {wallets.map((w) => (
              <li key={w} className="flex">
                <MiddleTruncate text={w} tail={6} />
              </li>
            ))}
          </ul>
        </div>
      </Sample>

      <Sample
        name="resize"
        title="any width, no measuring"
        description="Drag the corner. A count keeps a hash's end, a separator keeps a file or an extension whole."
      >
        <div className="w-full min-w-24 max-w-xl resize-x space-y-2 overflow-hidden rounded-lg border border-foreground/10 p-3 font-mono text-sm">
          <MiddleTruncate
            text="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
            tail={6}
          />
          <MiddleTruncate
            text="/Users/someone/Projects/garden/assets/photos/2025/greenhouse-tomatoes.jpg"
            tail="/"
          />
          <MiddleTruncate
            text="quarterly-report-for-the-board-meeting-second-draft.pdf"
            tail="."
          />
        </div>
      </Sample>

      <Sample
        name="copy"
        title="an address to copy"
        description="Copy round it: the text is the button, the mark shows under the hand, its tip the whole address."
        with="wallets"
      >
        <ul className="w-full max-w-56 space-y-1 font-mono text-xs">
          {wallets.map((w) => (
            <li key={w} className="flex">
              <Copy value={w} tip={w}>
                <MiddleTruncate text={w} tail={6} />
              </Copy>
            </li>
          ))}
        </ul>
      </Sample>
    </div>
  )
}

the source, every file an install writes

middle-truncate.tsx
"use client"

// TEXT CUT IN THE MIDDLE, never at the end. `truncate` cuts the tail, and the tail is
// what tells values apart: `server-1` and `server-2` both read `server…`, a wallet
// address keeps its prefix and loses the end people check, a path keeps its folders
// and loses its file.
//
// Two spans in a row: the head wears the ellipsis and shrinks, the tail never yields.
// No measurement, no resize observer: it follows any width the layout gives it, and
// when everything fits the two spans read as one word.
//
// `tail` says what must stay whole: a count of characters (`6` for a hash), or a
// separator, everything from its last occurrence (`"/"` keeps the file, `"."` the
// extension). A separator the text does not hold guarantees no tail:
// the text cuts at its end like any line. The tail is a promise: wider than the room,
// it overflows rather than break it.

import type * as React from "react"
import { cn } from "@/lib/utils"

export type Tail = number | string

/** Where `text` splits: the head shrinks, the tail is kept whole. */
export function splitTail(
  text: string,
  tail: Tail,
): [head: string, tail: string] {
  const at =
    typeof tail === "number"
      ? Math.max(0, text.length - tail)
      : text.includes(tail)
        ? text.lastIndexOf(tail)
        : text.length
  return [text.slice(0, at), text.slice(at)]
}

export interface MiddleTruncateProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  text: string
  /** What stays whole: characters from the end, or a separator to keep from its
   *  last occurrence. Default 4, enough for `r-12` or a short hash's end. */
  tail?: Tail
}

export function MiddleTruncate({
  text,
  tail = 4,
  className,
  onPointerEnter,
  ...props
}: MiddleTruncateProps) {
  const [head, end] = splitTail(text, tail)
  return (
    <span
      data-slot="middle-truncate"
      // The whole text on hover, only once something is cut: a title on a word shown
      // in full is noise. Read at the hover, the one moment it matters. One hover,
      // one tooltip: inside a tooltip's trigger (a `Copy tip`, a `Tip`) or under a
      // title of the caller's, that one speaks and this one stays quiet.
      onPointerEnter={(event) => {
        onPointerEnter?.(event)
        const self = event.currentTarget
        const shown = self.firstElementChild as HTMLElement
        // Base UI marks every tooltip trigger itself, whatever slot the element
        // wears (a `Copy` stays `data-slot="copy"` under its `Tip`).
        const owned = self.parentElement?.closest(
          "[data-base-ui-tooltip-trigger], [title]",
        )
        if (!owned && shown.scrollWidth > shown.clientWidth) self.title = text
        else self.removeAttribute("title")
      }}
      className={cn("inline-flex min-w-0 max-w-full", className)}
      {...props}
    >
      {/* `pre`, not `nowrap`: a space at the seam would otherwise vanish. */}
      <span
        data-slot="middle-truncate-head"
        className="min-w-0 overflow-hidden text-ellipsis whitespace-pre"
      >
        {head}
      </span>
      <span
        data-slot="middle-truncate-tail"
        className="shrink-0 whitespace-pre"
      >
        {end}
      </span>
    </span>
  )
}