Tooltip

Deck ships two, and the difference is worth understanding before choosing. .tooltip is a ::after driven by data-tip — no JavaScript, no extra element, and no ability to flip when it hits the top of the window. .tip is a real popover positioned by CSS anchor positioning, which can flip, shift, and carry an arrow that stays pointed at its trigger.

When to use one

A tooltip names something whose meaning is not obvious — an icon-only button, an abbreviation, a truncated value. It should be a label: a few words, no interaction, nothing the reader has to act on.

If it holds a sentence, a link or a button, it is not a tooltip. That is .pop, which the reader can move into and interact with.

Both tooltips here appear on hover and keyboard focus and cannot be reached by a pointer — they are labels, not containers. Anything a reader needs to click is in the wrong component.

.tooltip — the CSS-only one

Put .tooltip on the trigger and the text in data-tip. The ::after takes its content from the attribute, so there is no second element and nothing to keep in sync.

Hover, or tab to it
<button class="btn btn-icon tooltip" data-tip="Duplicate this export" aria-label="Duplicate this export"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg> </button> <button class="btn tooltip" data-tip="Runs the nightly job again from the beginning">Rerun</button>
<button class="btn btn-icon tooltip" data-tip="Duplicate this export" aria-label="Duplicate this export">
  <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
</button>
<button class="btn tooltip" data-tip="Runs the nightly job again from the beginning">Rerun</button>

What it cannot do

  • It cannot flip. It is pinned above the trigger with inset-block-end: calc(100% + 8px). Near the top of the window it is clipped or scrolled off, and nothing repositions it.
  • It cannot escape an overflow. The ::after is a child of the trigger, so an ancestor with overflow: hidden — a table wrapper, a card, a scroller — cuts it off.
  • It is hidden entirely on touch. @media (pointer: coarse) { .tooltip::after { display: none } }. There is no hover on a phone, and a tooltip that appears on tap and blocks the thing you tapped is worse than none.

That last one is the rule that matters: on a phone this tooltip does not exist. Anything in a data-tip is invisible to a touch reader, so it must never be the only place information lives. Pair it with aria-label, as the example above does.

.tip — the anchor-positioned one

.tip is a popover element with position-area: block-start and position-try-fallbacks: flip-block, flip-inline. When there is no room above, it goes below; when it would overflow an edge, it flips inline. The browser does that on the compositor, with no scroll listener and no measurement.

Opens on click here, because a popover has no hover trigger of its own
<button class="btn" popovertarget="dx-tip">What is a webhook?</button> <div class="tip" popover id="dx-tip" role="tooltip"> A callback URL we POST to when something happens in your project. <span class="tip-arrow"></span> </div>
<button class="btn" popovertarget="dx-tip">What is a webhook?</button>
<div class="tip" popover id="dx-tip" role="tooltip">
  A callback URL we POST to when something happens in your project.
  <span class="tip-arrow"></span>
</div>

justify-self: anchor-center centres it on the trigger, and .tip-arrow uses the same declaration so the arrow stays pointed at the anchor whichever way the tip has flipped. The arrow is a rotated square with background: inherit, so it takes the tip's colour automatically.

A popover opens on click, not hover. That makes .tip behave more like a small popover than a true tooltip — for hover-and-focus behaviour you need a few lines of JavaScript calling showPopover() on pointerenter and focus. Deck does not ship that, which is the honest gap in this component.

Choosing between them

Comparison of the two tooltip mechanisms
.tooltip.tip
Markup One attribute A popover element plus an id
Opens on Hover and focus, free Click, unless you add JS
Flips near an edge No Yes
Escapes overflow: hidden No Yes — it is in the top layer
On touch Hidden entirely Works — it opens on tap

In short: .tooltip for a short label on a control that is not near an edge and not inside a clipping ancestor, where the information is also available some other way. .tip when position matters or touch readers need it.

Classes

.tooltip lives in 07-components.css and is not part of the tip component; it is listed here because this page documents it.

Classes, generated from the stylesheet source
Class Layer What it does Source
.tooltip deck.components A CSS-only tooltip: the text comes from data-tip through ::after, so there is no second element. It cannot flip near a window edge, cannot escape an overflow:hidden ancestor, and is hidden entirely on a coarse pointer. Never put anything essential in it, and pair it with aria-label — generated content is not a reliable accessible name. 07-components.css:590
.tip deck.components The ::after tooltip could never flip. This one can, and it can carry a real arrow that stays pointed at the anchor. 25-anchor.css:72
.tip-arrow deck.components The arrow on a .tip. background: inherit takes the tip's colour, and justify-self: anchor-center keeps it pointed at the trigger whichever way the tip has flipped. 25-anchor.css:101

