UIOverlays and Menusmotion/react

Tooltip

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 props
400
300
8

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add tooltip

Writes 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.

example.tsx
1import { Tooltip } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Tooltip />6  );7}

Props

PropTypeDefault
content

Tooltip text for the labelled trigger, or for children when provided.

string"Add to library"
triggerLabel

Label of the text trigger button in the default demo.

string"Hover me"
children

Custom trigger; replaces the demo toolbar. Its first focusable element receives aria-describedby.

React.ReactNodeundefined
side

Preferred side; flips to the opposite side when the viewport has no room.

"top" | "right" | "bottom" | "left""top"
align

Alignment along the trigger edge (clamped to the viewport).

"start" | "center" | "end""center"
delayDuration

Hover delay in milliseconds before the tooltip opens.

number400
skipDelayDuration

After a tooltip closes, others open instantly within this window (ms).

number300
sideOffset

Gap between trigger and tooltip in pixels.

number8
showArrow

Show the pointing arrow.

booleantrue
variant

High-contrast, bordered surface, or accent-filled bubble.

"default" | "subtle" | "accent""default"
accentColor

Accent color used by the accent variant.

string"#6366f1"
showShortcuts

Show keyboard shortcut hints in the toolbar tooltips.

booleantrue
disabled

Disable all tooltips.

booleanfalse
className

Class merged onto the root.

string""
style

Inline styles merged onto the root.

React.CSSPropertiesundefined

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

components/ui/tooltip.tsx
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}