/* Animated tooltips: each fx-* class sets the hidden state; the open state always ends at no transform.
Under prefers-reduced-motion only a short fade remains. */
.tt{--tt-bg:#3b0d1f;--tt-ink:#fff5f8;--tt-gap:10px;--tt-arrow:6px;--tt-in:.2s;position:relative;display:inline-flex;vertical-align:middle}
.tt-tip{position:absolute;z-index:30;bottom:calc(100% + var(--tt-gap));left:50%;translate:calc(-50% + var(--tt-nudge,0px)) 0;width:max-content;max-width:200px;padding:7px 11px;border-radius:9px;
background:var(--tt-bg);color:var(--tt-ink);font:500 .8125rem/1.45 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;text-align:center;
box-shadow:0 12px 26px -12px rgba(59,13,31,.6);transform-origin:50% calc(100% + var(--tt-arrow));
visibility:hidden;opacity:0;scale:0;transition:opacity var(--tt-in),visibility var(--tt-in),transform var(--tt-in),filter var(--tt-in),scale 0s var(--tt-in)}
.tt:is(:hover,:focus-within,.is-open):not(.is-dismissed)>.tt-tip{visibility:visible;opacity:1;scale:1;transform:none;filter:none;
transition:opacity var(--tt-in),visibility 0s,transform var(--tt-in) var(--tt-ease,ease-out),filter var(--tt-in),scale 0s}
.tt-tip::before{content:"";position:absolute;top:100%;left:calc(50% - var(--tt-arrow) - var(--tt-nudge,0px));border:var(--tt-arrow) solid transparent;border-bottom-width:0;border-top-color:var(--tt-bg)}
.tt-tip::after{content:"";position:absolute;top:100%;left:0;right:0;height:var(--tt-gap)}
.fx-scale>.tt-tip{transform:scale(.8)}
.fx-slide>.tt-tip{transform:translateY(8px)}
.fx-spring{--tt-in:.5s;--tt-ease:linear(0,.25 8%,.82 24%,1.08 36%,1.1 42%,1.03 56%,.99 68%,1)}
.fx-spring>.tt-tip{transform:scale(.5)}
.fx-blur>.tt-tip{transform:scale(1.06);filter:blur(6px)}
/* Delayed: waits 400 ms on hover so sweeping the pointer across a row does not flash tooltips; keyboard focus is instant. */
.fx-delay:hover:not(:focus-within,.is-open,.is-dismissed)>.tt-tip{transition-delay:.4s}
/* Triggers */
.btn{display:inline-flex;align-items:center;gap:8px;height:38px;padding:0 14px;border:1px solid #ecc9d4;border-radius:999px;background:#fff;color:#2a1520;
font:600 .875rem/1 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;cursor:pointer;transition:background-color .15s}
.btn:hover{background:#fdf2f4}
.btn svg{width:17px;height:17px;color:#be123c}
.swatches{display:flex;flex-wrap:wrap;gap:12px;padding-top:56px}
.sw{width:44px;height:44px;padding:0;border:3px solid #fff;border-radius:50%;background:var(--c);box-shadow:0 0 0 1px #ecc9d4;cursor:pointer}
:is(.btn,.sw):focus-visible{outline:2px solid #be123c;outline-offset:3px}
@media (prefers-reduced-motion:reduce){
.tt .tt-tip,.tt:is(:hover,:focus-within,.is-open):not(.is-dismissed)>.tt-tip{transform:none;filter:none;transition:opacity .1s,visibility .1s}
.btn{transition:none}
}
JavaScript
// Tooltip behaviour for WCAG 1.4.13: Esc dismisses without moving focus or the pointer, a tap opens it on touch screens,
// opening one closes the others, and tooltips are nudged sideways to stay inside the window.
(() => {
const tts = [...document.querySelectorAll('.tt')];
const fit = t => {
const tip = t.querySelector(':scope > [role="tooltip"]'); 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 => { t.classList.remove('is-open'); t.classList.add('is-dismissed'); });
});
})();
@theme {
--color-tt: #3b0d1f;
--color-tt-ink: #fff5f8;
}
/* 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 a delayed tooltip waits before it appears. */
@custom-variant tt-hover (&:is(:where(.tt):hover:not(:focus-within, .is-open, .is-dismissed) > *));
JavaScript
// Tooltip behaviour for WCAG 1.4.13: Esc dismisses without moving focus or the pointer, a tap opens it on touch screens,
// opening one closes the others, and tooltips are nudged sideways to stay inside the window.
(() => {
const tts = [...document.querySelectorAll('.tt')];
const fit = t => {
const tip = t.querySelector(':scope > [role="tooltip"]'); 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 => { t.classList.remove('is-open'); t.classList.add('is-dismissed'); });
});
})();
About this set
Tooltip entrances that stay quick and calm. Five entrances gives each of five social buttons a different animation: Like fades, Repost scales up from the tip of its arrow, Save slides up eight pixels, Follow springs in with a slight overshoot drawn by the CSS linear() easing function, and Send sharpens from a blur. Delay on hover, instant on focus is a row of glaze colour swatches for a ceramics shop whose tooltips wait 400 milliseconds before appearing on hover, so sweeping the pointer across the row does not flash six labels, while keyboard focus shows them at once. Every entrance is one class that sets the hidden state, such as transform: scale(.8); the open state always returns to no transform, so mixing entrances needs no extra rules. All of them last 200 milliseconds except the spring. Under prefers-reduced-motion the movement, scaling and blur are removed and only a 100 millisecond fade remains. The Bootstrap version runs the same entrances as keyframes on Bootstrap Tooltip, using the separate scale and translate properties so they add to the position Popper sets.
What’s included
Fade, scale, slide, spring and blur entrances
Spring easing written with linear()
Hover delay of 400 ms with instant keyboard focus
One class per entrance
Only a short fade under prefers-reduced-motion
Bootstrap Tooltip with keyframe entrances
Accessibility
Each button is linked to its tooltip with aria-describedby and each swatch has an aria-label with its colour name. Tooltips open on hover and keyboard focus, Esc hides them, and they stay open under the pointer. The hover delay never applies to keyboard focus. With prefers-reduced-motion set, every transform, scale and blur is removed and a 100 ms fade is all that remains.
Customise it
Change the duration with --tt-in and the curve with --tt-ease on .tt, and the hidden state in the .fx-* rules. In Bootstrap edit the @keyframes and the animation rules on .tooltip.fx-*, and data-tt-delay sets the hover delay. In Tailwind edit the [--tt-in:...] and [transform:...] utilities.