Component
tooltip
Demo
tokens.css,
base.css and tooltip.css. No scripts.
Source
Copy both files, or run npx nojsui add tooltip.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| :focus-visible | widely | 86 | 86 | 85 | 15.4 | 86 | 85 | 15.4 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Forced colors | widely | 89 | 79 | 89 | 16 | 89 | 89 | 16 |
Read this before using it
A CSS-only tooltip cannot fully satisfy WCAG 2.2 SC 1.4.13 (Content on Hover or Focus). That success criterion has three parts, and this component meets two:
| Requirement | Met | Why |
|---|---|---|
| Hoverable — the pointer can move onto the bubble without it vanishing | yes | Hover is tracked on the wrapper, not just the trigger |
| Persistent — it stays until dismissed, focus moves, or it stops being valid | yes | It stays while hovered or focused |
| Dismissible — dismissable without moving pointer or focus | no | Needs Esc handling, which needs JavaScript |
So: never put information here that a user needs. Treat the bubble as a
convenience that repeats or expands something already available. If the content
is required to complete a task, use visible helper text or a <details>
disclosure instead — both are script-free and neither has this problem.
The trade is deliberate and documented rather than hidden. If your project must
meet 1.4.13 in full, add an Esc handler in your own code, the same way
docs/polyfills.md handles the dialog’s floor.
Usage
<span class="sk-tooltip">
<button type="button" class="sk-tooltip__trigger" aria-describedby="tip-save">
Save
</button>
<span id="tip-save" role="tooltip" class="sk-tooltip__bubble">
Saves without leaving the page
</span>
</span>
The id must be unique on the page, and aria-describedby must point at it.
How it works
Not built on popover. Showing a popover needs showPopover() or an
invoker, and CSS can do neither. interesttarget is the attribute meant to fix
exactly this — it is still origin-trial and, measured here, unsupported in
Chromium, WebKit and Gecko. popover="hint" fares no better: Chromium and Gecko
accept the value, WebKit silently falls back to manual.
So the bubble is an ordinary element toggled with sibling selectors, which works
in every engine today. When interesttarget ships broadly this component can
move onto popover="hint" and gain top-layer painting; until then this is the
honest implementation.
The screen-reader path does not depend on visibility. The bubble is a real
element referenced by aria-describedby, and an element referenced that way
describes its trigger even while hidden — verified in all three engines with
display: none, visibility: hidden, opacity: 0 and clip. So a screen-reader
user gets the text with the button; they are not waiting on a hover they cannot
perform.
Showing it never moves the page. The bubble is absolutely positioned, so appearing costs no layout shift — measured, the neighbouring tooltip does not move.
Theming
Set any of these anywhere above the tooltip — :root, a section, or one
instance. The component only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-tooltip-bg | --sk-color-text | Bubble background |
--sk-tooltip-fg | --sk-color-bg | Bubble text |
--sk-tooltip-radius | --sk-radius-sm | Bubble corner radius |
--sk-tooltip-gap | --sk-space-2xs | Distance from the trigger |
--sk-tooltip-max-inline-size | 16rem | Where the bubble wraps |
--sk-tooltip-trigger-bg | --sk-color-surface | Trigger background |
--sk-tooltip-trigger-fg | --sk-color-text | Trigger text |
--sk-tooltip-trigger-border-color | --sk-color-border-strong | Trigger border |
--sk-tooltip-trigger-radius | --sk-radius-md | Trigger corner radius |
/* every tooltip on the page */
:root { --sk-tooltip-bg: rebeccapurple; }
/* just the ones in the toolbar */
.toolbar { --sk-tooltip-max-inline-size: 12rem; }
The bubble’s foreground and background are a contrast pair — override one and check the other still reads. Nothing here changes how the tooltip is positioned or revealed; that is behaviour, not theme.
Keyboard contract
| Key | Behaviour |
|---|---|
Tab to the trigger | Shows the bubble (:focus-visible) |
Tab away | Hides it |
Esc | Nothing. See the 1.4.13 note above |
Safari caveat. Safari’s default tab order excludes buttons unless the OS
“Full Keyboard Access” setting is on, so a keyboard user on Safari may never
reach the trigger and never see the bubble — measured: Tab does not focus the
trigger in WebKit. The aria-describedby text is still announced, which is why
that path matters more than the visual one.
Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.
Accessibility notes
role="tooltip"is on the bubble, which is whataria-describedbyexpects to find.- The bubble is
pointer-events: none. A tooltip is a hint, never a target; leaving pointer events on lets the cursor catch it and hold it open over other content. - Under
forced-colorsthe bubble gets aCanvasTextborder, so it does not become an unreadable block of Canvas on Canvas. - Do not put interactive content in the bubble. It is
pointer-events: none, cannot be reached by keyboard, androle="tooltip"promises otherwise. If you need a control in there, you need a popover, not a tooltip.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
:focus-visible | widely | The bubble never appears on keyboard focus. Hover still works, and aria-describedby still announces. |
forced-colors | widely | No high-contrast border; the bubble uses the token palette. |
Everything here is Baseline widely available — the constraint on this component is not browser support, it is the 1.4.13 gap above.
One structural limitation: the bubble is not in the top layer, so an
ancestor with overflow: hidden clips it. Top-layer painting requires
popover, which requires an invoker CSS cannot trigger. If your trigger lives
inside a clipping container, that is the case for waiting on interesttarget.