Components · Overlays
Tooltip
A short label on hover and keyboard focus. Stays while hovered, Escape hides it (WCAG 1.4.13).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ tooltip.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ tooltip.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.
// Tooltip behaviour, shared by the Astro, React and HTML versions. A [data-tooltip] wrapper holds one focusable
// trigger and a [role=tooltip][popover=manual]. The trigger gets aria-describedby. The tooltip shows 300ms after the
// pointer enters and at once on keyboard focus; it stays while the pointer is over the trigger or the tooltip itself
// (WCAG 1.4.13: hoverable), and Escape hides it without moving focus (dismissable).
declare global {
interface Window {
__caseroomTooltip?: boolean;
}
}
const anchored = typeof CSS !== 'undefined' && CSS.supports('anchor-name: --a');
const timers = new WeakMap<HTMLElement, number>();
const partsOf = (root: HTMLElement) => ({
tip: root.querySelector<HTMLElement>(':scope > [role=tooltip]'),
trigger: root.querySelector<HTMLElement>(':scope > :not([role=tooltip])'),
});
export function wireTooltip(root: HTMLElement) {
const { tip, trigger } = partsOf(root);
if (!tip || !trigger) return;
const focusable = trigger.matches('a[href], button, input, select, textarea, [tabindex]') ? trigger : trigger.querySelector<HTMLElement>('a[href], button, input, select, textarea, [tabindex]');
const ids = new Set((focusable?.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean));
ids.add(tip.id);
focusable?.setAttribute('aria-describedby', [...ids].join(' '));
}
function place(tip: HTMLElement, trigger: HTMLElement) {
if (anchored) return;
const t = trigger.getBoundingClientRect();
const m = tip.getBoundingClientRect();
const bottom = tip.dataset.side === 'bottom' ? t.bottom + 10 + m.height <= innerHeight : t.top - 10 - m.height < 0;
tip.style.position = 'fixed';
tip.style.top = `${bottom ? t.bottom + 10 : t.top - 10 - m.height}px`;
tip.style.left = `${Math.max(8, Math.min(t.left + t.width / 2 - m.width / 2, innerWidth - m.width - 8))}px`;
}
function show(root: HTMLElement, delay: number) {
const { tip, trigger } = partsOf(root);
if (!tip || !trigger) return;
clearTimeout(timers.get(root));
// Already open (the pointer came back, or crossed the gap onto the tooltip): keep it, no new delay.
if (tip.matches(':popover-open')) return;
timers.set(
root,
window.setTimeout(() => {
if (!tip.matches(':popover-open')) tip.showPopover();
place(tip, trigger);
}, delay),
);
}
function hide(root: HTMLElement, delay: number) {
const { tip } = partsOf(root);
clearTimeout(timers.get(root));
timers.set(
root,
window.setTimeout(() => tip?.matches(':popover-open') && tip.hidePopover(), delay),
);
}
if (typeof window !== 'undefined' && !window.__caseroomTooltip) {
window.__caseroomTooltip = true;
const wireAll = () => document.querySelectorAll<HTMLElement>('[data-tooltip]').forEach(wireTooltip);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wireAll);
else wireAll();
const rootOf = (e: Event) => (e.target as Element).closest?.<HTMLElement>('[data-tooltip]');
document.addEventListener('pointerover', (e) => {
const root = rootOf(e);
if (root && !root.contains(e.relatedTarget as Node)) {
wireTooltip(root);
show(root, 300);
}
});
document.addEventListener('pointerout', (e) => {
const root = rootOf(e);
// A grace period, so the pointer can cross the gap between trigger and tooltip (1.4.13: hoverable).
if (root && !root.contains(e.relatedTarget as Node)) hide(root, 300);
});
document.addEventListener('focusin', (e) => {
const root = rootOf(e);
if (!root) return;
wireTooltip(root);
// Keyboard focus only: a click also focuses the trigger, and the tooltip would cover what it opened.
if ((e.target as Element).matches(':focus-visible')) show(root, 0);
});
document.addEventListener('focusout', (e) => {
const root = rootOf(e);
if (root && !root.contains(e.relatedTarget as Node)) hide(root, 0);
});
document.addEventListener('pointerdown', (e) => {
const root = rootOf(e);
if (root) hide(root, 0);
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
for (const tip of document.querySelectorAll<HTMLElement>('[data-tooltip] > [role=tooltip]:popover-open')) tip.hidePopover();
});
}
export {};import { useEffect, useRef, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { wireTooltip } from './tooltip.client';
import { tooltip } from './tooltip.variants';
export interface TooltipProps {
/** Unique on the page: the tooltip's id (the trigger's aria-describedby). */
id: string;
/** The text. Keep it short; it describes the trigger, it is not a place for links or buttons. */
content: string;
side?: 'top' | 'bottom';
className?: string;
/** One focusable trigger, such as an icon button. */
children: ReactNode;
}
/** A short label shown on hover (after 300ms) and on keyboard focus; Escape hides it. */
export function Tooltip({ id, content, side = 'top', className, children }: TooltipProps) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (ref.current) wireTooltip(ref.current);
}, []);
return (
<span ref={ref} data-tooltip="" className={cn(tooltip.root, className)} style={{ anchorName: `--${id}` } as React.CSSProperties}>
{children}
<span id={id} role="tooltip" popover="manual" data-side={side} className={tooltip.content} style={{ positionAnchor: `--${id}` } as React.CSSProperties}>
{content}
</span>
</span>
);
}/** A short label for a control, on a manual popover in the top layer. Above the trigger by default, flipped when there
* is no room (CSS anchor positioning; the script places it where that is missing). It fades in after a short delay
* on hover and at once on keyboard focus; with reduced motion it only appears. */
export const tooltip = {
root: 'inline-flex',
content:
'pointer-events-auto m-0 max-w-64 rounded-md bg-foreground px-2.5 py-1.5 text-xs leading-snug font-medium text-background shadow-md [inset:auto] opacity-0 transition-[opacity,display,overlay] transition-discrete duration-100 open:opacity-100 starting:open:opacity-0 supports-[anchor-name:--a]:[position-area:top] supports-[anchor-name:--a]:mb-2.5 supports-[anchor-name:--a]:[position-try-fallbacks:flip-block] data-[side=bottom]:supports-[anchor-name:--a]:[position-area:bottom] data-[side=bottom]:supports-[anchor-name:--a]:mt-2.5 data-[side=bottom]:supports-[anchor-name:--a]:mb-0',
};---
import { cn } from '@/lib/utils';
import { tooltip } from './tooltip.variants';
interface Props {
/** Unique on the page: the tooltip's id (the trigger's aria-describedby). */
id: string;
/** The text. Keep it short; it describes the trigger, it is not a place for links or buttons. */
content: string;
side?: 'top' | 'bottom';
class?: string;
}
const { id, content, side = 'top', class: className } = Astro.props;
---
<span data-tooltip class={cn(tooltip.root, className)} style={`anchor-name: --${id}`}>
<slot />
<span id={id} role="tooltip" popover="manual" data-side={side} class={tooltip.content} style={`position-anchor: --${id}`}>{content}</span>
</span>
<script>
import './tooltip.client';
</script>// Tooltip behaviour, shared by the Astro, React and HTML versions. A [data-tooltip] wrapper holds one focusable
// trigger and a [role=tooltip][popover=manual]. The trigger gets aria-describedby. The tooltip shows 300ms after the
// pointer enters and at once on keyboard focus; it stays while the pointer is over the trigger or the tooltip itself
// (WCAG 1.4.13: hoverable), and Escape hides it without moving focus (dismissable).
declare global {
interface Window {
__caseroomTooltip?: boolean;
}
}
const anchored = typeof CSS !== 'undefined' && CSS.supports('anchor-name: --a');
const timers = new WeakMap<HTMLElement, number>();
const partsOf = (root: HTMLElement) => ({
tip: root.querySelector<HTMLElement>(':scope > [role=tooltip]'),
trigger: root.querySelector<HTMLElement>(':scope > :not([role=tooltip])'),
});
export function wireTooltip(root: HTMLElement) {
const { tip, trigger } = partsOf(root);
if (!tip || !trigger) return;
const focusable = trigger.matches('a[href], button, input, select, textarea, [tabindex]') ? trigger : trigger.querySelector<HTMLElement>('a[href], button, input, select, textarea, [tabindex]');
const ids = new Set((focusable?.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean));
ids.add(tip.id);
focusable?.setAttribute('aria-describedby', [...ids].join(' '));
}
function place(tip: HTMLElement, trigger: HTMLElement) {
if (anchored) return;
const t = trigger.getBoundingClientRect();
const m = tip.getBoundingClientRect();
const bottom = tip.dataset.side === 'bottom' ? t.bottom + 10 + m.height <= innerHeight : t.top - 10 - m.height < 0;
tip.style.position = 'fixed';
tip.style.top = `${bottom ? t.bottom + 10 : t.top - 10 - m.height}px`;
tip.style.left = `${Math.max(8, Math.min(t.left + t.width / 2 - m.width / 2, innerWidth - m.width - 8))}px`;
}
function show(root: HTMLElement, delay: number) {
const { tip, trigger } = partsOf(root);
if (!tip || !trigger) return;
clearTimeout(timers.get(root));
// Already open (the pointer came back, or crossed the gap onto the tooltip): keep it, no new delay.
if (tip.matches(':popover-open')) return;
timers.set(
root,
window.setTimeout(() => {
if (!tip.matches(':popover-open')) tip.showPopover();
place(tip, trigger);
}, delay),
);
}
function hide(root: HTMLElement, delay: number) {
const { tip } = partsOf(root);
clearTimeout(timers.get(root));
timers.set(
root,
window.setTimeout(() => tip?.matches(':popover-open') && tip.hidePopover(), delay),
);
}
if (typeof window !== 'undefined' && !window.__caseroomTooltip) {
window.__caseroomTooltip = true;
const wireAll = () => document.querySelectorAll<HTMLElement>('[data-tooltip]').forEach(wireTooltip);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wireAll);
else wireAll();
const rootOf = (e: Event) => (e.target as Element).closest?.<HTMLElement>('[data-tooltip]');
document.addEventListener('pointerover', (e) => {
const root = rootOf(e);
if (root && !root.contains(e.relatedTarget as Node)) {
wireTooltip(root);
show(root, 300);
}
});
document.addEventListener('pointerout', (e) => {
const root = rootOf(e);
// A grace period, so the pointer can cross the gap between trigger and tooltip (1.4.13: hoverable).
if (root && !root.contains(e.relatedTarget as Node)) hide(root, 300);
});
document.addEventListener('focusin', (e) => {
const root = rootOf(e);
if (!root) return;
wireTooltip(root);
// Keyboard focus only: a click also focuses the trigger, and the tooltip would cover what it opened.
if ((e.target as Element).matches(':focus-visible')) show(root, 0);
});
document.addEventListener('focusout', (e) => {
const root = rootOf(e);
if (root && !root.contains(e.relatedTarget as Node)) hide(root, 0);
});
document.addEventListener('pointerdown', (e) => {
const root = rootOf(e);
if (root) hide(root, 0);
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
for (const tip of document.querySelectorAll<HTMLElement>('[data-tooltip] > [role=tooltip]:popover-open')) tip.hidePopover();
});
}
export {};/** A short label for a control, on a manual popover in the top layer. Above the trigger by default, flipped when there
* is no room (CSS anchor positioning; the script places it where that is missing). It fades in after a short delay
* on hover and at once on keyboard focus; with reduced motion it only appears. */
export const tooltip = {
root: 'inline-flex',
content:
'pointer-events-auto m-0 max-w-64 rounded-md bg-foreground px-2.5 py-1.5 text-xs leading-snug font-medium text-background shadow-md [inset:auto] opacity-0 transition-[opacity,display,overlay] transition-discrete duration-100 open:opacity-100 starting:open:opacity-0 supports-[anchor-name:--a]:[position-area:top] supports-[anchor-name:--a]:mb-2.5 supports-[anchor-name:--a]:[position-try-fallbacks:flip-block] data-[side=bottom]:supports-[anchor-name:--a]:[position-area:bottom] data-[side=bottom]:supports-[anchor-name:--a]:mt-2.5 data-[side=bottom]:supports-[anchor-name:--a]:mb-0',
};<div class="flex items-center gap-3">
<span data-tooltip class="inline-flex" style="anchor-name: --ex-tip-copy">
<button type="button" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed size-9" aria-label="Copy">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"></rect><path d="M10.5 3.5V3A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5"></path></svg>
</button>
<span id="ex-tip-copy" role="tooltip" popover="manual" data-side="top" class="pointer-events-auto m-0 max-w-64 rounded-md bg-foreground px-2.5 py-1.5 text-xs leading-snug font-medium text-background shadow-md [inset:auto] opacity-0 transition-[opacity,display,overlay] transition-discrete duration-100 open:opacity-100 starting:open:opacity-0 supports-[anchor-name:--a]:[position-area:top] supports-[anchor-name:--a]:mb-2.5 supports-[anchor-name:--a]:[position-try-fallbacks:flip-block] data-[side=bottom]:supports-[anchor-name:--a]:[position-area:bottom] data-[side=bottom]:supports-[anchor-name:--a]:mt-2.5 data-[side=bottom]:supports-[anchor-name:--a]:mb-0" style="position-anchor: --ex-tip-copy">
Copy the install command
</span>
</span>
<span data-tooltip class="inline-flex" style="anchor-name: --ex-tip-deploy">
<button type="button" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4">
Deploy
</button>
<span id="ex-tip-deploy" role="tooltip" popover="manual" data-side="bottom" class="pointer-events-auto m-0 max-w-64 rounded-md bg-foreground px-2.5 py-1.5 text-xs leading-snug font-medium text-background shadow-md [inset:auto] opacity-0 transition-[opacity,display,overlay] transition-discrete duration-100 open:opacity-100 starting:open:opacity-0 supports-[anchor-name:--a]:[position-area:top] supports-[anchor-name:--a]:mb-2.5 supports-[anchor-name:--a]:[position-try-fallbacks:flip-block] data-[side=bottom]:supports-[anchor-name:--a]:[position-area:bottom] data-[side=bottom]:supports-[anchor-name:--a]:mt-2.5 data-[side=bottom]:supports-[anchor-name:--a]:mb-0" style="position-anchor: --ex-tip-deploy">
Deploys the main branch to production
</span>
</span>
</div><script type="module" src="/caseroom/r/html/tooltip.js"></script>