Truncation Component

Solving accessible text truncation

By Melanie Sumner Temple & Joseph Sumner Temple

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.

my-design-system · styles/components/_truncation.css

my-design-system · components, modifiers, utils

my-app (consumers)

Reveal Button

when clipped

is it clipped?

Table cells
names · emails · titles

Profile card
full name

Interactive elements
link · button · file upload · select · menu

Truncation
component

truncationHost modifier
link · button · trigger

applyTooltip
(tippy.js)

isTextOverflowing
clippedTextLines

Overflow probe
scroll-timeline

Keyframes set
--overflowing: 1

Container style query
swaps the branches

Host ellipsis
.c-truncation-host

my-design-system · styles/components/_truncation.css

my-design-system · components, modifiers, utils

my-app (consumers)

Reveal Button

when clipped

is it clipped?

Table cells
names · emails · titles

Profile card
full name

Interactive elements
link · button · file upload · select · menu

Truncation
component

truncationHost modifier
link · button · trigger

applyTooltip
(tippy.js)

isTextOverflowing
clippedTextLines

Overflow probe
scroll-timeline

Keyframes set
--overflowing: 1

Container style query
swaps the branches

Host ellipsis
.c-truncation-host

Solid arrows: "uses". The plain-text path decides visibility with CSS only. The host path asks the DOM whether its text is clipped, and only when its tooltip is about to show.

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.

ConceptEmber (used here)ReactVueSvelteAngularWeb Components
Component
<Truncation>
Template-only componentFunction componentSingle-file componentComponentComponentCustom element
Modifier
truncationHost
Element modifierRef callback, or an effect on a refCustom directiveAction (use:)Attribute directiveA function called with the element
TeardownFunction the modifier returnsEffect cleanupDirective unmountedAction destroyngOnDestroydisconnectedCallback
Attribute pass-through...attributesSpread {...rest}$attrs fallthroughSpread {...rest}Attributes on the host elementAttributes 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.

VariationBuilt withWhen it appliesHow it revealsWhere it lives
Reveal ButtoncomponentNon-interactive text: names, emails, file names, titlesA trimmed, faded copy plus a ⋯ button whose accessible name and tooltip are the full value<Truncation @text>
Cut at the start or middlecomponentConcatenated text whose end matters, such as a breadcrumb trail shown as a valueThe same faded copy, clipped at its start or split in two, with the ⋯ button at the cut<Truncation @text @position>
Reveal HostmodifierA link, a button, a select trigger, a menu button, or a file upload whose own text clipsA 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 startmodifierA link whose end tells it apart: a URL shared in a message, a file linkA 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 hostmodifierA host with several clipped parts: a name over an email, a link with stacked linesNo text argument: the tooltip lists one row per line of its clips, read when it showsdata-truncation-clip + revealHostContent
Grouped values "…"button, reusedA cell that stands for several values, shown by the first one's nameAlways shows the ⋯ button, which lists every value; the first name clips under itAn items-summary cell that reuses .c-truncation__reveal
Locked host with a reason (optional)applyTooltipA control that is aria-disabled but still focusableShows the reason even when nothing is clipped; "value: reason" when it is clippedreason + formatReason
No scroll timelineCSSBrowsers without scroll-driven animationsThe plain text wraps anywhere; nothing is lost@supports not (…)
Visually hidden copynoneA value repeated only for screen readers, such as a visually hidden child rowPlain text, because a Reveal Button would be an invisible tab stopThe 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.

  1. While the text fits, the timeline is inactive, so the animation applies nothing. --c-truncation-overflowing stays unset, and the plain text shows.
  2. Once the text overflows, the timeline turns active. The keyframe, which is the same at from and to, sets --c-truncation-overflowing: 1 on the wrapper.
  3. 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.
  4. 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.

plain text wider than its box (timeline becomes active)

box widens or text shortens (timeline inactive)

Fits

Overflowing

.c-truncation__plain shows
.c-truncation__overflow is display: none

plain: absolute, 0 high, hidden (still the probe)
overflow: faded copy + Reveal Button

