Tooltip
stimeo--tooltip
A hover and focus hint: show and hide delays, survives the pointer moving onto it, Esc dismisses.
A hint that appears on hover and on focus. Showing and hiding each have their own delay, so a cursor passing over never makes it flicker. Moving the pointer from the trigger onto the hint keeps it up, so its text can be read and selected. Esc dismisses it while it shows, including when hover opened it and focus sits somewhere else entirely. The hint never takes focus and holds nothing interactive. That satisfies WCAG 1.4.13: hoverable, dismissible, persistent. Placement here is this demo'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">
<%= 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>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.events") %></span>
<span class="demo-readout__value" data-readout>—</span>
</p>
/*
* 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();
});
// Every move of the open state is reported, with a `reason` saying what drove it
// — a trigger, Escape, a click outside, or a call from your own code. A real app
// subscribes to mirror the state elsewhere or to send an analytics event; this
// demo writes the event name and its reason into the readout so both are visible
// while you interact. `reason: "api"` is the one to watch for: a close you asked
// for comes back to your own listener.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const stateReadout = demoStage.querySelector("[data-readout]");
const stateRoot = demoStage.querySelector("[data-controller~='stimeo--tooltip']");
for (const name of ["open", "close"]) {
stateRoot?.addEventListener(`stimeo--tooltip:${name}`, (event) => {
const reason = event.detail?.reason;
if (stateReadout) {
stateReadout.textContent = reason
? `stimeo--tooltip:${name} · reason: ${reason}`
: `stimeo--tooltip:${name}`;
}
});
}
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 |
closeOnScroll
|
Whether window or scrollable-ancestor scrolling dismisses a shown tooltip without moving focus (default false). | data-stimeo--tooltip-close-on-scroll-value |
Actions
| Name | Description | Action |
|---|---|---|
hide
|
Hides the tooltip after hideDelay (or immediately at 0); cancels a pending show. |
stimeo--tooltip#hide |
show
|
Shows the tooltip after showDelay (or immediately at 0); cancels a pending hide. |
stimeo--tooltip#show |
Events
| Name | Description | Event |
|---|---|---|
close
|
Fires right after the tooltip hides; detail.reason says what moved it. |
stimeo--tooltip:close |
open
|
Fires right after the tooltip appears; detail.reason says what moved it. |
stimeo--tooltip:open |
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. |