Put three endpoint IDs in a narrow column with text-overflow: ellipsis, and all three read ep_2abcdEFGhijKL…. The ellipsis eats the end of each ID, and the end is the only part that tells them apart.
mantle, ngrok's React component library, has a component for this now. TruncatedText clips the middle and keeps the tail. Here are the same three IDs both ways, rendered by the real component:
In the right-hand column, try these:
- Drag the frame wider until every ID fits, then narrow it again. The head gives way, and the last six characters stay put.
- Copy a clipped ID and paste it somewhere. You get the whole ID on one line, with no ellipsis in it.
- Hover a clipped ID, or Tab to it, to read the whole value in a tooltip.
Long URLs, without the tricks
Long URLs have caused problems in the ngrok dashboard for years. The usual fixes all give something up. Break a URL into pieces, or drop characters until it fits, and the page shows something that isn't the real value anymore.
TruncatedText only changes how the value looks. The middle ellipsis is presentation, and the full value is still there:
- Hover or focus a clipped URL, and a tooltip shows all of it.
- One click selects the whole value, not only the part you can see.
- A copy gets the full URL, every time.
For us, it's the first answer to long URLs that doesn't feel like a hack.
Use it
IDs aren't the only values that differ at the end. In each pair below, only the end changes:
- A URL differs in its last path segment or its query, so keep about 10 characters.
- An image reference differs in its tag.
asChildrenders the value inside your own<code>. - A file path differs in its file name, so set
tailto the length of the names you show. - An ID with a type prefix reads best as
mem_…A8KImPbQ.headkeeps the prefix too.
import { TruncatedText } from "@ngrok/mantle/truncated-text";
<TruncatedText tail={6} value="ep_2abcdEFGhijKL123456" />
<TruncatedText tail={10} value="https://example.ngrok.app/api/items?filter=…&limit=200" />
<TruncatedText asChild tail={12} value="ghcr.io/ngrok/ngrok-operator:0.14.0-rc.2">
<code />
</TruncatedText>
<TruncatedText tail={18} value="packages/mantle/src/components/truncated-text/truncated-text.tsx" />
<TruncatedText head={4} value="mem_21h80N9qQuAmhSB8nlTA8KImPbQ" />tail defaults to 8. It counts graphemes, not characters, so an emoji or a letter with a combining accent never splits in half.
Prose that can lose its end doesn't need it: plain text-overflow: ellipsis covers that. A value that may wrap to a second line doesn't either. Let it wrap.
How it clips
TruncatedText splits the value with Intl.Segmenter and renders the two parts as inline-block spans. The head clips with a plain CSS ellipsis and takes whatever width the tail leaves. A ResizeObserver measures both on mount and on every resize. Only a value that overflows gets a data-truncated attribute, a tab stop, and a tooltip. A value that fits renders as plain text.
Keep the ellipsis tight
I wanted one more thing from the start: at every width, the ellipsis should hug the text as tightly as plain CSS truncate does. When a container narrows, CSS swaps the last characters for … right against what's left. A split value is two boxes, and that's where it can fall apart. The head's box ends partway through a character, so a sliver of empty space opens between the ellipsis and the tail, and the sliver changes size with every pixel you drag.
So while a value overflows, the head's width rounds down to whole ch cells. In a monospace font, the last clipped character, the ellipsis, and the tail sit flush at every width, and the ellipsis steps one whole character at a time as the frame narrows. Drag the first demo to watch it. A proportional font still leaves a gap, but it's always narrower than one character.1
Ligatures are off for the same reason. JetBrains Mono shapes // as one glyph, so a clip inside it dropped both slashes and parked the ellipsis a whole cell away from the text.
Why not flex
The issue proposed flex: a row with a head that shrinks and a tail that doesn't. It lays out fine, and it copies wrong. Chromium and WebKit put a newline between two flex items when they serialize a selection, so a copied ID pasted as two lines.
Two inline-block spans copy as one line in Chromium, Firefox, and WebKit. I measured all three through Playwright before I picked the layout. user-select: all on the root selects the whole value in one click, and CSS draws the ellipsis, so it never lands in what you copy.
What a screen reader hears
Inline-block fixed the copy and broke the accessible name. Chromium puts a space between two inline-block boxes when it builds a name, so a screen reader announced the ID as two strings.
So the two visible halves are aria-hidden, and a visually hidden span holds the whole value as one text node. A clipped ID renders like this, with the classes left out:
<span data-slot="truncated-text" data-truncated tabindex="0" translate="no" dir="ltr">
<span data-slot="truncated-text-value">ep_2abcdEFGhijKL123456</span>
<span aria-hidden="true" data-slot="truncated-text-head">ep_2abcdEFGhijKL</span>
<span aria-hidden="true" data-slot="truncated-text-tail" dir="rtl">
<span dir="ltr">123456</span>
</span>
</span>The hidden copy has user-select: none, so one click still copies the value once, not twice. translate="no" keeps a browser's translate feature away from a value you're meant to copy.
What it costs
Every instance runs its own ResizeObserver. A paged list of 20 IDs runs 20 observers. A table that renders every row runs one per row, so page a long table or virtualize it.
An auto-layout table also grows its column to fit the whole value, which defeats the clip. Set table-layout: fixed on the table and give it a width.
TruncatedText shipped in mantle 0.89.0 on October 14. The docs cover head, asChild, and styling each half, and mantle#1550 has the browser measurements behind the layout.

