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.
<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
::afteris a child of the trigger, so an ancestor withoverflow: 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.
<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
| .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.
| 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.
| 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-tipis 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 needsaria-labelas 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::afterispointer-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
.tipa role.role="tooltip"on the popover, andaria-describedbyfrom 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
.helpline 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-labelwhether 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..tipescapes to the top layer and does not have this problem. -
Not for a paragraph.
max-inline-size: 16remis a deliberate ceiling. If the explanation does not fit in it, the explanation belongs somewhere a reader can keep it open while they work.