plain text wider than its box (timeline becomes active)

box widens or text shortens (timeline inactive)

Fits

Overflowing

.c-truncation__plain shows
.c-truncation__overflow is display: none

plain: absolute, 0 high, hidden (still the probe)
overflow: faded copy + Reveal Button

The state re-resolves on the next animation frame after any resize, zoom, font load, or text change. Nothing observes the element.
The wrapper, its focus-ring budget, and the keyframemy-design-system · styles/components/_truncation.css
/* ------------ 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;
  }
}
The two branches: the probe, and the faded copymy-design-system · styles/components/_truncation.css
.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;
}
Where the cut falls: start and middlemy-design-system · styles/components/_truncation.css
/* 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.

The style query that swaps themmy-design-system · styles/components/_truncation.css
@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;
  }
}
The Reveal Button: one line tall including its ringmy-design-system · styles/components/_truncation.css
/* 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.

Fallback, and the Reveal Host's native ellipsismy-design-system · styles/components/_truncation.css
/* 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.

components/truncation.gts (complete)tippy.js option: hideOnClickmy-design-system · components/truncation.gts
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;

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'.

modifiers/truncation-host.ts (complete)my-design-system · modifiers/truncation-host.ts
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.

utils/tooltips.ts: showAsRevealHosttippy.js: onShow, Instance, setContentmy-design-system · utils/tooltips.ts
// 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.

utils/text-overflow.ts (complete)plain DOMmy-design-system · utils/text-overflow.ts
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;
}
utils/tooltips.ts: hide while the host's popup is open, and multi-line contenttippy.js: Plugin, instance.hide, props.pluginsmy-design-system · utils/tooltips.ts
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:

utils/tooltips.ts: applyTooltiptippy.js: tippy(), _tippy, every optionmy-design-system · utils/tooltips.ts
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.

HostTakes focusClipText argumentHides for
Link<a href>The link itselfThe link textNothing
Shared link, cut at the start<a href>The link itself, with .c-truncation-host--startNone: read from the clipNothing
Button with an icon<button>The label spanNone: read from the clipNothing
File uploadThe visually hidden <input type="file">The file name in its <label>None: read from the clipThe native file picker
Select trigger<button aria-haspopup="listbox">The selected value spanThe selected valueIts listbox (aria-expanded)
Menu button<button aria-haspopup="menu">A two-line wrapperNone: one row per lineIts 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 link whose text clipsmy-app · any template
<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 shared link that keeps its endmy-app · components/message/shared-link.gts
<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).

A button whose label clips beside an iconmy-app · any template
<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).

Markupmy-design-system · components/file-upload.gts
<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 hosts the label's clipped file nametippy.js: triggerTarget, getReferenceClientRectmy-design-system · components/file-upload.gts
// 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.

A button-based select triggermy-design-system · components/select/trigger.gts
<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>
A native <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.

A menu button whose clip has two linesmy-app · components/profile-chip.gts
<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.

aria-expanded is true

fits, no reason

clipped, or a reason

aria-expanded flips,
or the file picker opens

hover or focus
(tippy trigger)

onShow
(tippy hook)

return false
(tippy cancels)

isTextOverflowing
(plain DOM)

instance.setContent
(tippy)

shown: Popper, fixed
popper aria-hidden

instance.hide
(plugin)

aria-expanded is true

fits, no reason

clipped, or a reason

aria-expanded flips,
or the file picker opens

hover or focus
(tippy trigger)

onShow
(tippy hook)

return false
(tippy cancels)

isTextOverflowing
(plain DOM)

instance.setContent
(tippy)

shown: Popper, fixed
popper aria-hidden

instance.hide
(plugin)

A Reveal Host at show time. Everything labeled "tippy" is a hook or method of the tippy.js instance; the measurement in the middle is the only part every library would share.
tippy.js APIWhat the implementation relies onWhereWithout tippy
onShow returning falseCancels showing. This is what lets a Reveal Host measure at show time instead of observing resizes.showAsRevealHostCheck before setting the tooltip open. With the Popover API, cancel beforetoggle when its newState is "open".
instance.setContentSwaps the content just before it shows: a function's result, or the reason.showAsRevealHost, the second-source path in applyTooltipRender into the floating element in the same open handler.
instance.reference, instance.popperThe trigger to measure, and the tooltip element to mark aria-hidden.Throughout utils/tooltips.tsYour own trigger and floating-element references.
element._tippytippy's pointer from a trigger to its instance; used to enforce one tooltip per trigger.applyTooltipA WeakMap<Element, Tooltip>.
Plugins: { fn(instance) } returning onShow, onHide, onDestroyStarts and stops watching aria-expanded and the file picker only while the tooltip is visible.hideWhenExpandedDo the same work in your open and close handlers.
instance.props.pluginsIdentifies a Reveal Host by whether hideWhenExpanded is installed, for the second-source warning.isRevealHostStore a flag with the instance.
hideOnEscape plugintippy has no built-in Esc dismissal; this is the plugin recipe from tippy's documentation.applyTooltipThe Popover API dismisses on Esc; otherwise a keydown listener.
hideOnClicktippy 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, hideWhenExpandedDecide in your own click handling whether a click closes the tooltip.
interactive and ariaWith 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 callWrite 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.applyTooltipRender 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.applyTooltipFloating UI's strategy: 'fixed', or a top-layer popover, which no ancestor can clip.
triggerTarget, getReferenceClientRectListen 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 uploadFloating UI's virtual element (an object with getBoundingClientRect), plus your own listeners.
maxWidth, theme, placementPresentation only.applyTooltipThe 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.

A button that can be lockedmy-app · any template
<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}}
Its tooltipmy-app · any component
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();
}

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.

Text cellmy-app · components/table/cells/text.gts
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.

Location and file cellsmy-app · components/table/cells/location.gts
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).

Link cell: each stacked line is its own clip inside one hostmy-design-system · components/table/cells/link.gts
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.

Name cell: link or textmy-app · components/table/cells/name.gts
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.

Title and identifier: clip or revealmy-design-system · components/title-with-id.gts
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>;
…and how the variation is chosenmy-design-system · components/title-with-id.gts
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.

items-summary: an always-on Reveal Button for a group of valuestippy.js option: hideOnClickmy-app · components/items-summary.gts
// 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>;
name-label: the placeholdermy-app · components/name-label.gts
// Translated in place of the person's name, so the copy around the name can be split off it.
const NAME_PLACEHOLDER = '\uE000';
NameLabelmy-app · components/name-label.gts
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>;
copyAroundNamemy-app · components/name-label.gts
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.

test-support/helpers/settle-truncation.tsmy-design-system · test-support/helpers/settle-truncation.ts
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())));
}
test-support/pages/components/truncation.tsmy-design-system · test-support/pages/components/truncation.ts
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

  1. 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 through applyTooltip. Never put a Reveal Button inside a link or button.
  2. Tooltip triggers are focusable. Attach tooltips to interactive elements only. A control that must explain itself while unavailable uses aria-disabled, never disabled; applyTooltip asserts this in development.
  3. 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.
  4. No mouse-only title. Converting a value means removing its title; the reveal replaces it.
  5. 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.
  6. Leave room for the ring. The 4px --c-truncation-focus-ring budget 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".
  7. Cut where the value differs. Use @position="start" or "middle" on a <Truncation>, or .c-truncation-host--start on 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.
  8. The fallback wraps. Without scroll-driven animations, values wrap in full instead of truncating, so nothing is lost.
  9. An icon beside truncating text keeps its size. An icon whose <svg> is itself a flex item next to clipped text gets flex-shrink: 0, or the default flex-shrink: 1 squeezes 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.
  10. 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).
  11. 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.
  12. 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.
  13. Multi-part hosts mark their clips. Put data-truncation-clip on each part that can clip, or on a wrapper whose lines are blocks, and let revealHostContent build one row per line.
Screen readers announce a Reveal Button's value twice: once as its name, once through the 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.