Tooltip
Tooltip — a short text hint shown on hover, focus or long-press.
How this differs from what it looks like
Not popover
A tooltip is a label or a short description of the thing it is attached to, it appears on hover and focus without being asked for, it holds no interactive content, and it is announced as part of its trigger through aria-describedby. A popover is a panel you opened on purpose: it can hold a form, links, a menu; it takes focus; it is dismissed with Escape or a click outside. The rule for choosing: if the content contains anything the user can operate, or if it would still be worth reading after the pointer moves away, it is a popover. If it is a sentence explaining the control under the cursor, it is a tooltip. Putting a button inside a tooltip makes it unreachable — the tooltip closes as soon as the pointer leaves the trigger, and no keyboard user can get into it at all.
Not hover-card
A hover card is the middle case, and it is a rich preview rather than a hint: a user avatar that reveals a profile card, a link that reveals the page it points at. It opens on hover like a tooltip, may contain links and images unlike a tooltip, and stays open while the pointer travels into it. Reach for it when the content is worth looking at; reach for tooltip when it is worth reading once.
Examples
<button class="btn btn-outline btn-icon" id="demo-tip-save"
aria-label="Save" interestfor="demo-tip-save-hint">
<svg class="icon" aria-hidden="true"><use href="#icon-check"></use></svg>
</button>
<div class="tooltip" id="demo-tip-save-hint" popover="hint" data-ui="tooltip">
Saves without leaving the page
</div>
<button class="btn btn-outline" id="demo-tip-below"
interestfor="demo-tip-below-hint">Below instead</button>
<div class="tooltip tooltip-block-end" id="demo-tip-below-hint" popover="hint" data-ui="tooltip">
Opens under its trigger
</div>Hover it, then Tab to it, then press Escape. data-ui="tooltip" is not an enhancement on this component — it is what makes it work: measured 2026-08-08, interestfor is Chromium-only, so nothing opens the tooltip in Firefox or WebKit without it, and in WebKit popover="hint" degrades to popover="manual", which has neither Escape nor light dismiss.
The module fills exactly the gap each engine has and attaches nothing at all on Chromium. Note there is no aria-describedby in this markup: where interestfor is native the browser makes the association itself, and adding it by hand would double the announcement.
The build asserts the is present in every emitted file.
@layer ui.reset, ui.tokens, ui.base, ui.components, ui.blocks, ui.utilities;
<p>
The build asserts the
<button class="tooltip-term" type="button" interestfor="demo-tip-layer">layer statement</button>
is present in every emitted file.
</p>
<div class="tooltip" id="demo-tip-layer" popover="hint" data-ui="tooltip">
<code>@layer ui.reset, ui.tokens, ui.base, ui.components, ui.blocks, ui.utilities;</code>
</div>The trigger is a <button>, not a <span>: interestfor is defined on <button> and <a> only, and a hint a keyboard user cannot reach is not a hint.
The dotted underline is the affordance — a tooltip nobody can see is a tooltip nobody reads. Retune the delays with --tooltip-delay-in and --tooltip-delay-out on any ancestor; they drive Chromium’s native interest-delay-* and the module’s timers from one declaration.
Tokens 13
Level 2, declared on .tooltip itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --tooltip-duration | var(--duration-instant) |
| --tooltip-offset | var(--space-2) |
| --tooltip-area | block-start span-all |
| --tooltip-try | flip-block, flip-inline, flip-block flip-inline |
| --tooltip-bg | var(--color-surface-inverse) |
| --tooltip-color | var(--color-on-surface-inverse) |
| --tooltip-font-family | var(--font-secondary) |
| --tooltip-font-size | var(--font-size-xs) |
| --tooltip-max-inline-size | 18rem |
| --tooltip-padding-block | var(--space-1) |
| --tooltip-padding-inline | var(--space-2) |
| --tooltip-radius | var(--radius-field) |
| --tooltip-shadow | var(--elevation-2) |
Variants and states
Variants
.tooltip-block-end.tooltip-inline-end.tooltip-inline-start
Inside it
.tooltip-term
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:focus:popover-opendata-legacy
Before you ship it
This one needs a script. Opt the markup in with data-ui="tooltip" and import import 'mostlycss/js/components/tooltip' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.
What you have to do 4 requirements
- Ship
data-ui="tooltip". It is the component, not an enhancement: without it the tooltip never opens in Firefox or WebKit, and in WebKit it cannot be dismissed once it has, which fails WCAG 1.4.13 outright. - Trigger it from a
<button>or an<a>, and nothing else. A tooltip on a<span>is a tooltip a keyboard user cannot reach. - Write neither
aria-describedbynorrole="tooltip". Both are supplied for you on whichever path runs, and writing them by hand doubles them. - For a control that is unavailable, use
aria-disabled="true"and leavedisabledoff, handling the click yourself. Adisabledbutton fires no pointer events in any engine — which is exactly the button whose tooltip would explain why it is disabled.
src/css/components/tooltip.css · npx mostlycss add tooltip