# Keep the tail, clip the middle

> CSS truncate hides the end of an ID, and the end is the part that differs. mantle's TruncatedText clips the middle instead. Here's why it isn't flex, what a screen reader hears, and what it costs.

Published 2026-11-04 · frontend · https://codydotjs.dev/posts/keep-the-tail

Put three endpoint IDs in a narrow column with [`text-overflow: ellipsis`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow), 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](https://mantle.ngrok.com), ngrok's React component library, has a component for this now. [`TruncatedText`](https://mantle.ngrok.com/components/data-display/truncated-text/) clips the middle and keeps the tail. Here are the same three IDs both ways, rendered by the real component:

[Live demo: Three endpoint IDs under CSS truncate and under TruncatedText](https://codydotjs.dev/posts/keep-the-tail/demo)

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.

> The frame runs [`@ngrok/mantle`](https://www.npmjs.com/package/@ngrok/mantle) 0.93.7 from npm, not
> a copy of it. mantle is [open source](https://github.com/ngrok/mantle) under the MIT license.

## 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. [`asChild`](https://mantle.ngrok.com/components/data-display/truncated-text/#polymorphism) renders the value inside your own [`<code>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/code).
- A file path differs in its file name, so set [`tail`](https://mantle.ngrok.com/components/data-display/truncated-text/#tail-length) to the length of the names you show.
- An ID with a type prefix reads best as `mem_…A8KImPbQ`. [`head`](https://mantle.ngrok.com/components/data-display/truncated-text/#head-length) keeps the prefix too.

[Live demo: A URL, an image reference, a file path, and a prefixed ID under CSS truncate and under TruncatedText](https://codydotjs.dev/posts/keep-the-tail/demo-values)

```tsx
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`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow) covers that. A value that may wrap to a second line doesn't either. Let it wrap.

## How it clips

[`TruncatedText`](https://mantle.ngrok.com/components/data-display/truncated-text/) splits the value with [`Intl.Segmenter`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) measures both on mount and on every resize. Only a value that overflows gets a [`data-truncated`](https://mantle.ngrok.com/components/data-display/truncated-text/#data-attributes) 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`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length#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.[^head]

[^head]: [`head`](https://mantle.ngrok.com/components/data-display/truncated-text/#head-length) rounds the other way, up to whole `ch` cells. So with `head` set, a proportional font can show one more narrow character before the ellipsis.

[Ligatures](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variant-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](https://github.com/ngrok/mantle/issues/1549) 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`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/user-select) 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`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/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:

```html
<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`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/user-select), so one click still copies the value once, not twice. [`translate="no"`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/translate) keeps a browser's translate feature away from a value you're meant to copy.

> Browser translation rewrites the page's text nodes behind React's back. React has had an [open
> issue about it since 2017](https://github.com/facebook/react/issues/11538). An ID never needs
> translating, so the component opts out.

## What it costs

Every instance runs its own [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/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`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout) on the table and give it a width.

[`TruncatedText`](https://mantle.ngrok.com/components/data-display/truncated-text/) shipped in mantle 0.89.0 on October 14. The [docs](https://mantle.ngrok.com/components/data-display/truncated-text) cover [`head`](https://mantle.ngrok.com/components/data-display/truncated-text/#head-length), [`asChild`](https://mantle.ngrok.com/components/data-display/truncated-text/#polymorphism), and styling each half, and [mantle#1550](https://github.com/ngrok/mantle/pull/1550) has the browser measurements behind the layout.

**See it in the ngrok dashboard**

The ngrok dashboard shows endpoint, agent, domain, and vault IDs with `TruncatedText`. Open it to
see the tails on your own resources, or sign up for free and get your first endpoint online.

[Open the dashboard](https://dashboard.ngrok.com/) or [sign up for free](https://dashboard.ngrok.com/signup).