Tokens

Both use the same near-black on near-white pairing — --ink-900 and --ink-50 — which is fixed rather than theme-dependent, so a tooltip is dark in light mode and dark in dark mode. That is deliberate: a tooltip should read as a layer above the page, not as part of it.

Design tokens this component reads
Token Live Declared value
--ink-900 oklch(22.6% calc(var(--chroma-neutral) * 2.2) var(--hue-neutral))
--ink-50 oklch(97.8% var(--chroma-neutral) var(--hue-neutral))
--r-xs 4px
--text-xs .75rem
--shadow-3 0 4px 8px oklch(20% .03 var(--shadow-color) / .06), 0 12px 28px oklch(20% .03 var(--shadow-color) / .10)
--space-2 .5rem
--space-3 .75rem
--dur-2 190ms
--z-toast — 500

Accessibility

  • data-tip is not an accessible name. A ::after's generated content is exposed inconsistently and should never be relied on. An icon-only button with a tooltip needs aria-label as well — the tooltip is for sighted mouse users, the label is for everyone else.
  • That means the text is written twice, in the attribute and in the label, with nothing keeping them in step. It is the cost of the CSS-only approach, and it is worth knowing before choosing it.
  • The CSS tooltip does appear on :focus-visible, so a keyboard user sees it. It does not appear on hover of a non-focusable element unless that element can take focus.
  • Neither can be hovered into. .tooltip::after is pointer-events: none. WCAG's hoverable criterion asks that a reader be able to move the pointer onto a tooltip to read it — which these cannot satisfy, and which is another reason to keep them to a few words.
  • Neither is dismissible with Escape in the CSS-only case. WCAG asks for that too. .tip, being a popover, does close on Escape.
  • Nothing on a touch device sees .tooltip. The information has to exist elsewhere.
  • Give .tip a role. role="tooltip" on the popover, and aria-describedby from the trigger, is what associates the two. The examples here show the role; the association needs the attribute on the trigger as well.

Right to left

.tooltip::after is centred with inset-inline-start: 50% and translate: -50%, which is symmetrical and therefore correct in both directions. .tip uses justify-self: anchor-center and logical position-area keywords, so its flip fallbacks follow the writing direction.

<div dir="rtl" class="cluster"> <button class="btn tooltip" data-tip="أعد تشغيل المهمة الليلية" aria-label="أعد تشغيل المهمة الليلية">إعادة التشغيل</button> </div>
<div dir="rtl" class="cluster">
  <button class="btn tooltip" data-tip="أعد تشغيل المهمة الليلية" aria-label="أعد تشغيل المهمة الليلية">إعادة التشغيل</button>
</div>

Reduced motion

.tooltip::after fades and rises four pixels; .tip fades. Both are transitions, so the global reset collapses them to .01ms under prefers-reduced-motion: reduce and the tooltip simply appears.

Printing

Neither has a print rule. .tooltip::after is opacity: 0 unless hovered, so it does not print — a tooltip is a hover state and paper has no hover. A .tip that happened to be open would print where it is.

The consequence is worth stating: anything in a tooltip is absent from the printed page, for the same reason it is absent on touch. It is decoration on top of information that must exist elsewhere.

Overriding it

@layer app.components {
  /* Tooltip below the trigger instead of above */
  .tooltip-below::after {
    inset-block-end: auto;
    inset-block-start: calc(100% + 8px);
  }

  /* Let it show on touch after all — think hard first */
  @media (pointer: coarse) {
    .tooltip-touch::after { display: block; }
  }
}

Before re-enabling tooltips on touch: there is no hover, so it will only appear on tap — over the top of the control the reader just tapped, at the moment they are acting on it.

When not to use it

  • Not for anything essential. Hidden on touch, absent in print, unreliable to screen readers. If a reader needs it to complete a task, put it on the page — a .help line under a field costs nothing.
  • Not for interactive content. Neither can be hovered into, so a link or a button inside one is unreachable. Use .pop.
  • Not as a replacement for a label. An icon-only button needs aria-label whether or not it has a tooltip.
  • Not inside a table cell or a scroller, if you are using .tooltip — the clipping ancestor will cut it off. .tip escapes to the top layer and does not have this problem.
  • Not for a paragraph. max-inline-size: 16rem is a deliberate ceiling. If the explanation does not fit in it, the explanation belongs somewhere a reader can keep it open while they work.