A delayed hover and keyboard-focus tooltip that fades and slides in from its side with a pointing arrow, measured placement and an automatic viewport flip. The demo shows a formatting toolbar with shortcut hints plus a labelled button.
Preview
Customize
12 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add tooltipWrites components/ui/tooltip.tsx and components/styles/tooltip.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Tooltip } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Tooltip />6 );7}Props
Accessibility
Implements the WAI-ARIA tooltip pattern: the bubble has role="tooltip" and the focusable trigger references it with aria-describedby while it is shown. Opens after a delay on mouse hover and immediately on keyboard focus (focus-visible only), stays open while the pointer is over the bubble, and closes on blur, pointer down, pointer leave or Escape. Icon-only triggers carry aria-label. The bubble is portaled to document.body only after mount and copies the --mf-* theme variables from its trigger. Under prefers-reduced-motion it only fades.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/tooltip.module.css";7 8type Side = "top" | "right" | "bottom" | "left";9type Align = "start" | "center" | "end";10 11export interface TooltipProps {12 /** Tooltip text for the labelled trigger (or for `children` when provided) */13 content?: string;14 /** Label of the text trigger button in the default demo */15 triggerLabel?: string;16 /** Custom trigger; replaces the demo toolbar and gets the `content` tooltip */17 children?: React.ReactNode;18 /** Preferred side (flips to the opposite side when there is no room) */19 side?: Side;20 /** Alignment along the trigger edge */21 align?: Align;22 /** Hover delay in milliseconds before the tooltip opens */23 delayDuration?: number;24 /** Window in ms after a tooltip closes during which the next one opens instantly */25 skipDelayDuration?: number;26 /** Gap between trigger and tooltip in pixels */27 sideOffset?: number;28 /** Show the pointing arrow */29 showArrow?: boolean;30 /** Visual style of the tooltip bubble */31 variant?: "default" | "subtle" | "accent";32 /** Accent color used by the accent variant and focus highlights */33 accentColor?: string;34 /** Show keyboard shortcut hints in the toolbar tooltips */35 showShortcuts?: boolean;36 /** Disable all tooltips */37 disabled?: boolean;38 /** Optional className merged onto the root */39 className?: string;40 /** Optional inline styles merged onto the root */41 style?: React.CSSProperties;42}43 44interface Config {45 side: Side;46 align: Align;47 delay: number;48 skip: number;49 offset: number;50 arrow: boolean;51 disabled: boolean;52 layerStyle: React.CSSProperties;53 variantClass: string;54}55 56const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];57const FOCUSABLE = 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])';58const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;59const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(v, max));60const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };61let lastClosedAt = -Infinity;62 63function readThemeVars(el: Element | null): Record<string, string> {64 const out: Record<string, string> = {};65 if (!el) return out;66 const cs = getComputedStyle(el);67 for (const t of THEME_TOKENS) {68 const v = cs.getPropertyValue(t).trim();69 if (v) out[t] = v;70 }71 return out;72}73 74/** Measured placement with a collision flip and cross-axis clamping against the viewport. */75function computePlacement(r: DOMRect, w: number, h: number, side: Side, align: Align, off: number) {76 const vw = window.innerWidth;77 const vh = window.innerHeight;78 const pad = 8;79 const room: Record<Side, number> = { top: r.top, bottom: vh - r.bottom, left: r.left, right: vw - r.right };80 const need = (side === "top" || side === "bottom" ? h : w) + off + pad;81 const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;82 const vertical = s === "top" || s === "bottom";83 let top: number;84 let left: number;85 if (vertical) {86 top = s === "top" ? r.top - off - h : r.bottom + off;87 left = align === "start" ? r.left : align === "end" ? r.right - w : r.left + r.width / 2 - w / 2;88 left = clamp(left, pad, vw - w - pad);89 } else {90 left = s === "left" ? r.left - off - w : r.right + off;91 top = align === "start" ? r.top : align === "end" ? r.bottom - h : r.top + r.height / 2 - h / 2;92 top = clamp(top, pad, vh - h - pad);93 }94 const arrow = vertical ? clamp(r.left + r.width / 2 - left, 10, w - 10) : clamp(r.top + r.height / 2 - top, 10, h - 10);95 return { top, left, side: s, arrow };96}97 98function TooltipAnchor({ content, shortcut, cfg, children }: { content: string; shortcut?: string; cfg: Config; children: React.ReactNode }) {99 const reduce = useReducedMotion();100 const id = React.useId();101 const [open, setOpen] = React.useState(false);102 const [mounted, setMounted] = React.useState(false);103 const [pos, setPos] = React.useState<ReturnType<typeof computePlacement> | null>(null);104 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});105 const wrapRef = React.useRef<HTMLSpanElement>(null);106 const tipRef = React.useRef<HTMLDivElement>(null);107 const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);108 109 React.useEffect(() => {110 setMounted(true);111 return () => clearTimeout(timer.current);112 }, []);113 114 const show = (instant: boolean) => {115 clearTimeout(timer.current);116 if (cfg.disabled) return;117 if (instant || cfg.delay <= 0 || performance.now() - lastClosedAt < cfg.skip) setOpen(true);118 else timer.current = setTimeout(() => setOpen(true), cfg.delay);119 };120 const hide = (instant: boolean) => {121 clearTimeout(timer.current);122 if (instant) setOpen(false);123 else timer.current = setTimeout(() => setOpen(false), 100);124 };125 126 const place = React.useCallback(() => {127 const anchor = wrapRef.current;128 const tip = tipRef.current;129 if (!anchor || !tip) return;130 setPos(computePlacement(anchor.getBoundingClientRect(), tip.offsetWidth, tip.offsetHeight, cfg.side, cfg.align, cfg.offset));131 }, [cfg.side, cfg.align, cfg.offset]);132 133 useIsoLayoutEffect(() => {134 const target = wrapRef.current?.querySelector<HTMLElement>(FOCUSABLE) ?? wrapRef.current;135 if (!open) {136 target?.removeAttribute("aria-describedby");137 lastClosedAt = performance.now();138 return;139 }140 setThemeVars(readThemeVars(wrapRef.current));141 target?.setAttribute("aria-describedby", id);142 place();143 const onKey = (e: KeyboardEvent) => {144 if (e.key === "Escape") hide(true);145 };146 document.addEventListener("keydown", onKey);147 window.addEventListener("resize", place);148 window.addEventListener("scroll", place, true);149 return () => {150 document.removeEventListener("keydown", onKey);151 window.removeEventListener("resize", place);152 window.removeEventListener("scroll", place, true);153 };154 }, [open, place, id]);155 156 const s = pos?.side ?? cfg.side;157 const dist = reduce ? 0 : 4;158 const from = s === "top" ? { y: dist } : s === "bottom" ? { y: -dist } : s === "left" ? { x: dist } : { x: -dist };159 160 return (161 <span162 ref={wrapRef}163 className={styles.anchor}164 onPointerEnter={(e) => e.pointerType !== "touch" && show(false)}165 onPointerLeave={() => hide(false)}166 onPointerDown={() => hide(true)}167 onFocus={(e) => {168 let visible = true;169 try {170 visible = e.target.matches(":focus-visible");171 } catch {172 visible = true;173 }174 if (visible) show(true);175 }}176 onBlur={() => hide(true)}177 >178 {children}179 {mounted &&180 createPortal(181 <AnimatePresence>182 {open && (183 <motion.div184 ref={tipRef}185 id={id}186 role="tooltip"187 data-side={s}188 className={`${styles.tooltip} ${cfg.variantClass}`.trim()}189 style={{ ...themeVars, ...cfg.layerStyle, top: pos?.top ?? 0, left: pos?.left ?? 0, visibility: pos ? "visible" : "hidden", "--mf-tt-arrow": `${pos?.arrow ?? 0}px` } as React.CSSProperties}190 initial={{ opacity: 0, scale: reduce ? 1 : 0.96, ...from }}191 animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}192 exit={{ opacity: 0, scale: reduce ? 1 : 0.98, transition: { duration: 0.1 } }}193 transition={{ duration: reduce ? 0.08 : 0.16, ease: [0.16, 1, 0.3, 1] }}194 onPointerEnter={() => clearTimeout(timer.current)}195 onPointerLeave={() => hide(false)}196 >197 <span>{content}</span>198 {shortcut && <kbd className={styles.kbd}>{shortcut}</kbd>}199 {cfg.arrow && <span aria-hidden="true" className={styles.arrow} />}200 </motion.div>201 )}202 </AnimatePresence>,203 document.body,204 )}205 </span>206 );207}208 209const Icon = ({ d }: { d: string }) => (210 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">211 <path d={d} />212 </svg>213);214 215const TOOLBAR = [216 { label: "Bold", shortcut: "Ctrl B", d: "M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" },217 { label: "Italic", shortcut: "Ctrl I", d: "M19 4h-9M14 20H5M15 4 9 20" },218 { label: "Insert link", shortcut: "Ctrl K", d: "M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" },219];220 221export function Tooltip({222 content = "Add to library",223 triggerLabel = "Hover me",224 children,225 side = "top",226 align = "center",227 delayDuration = 400,228 skipDelayDuration = 300,229 sideOffset = 8,230 showArrow = true,231 variant = "default",232 accentColor = "#6366f1",233 showShortcuts = true,234 disabled = false,235 className = "",236 style,237}: TooltipProps) {238 const cfg: Config = {239 side,240 align,241 delay: Math.max(0, delayDuration),242 skip: Math.max(0, skipDelayDuration),243 offset: sideOffset,244 arrow: showArrow,245 disabled,246 layerStyle: { "--mf-tt-accent": accentColor } as React.CSSProperties,247 variantClass: styles[variant] ?? "",248 };249 250 return (251 <div className={`${styles.root} ${className}`.trim()} style={{ "--mf-tt-accent": accentColor, ...style } as React.CSSProperties}>252 {children !== undefined ? (253 <TooltipAnchor content={content} cfg={cfg}>254 {children}255 </TooltipAnchor>256 ) : (257 <>258 <div role="group" aria-label="Formatting" className={styles.toolbar}>259 {TOOLBAR.map((t) => (260 <TooltipAnchor key={t.label} content={t.label} shortcut={showShortcuts ? t.shortcut : undefined} cfg={cfg}>261 <button type="button" aria-label={t.label} className={styles.iconButton}>262 <Icon d={t.d} />263 </button>264 </TooltipAnchor>265 ))}266 </div>267 <TooltipAnchor content={content} cfg={cfg}>268 <button type="button" className={styles.button}>269 {triggerLabel}270 </button>271 </TooltipAnchor>272 </>273 )}274 </div>275 );276}