Tooltip
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it. Uses the Popover API with CSS anchor positioning for placement.
Default
<button class="btn" data-variant="outline" data-tooltip-trigger="my-tip">Hover me</button>
<div class="tooltip" id="my-tip" popover="hint" role="tooltip">Add to library</div>
Side
Use data-side on the tooltip to control placement.
<!-- Top (default) -->
<button data-tooltip-trigger="tip-top">Top</button>
<div class="tooltip" id="tip-top" popover="hint" role="tooltip">Top tooltip</div>
<!-- Bottom -->
<button data-tooltip-trigger="tip-bottom">Bottom</button>
<div class="tooltip" id="tip-bottom" popover="hint" role="tooltip" data-side="bottom">Bottom tooltip</div>
<!-- Left -->
<button data-tooltip-trigger="tip-left">Left</button>
<div class="tooltip" id="tip-left" popover="hint" role="tooltip" data-side="left">Left tooltip</div>
<!-- Right -->
<button data-tooltip-trigger="tip-right">Right</button>
<div class="tooltip" id="tip-right" popover="hint" role="tooltip" data-side="right">Right tooltip</div>
With Arrow
Add <div data-arrow></div> inside the tooltip for a connecting caret.
<button data-tooltip-trigger="my-tip">Hover me</button>
<div class="tooltip" id="my-tip" popover="hint" role="tooltip">
Tooltip text
<div data-arrow></div>
</div>
With Keyboard Shortcut
<button class="btn" data-variant="outline" data-tooltip-trigger="tip-kbd">Save</button>
<div class="tooltip" id="tip-kbd" popover="hint" role="tooltip"
style="display:flex;align-items:center;gap:0.5rem;" data-side="bottom">
Save document
<kbd style="font-size:0.625rem;padding:0.125rem 0.375rem;border-radius:var(--radius-sm);
background:oklch(from var(--primary-foreground) l c h / 0.15);
color:var(--primary-foreground);font-family:var(--font-mono);">⌘S</kbd>
</div>
Alignment
Use data-align to control tooltip alignment relative to the trigger.
<!-- Start-aligned -->
<div class="tooltip" id="tip" popover="hint" role="tooltip" data-align="start">...</div>
<!-- End-aligned -->
<div class="tooltip" id="tip" popover="hint" role="tooltip" data-align="end">...</div>
Disabled Button
Wrap a disabled button in a <span> with the trigger attribute, since disabled elements don't fire mouse events.
<span data-tooltip-trigger="tip-disabled" style="cursor:not-allowed;">
<button class="btn" disabled style="pointer-events:none;">Disabled</button>
</span>
<div class="tooltip" id="tip-disabled" popover="hint" role="tooltip">
This action is unavailable
</div>
Custom Delay
Use data-delay on the trigger to override the default 700 ms open delay.
<!-- Instant -->
<button data-tooltip-trigger="tip" data-delay="0">Instant</button>
<!-- Custom delay -->
<button data-tooltip-trigger="tip" data-delay="200">Fast</button>
Group Behavior
Once any tooltip opens, subsequent tooltips in the page skip the delay and appear instantly. After 400 ms with no tooltip visible, the delay resets. Hover across the buttons below to see the effect.
<!-- Once any tooltip is open, the rest appear instantly -->
<button data-tooltip-trigger="tip-bold">B</button>
<div class="tooltip" id="tip-bold" popover="hint" role="tooltip">Bold</div>
<button data-tooltip-trigger="tip-italic">I</button>
<div class="tooltip" id="tip-italic" popover="hint" role="tooltip">Italic</div>
CSS view file
Uses position-area for placement, position-try-fallbacks for collision avoidance, and @starting-style for direction-aware animations.
@layer components {
.tooltip {
position: fixed;
inset: auto;
margin: 0;
border: none;
border-radius: var(--radius-md);
background-color: var(--primary);
color: var(--primary-foreground);
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
line-height: 1.4;
box-shadow: var(--shadow-sm);
pointer-events: none;
max-width: 16rem;
width: max-content;
opacity: 0;
transition: opacity 150ms ease, translate 150ms ease, display 150ms allow-discrete;
/* -- Default: top center -- */
position-area: top;
margin-bottom: 6px;
translate: 0 2px;
position-try-fallbacks: flip-block, flip-inline;
&:popover-open { opacity: 1; translate: 0 0; }
/* ----- Side variants ----- */
&[data-side="bottom"] {
position-area: bottom;
margin-bottom: 0;
margin-top: 6px;
translate: 0 -2px;
&:popover-open { translate: 0 0; }
}
&[data-side="left"] {
position-area: left;
margin-bottom: 0;
margin-right: 6px;
translate: 2px 0;
&:popover-open { translate: 0 0; }
}
&[data-side="right"] {
position-area: right;
margin-bottom: 0;
margin-left: 6px;
translate: -2px 0;
&:popover-open { translate: 0 0; }
}
/* ----- Align variants (combined with side) ----- */
&[data-align="start"] {
&:not([data-side="left"]):not([data-side="right"]) { position-area: top left; }
&[data-side="bottom"] { position-area: bottom left; }
&[data-side="left"] { position-area: left top; }
&[data-side="right"] { position-area: right top; }
}
&[data-align="end"] {
&:not([data-side="left"]):not([data-side="right"]) { position-area: top right; }
&[data-side="bottom"] { position-area: bottom right; }
&[data-side="left"] { position-area: left bottom; }
&[data-side="right"] { position-area: right bottom; }
}
/* ----- Arrow ----- */
& [data-arrow] {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
rotate: 45deg;
border: none;
}
/* Arrow placement — default (top side): arrow at bottom center */
&:not([data-side]), &[data-side="top"] {
& [data-arrow] { bottom: -4px; left: 50%; margin-left: -4px; }
}
&[data-side="bottom"] {
& [data-arrow] { top: -4px; left: 50%; margin-left: -4px; }
}
&[data-side="left"] {
& [data-arrow] { right: -4px; top: 50%; margin-top: -4px; }
}
&[data-side="right"] {
& [data-arrow] { left: -4px; top: 50%; margin-top: -4px; }
}
}
@starting-style {
.tooltip:popover-open { opacity: 0; translate: 0 2px; }
.tooltip[data-side="bottom"]:popover-open { opacity: 0; translate: 0 -2px; }
.tooltip[data-side="left"]:popover-open { opacity: 0; translate: 2px 0; }
.tooltip[data-side="right"]:popover-open { opacity: 0; translate: -2px 0; }
}
}
JavaScript view file
Delay, group behavior, ARIA wiring, and scroll dismiss.
// -- Tooltip --------------------------------------------------
// Popover API tooltips with delay, group behavior, ARIA wiring,
// CSS anchor positioning, and scroll dismiss.
const DELAY_DEFAULT = 700; // ms before first tooltip opens
const CLOSE_DELAY_DEFAULT = 0; // ms before tooltip closes
const GROUP_TIMEOUT = 400; // ms after last tooltip hides before delay resets
let groupOpen = false; // true while any tooltip is visible
let groupTimer = null; // timeout to reset groupOpen
function markGroupOpen() {
groupOpen = true;
clearTimeout(groupTimer);
}
function scheduleGroupReset() {
clearTimeout(groupTimer);
groupTimer = setTimeout(() => { groupOpen = false; }, GROUP_TIMEOUT);
}
function init() {
document.querySelectorAll('[data-tooltip-trigger]:not([data-init])').forEach((trigger) => {
trigger.dataset.init = '';
const tip = document.getElementById(trigger.dataset.tooltipTrigger);
if (!tip) return;
// CSS anchor positioning — unique name per trigger-tooltip pair
const anchorId = `--tooltip-${tip.id}`;
trigger.style.anchorName = anchorId;
tip.style.positionAnchor = anchorId;
// ARIA — link trigger to tooltip
trigger.setAttribute('aria-describedby', tip.id);
const delay = Number(trigger.dataset.delay ?? DELAY_DEFAULT);
const closeDelay = Number(trigger.dataset.closeDelay ?? CLOSE_DELAY_DEFAULT);
let openTimer = null;
let closeTimer = null;
function show() {
clearTimeout(closeTimer);
clearTimeout(openTimer);
const wait = groupOpen ? 0 : delay;
openTimer = setTimeout(() => {
try { tip.showPopover(); } catch (e) { /* already open */ }
markGroupOpen();
}, wait);
}
function hide() {
clearTimeout(openTimer);
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
try { tip.hidePopover(); } catch (e) { /* already closed */ }
scheduleGroupReset();
}, closeDelay);
}
trigger.addEventListener('mouseenter', show);
trigger.addEventListener('mouseleave', hide);
trigger.addEventListener('focus', show);
trigger.addEventListener('blur', hide);
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });
// -- Scroll dismiss -------------------------------------------
// Hide any open tooltip when the page scrolls.
if (!document.__tooltipScrollInit) {
document.__tooltipScrollInit = true;
document.addEventListener('scroll', () => {
document.querySelectorAll('.tooltip:popover-open').forEach((tip) => {
try { tip.hidePopover(); } catch (e) {}
});
}, { passive: true, capture: true });
}