Summary
Truncating text with an ellipsis hides content from everyone who can't hover over it, specifically keyboard-only users. We propose a solution—a component that keeps the full value reachable by keyboard, screen reader, and pointer. Plain text that overflows its container gets a button element that appears similarly to the way the CSS ellipsis would show. This Reveal Button component will show the full text in a tooltip. CSS detects the overflow without measuring layout in JavaScript, so the page never has to watch for resizes… at least not for this.
1 · Architecture
Three layers, two ways to reveal
Throughout, my-design-system stands for your design system package: its stylesheets under styles/, with its components, modifiers, and utilities beside them. my-app stands for any app that uses it. If your styles ship as a separate package (say my-design-system-styles), the CSS layer moves there unchanged. The examples are written as Ember (Glimmer templates and element modifiers) with tippy.js for tooltips, but the technique is CSS first: the overflow decision, in most instances, needs no JavaScript at all, and the two primitives map onto concepts every component framework has. The code samples in this document are illustrative, not drop-in: adapt the names, imports, and tooltip library to your own stack.
The CSS, overall, owns the overflow decision. Two primitives sit on top of it: the <Truncation> component, for plain text, and the truncationHost modifier, for elements that are already interactive. Both rely on applyTooltip, a thin wrapper around tippy.js; section 7 lists exactly what it borrows from tippy. The app never measures anything itself. It picks a primitive per value, in the template that renders it.
The same two primitives in other frameworks
Nothing here depends on Ember beyond syntax. A component renders markup from arguments; a modifier receives an element that something else rendered and attaches behavior to it, with a teardown. Most frameworks name both.
| Concept | Ember (used here) | React | Vue | Svelte | Angular | Web Components |
|---|---|---|---|---|---|---|
Component<Truncation> | Template-only component | Function component | Single-file component | Component | Component | Custom element |
ModifiertruncationHost | Element modifier | Ref callback, or an effect on a ref | Custom directive | Action (use:) | Attribute directive | A function called with the element |
| Teardown | Function the modifier returns | Effect cleanup | Directive unmounted | Action destroy | ngOnDestroy | disconnectedCallback |
| Attribute pass-through | ...attributes | Spread {...rest} | $attrs fallthrough | Spread {...rest} | Attributes on the host element | Attributes on the host element |
2 · Variations
Variations and how they are applied
The rule is decided by what the value is, not by where it sits. Plain text gets a Reveal Button. An element that already takes focus becomes a Reveal Host, because a button nested inside a link or a select trigger would be a nested interactive element. Every variation below is one of these two, or builds on one of them.
| Variation | Built with | When it applies | How it reveals | Where it lives |
|---|---|---|---|---|
| Reveal Button | component | Non-interactive text: names, emails, file names, titles | A trimmed, faded copy plus a ⋯ button whose accessible name and tooltip are the full value | <Truncation @text> |
| Cut at the start or middle | component | Concatenated text whose end matters, such as a breadcrumb trail shown as a value | The same faded copy, clipped at its start or split in two, with the ⋯ button at the cut | <Truncation @text @position> |
| Reveal Host | modifier | A link, a button, a select trigger, a menu button, or a file upload whose own text clips | A native ellipsis; the element's own tooltip shows the full value, only while it is clipped | {{truncationHost text}} + .c-truncation-host; a file upload calls applyTooltip on its input |
| Host cut at the start | modifier | A link whose end tells it apart: a URL shared in a message, a file link | A native ellipsis at the start; the tooltip still shows the full value while it is clipped | .c-truncation-host--start with the value in an inner <span> |
| Multi-clip host | modifier | A host with several clipped parts: a name over an email, a link with stacked lines | No text argument: the tooltip lists one row per line of its clips, read when it shows | data-truncation-clip + revealHostContent |
| Grouped values "…" | button, reused | A cell that stands for several values, shown by the first one's name | Always shows the ⋯ button, which lists every value; the first name clips under it | An items-summary cell that reuses .c-truncation__reveal |
| Locked host with a reason (optional) | applyTooltip | A control that is aria-disabled but still focusable | Shows the reason even when nothing is clipped; "value: reason" when it is clipped | reason + formatReason |
| No scroll timeline | CSS | Browsers without scroll-driven animations | The plain text wraps anywhere; nothing is lost | @supports not (…) |
| Visually hidden copy | none | A value repeated only for screen readers, such as a visually hidden child row | Plain text, because a Reveal Button would be an invisible tab stop | The cell's own template |
3 · The CSS
The overflow probe, the keyframe, and the style query
The trick is that an overflow: hidden box is a scroll container, and a scroll timeline is only active while its container has something to scroll. .c-truncation__plain is that box. It names its timeline --c-truncation, and timeline-scope on the wrapper hoists the name, so the wrapper's own animation can bind to it.
- While the text fits, the timeline is inactive, so the animation applies nothing.
--c-truncation-overflowingstays unset, and the plain text shows. - Once the text overflows, the timeline turns active. The keyframe, which is the same at
fromandto, sets--c-truncation-overflowing: 1on the wrapper. - Every element is a style container for its descendants, so
@container style(--c-truncation-overflowing: 1)now matches the wrapper's children. The plain text collapses to an absolutely positioned, zero-height, hidden box, and the overflow branch (the faded copy plus the button) displays. - The plain text keeps the wrapper's full inline size, set by
inset-inline: 0, so it still overflows and keeps the timeline active. Because it measures without the button's 22px, showing the button can never change the answer, so the two states don't flicker back and forth.
/* ------------ Truncation ------------ */
/* A one-line value that trims itself only while it overflows the space its container offers.
The plain text is its own overflow probe: an `overflow: hidden` box has a scroll timeline only
while its content overflows, and the animation bound to that timeline flips the state both
branches read. It is measured at the full width the Reveal Button never takes, so showing the
button cannot change the answer. */
.c-truncation {
/* The Reveal Button's focus ring: `.c-button--icon-only` draws a 2px outline 2px outside it.
The button and its ring must fit inside the space the value takes, because table cells and
their lines clip with `overflow: hidden` and leave no padding for it. */
--c-truncation-focus-ring: 4px;
align-items: center;
animation: c-truncation-overflowing linear both;
animation-timeline: --c-truncation;
display: flex;
flex: 0 1 auto;
min-inline-size: 0;
position: relative;
timeline-scope: --c-truncation;
}
@keyframes c-truncation-overflowing {
from,
to {
--c-truncation-overflowing: 1;
}
}.c-truncation__plain {
min-inline-size: 0;
overflow: hidden;
scroll-timeline: --c-truncation inline;
white-space: nowrap;
}
.c-truncation__overflow {
align-items: center;
column-gap: var(--spacing-3xs);
display: none;
min-inline-size: 0;
padding-inline-end: var(--c-truncation-focus-ring);
}
.c-truncation__text {
mask-image: linear-gradient(to left, transparent, #000 2.5ch);
min-inline-size: 0;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}/* Clips at its start: an end-aligned flex item that is too wide overflows toward the start. */
.c-truncation__text--start {
display: flex;
justify-content: flex-end;
mask-image: linear-gradient(to right, transparent, #000 2.5ch);
}
.c-truncation__text--start > span {
flex: none;
}
/* The last segment stays whole until the head is gone; past that it clips at its start. */
.c-truncation__text--whole {
flex-shrink: 0;
mask-image: none;
max-inline-size: calc(100% - 1.375rem - var(--spacing-3xs) - var(--c-truncation-focus-ring));
}
/* The focus-ring budget follows the Reveal Button to wherever the cut is. */
.c-truncation--start .c-truncation__overflow {
padding-inline: var(--c-truncation-focus-ring) 0;
}
.c-truncation--middle .c-truncation__overflow {
padding-inline-end: 0;
}
/* A collapsed head still leaves room for the Reveal Button's focus ring. */
.c-truncation--middle .c-truncation__text:first-child {
min-inline-size: calc(var(--c-truncation-focus-ring) - var(--spacing-3xs));
}The faded copy has no ellipsis to move, so its start cut uses flex alignment rather than direction. The overflow probe doesn't change: it asks whether the value fits, not where it would be cut.
In the middle position, a value with a separator (/, \, ›, or ») keeps its last segment whole: .c-truncation__text--whole doesn't shrink and has no fade, so the head gives up all its space first. Only when the last segment alone can't fit beside the button does it clip at its start, with a hard edge next to the button. A value without a separator splits in half, and both halves fade.
The fades are physical (to left, to right), because a mask gradient has no logical direction. In right-to-left text, flip them under :dir(rtl). The flex alignment and padding are already logical.
@container style(--c-truncation-overflowing: 1) {
.c-truncation__plain {
block-size: 0;
inset-block-start: 0;
inset-inline: 0;
position: absolute;
visibility: hidden;
}
.c-truncation__overflow {
display: flex;
}
}/* Rests on accent-200, darker than a selected row's background, so it never merges into a
selected row; hover and focus deepen to accent-300. The dots stay in the text color. */
.c-truncation__reveal {
background-color: var(--color-accent-200);
/* One line tall including its ring, so revealing never changes the line's height. */
block-size: calc(1lh - 2 * var(--c-truncation-focus-ring));
border: 1px solid var(--color-accent-300);
border-radius: 2px;
color: var(--color-text);
flex: none;
inline-size: 1.375rem;
}
/* The whole border, because .c-button--reset:focus removes it. */
.c-truncation__reveal:hover,
.c-truncation__reveal:focus,
.c-truncation__reveal:focus-visible {
background-color: var(--color-accent-300);
border: 1px solid var(--color-accent-500);
}
.c-truncation__reveal-icon {
fill: currentcolor;
inline-size: 0.75rem;
}The button is calc(1lh - 2 × 4px) tall, so it fits inside one line of text together with its 2px ring at a 2px offset. Revealing never changes a row's height, and the ring is never clipped by the cell's overflow: hidden. The overflow branch reserves the same 4px on the side where the button sits: the end by default, the start for @position="start". In the middle position, a head that has shrunk away keeps the same 4px beside the button.
/* Without scroll-driven animations the value wraps, which loses nothing. */
@supports not ((animation-timeline: --a) and (timeline-scope: --a)) {
.c-truncation__plain {
overflow-wrap: anywhere;
white-space: normal;
}
}
/* An element that is already interactive keeps a native ellipsis; its own tooltip reveals the rest. */
.c-truncation-host {
display: block;
max-inline-size: 100%;
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Native ellipsis at the start. The isolated inner span keeps a URL or path left to right. */
.c-truncation-host--start {
direction: rtl;
text-align: left;
}
.c-truncation-host--start > span {
direction: ltr;
unicode-bidi: isolate;
}A host's ellipsis is drawn by the browser at the end of the line, so the start variant makes the line right-to-left and isolates the value inside it as left-to-right. Without the inner span, direction: rtl alone moves punctuation such as a trailing ) or / to the wrong end. text-align: left keeps a value that fits where it would be without the variant.
4 · The component
The markup for each state
<Truncation> is a template-only component that takes @text and an optional @position. It renders the value twice. The plain copy is real text that screen readers read while it fits. The overflow copy is aria-hidden, so while the value is trimmed, the full value reaches assistive technology through the button's aria-label and its tooltip instead. The hidden plain copy is visibility: hidden, which takes it out of the accessibility tree, so the value is never read twice.
import type { TOC } from '@ember/component/template-only';
import { concat, hash } from '@ember/helper';
import EllipsisDots from 'my-design-system/icons/ellipsis-dots';
import Button from 'my-design-system/components/button';
import attachTooltip from 'my-design-system/modifiers/attach-tooltip';
export type TruncationPosition = 'end' | 'start' | 'middle';
export interface Signature {
Args: {
text: string;
/** Where the cut falls while the value overflows. Defaults to `'end'`. */
position?: TruncationPosition;
};
Element: HTMLSpanElement;
}
const SEPARATORS = ['/', '\\', '›', '»'];
// The faded copy, split around the Reveal Button: `head` clips at its end, `tail` at its start.
function splitText(text: string, position: TruncationPosition = 'end') {
if (position === 'start') return { head: '', tail: text };
if (position === 'end') return { head: text, tail: '' };
// A trailing separator doesn't count, so `/docs/src/` keeps `/src/` whole rather than splitting off `/`.
const at = Math.max(...SEPARATORS.map((separator) => text.lastIndexOf(separator, text.length - 2)));
if (at > 0) return { head: text.slice(0, at), tail: text.slice(at), wholeTail: true };
// Between graphemes, so an emoji or accented letter is never cut in two.
const graphemes = Array.from(new Intl.Segmenter().segment(text), ({ segment }) => segment);
const half = Math.ceil(graphemes.length / 2);
return { head: graphemes.slice(0, half).join(''), tail: graphemes.slice(half).join('') };
}
// Which branch shows is decided in CSS (`styles/components/_truncation.css`): the plain text
// measures its own overflow, so there is nothing to observe, settle, or re-measure here.
const TruncationComponent: TOC<Signature> = <template>
<span class='c-truncation {{if @position (concat "c-truncation--" @position)}}' data-test-truncation ...attributes>
<span class='c-truncation__plain' data-test-truncation-plain>{{@text}}</span>
<span class='c-truncation__overflow'>
{{#let (splitText @text @position) as |copy|}}
{{#if copy.head}}
<span class='c-truncation__text' aria-hidden='true' data-truncation-copy>{{copy.head}}</span>
{{/if}}
<Button
class='c-button--reset c-button--icon-only c-truncation__reveal'
aria-label={{@text}}
data-test-truncation-reveal
{{attachTooltip (hash content=@text hideOnClick=false)}}
>
<EllipsisDots class='c-truncation__reveal-icon' />
</Button>
{{#if copy.tail}}
<span class='c-truncation__text c-truncation__text--start {{if copy.wholeTail "c-truncation__text--whole"}}' aria-hidden='true' data-truncation-copy><span>{{copy.tail}}</span></span>
{{/if}}
{{/let}}
</span>
</span>
</template>;
export default TruncationComponent;hideOnClick=falsekeeps the tooltip open when the button is clicked. There is no pinned state. The option name is tippy's, passed straight through.- The button is the design system's own
Button, so it is a tab stop exactly when it is visible. In the fits state it isdisplay: noneand not focusable. ...attributeslands on the wrapper, so consumers add classes and test selectors there (class,data-test-*).@positionmoves only the visible cut."start"keeps the end of a breadcrumb trail likeHome › Projects › … › Network Migration Planin view, and"middle"keeps the whole last segment and as much of the start as fits, so/src/components/Menu/Menu.module.cssbecomes/src/compon…/Menu.module.css. It splits at the last/,\,›, or»; a value with none of these splits in half, between graphemes. The two halves together are still the whole value, and the button's accessible name doesn't change.- The middle split uses
Intl.Segmenter, available in every current browser. The visible split never reaches assistive technology: both halves are inside thearia-hiddencopy.
5 · The modifier
An interactive element reveals its own text
A link or a select trigger can't hold a Reveal Button, because that would be a control inside a control. It keeps a native ellipsis (.c-truncation-host), and the element itself carries the tooltip. The modifier is a thin call to applyTooltip with showWhen: 'overflowing'.
import { modifier } from 'ember-modifier';
import { applyTooltip, revealHostContent } from 'my-design-system/utils/tooltips';
import type { TooltipTrigger } from 'my-design-system/utils/tooltips';
// For an element that is already interactive (a link, a button, a select trigger, a menu button):
// it keeps a native ellipsis (`c-truncation-host`) and its own tooltip shows the full value, only
// while clipped. Without text, the tooltip shows the host's clipped lines (`data-truncation-clip`)
// as they read when it shows. Visual only, because the element's accessible name already
// carries the full value.
const truncationHostModifier = modifier((element: TooltipTrigger, [text]: [string?]) =>
applyTooltip(element, {
content: text ?? revealHostContent,
showWhen: 'overflowing',
aria: { content: null, expanded: false },
}),
);
export default truncationHostModifier;With showWhen: 'overflowing', the tooltip decides when it shows rather than when it is attached. tippy's onShow hook returns false to cancel. So a host that fits never shows a tooltip, a host that clips always does, and nothing has to watch for resizes in between.
// A Reveal Host reads its state when it shows, so the answer is always current. A function
// `content` is read then too, for a host whose value is rendered markup rather than a string.
// It never shows over the popup it controls (a select's options, a menu).
function showAsRevealHost(
content: Content | undefined,
reason?: TooltipOpts['reason'],
formatReason: NonNullable<TooltipOpts['formatReason']> = (value, why) => `${value}: ${why}`,
) {
return (instance: Instance) => {
if (isExpanded(instance.reference)) return false;
const isClipped = isTextOverflowing(instance.reference);
const why = reason?.();
if (!isClipped && !why) return false;
if (why) {
const value = clippedTextLines(instance.reference).join(' ');
instance.setContent(isClipped && value ? formatReason(value, why) : why);
} else if (typeof content === 'function') {
instance.setContent(content(instance.reference));
} else if (content !== undefined) {
instance.setContent(content);
}
return undefined;
};
}"Clipped" is measured on the element's clips. A clip is the host itself, or any descendant marked data-truncation-clip, or a clip inside the host's <label> (for a file upload). The measurement compares a text Range's width with the clip's content box, both read from getBoundingClientRect, so zoom and transforms cancel out, and a fraction-of-a-pixel overflow still counts. This file is plain DOM: it knows nothing about tippy.
const CLIP = '[data-truncation-clip]';
// The elements whose text a Reveal Host clips: the host itself when it is marked, else every
// clip marked inside it (a menu button clips a name and an email separately), else every clip in
// its labels (a file upload's file name sits beside the input in its label), else the host.
export function truncationClips(host: Element): Element[] {
if (host.matches(CLIP)) return [host];
const clips = [...host.querySelectorAll(CLIP)];
if (clips.length) return clips;
const labelClips = [...((host as Partial<HTMLInputElement>).labels ?? [])].flatMap((label) => [
...label.querySelectorAll(CLIP),
]);
return labelClips.length ? labelClips : [host];
}
export function isTextOverflowing(element: Element): boolean {
return truncationClips(element).some(isClipOverflowing);
}
// One entry per line of a host's clipped text, as rendered: a clip whose children are blocks
// (a name over an email) gives one line per child; any other clip is one line. Children with
// `display: none` are neither lines nor text.
export function clippedTextLines(host: Element): string[] {
return truncationClips(host)
.flatMap((clip) => {
const blocks = [...clip.children].filter(isBlockLine);
return blocks.length ? blocks : [clip];
})
.map((line) => renderedText(line).replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
function isBlockLine(element: Element): boolean {
const { display } = getComputedStyle(element);
return display !== 'none' && display !== 'contents' && !display.startsWith('inline');
}
const renderedText = (element: Element) =>
(element instanceof HTMLElement ? element.innerText : element.textContent) ?? '';
// Fractional on purpose: scrollWidth and clientWidth are rounded, so text that overflows by a
// fraction of a pixel shows an ellipsis while the integer comparison still says it fits.
// Both sides come from getBoundingClientRect so zoom and transforms cancel out.
function isClipOverflowing(clip: Element): boolean {
if (!(clip instanceof HTMLElement) || clip.offsetWidth === 0) return false;
const range = document.createRange();
range.selectNodeContents(clip);
const textWidth = range.getBoundingClientRect().width;
const box = clip.getBoundingClientRect();
const scale = box.width / clip.offsetWidth;
const { paddingInlineStart, paddingInlineEnd } = getComputedStyle(clip);
const contentWidth = box.width - (parseFloat(paddingInlineStart) + parseFloat(paddingInlineEnd)) * scale;
return textWidth > contentWidth;
}const isExpanded = (element: Element) => element.getAttribute('aria-expanded') === 'true';
const isRevealHost = (instance: Instance) => instance.props.plugins.includes(hideWhenExpanded);
const isFileInput = (element: Element) => element instanceof HTMLInputElement && element.type === 'file';
// Opening a host's popup from the keyboard leaves focus on the host, so the tooltip would stay
// up and cover the popup. Watch for that only while the tooltip is visible. A file input's popup
// is the native picker, which opens on any click the input doesn't cancel: Enter and Space
// arrive as a click with no mousedown, so tippy's own hideOnClick never sees them.
const hideWhenExpanded: Plugin = {
fn(instance) {
const observer = new MutationObserver(() => {
if (isExpanded(instance.reference)) instance.hide();
});
const hideAsPickerOpens = (event: Event) => {
if (!event.defaultPrevented) instance.hide();
};
const stopWatching = () => {
observer.disconnect();
instance.reference.removeEventListener('click', hideAsPickerOpens);
};
return {
onShow() {
observer.observe(instance.reference, { attributeFilter: ['aria-expanded'] });
if (isFileInput(instance.reference)) instance.reference.addEventListener('click', hideAsPickerOpens);
},
onHide: stopWatching,
onDestroy: stopWatching,
};
},
};
// Tooltip content for a host without a string value: one row per line of its clipped text.
export function revealHostContent(host: Element): HTMLElement {
const content = document.createElement('div');
for (const line of clippedTextLines(host)) {
const row = document.createElement('div');
row.textContent = line;
content.append(row);
}
return content;
}Every tooltip, whether a Reveal Button's or a host's, goes through the same applyTooltip. It enforces the trigger rules:
- The trigger must be focusable and must not be natively disabled; otherwise a development assert fires.
- The tooltip mounts beside its trigger.
- It is positioned with Popper's
fixedstrategy, so table cells and scroll containers can't clip it. - Esc dismisses it.
- A host's tooltip is
aria-hidden, because it only repeats the host's own text.
export function applyTooltip(element: TooltipTrigger, opts: TooltipOpts | undefined): (() => void) | undefined {
// tippy.js keeps its instance on the trigger element, as `_tippy`.
const existingTooltip = (element as TooltipTrigger & { _tippy?: Instance })._tippy;
const options = { ...(opts ?? {}) };
const tooltipInactive = !options.content || options.hideTooltip;
if (tooltipInactive && !existingTooltip) return;
if (tooltipInactive && existingTooltip) {
destroyInstance(existingTooltip);
return () => {};
}
// Types can't see an anchor without href, splatted attributes landing on the wrong element, or
// native `disabled`, which takes a control out of the tab order along with its tooltip.
assert(
`A tooltip needs a trigger a keyboard can reach, but this <${element.localName}> ${
element.matches(':disabled') ? 'is natively disabled; use aria-disabled instead' : 'is not focusable'
}.`,
isFocusable(element) && !element.matches(':disabled'),
);
if (existingTooltip && options.content) {
// One tooltip per trigger: a second source here only swaps the content and keeps the first
// one's behavior, as when a form field's help tooltip lands on a select trigger that is
// already a Reveal Host.
warn(
'A plain tooltip and a Reveal Host tooltip were both attached to the same trigger; only the first one\'s behavior applies.',
isRevealHost(existingTooltip) === (options.showWhen === 'overflowing'),
{ id: 'my-design-system.tooltip-second-source' },
);
existingTooltip.setContent(options.content);
return () => {
destroyInstance(existingTooltip);
};
}
if (!opts) return () => {};
const reference: Element = element;
const instance = tippy(reference, {
content: opts.content,
placement: opts.placement || 'top',
// Mounted beside its trigger so the tooltip follows it through focus order, dialogs, and
// scroll containers; the trigger's container decides stacking.
appendTo: opts.appendTo ?? 'parent',
// Positioned against the viewport, so a scrolling or clipping container around the trigger
// (a side nav, a table cell, a filter bar) can't cut the tooltip off. Popper still tracks the
// trigger as those containers scroll.
popperOptions: { ...opts.popperOptions, strategy: opts.popperOptions?.strategy ?? 'fixed' },
theme: opts.theme,
// WCAG 1.4.13 hoverable: the pointer can move onto the tooltip without it closing.
interactive: opts.interactive ?? true,
// `interactive` would otherwise drop the describedby link and stamp aria-expanded on the trigger.
aria: opts.aria ?? { content: 'describedby', expanded: false },
hideOnClick: opts.hideOnClick,
maxWidth: opts.maxWidth ?? VIEWPORT_MAX_WIDTH,
triggerTarget: opts.triggerTarget,
getReferenceClientRect: opts.getReferenceClientRect,
plugins: opts.showWhen === 'overflowing' ? [hideOnEscape, hideWhenExpanded] : [hideOnEscape],
onShow: opts.showWhen === 'overflowing' ? showAsRevealHost(opts.content, opts.reason, opts.formatReason) : undefined,
});
// A Reveal Host's tooltip only repeats what the host already says, so assistive technology skips
// it. Mounted inside a <label>, it would also join the name of the label's control.
if (opts.showWhen === 'overflowing') instance.popper.setAttribute('aria-hidden', 'true');
return () => {
destroyInstance(instance);
};
}6 · Reveal Hosts
One modifier, any element that already takes focus
A Reveal Host is whatever element receives focus. Its visible text keeps a native ellipsis, and the modifier on the focusable element reveals that text while it is clipped. What changes from element to element is only where the clip is, and whether the element opens a popup of its own.
| Host | Takes focus | Clip | Text argument | Hides for |
|---|---|---|---|---|
| Link | <a href> | The link itself | The link text | Nothing |
| Shared link, cut at the start | <a href> | The link itself, with .c-truncation-host--start | None: read from the clip | Nothing |
| Button with an icon | <button> | The label span | None: read from the clip | Nothing |
| File upload | The visually hidden <input type="file"> | The file name in its <label> | None: read from the clip | The native file picker |
| Select trigger | <button aria-haspopup="listbox"> | The selected value span | The selected value | Its listbox (aria-expanded) |
| Menu button | <button aria-haspopup="menu"> | A two-line wrapper | None: one row per line | Its menu (aria-expanded) |
A link
The simplest host: the link is the focusable element and its own text is the clip. The text argument is optional here, since the link's text is what would be read anyway; passing it skips reading the DOM.
<a
class='c-truncation-host'
href='/documents/42'
{{truncationHost 'Link Host with Overflowing Text'}}
>Link Host with Overflowing Text</a>A link cut at the start
A URL shared in a message is recognized by its end, the file or page it points to, so https://example.com/team/codepen/pen/pen.config.json should read …pen/pen.config.json, not https://example.co…. Add .c-truncation-host--start and wrap the value in a <span>. Everything else stays the same: the link's accessible name is the full URL, and its tooltip shows the full URL only while it is clipped.
<a
class='c-message__link c-truncation-host c-truncation-host--start'
href={{@url}}
{{truncationHost}}
><span>{{@url}}</span></a>The variant assumes a left-to-right value, as URLs and file paths usually are. Don't use it for prose or names, which can be in either direction, or for a path whose folder or file names might be right-to-left.
A button with an icon
The button's box also holds the icon, so the button itself is not the clip: its label is. Mark the label data-truncation-clip and leave out the text argument, and the tooltip reads the label when it shows. The icon keeps its size because the button holds it as a separate flex item with flex-shrink: 0 (see the rules).
<button type='button' class='c-button c-button--with-icon' {{truncationHost}} {{on 'click' this.download}}>
<DownloadIcon class='c-button__icon' aria-hidden='true' />
<span class='c-truncation-host' data-truncation-clip>Button Host with an Overflowing Label</span>
</button>A file upload
A styled file upload is usually a <label> that wraps a visually hidden <input type="file">. The input is what takes focus, so it hosts the tooltip, but the file name it should reveal sits beside it in the label. truncationClips finds clips through input.labels, so no wiring is needed for the measurement. The pointer, though, hovers the label rather than the hidden input, so the tooltip listens on both and is positioned against the label. It mounts beside the label, not inside it (see the rules).
<label class='c-file-upload'>
<input
type='file'
class='c-visually-hidden'
{{this.inputTooltip}}
{{on 'change' this.pick}}
/>
<span class='c-file-upload__button'>Choose file</span>
<span
class='c-file-upload__name c-truncation-host'
data-truncation-clip
>{{this.fileName}}</span>
</label>// The input takes focus, so it hosts the tooltip. It is visually hidden behind its label, so the
// label is what a pointer hovers and what the tooltip points at.
inputTooltip = modifier((input: HTMLInputElement) => {
const label = input.closest('label');
return applyTooltip(input, {
content: revealHostContent,
showWhen: 'overflowing',
aria: { content: null, expanded: false },
// Beside the label, not inside it, so a click in the tooltip can't reopen the picker.
appendTo: label?.parentElement ?? 'parent',
triggerTarget: label ? [input, label] : null,
getReferenceClientRect: label ? () => label.getBoundingClientRect() : null,
});
});With a file chosen, a name like my-long-breakable-file-name-for-the-quarterly-report-final-v2.pdf clips in the label, and focusing the input shows it whole. Pressing Enter or Space opens the picker, and hideWhenExpanded hides the tooltip as it opens.
A select trigger
A custom select is a button that opens a listbox. The selected value is the clip; the button is the host. While the listbox is open the trigger carries aria-expanded="true", so the tooltip neither shows nor stays up over the options.
<button
type='button'
class='c-select__trigger'
aria-haspopup='listbox'
aria-expanded={{if this.isOpen 'true' 'false'}}
{{truncationHost this.selected.label}}
{{on 'click' this.toggle}}
>
<span class='c-truncation-host' data-truncation-clip>{{this.selected.label}}</span>
<ChevronDownIcon class='c-select__chevron' aria-hidden='true' />
</button><select> draws its selected value itself, outside the DOM, so there is no text to measure. Use a button-based trigger like the one above when the selected value can clip.A menu button with two lines
A profile chip often stacks a display name over an email, and either line can clip on its own. Mark the wrapper as the clip and make each line a block: clippedTextLines returns one entry per block child, and revealHostContent turns them into one tooltip row each. No text argument is passed, so the tooltip always matches what is rendered.
<button
type='button'
class='c-profile-chip'
aria-haspopup='menu'
aria-expanded={{if this.isOpen 'true' 'false'}}
{{truncationHost}}
{{on 'click' this.toggle}}
>
<Avatar class='c-profile-chip__avatar' @src={{@user.avatarUrl}} />
<span class='c-profile-chip__lines' data-truncation-clip>
<span class='c-truncation-host'>My Long Breakable Display Name</span>
<span class='c-truncation-host'>my.long.breakable.email.address@example.com</span>
</span>
</button>7 · tippy.js
What comes from the tooltip library, and what doesn't
The CSS layer knows nothing about tippy.js, and neither does utils/text-overflow.ts, which is plain DOM. Everything that touches tippy sits in utils/tooltips.ts: applyTooltip, showAsRevealHost, and the two plugins. A few option names also pass straight through from callers (hideOnClick, appendTo, triggerTarget, getReferenceClientRect, aria, placement). Replacing tippy means rewriting that one file against the same TooltipOpts, and renaming those options if the new library spells them differently.
| tippy.js API | What the implementation relies on | Where | Without tippy |
|---|---|---|---|
onShow returning false | Cancels showing. This is what lets a Reveal Host measure at show time instead of observing resizes. | showAsRevealHost | Check before setting the tooltip open. With the Popover API, cancel beforetoggle when its newState is "open". |
instance.setContent | Swaps the content just before it shows: a function's result, or the reason. | showAsRevealHost, the second-source path in applyTooltip | Render into the floating element in the same open handler. |
instance.reference, instance.popper | The trigger to measure, and the tooltip element to mark aria-hidden. | Throughout utils/tooltips.ts | Your own trigger and floating-element references. |
element._tippy | tippy's pointer from a trigger to its instance; used to enforce one tooltip per trigger. | applyTooltip | A WeakMap<Element, Tooltip>. |
Plugins: { fn(instance) } returning onShow, onHide, onDestroy | Starts and stops watching aria-expanded and the file picker only while the tooltip is visible. | hideWhenExpanded | Do the same work in your open and close handlers. |
instance.props.plugins | Identifies a Reveal Host by whether hideWhenExpanded is installed, for the second-source warning. | isRevealHost | Store a flag with the instance. |
hideOnEscape plugin | tippy has no built-in Esc dismissal; this is the plugin recipe from tippy's documentation. | applyTooltip | The Popover API dismisses on Esc; otherwise a keydown listener. |
hideOnClick | tippy hides on the trigger's mousedown. false keeps a Reveal Button's tooltip open when it is clicked. Keyboard activation sends a click with no mousedown, which is why the file picker needs its own listener. | <Truncation>, items summary, hideWhenExpanded | Decide in your own click handling whether a click closes the tooltip. |
interactive and aria | With interactive: true, tippy's defaults stop writing aria-describedby and start writing aria-expanded, so applyTooltip overrides both. Reveal Hosts pass aria.content: null for no description at all. | applyTooltip, every Reveal Host call | Write aria-describedby yourself, and only for Reveal Buttons. |
appendTo: 'parent' | Mounts the tooltip beside its trigger rather than at the end of <body> (tippy's default), so it stays inside dialogs and next to the trigger in reading order. | applyTooltip | Render the floating element next to the trigger, or in the top layer with the Popover API. |
popperOptions.strategy: 'fixed' | Popper v2 positioning, which tippy builds on, against the viewport so clipping containers can't cut the tooltip off. | applyTooltip | Floating UI's strategy: 'fixed', or a top-layer popover, which no ancestor can clip. |
triggerTarget, getReferenceClientRect | Listen on more than the reference, and position against another box. Used by the file upload: events from the input and its label, position from the label. | File upload | Floating UI's virtual element (an object with getBoundingClientRect), plus your own listeners. |
maxWidth, theme, placement | Presentation only. | applyTooltip | The equivalent options or CSS. |
Two behaviors in the rest of this document follow from how applyTooltip configures tippy rather than from the technique: a Reveal Button's value is announced twice, because applyTooltip turns tippy's aria-describedby link back on (see the closing note), and the test page object reads that same attribute.
8 · Locked hosts
Optional: a control that stays reachable and says why it is unavailable
This is an extension, not part of the core. Some controls are temporarily unavailable and should explain why. Such a control stays focusable with aria-disabled="true" instead of disabled, so its tooltip is still reachable from the keyboard. reason is read when the tooltip shows, so locking and unlocking need no re-attach. When the value is clipped too, formatReason joins the two as "value: reason". If your design system has no such controls, leave both options out; showAsRevealHost then behaves exactly as in section 5.
<button
type='button'
class='c-button c-truncation-host'
aria-disabled={{if this.lockedReason 'true'}}
aria-describedby={{if this.lockedReason this.reasonId}}
{{attachTooltip this.tooltip}}
{{on 'click' this.publish}}
>Publish My Long Breakable Text</button>
{{#if this.lockedReason}}
<span id={{this.reasonId}} class='c-visually-hidden'>
{{this.lockedReason}}
</span>
{{/if}}get tooltip(): TooltipOpts {
return {
content: revealHostContent,
showWhen: 'overflowing',
aria: { content: null, expanded: false },
reason: () => this.lockedReason,
formatReason: (value, why) => `${value}: ${why}`,
};
}
@action publish() {
// aria-disabled doesn't stop clicks.
if (this.lockedReason) return;
this.save();
}- The reason is also the button's description (
aria-describedby), so a screen reader hears it without the tooltip, which staysaria-hiddenlike any Reveal Host's. - Translate the separator in
formatReason; not every language joins with a colon.
9 · Consumers
How an app picks a variation
An app never measures anything. It picks a primitive per value, in the template that renders it. The examples below are common places where a long value meets a narrow box.
Table cells: text and links
A text cell wraps its value in a <Truncation>. A cell whose value is a link makes the link a Reveal Host instead; when the link stacks several lines, each line is its own clip.
const TextCell: TOC<Signature> = <template>
<td
class='c-table__cell {{if @cell.isVisuallyHidden "c-visually-hidden"}}'
data-test-text-cell
>
{{! A visually hidden copy is read, never seen; a Reveal Button would be an invisible tab stop. }}
{{#if @cell.isVisuallyHidden}}
{{@cell.text}}
{{else if @cell.text}}
<Truncation data-test-text @text={{@cell.text}} />
{{/if}}
</td>
</template>;
export default TextCell;Where the cut falls
Pick @position by which part of the value tells rows apart. A breadcrumb trail shown as a location value usually differs at its end, so it keeps the end with "start". A file path is recognized by both its root and its file name, so it uses "middle", which keeps the file name whole. Names, titles, and sentences keep the default: people read them from the start.
const LocationCell: TOC<{ Args: { location: string; path: string } }> = <template>
<td class='c-table__cell'>
{{! Home › Projects › … › Network Migration Plan }}
<Truncation @text={{@location}} @position='start' />
</td>
<td class='c-table__cell'>
{{! /src/compon…/Menu.module.css }}
<Truncation @text={{@path}} @position='middle' />
</td>
</template>;This is for a trail shown as a value. A breadcrumb <nav> is a list of links, so each link is its own Reveal Host; a link that should keep its end uses .c-truncation-host--start (see Reveal Hosts).
const LinkCell: TOC<{ Args: { href: string; lines: string[] } }> = <template>
<td class='c-table__cell'>
<a class='c-link' href={{@href}} data-test-link-cell {{truncationHost}}>
{{#each @lines as |line|}}
<span class='c-truncation-host' data-truncation-clip data-test-link-cell-line>{{line}}</span>
{{/each}}
</a>
</td>
</template>;Link or text, per row
The same value can be a link in one row and plain text in another, for example when the user can't open that record. The variation follows the element: a Reveal Host when it links, a Reveal Button when it doesn't. An identifier that sits next to its name stays whole; only the name truncates.
const NameCell: TOC<{ Args: { name: string; href?: string } }> = <template>
{{#if @href}}
<a class='c-truncation-host' href={{@href}} {{truncationHost @name}}>{{@name}}</a>
{{else}}
<Truncation @text={{@name}} />
{{/if}}
</template>;
export default NameCell;Inside a link or not: two variations for one value
A component that shows an identifier beside a title is sometimes rendered inside a link and sometimes not. Inside a link, the title can only clip, because the link is the host; outside one, it gets its own Reveal Button.
type TextVariation = 'clip' | 'reveal';
const isClip = (variation: TextVariation) => variation === 'clip';
const TitleWithId: TOC<{
Args: { identifier?: string; title?: string; titleVariation: TextVariation };
}> = <template>
{{#if @identifier}}
<span class='c-title-with-id__identifier' data-test-identifier>{{@identifier}}</span>
{{/if}}
{{#if @title}}
{{#if (isClip @titleVariation)}}
<span
class='c-title-with-id__title c-truncation-host'
data-test-title
data-truncation-clip
>{{@title}}</span>
{{else}}
<Truncation class='c-title-with-id__title' data-test-title @text={{@title}} />
{{/if}}
{{/if}}
</template>;get titleVariation(): TextVariation {
return this.args.insideLink || this.showLink ? 'clip' : 'reveal';
}Reusing the parts
A cell that stands for several values reuses the Reveal Button's classes for a button that is always present, because there are always more values to list. A label that puts a name inside a sentence splits its translated copy around a placeholder character. That way the name can sit on its own line with a <Truncation>, in any language's word order.
// A group's Reveal Button is always there: it lists every item, and it stands in for the
// ellipsis when the first item's name is trimmed.
export const ItemsSummary: TOC<ItemsSummarySignature> = <template>
<div class='c-items-summary' data-test-items-summary ...attributes>
{{#if @group.hasMany}}
<span class='c-items-summary__name' data-test-items-name>{{@group.firstName}}</span>
<Button
class='c-button--reset c-button--icon-only c-truncation__reveal c-items-summary__reveal'
aria-label={{@group.allNames}}
data-test-items-reveal
{{attachTooltip (hash content=@group.allNames hideOnClick=false)}}
>
<EllipsisDots class='c-truncation__reveal-icon' />
</Button>
<span class='c-items-summary__count' data-test-items-count>{{@group.countLabel}}</span>
{{else}}
<Truncation data-test-items-name @text={{@group.firstName}} />
{{/if}}
</div>
</template>;// Translated in place of the person's name, so the copy around the name can be split off it.
const NAME_PLACEHOLDER = '\uE000';const NameLabel: TOC<NameLabelSignature> = <template>
<div class='c-name-label' ...attributes>
{{#if @copy.before}}
<span class='c-name-label__copy' data-test-label-before>{{@copy.before}}</span>
{{/if}}
<span class='c-name-label__name'>
{{yield}}
<Truncation @text={{@name}} data-test-label-name />
</span>
{{#if @copy.after}}
<span class='c-name-label__copy' data-test-label-after>{{@copy.after}}</span>
{{/if}}
</div>
</template>;private copyAroundName(key: string): CopyAroundName | undefined {
if (!this.name) return undefined;
const label = this.intl.t(key, { name: NAME_PLACEHOLDER });
const at = label.indexOf(NAME_PLACEHOLDER);
if (at === -1) return undefined;
return {
before: label.slice(0, at).trim(),
after: label.slice(at + NAME_PLACEHOLDER.length).trim(),
};
}10 · Testing
Waiting for the timeline, and asserting the state
The scroll timeline resolves on the animation frame after the framework has settled, so tests await two more frames. The plain copy stays laid out (zero height, hidden) while overflowing, so a visibility check can't tell the states apart. The page object asks whether the Reveal Button renders any box instead. Some test runners scale their container down (Ember's does, to 50% by default), so tests assert states and relative measurements, never absolute pixel widths. The helpers below use Ember's test helpers and page objects; the two ideas carry over to any runner.
With @position="start" or "middle", each [data-truncation-copy] holds only part of the value. Assert the full value through text (the plain copy) or reveal.ariaLabel, never through the faded copies. To check where the cut falls, assert the wrapper's c-truncation--start or c-truncation--middle class rather than measuring.
import { settled } from '@ember/test-helpers';
// A Truncation's overflow state comes from a scroll timeline, which the browser resolves on the
// next animation frame, after `settled()` has already returned.
export async function settleTruncation(): Promise<void> {
await settled();
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
}import { attribute, create, text } from 'ember-cli-page-object';
import { getter } from 'ember-cli-page-object/macros';
import { findOne } from 'ember-cli-page-object/extend';
import type { Component } from 'ember-cli-page-object/-private';
// The plain branch stays laid out (zero height, hidden) while overflowing, so `isVisible` cannot
// tell the two branches apart; whether the Reveal Button renders a box can.
export const truncation = {
scope: '[data-test-truncation]',
text: text('[data-test-truncation-plain]'),
isOverflowing: getter(function (this: Component): boolean {
return findOne(this, '[data-test-truncation-reveal]').getClientRects().length > 0;
}),
reveal: {
scope: '[data-test-truncation-reveal]',
ariaLabel: attribute('aria-label'),
ariaDescribedBy: attribute('aria-describedby'),
element: getter(function (this: Component): Element {
return findOne(this, '');
}),
},
};
export default create(truncation);11 · Rules
Rules that keep it correct
- Pick the variation by the element, not the place. Plain text gets
<Truncation>; a link, button, trigger, or menu button gets{{truncationHost}}, and a file upload's input gets the same options throughapplyTooltip. Never put a Reveal Button inside a link or button. - Tooltip triggers are focusable. Attach tooltips to interactive elements only. A control that must explain itself while unavailable uses
aria-disabled, neverdisabled;applyTooltipasserts this in development. - One tooltip per trigger. A second source only swaps content, with a warning. If a host needs to say more, give it a
reason; don't add a second tooltip. - No mouse-only
title. Converting a value means removing itstitle; the reveal replaces it. - IDs stay whole unless they are the link. An ID that shares a line with its name never truncates; only the name does. A standalone ID truncates only where it is itself the link.
- Leave room for the ring. The 4px
--c-truncation-focus-ringbudget sits inside the value's space, because cells clip with no padding. It moves with the Reveal Button: to the start for@position="start", and beside a collapsed head for"middle". - Cut where the value differs. Use
@position="start"or"middle"on a<Truncation>, or.c-truncation-host--starton a host, only when the end of a value is what tells it apart, such as a shared URL, a breadcrumb trail, or a file path. Prose, names, and titles keep the default end cut. - The fallback wraps. Without scroll-driven animations, values wrap in full instead of truncating, so nothing is lost.
- An icon beside truncating text keeps its size. An icon whose
<svg>is itself a flex item next to clipped text getsflex-shrink: 0, or the defaultflex-shrink: 1squeezes it to a sliver. An icon inside a button or link is already held by that wrapper's minimum width. Test it by comparing the icon's width to its height with a very long value; the ratio holds under the test runner's scaling. - A tooltip on a labeled trigger mounts beside the label. Put it next to the
<label>, not inside it, so a click in the tooltip doesn't activate the label's control (a file name's tooltip would otherwise reopen the file picker). - An open cell lifts while its tooltip is open. A table cell holding an open tooltip lifts above its neighbors and a sticky header, so their shadows can't erase its border. While scrolled under the header, it paints over the header until the tooltip closes; that is an accepted trade-off.
- Rows act only through explicit controls. A table row takes no click of its own: an expand button expands it and a link opens it. So a Reveal Button is never nested in an interactive row, and expandable rows use the same cells as flat rows.
- Multi-part hosts mark their clips. Put
data-truncation-clipon each part that can clip, or on a wrapper whose lines are blocks, and letrevealHostContentbuild one row per line.
aria-describedby that applyTooltip asks tippy to write. This is an accepted trade-off; passing aria: { content: null }, as Reveal Hosts do, avoids it. A Reveal Host's tooltip is aria-hidden, because the host's accessible name is already the full text.