@theme {
--color-tt: #14302c;
--color-tt-ink: #f3faf8;
}
/* tt-open: a tooltip inside a .tt wrapper that is hovered, holds focus or was opened by tap, and was not dismissed with Esc. */
@custom-variant tt-open (&:is(:where(.tt):is(:hover, :focus-within, .is-open):not(.is-dismissed) > *));
/* tt-hover: hovered but not focused, so the hovercard waits 250 ms before it appears. */
@custom-variant tt-hover (&:is(:where(.tt):hover:not(:focus-within, .is-open, .is-dismissed) > *));
JavaScript
// Hovercards and tooltips (WCAG 1.4.13): Esc hides the open one and, if focus was inside a card, returns it to the trigger;
// a tap opens it on touch screens; opening one closes the others; cards and tooltips are nudged to stay inside the window.
(() => {
const tts = [...document.querySelectorAll('.tt')];
const fit = t => {
const tip = t.querySelector(':scope > .tt-top, :scope > .tt-card'); if (!tip) return;
tip.style.removeProperty('--tt-nudge');
requestAnimationFrame(() => {
const r = tip.getBoundingClientRect(), w = document.documentElement.clientWidth;
const d = r.left < 8 ? 8 - r.left : r.right > w - 8 ? w - 8 - r.right : 0;
if (d) tip.style.setProperty('--tt-nudge', d + 'px');
});
};
const enter = t => { tts.forEach(o => o !== t && o.classList.remove('is-open')); t.classList.remove('is-dismissed'); fit(t); };
tts.forEach(t => { t.addEventListener('pointerenter', () => enter(t)); t.addEventListener('focusin', () => enter(t)); if (t.classList.contains('is-open')) fit(t); });
document.addEventListener('pointerdown', e => {
const t = e.target.closest('.tt');
if (!t) tts.forEach(o => o.classList.remove('is-open'));
else if (e.pointerType === 'touch') t.classList.add('is-open');
});
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
tts.filter(t => t.matches(':hover, :focus-within, .is-open')).forEach(t => {
const trigger = t.firstElementChild;
if (t.contains(document.activeElement) && document.activeElement !== trigger) trigger.focus();
t.classList.remove('is-open'); t.classList.add('is-dismissed');
});
});
// Click popovers: place each one under its button (the popover attribute already handles Esc, outside clicks and focus).
document.querySelectorAll('.pop[popover]').forEach(pop => {
const btn = document.querySelector(`[popovertarget="${pop.id}"]`);
const place = () => {
const r = btn.getBoundingClientRect(), vw = document.documentElement.clientWidth, w = pop.offsetWidth;
const left = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), vw - w - 8);
pop.style.left = left + 'px'; pop.style.top = r.bottom + 12 + 'px';
pop.style.setProperty('--arrow-x', r.left + r.width / 2 - left + 'px');
};
pop.addEventListener('toggle', e => {
const on = e.newState === 'open' ? addEventListener : removeEventListener;
if (e.newState === 'open') place();
on('scroll', place, true); on('resize', place);
});
});
document.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => {
navigator.clipboard?.writeText(b.dataset.copy).catch(() => {});
b.closest('.pop-acts').querySelector('[role="status"]').textContent = 'Link copied';
}));
})();
About this set
Tooltips that carry more than a word, and a popover for content you can act on. Hovercard on a name shows a profile card for Mara Okafor in an activity feed, with her role, a short note, her local time and a Message link; it waits 250 milliseconds before opening on hover so the card does not flash as the pointer crosses the feed, but opens at once on keyboard focus, and Tab moves into it. Rich tooltip with a title explains two connection badges, Sync paused and Connected, with a bold heading and a sentence, and holds no controls. Popover that opens on click shares a Q3 revenue report: a heading, a sentence about who can see it, the link, a Copy link button that reports Link copied in a status message, and a close button. Use a rich tooltip for read-only detail, a hovercard for an optional shortcut, and a click popover whenever the content has buttons or links people need. The plain CSS and Tailwind popovers use the popover attribute, so Esc, outside clicks and returning focus are handled by the browser, with a few lines that place it under its button. The Bootstrap version uses Bootstrap Tooltip and Popover.
What’s included
Hovercard with a profile, local time and a Message link
Rich tooltip with a heading and a sentence
Click popover with a copy action and a status message
Hovercard waits 250 ms on hover and opens at once on focus
Esc closes and returns focus to the trigger
Bootstrap Tooltip and Popover in the Bootstrap version
Accessibility
The rich tooltip is linked with aria-describedby and has role="tooltip". The hovercard is reached with Tab from its link, and Esc closes it and moves focus back to the link. The click popover is a labelled dialog opened by a button with aria-expanded; Esc and an outside click close it and focus returns to the button, and the copy result is announced through role="status". Focus outlines are visible and motion stops under prefers-reduced-motion.
Customise it
Colours are --tt-bg and --tt-ink for the rich tooltip and plain hex values on .tt-card and .pop in plain CSS. In Bootstrap edit the --bs-tooltip-* variables on .tooltip.tt-rich, the --bs-popover-* variables on .popover.pop and the --bs-card-* variables on .tt-card. In Tailwind change the theme colours and the hex utilities.