text cut in the middle, so the end that tells addresses, hashes and paths apart stays
npx shadcn add @ag/middle-truncateAlready installed? Preview an update
npx shadcn add @ag/middle-truncate --dry-runYour 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
Copywithtip, aTip) or under atitleof yours, it no longer shows its own: one hover, one tooltip.
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
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
"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>
)
}