Skip to content

Component

tooltip

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and tooltip.css. No scripts.

Source

Copy both files, or run npx nojsui add tooltip.

Source for tooltip
<!-- .sk-tooltip — a hint shown on hover and on keyboard focus.

     The bubble is a real element referenced by aria-describedby, so a screen
     reader announces it with the trigger whether or not it is visible. It is
     never the only place the information exists — see the README on WCAG
     1.4.13, which this pattern cannot fully satisfy without script. -->
<span class="sk-tooltip">
  <button
    type="button"
    class="sk-tooltip__trigger"
    aria-describedby="sk-tooltip-save"
  >
    Save
  </button>
  <span id="sk-tooltip-save" role="tooltip" class="sk-tooltip__bubble">
    Saves without leaving the page
  </span>
</span>

<span class="sk-tooltip">
  <button
    type="button"
    class="sk-tooltip__trigger"
    aria-describedby="sk-tooltip-archive"
  >
    Archive
  </button>
  <span id="sk-tooltip-archive" role="tooltip" class="sk-tooltip__bubble">
    Hidden from the list, kept for 30 days
  </span>
</span>
/* .sk-tooltip — a hint on hover and on keyboard focus, with no JavaScript.

   Not built on popover. Showing a popover requires showPopover() or an
   invoker, and CSS can do neither — `interesttarget` is the attribute meant to
   fix that and it is still origin-trial (unsupported in Chromium, WebKit and
   Gecko as of this writing). So the bubble is an ordinary element toggled by
   sibling selectors, which works everywhere today.

   The trade is that the bubble is not in the top layer: it can be clipped by an
   ancestor with overflow: hidden. The README says so.

   Specs:
   - :focus-visible ......... https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo
   - role=tooltip ........... https://w3c.github.io/aria/#tooltip
   - SC 1.4.13 .............. https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html */

/* Public theme knobs (ADR 0011). Read, never declared — so a consumer can set
   any of them at :root, on a section, or on one instance, and it wins. */
.sk-tooltip {
  --_trigger-bg: var(--sk-tooltip-trigger-bg, var(--sk-color-surface));
  --_trigger-fg: var(--sk-tooltip-trigger-fg, var(--sk-color-text));
  --_trigger-border: var(--sk-tooltip-trigger-border-color, var(--sk-color-border-strong));
  --_trigger-radius: var(--sk-tooltip-trigger-radius, var(--sk-radius-md));
  --_bg: var(--sk-tooltip-bg, var(--sk-color-text));
  --_fg: var(--sk-tooltip-fg, var(--sk-color-bg));
  --_radius: var(--sk-tooltip-radius, var(--sk-radius-sm));
  --_gap: var(--sk-tooltip-gap, var(--sk-space-2xs));
  --_max-inline-size: var(--sk-tooltip-max-inline-size, 16rem);

  position: relative;
  display: inline-block;
}

.sk-tooltip__trigger {
  border: var(--sk-border-width) solid var(--_trigger-border);
  border-radius: var(--_trigger-radius);
  background: var(--_trigger-bg);
  color: var(--_trigger-fg);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  font-weight: var(--sk-weight-medium);
  min-block-size: var(--sk-size-tap-target);
}

/* Absolutely positioned, so showing it never moves the page — the layout-shift
   requirement in the PRD. It is hidden with visibility rather than display so
   it can transition, and both keep it available to aria-describedby (verified
   in all three engines: a directly referenced element describes its trigger
   even while hidden). */
.sk-tooltip__bubble {
  position: absolute;
  inset-block-end: calc(100% + var(--_gap));
  inset-inline-start: 50%;
  translate: -50% 0;
  z-index: 1;
  visibility: hidden;
  opacity: 0;
  inline-size: max-content;
  max-inline-size: var(--_max-inline-size);
  padding-block: var(--sk-space-2xs);
  padding-inline: var(--sk-space-xs);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  font-size: var(--sk-text-xs);
  line-height: var(--sk-leading-snug);
  text-align: start;

  /* A tooltip is a hint, never a target. Pointer events on it would let the
     cursor "catch" the bubble and keep it open over other content. */
  pointer-events: none;
}

/* Hover on the whole wrapper rather than only the trigger, so the bubble does
   not flicker out when the pointer crosses the gap between them — the
   "hoverable" half of SC 1.4.13. */
.sk-tooltip:hover .sk-tooltip__bubble,
.sk-tooltip__trigger:focus-visible + .sk-tooltip__bubble {
  visibility: visible;
  opacity: 1;
}

@media (prefers-reduced-motion: no-preference) {
  .sk-tooltip__bubble {
    transition:
      opacity var(--sk-motion-fast) var(--sk-ease-out),
      visibility var(--sk-motion-fast) var(--sk-ease-out);
  }
}

/* Under forced colors the bubble must not be an invisible block of Canvas on
   Canvas; give it a border so its edges survive. */
@media (forced-colors: active) {
  .sk-tooltip__bubble {
    border: var(--sk-border-width) solid CanvasText;
  }
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
:focus-visiblewidely86868515.4868515.4
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Forced colorswidely89798916898916

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:

RequirementMetWhy
Hoverable — the pointer can move onto the bubble without it vanishingyesHover is tracked on the wrapper, not just the trigger
Persistent — it stays until dismissed, focus moves, or it stops being validyesIt stays while hovered or focused
Dismissible — dismissable without moving pointer or focusnoNeeds 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).

PropertyDefaultControls
--sk-tooltip-bg--sk-color-textBubble background
--sk-tooltip-fg--sk-color-bgBubble text
--sk-tooltip-radius--sk-radius-smBubble corner radius
--sk-tooltip-gap--sk-space-2xsDistance from the trigger
--sk-tooltip-max-inline-size16remWhere the bubble wraps
--sk-tooltip-trigger-bg--sk-color-surfaceTrigger background
--sk-tooltip-trigger-fg--sk-color-textTrigger text
--sk-tooltip-trigger-border-color--sk-color-border-strongTrigger border
--sk-tooltip-trigger-radius--sk-radius-mdTrigger 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

KeyBehaviour
Tab to the triggerShows the bubble (:focus-visible)
Tab awayHides it
EscNothing. 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 what aria-describedby expects 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-colors the bubble gets a CanvasText border, 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, and role="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 usedBaseline statusBehavior without it
:focus-visiblewidelyThe bubble never appears on keyboard focus. Hover still works, and aria-describedby still announces.
forced-colorswidelyNo 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.