Skip to content

Truncate

Text shortened to fit its container — trailing off at the end, cut in the middle so both ends survive, or clamped to a few lines with a toggle. The tooltip appears only when text is actually hidden.

/Users/ada/Documents/annual-reports/2026/q3-revenue-report-final.pdf
Drag the bottom-right corner — the tooltip only appears once it clips.
import { Truncate } from '@ontoui/react';
<Truncate.Root>
<Truncate.Text>{filePath}</Truncate.Text>
<Truncate.Tooltip />
</Truncate.Root>;

Anatomy

PartDescription
Truncate.Root<span> — groups the parts and holds the mode and the state.
Truncate.Text<span> — the text. Shortened by CSS, and the tooltip’s trigger.
Truncate.TooltipThe full text, in a portal, and only while the text is cut.
Truncate.Toggle<button> — expands and collapses clamped text.

Truncate.Text takes a string, not arbitrary nodes: middle mode has to split it, and the tooltip has to repeat it.

Nothing is cut out of the DOM. All three modes leave the whole string in the markup and let CSS hide the overflow, so a screen reader reads the full value and a selection copies it — ellipsis and all.

Modes

End

The default. One line, trailing ellipsis, placed by the browser. Reach for it whenever the start of the value is the part that identifies it.

<Truncate.Root mode="end">
<Truncate.Text>{title}</Truncate.Text>
<Truncate.Tooltip />
</Truncate.Root>

Middle

For values whose two ends both matter and whose middle does not — paths, hashes, wallet addresses, signed URLs. endChars is how many characters are pinned at the tail; everything before them takes the leftover width and ellipses into it.

/Users/ada/Documents/annual-reports/2026/q3-revenue-report-final.pdf0x71C7656EC7ab88b098defB751B7401B5f6d8976F
Drag the corner — the file name and the last six digits never go.
<Truncate.Root mode="middle" endChars={16}>
<Truncate.Text>{filePath}</Truncate.Text>
<Truncate.Tooltip />
</Truncate.Root>
<Truncate.Root mode="middle" endChars={6}>
<Truncate.Text>{walletAddress}</Truncate.Text>
<Truncate.Tooltip />
</Truncate.Root>

The split is where middle truncation is usually got wrong. Cutting the string at a character ratio assumes every character is the same width, which no proportional font makes true, and re-measuring the text in JavaScript on every resize is both expensive and a frame behind.

Instead the string is rendered as two segments in a row: the tail at its natural width, the head taking whatever is left and ellipsing itself. The browser does the measuring it is already doing for every other line of text on the page, so the cut lands in the right place at any width, moves with a resize, survives a webfont swapping in — and, because both halves are real text, a copy still yields the whole value rather than one with in it.

Set endChars to the length of the part that has to survive — the file name, the last six digits of an address. Count characters, not bytes; the split is code-point-aware, so an emoji on the boundary stays whole. The tail never shrinks, so leave the container room for it: below that width it is clipped rather than allowed to push into whatever sits next to it.

Clamp

For prose. lines is how many lines are shown; Truncate.Toggle reveals the rest.

The migration went through in a single afternoon, which surprised everyone who had budgeted a week for it. Most of the components mapped over one to one, and the two that did not were the ones we had already been meaning to rewrite. The only real friction was the design tokens, which needed a pass to line up with the new scale.
<Truncate.Root mode="clamp" lines={2}>
<Truncate.Text>{review}</Truncate.Text>
<Truncate.Toggle />
</Truncate.Root>

Use a tooltip or a toggle, not both. A toggle is the better answer for a paragraph — a tooltip holding four lines of prose is a tooltip nobody can read.

The tooltip

/Users/ada/Documents/annual-reports/2026/q3-revenue-report-final.pdfnotes.txt
Both rows are the same component. Only the clipped one answers to hover, and only it is reachable with Tab.

A tooltip on text that is fully visible is noise, and a tab stop that reveals nothing is worse. So Truncate.Text measures itself — scrollWidth against clientWidth, or scrollHeight against clientHeight when clamped — and the tooltip stays switched off until that measurement says something is hidden. The same measurement decides whether the text is focusable at all, and whether Truncate.Toggle renders.

The measurement re-runs whenever the element resizes, whenever the text changes, and once more after webfonts finish loading, since the first reading ran against the fallback face.

Omitting Truncate.Tooltip turns the tooltip off entirely — and with it the tab stop, because there would be nothing to reveal.

// Places the tooltip and gives it its own wording.
<Truncate.Root>
<Truncate.Text>{filePath}</Truncate.Text>
<Truncate.Tooltip side="bottom">Open {fileName}</Truncate.Tooltip>
</Truncate.Root>

The tooltip joins the delay group of an enclosing Tooltip.Provider, so a table of truncated cells opens instantly after the first one instead of waiting out the delay on every cell.

<Tooltip.Provider delay={300}>{/* rows of Truncate */}</Tooltip.Provider>

Layout

Truncate.Root is a <span> with display: block, so it is valid anywhere text is — including inside a <p> — and it sizes itself from its container. There is nothing to truncate against until something bounds its width: a fixed or maximum width, a table cell, or a flex or grid track.

A flex or grid item is sized by its content unless told otherwise, so an ancestor between the track and the text needs min-width: 0 for the text to be allowed to clip. Truncate.Root already sets it on itself.

<div style={{ display: 'flex', gap: 8 }}>
<Icon />
<Truncate.Root>
<Truncate.Text>{filePath}</Truncate.Text>
<Truncate.Tooltip />
</Truncate.Root>
</div>

Accessibility

  • The full text is always in the DOM. Truncation is visual, so a screen reader reads the whole value whether or not it fits. The tooltip repeats it for sighted users.
  • Truncated text is a tab stop, and only then, so a keyboard user can bring up the tooltip. Text that fits, or that has no Truncate.Tooltip, stays out of the tab order.
  • Truncate.Toggle carries aria-expanded and points aria-controls at the text it reveals. Give it a label in your own wording through its render prop when “Show more” is too vague to stand on its own in a list of them.
  • Nothing can be measured on the server. Under SSR the text renders unmeasured, so a toggle appears at hydration rather than in the first paint. Reserve its space in a layout where that shift would be disruptive.

API Reference

Truncate.Root

Prop Type Default
children? ReactNode
className? string
style? CSSProperties
mode? "end" | "middle" | "clamp" 'end'
lines? number 3
endChars? number 8
expanded? boolean
defaultExpanded? boolean false
onExpandedChange? (expanded: boolean) => void
ref? Ref<HTMLSpanElement>

Truncate.Text

Prop Type Default
children string
className? string
style? CSSProperties
ref? Ref<HTMLSpanElement>

Truncate.Tooltip

Prop Type Default
children? ReactNode the full text
className? string
side? "top" | "right" | "bottom" | "left" 'top'
sideOffset? number 8
ref? Ref<HTMLSpanElement>

Truncate.Toggle

Prop Type Default
children? (expanded: boolean) => ReactNode (expanded) => (expanded ? 'Show less' : 'Show more')
className? string
style? CSSProperties
ref? Ref<HTMLSpanElement>