Tooltip Pop
Tooltips pop up above icon buttons with a slight springy overshoot after a short pause, so they help without getting in the way.
Create icon-button tooltips that pop in with CSS. Each button is position: relative and holds a tooltip element with role="tooltip", referenced by aria-describedby on the button, positioned above it and centred with a small arrow from a rotated pseudo-element. At rest the tooltip has opacity 0, transform: translate(-50%, 4px) scale(0.85), transform-origin bottom centre, and pointer-events none. On :hover and :focus-visible, transition to opacity 1 and translate(-50%, 0) scale(1) over 220ms with cubic-bezier(0.34, 1.56, 0.64, 1) for a slight overshoot, after a 250ms delay so passing the pointer over the toolbar does not flash every tip. On the way out, use no delay and a 120ms fade. Under prefers-reduced-motion, fade without scaling. The tooltips read "Bold", "Italic", and "Add link".
Source
'use client'
import { useId } from 'react'
import s from './demo.module.css'
const ICONS = {
bold: <path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />,
italic: <path d="M14 5h-4M14 19h-4M13.5 5l-3 14" />,
link: <path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1" />,
}
export default function TooltipPop({ labels }: { labels: Record<string, string> }) {
const id = useId()
return (
<div className={s.bar} role="toolbar" aria-label={labels.toolbar}>
{(Object.keys(ICONS) as (keyof typeof ICONS)[]).map((k) => (
<button key={k} type="button" className={s.btn} aria-label={labels[k]} aria-describedby={`${id}-${k}`}>
<svg viewBox="0 0 24 24" aria-hidden="true">
{ICONS[k]}
</svg>
<span id={`${id}-${k}`} role="tooltip" className={s.tip}>
{labels[k]}
</span>
</button>
))}
</div>
)
}
.bar {
display: flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--stage-rule);
border-radius: 12px;
background: var(--stage-surface);
}
.btn {
position: relative;
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--stage-ink);
cursor: pointer;
}
.btn:hover { background: var(--stage-bg); }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 1px; }
.btn svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.tip {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
padding: 6px 9px;
border-radius: 6px;
background: var(--stage-ink);
color: var(--stage-bg);
font: 500 var(--text-xs) / 1 var(--font-body);
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translate(-50%, 4px) scale(0.85);
transform-origin: 50% 100%;
transition: opacity 120ms, transform 120ms;
}
.tip::after {
content: '';
position: absolute;
left: 50%;
top: 100%;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
background: inherit;
rotate: 45deg;
}
.btn:hover .tip,
.btn:focus-visible .tip {
opacity: 1;
transform: translate(-50%, 0) scale(1);
transition: opacity 220ms 250ms, transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1) 250ms;
}
@media (prefers-reduced-motion: reduce) {
.tip, .btn:hover .tip, .btn:focus-visible .tip { transform: translate(-50%, 0); }
}