Tooltip
Delayed, keyboard-accessible tooltips with shortcut hints.
components/ui/tooltip.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Bold, Italic, Link2, Underline } from "lucide-react";
import { useId, useRef, useState, type ReactElement, type ReactNode } from "react";
type TooltipProps = {
content: ReactNode;
children: ReactElement;
side?: "top" | "bottom";
/** Milliseconds before showing */
delay?: number;
};
export function Tooltip({ content, children, side = "top", delay = 150 }: TooltipProps) {
const [open, setOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const id = useId();
const show = () => {
clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(true), delay);
};
const hide = () => {
clearTimeout(timer.current);
setOpen(false);
};
return (
<span className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide} aria-describedby={open ? id : undefined}>
{children}
<AnimatePresence>
{open && (
<motion.span
id={id}
role="tooltip"
initial={{ opacity: 0, y: side === "top" ? 4 : -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12 }}
className={`pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-xs font-medium text-white shadow-lg ${
side === "top" ? "bottom-full mb-2" : "top-full mt-2"
}`}
>
{content}
</motion.span>
)}
</AnimatePresence>
</span>
);
}
export default function Demo() {
const tools = [
{ icon: Bold, label: "Bold", keys: "⌘B" },
{ icon: Italic, label: "Italic", keys: "⌘I" },
{ icon: Underline, label: "Underline", keys: "⌘U" },
{ icon: Link2, label: "Insert link", keys: "⌘K" },
];
return (
<div className="flex gap-1 rounded-xl border border-zinc-200 bg-white p-1 shadow-sm">
{tools.map((t) => (
<Tooltip
key={t.label}
content={
<span className="flex items-center gap-2">
{t.label} <kbd className="font-mono text-zinc-400">{t.keys}</kbd>
</span>
}
>
<button aria-label={t.label} className="grid size-9 place-items-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900">
<t.icon className="size-4" />
</button>
</Tooltip>
))}
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/tooltip.tsxcomponents/ui/tooltip.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Bold, Italic, Link2, Underline } from "lucide-react"; import { useId, useRef, useState, type ReactElement, type ReactNode } from "react"; type TooltipProps = { content: ReactNode; children: ReactElement; side?: "top" | "bottom"; /** Milliseconds before showing */ delay?: number; }; export function Tooltip({ content, children, side = "top", delay = 150 }: TooltipProps) { const [open, setOpen] = useState(false); const timer = useRef<ReturnType<typeof setTimeout>>(undefined); const id = useId(); const show = () => { clearTimeout(timer.current); timer.current = setTimeout(() => setOpen(true), delay); }; const hide = () => { clearTimeout(timer.current); setOpen(false); }; return ( <span className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide} onFocus={show} onBlur={hide} aria-describedby={open ? id : undefined}> {children} <AnimatePresence> {open && ( <motion.span id={id} role="tooltip" initial={{ opacity: 0, y: side === "top" ? 4 : -4, scale: 0.95 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.12 }} className={`pointer-events-none absolute left-1/2 z-50 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 text-xs font-medium text-white shadow-lg ${ side === "top" ? "bottom-full mb-2" : "top-full mt-2" }`} > {content} </motion.span> )} </AnimatePresence> </span> ); } export default function Demo() { const tools = [ { icon: Bold, label: "Bold", keys: "⌘B" }, { icon: Italic, label: "Italic", keys: "⌘I" }, { icon: Underline, label: "Underline", keys: "⌘U" }, { icon: Link2, label: "Insert link", keys: "⌘K" }, ]; return ( <div className="flex gap-1 rounded-xl border border-zinc-200 bg-white p-1 shadow-sm"> {tools.map((t) => ( <Tooltip key={t.label} content={ <span className="flex items-center gap-2"> {t.label} <kbd className="font-mono text-zinc-400">{t.keys}</kbd> </span> } > <button aria-label={t.label} className="grid size-9 place-items-center rounded-lg text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"> <t.icon className="size-4" /> </button> </Tooltip> ))} </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.