Tooltip
stimeo--tooltip
A hover/focus hint with show/hide delays, a hoverable bridge, and Escape dismissal.
The stimeo--tooltip controller implements the WAI-ARIA Tooltip pattern and WCAG 1.4.13 (hoverable / dismissible / persistent). It shows on mouseenter/focusin and hides on mouseleave/focusout, each gated by showDelay/hideDelay to prevent flicker. Binding show/hide on the tooltip too keeps it up while the pointer bridges from the trigger into it. While shown, Escape is watched at the document level so it dismisses even when a hover (not focus) triggered it. The tooltip never takes focus and holds no interactive content; the aria-describedby association is declared in markup and always preserved. Placement is owned by this Playground's CSS.
Keyboard
| Key | Action |
|---|---|
| Esc | Dismiss the visible tooltip (watched document-wide, regardless of focus). |
<%# Markup for the tooltip demo.
Hover/focus on the trigger shows the supplementary description. The content is
role="tooltip", referenced from the trigger via aria-describedby. The show/hide delay
and the hoverable bridge between trigger and tooltip are the library's. Placement is
the consumer's CSS. %>
<span class="tooltip" data-controller="stimeo--tooltip"
data-stimeo--tooltip-show-delay-value="150"
data-stimeo--tooltip-hide-delay-value="200">
<button
class="demo-trigger"
type="button"
data-stimeo--tooltip-target="trigger"
aria-describedby="tooltip-content"
data-action="
mouseenter->stimeo--tooltip#show
mouseleave->stimeo--tooltip#hide
focusin->stimeo--tooltip#show
focusout->stimeo--tooltip#hide
keydown->stimeo--tooltip#onKeydown">
<%= t("components.tooltip.demo.trigger") %>
</button>
<span
class="tooltip__content"
id="tooltip-content"
role="tooltip"
data-stimeo--tooltip-target="content"
data-action="
mouseenter->stimeo--tooltip#show
mouseleave->stimeo--tooltip#hide"
hidden>
<%= t("components.tooltip.demo.body") %>
</span>
</span>
/*
* Presentation-only styles for the tooltip demo.
* Show/hide is the library toggling the content's hidden; data-state (open / closed)
* can also drive transitions like a fade. Placement is static — the consumer's CSS.
*/
.tooltip {
position: relative;
display: inline-block;
}
.tooltip__content {
position: absolute;
bottom: calc(100% + 0.5rem);
/* Anchor to the trigger's left for the no-JS fallback. The opt-in positioning
module (demo.js) centers it via placement: "top" and shifts it away from the
viewport edge. Do NOT use transform: translateX(-50%) here — it stacks on top
of the inline left the positioning module sets, double-shifting the tooltip off
the left edge (it gets clipped by the scrollable .content container). */
left: 0;
z-index: 10;
width: max-content;
max-width: 16rem;
padding: 0.375rem 0.625rem;
background: var(--color-text);
color: var(--surface-page);
border-radius: 0.375rem;
font-size: 0.8125rem;
line-height: 1.4;
box-shadow: 0 4px 12px rgb(15 23 42 / 0.2);
}
/* Optional fade driven by data-state (the library only toggles hidden). */
.tooltip__content[data-state="open"] {
opacity: 1;
}
// tooltip opt-in positioning demo (consumer-side JS).
//
// The core controller (stimeo--tooltip) only handles show/hide, delay, and dismiss.
// Viewport-edge flip/shift is optionally connected to the opt-in stimeo-ui/positioning
// module. We watch the content's hidden and, while shown, track the trigger's coordinates.
import { attachPositioning } from 'stimeo-ui/positioning';
document.querySelectorAll('[data-controller~="stimeo--tooltip"]').forEach((root) => {
const trigger = root.querySelector('[data-stimeo--tooltip-target="trigger"]');
const content = root.querySelector('[data-stimeo--tooltip-target="content"]');
if (!trigger || !content) return;
let detach = null;
const sync = () => {
if (!content.hidden && !detach) {
detach = attachPositioning(trigger, content, { placement: 'top', offset: 8, padding: 8 });
} else if (content.hidden && detach) {
detach();
detach = null;
content.style.position = content.style.left = content.style.top = '';
}
};
new MutationObserver(sync).observe(content, { attributes: true, attributeFilter: ['hidden'] });
sync();
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--tooltip"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
The element described by the tooltip (aria-describedby); shows/hides it on hover/focus. |
data-stimeo--tooltip-target="trigger" |
content
required
|
The role="tooltip" text shown/hidden via hidden and data-state; never focused and non-interactive (a hoverable bridge keeps it up). |
data-stimeo--tooltip-target="content" |
Values
| Name | Description | Attribute |
|---|---|---|
showDelay
|
Milliseconds to wait before showing on hover/focus to prevent flicker (default 0). | data-stimeo--tooltip-show-delay-value |
hideDelay
|
Milliseconds to wait before hiding on leave/blur to prevent flicker (default 0). | data-stimeo--tooltip-hide-delay-value |
Actions
| Name | Description | Action |
|---|---|---|
hide
|
Hides the tooltip after hideDelay (or immediately at 0); cancels a pending show. |
stimeo--tooltip#hide |
onKeydown
|
Dismisses the tooltip on Escape from the trigger while shown. | stimeo--tooltip#onKeydown |
show
|
Shows the tooltip after showDelay (or immediately at 0); cancels a pending hide. |
stimeo--tooltip#show |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
hidden |
Content | Present while hidden, removed while shown. |
data-state |
Content | "open" / "closed" — an optional hook for fade transitions. |