diff --git a/apps/www/components/ui/animated-tooltip.tsx b/apps/www/components/ui/animated-tooltip.tsx new file mode 100644 index 0000000..4e3b8fb --- /dev/null +++ b/apps/www/components/ui/animated-tooltip.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +type Side = "top" | "bottom" | "left" | "right"; +type Align = "start" | "center" | "end"; + +export interface AnimatedTooltipProps { + /** The trigger element. Must be a single focusable React element. */ + children: React.ReactElement; + /** Content rendered inside the tooltip bubble. */ + content: React.ReactNode; + /** Which side of the trigger the tooltip appears on. */ + side?: Side; + /** Alignment along the trigger's edge. */ + align?: Align; + /** Background colour of the tooltip bubble. */ + background?: string; + /** Text colour inside the tooltip. */ + color?: string; + /** Arrow fill colour. Defaults to `background` so it always matches. */ + arrowColor?: string; + /** Whether to render the pointing arrow. */ + arrow?: boolean; + /** Arrow edge length in px. */ + arrowSize?: number; + /** Gap between trigger and tooltip in px. */ + offset?: number; + /** Delay before the tooltip appears, in ms. */ + delay?: number; + /** Disable the tooltip entirely (trigger still renders). */ + disabled?: boolean; + /** Extra classes merged onto the tooltip bubble. */ + className?: string; +} + +function getLayoutStyle( + side: Side, + align: Align, + offset: number, +): React.CSSProperties { + const style: React.CSSProperties = {}; + const gap = `calc(100% + ${offset}px)`; + + if (side === "top") style.bottom = gap; + if (side === "bottom") style.top = gap; + if (side === "left") style.right = gap; + if (side === "right") style.left = gap; + + if (side === "top" || side === "bottom") { + if (align === "center") { + style.left = "50%"; + style.transform = "translateX(-50%)"; + } else if (align === "start") { + style.left = 0; + } else { + style.right = 0; + } + } else { + if (align === "center") { + style.top = "50%"; + style.transform = "translateY(-50%)"; + } else if (align === "start") { + style.top = 0; + } else { + style.bottom = 0; + } + } + + return style; +} + +function getArrowStyle( + side: Side, + align: Align, + size: number, + fill: string, +): React.CSSProperties { + const half = size / 2; + const style: React.CSSProperties = { + position: "absolute", + width: size, + height: size, + background: fill, + transform: "rotate(45deg)", + borderRadius: 2, + }; + + // Sit the rotated square half-way over the edge facing the trigger. + if (side === "top") style.bottom = -half; + if (side === "bottom") style.top = -half; + if (side === "left") style.right = -half; + if (side === "right") style.left = -half; + + // Position the arrow along the edge to track the chosen alignment. + const edgePad = Math.max(size, 8); + if (side === "top" || side === "bottom") { + if (align === "center") { + style.left = "50%"; + style.marginLeft = -half; + } else if (align === "start") { + style.left = edgePad; + } else { + style.right = edgePad; + } + } else { + if (align === "center") { + style.top = "50%"; + style.marginTop = -half; + } else if (align === "start") { + style.top = edgePad; + } else { + style.bottom = edgePad; + } + } + + return style; +} + +export function AnimatedTooltip({ + children, + content, + side = "top", + align = "center", + background = "#18181b", + color = "#fafafa", + arrowColor, + arrow = true, + arrowSize = 8, + offset = 8, + delay = 200, + disabled = false, + className, +}: AnimatedTooltipProps) { + const [open, setOpen] = React.useState(false); + const tooltipId = React.useId(); + const timerRef = React.useRef | null>(null); + const prefersReducedMotion = useReducedMotion(); + + const clearTimer = React.useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const show = React.useCallback(() => { + if (disabled) return; + clearTimer(); + timerRef.current = setTimeout(() => setOpen(true), delay); + }, [disabled, delay, clearTimer]); + + const hide = React.useCallback(() => { + clearTimer(); + setOpen(false); + }, [clearTimer]); + + // Clean up any pending show timer on unmount. + React.useEffect(() => clearTimer, [clearTimer]); + + // Dismiss on Escape, per WAI-ARIA tooltip guidance. + React.useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") hide(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, hide]); + + // Link the trigger to the bubble for assistive tech while it is visible. + // Event handlers live on the wrapper below — onFocus/onBlur bubble via + // focusin/focusout, so descendant focus is captured without cloning refs in. + // Merge with any existing aria-describedby so other descriptions are kept. + const originalDescribedBy = ( + children.props as React.HTMLAttributes + )["aria-describedby"]; + const mergedDescribedBy = open + ? originalDescribedBy + ? `${originalDescribedBy} ${tooltipId}` + : tooltipId + : originalDescribedBy; + const trigger = React.cloneElement(children, { + "aria-describedby": mergedDescribedBy, + } as React.HTMLAttributes); + + const directional = + side === "top" + ? { y: 4 } + : side === "bottom" + ? { y: -4 } + : side === "left" + ? { x: 4 } + : { x: -4 }; + + const motionProps = prefersReducedMotion + ? { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.12 }, + } + : { + initial: { opacity: 0, scale: 0.92, ...directional }, + animate: { opacity: 1, scale: 1, x: 0, y: 0 }, + exit: { opacity: 0, scale: 0.92, ...directional }, + transition: { type: "spring" as const, stiffness: 500, damping: 30 }, + }; + + return ( + + {trigger} + + {open && ( + + + {content} + {arrow && ( + + + )} + + + ); +} diff --git a/apps/www/config/components.ts b/apps/www/config/components.ts index da0a89c..476cc12 100644 --- a/apps/www/config/components.ts +++ b/apps/www/config/components.ts @@ -40,6 +40,7 @@ import { LucideWand2, LucideWaves, LucideWind, + MessageSquare, Terminal, } from "lucide-react"; @@ -113,6 +114,7 @@ const iconMap = { LucideWand2, LucideWaves, LucideWind, + MessageSquare, Terminal, } satisfies Record; @@ -3456,6 +3458,90 @@ const componentDefinitions = [ } ], "usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return (\n \n The backbone\n
\n of global commerce\n \n }\n />\n );\n}" + }, + { + "slug": "animated-tooltip", + "name": "Animated Tooltip", + "description": "An accessible, spring-animated tooltip with a customizable pointing arrow. Background, text, and arrow colours are independently configurable, and it follows WAI-ARIA guidance — hover/focus triggers, role=\"tooltip\", aria-describedby, and Escape to dismiss.", + "icon": "MessageSquare", + "category": "Navigation & Overlays", + "props": [ + { + "name": "children", + "type": "React.ReactElement", + "description": "The trigger element. Must be a single focusable React element (e.g. a button or link)." + }, + { + "name": "content", + "type": "React.ReactNode", + "description": "Content rendered inside the tooltip bubble." + }, + { + "name": "side", + "type": "\"top\" | \"bottom\" | \"left\" | \"right\"", + "default": "\"top\"", + "description": "Which side of the trigger the tooltip appears on." + }, + { + "name": "align", + "type": "\"start\" | \"center\" | \"end\"", + "default": "\"center\"", + "description": "Alignment along the trigger's edge." + }, + { + "name": "background", + "type": "string", + "default": "\"#18181b\"", + "description": "Background colour of the tooltip bubble." + }, + { + "name": "color", + "type": "string", + "default": "\"#fafafa\"", + "description": "Text colour inside the tooltip." + }, + { + "name": "arrowColor", + "type": "string", + "description": "Arrow fill colour. Defaults to `background` so it always matches." + }, + { + "name": "arrow", + "type": "boolean", + "default": "true", + "description": "Whether to render the pointing arrow." + }, + { + "name": "arrowSize", + "type": "number", + "default": "8", + "description": "Arrow edge length in px." + }, + { + "name": "offset", + "type": "number", + "default": "8", + "description": "Gap between the trigger and the tooltip in px." + }, + { + "name": "delay", + "type": "number", + "default": "200", + "description": "Delay before the tooltip appears, in ms." + }, + { + "name": "disabled", + "type": "boolean", + "default": "false", + "description": "Disable the tooltip entirely (the trigger still renders)." + }, + { + "name": "className", + "type": "string", + "description": "Extra Tailwind classes merged onto the tooltip bubble." + } + ], + "usageCode": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function Example() {\n return (\n \n \n \n );\n}" } ] satisfies ComponentDefinition[]; diff --git a/apps/www/config/demos.tsx b/apps/www/config/demos.tsx index 1073ecc..d90fc9d 100644 --- a/apps/www/config/demos.tsx +++ b/apps/www/config/demos.tsx @@ -80,6 +80,7 @@ import { MagneticLettersHero } from "@/components/ui/hero-magnetic-letters"; import { TerminalHero } from "@/components/ui/hero-terminal"; import { FlowFieldHero } from "@/components/ui/hero-flow-field"; import { RadialBurstHero } from "@/components/ui/hero-radial-burst"; +import { AnimatedTooltip } from "@/components/ui/animated-tooltip"; import { motion } from "motion/react"; import { useRef, useState } from "react"; import { @@ -102,6 +103,7 @@ import { Clock, Link2, Share2, + Info, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -3847,4 +3849,122 @@ export const componentDemos: Record = { } /> ), + "animated-tooltip": ({ theme = "dark" }) => { + const isDark = theme === "dark"; + const triggerClass = cn( + "rounded-md px-4 py-2 text-sm font-medium transition-colors", + isDark + ? "bg-neutral-800 text-neutral-100 hover:bg-neutral-700" + : "bg-neutral-100 text-neutral-900 hover:bg-neutral-200", + ); + + return ( +
+ {/* Placement on every side */} +
+ + + + + + + + + + + + +
+ + {/* Independent colour control */} +
+ + + + + + + + + +
+ + {/* Rich content + non-button triggers */} +
+ +

Keyboard shortcut

+

+ Press {"⌘"}K to open the command palette from anywhere. +

+
+ } + > + + + + + + + + + + Link trigger + + +
+ + ); + } +, }; diff --git a/apps/www/config/docs-scenarios.ts b/apps/www/config/docs-scenarios.ts index 7624c63..7113734 100644 --- a/apps/www/config/docs-scenarios.ts +++ b/apps/www/config/docs-scenarios.ts @@ -1131,5 +1131,36 @@ export const docsScenarios: Record = { "slug": "hero-radial-burst", "overview": "An interactive fiber-optic burst rises from a bottom-center origin that touches the bottom edge: fine rays fan across the upper semicircle (longest near vertical, forming a soft dome), each a base-bright→tip-faint gradient drawn as a soft wide glow pass plus a crisp core, with a single glowing dot riding its tip. Every ray continuously grows, slightly over-extends, fades, and respawns with a fresh angle, length, speed, and opacity, so the loop is seamless with no global reset. Hovering the middle or tip of a fiber makes it and its neighbours brighten, stretch, and bend toward the cursor before easing back; the dense zone near the origin does not react. The burst is kept to a short lower band, masked so it never reaches the headline above. Six time-of-day themes — Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night — are selectable from an in-block dropdown; switching crossfades the background gradient and eases the burst colors between palettes. `RadialBurst` is exported separately for reuse under your own layout.", "scenarios": [] + }, + "animated-tooltip": { + "slug": "animated-tooltip", + "overview": "AnimatedTooltip wraps any single focusable element and shows a floating label on hover and keyboard focus. It follows the WAI-ARIA tooltip pattern: the bubble carries role=\"tooltip\", the trigger is linked via aria-describedby while open, and pressing Escape dismisses it. The bubble and its pointing arrow are styled through independent props, so you can recolour the background, the text, and the arrow separately. Enter/exit uses a spring and automatically falls back to a plain fade when the user prefers reduced motion.", + "scenarios": [ + { + "title": "Recolour background, text, and arrow independently", + "description": "Set `background`, `color`, and `arrowColor` to fully theme the tooltip. The arrow defaults to the background colour but can be overridden for a contrasting tip.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function ThemedTooltip() {\n return (\n
\n \n \n \n\n \n \n \n
\n );\n}" + }, + { + "title": "Placement on any side", + "description": "Use `side` and `align` to position the tooltip — the arrow tracks the chosen edge and alignment automatically.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function PlacedTooltips() {\n const btn = \"rounded-md bg-neutral-800 px-4 py-2 text-sm text-white\";\n return (\n
\n \n \n \n \n
\n );\n}" + }, + { + "title": "No arrow with a custom delay", + "description": "Set `arrow={false}` for a flat label and tune `delay` to control how quickly it appears.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function MinimalTooltip() {\n return (\n
\n \n \n \n
\n );\n}" + }, + { + "title": "Rich, multi-line content", + "description": "`content` accepts any React node, so you can render a heading, body copy, or a keyboard shortcut. Use `className` to widen the bubble.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function RichTooltip() {\n return (\n
\n \n

Keyboard shortcut

\n

\n Press {\"\\u2318\"}K to open the command palette from anywhere.\n

\n
\n }\n >\n \n \n \n );\n}" + }, + { + "title": "Any trigger — icon buttons and links", + "description": "The trigger can be any single focusable element. Hover and keyboard focus both work because the tooltip listens via bubbling focus events.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\nimport { Info } from \"lucide-react\";\n\nexport default function TriggerVariants() {\n return (\n
\n \n \n \n \n \n\n \n \n Documentation\n \n \n
\n );\n}" + } + ] } }; diff --git a/apps/www/public/r/animated-tooltip.json b/apps/www/public/r/animated-tooltip.json new file mode 100644 index 0000000..36944a6 --- /dev/null +++ b/apps/www/public/r/animated-tooltip.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "animated-tooltip", + "type": "registry:ui", + "title": "Animated Tooltip", + "description": "An accessible, spring-animated tooltip with a customizable pointing arrow. Background, text, and arrow colours are independently configurable, and it follows WAI-ARIA guidance — hover/focus triggers, role=\"tooltip\", aria-describedby, and Escape to dismiss.", + "dependencies": [ + "motion", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "components/ui/animated-tooltip.tsx", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\";\ntype Align = \"start\" | \"center\" | \"end\";\n\nexport interface AnimatedTooltipProps {\n /** The trigger element. Must be a single focusable React element. */\n children: React.ReactElement;\n /** Content rendered inside the tooltip bubble. */\n content: React.ReactNode;\n /** Which side of the trigger the tooltip appears on. */\n side?: Side;\n /** Alignment along the trigger's edge. */\n align?: Align;\n /** Background colour of the tooltip bubble. */\n background?: string;\n /** Text colour inside the tooltip. */\n color?: string;\n /** Arrow fill colour. Defaults to `background` so it always matches. */\n arrowColor?: string;\n /** Whether to render the pointing arrow. */\n arrow?: boolean;\n /** Arrow edge length in px. */\n arrowSize?: number;\n /** Gap between trigger and tooltip in px. */\n offset?: number;\n /** Delay before the tooltip appears, in ms. */\n delay?: number;\n /** Disable the tooltip entirely (trigger still renders). */\n disabled?: boolean;\n /** Extra classes merged onto the tooltip bubble. */\n className?: string;\n}\n\nfunction getLayoutStyle(\n side: Side,\n align: Align,\n offset: number,\n): React.CSSProperties {\n const style: React.CSSProperties = {};\n const gap = `calc(100% + ${offset}px)`;\n\n if (side === \"top\") style.bottom = gap;\n if (side === \"bottom\") style.top = gap;\n if (side === \"left\") style.right = gap;\n if (side === \"right\") style.left = gap;\n\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.transform = \"translateX(-50%)\";\n } else if (align === \"start\") {\n style.left = 0;\n } else {\n style.right = 0;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.transform = \"translateY(-50%)\";\n } else if (align === \"start\") {\n style.top = 0;\n } else {\n style.bottom = 0;\n }\n }\n\n return style;\n}\n\nfunction getArrowStyle(\n side: Side,\n align: Align,\n size: number,\n fill: string,\n): React.CSSProperties {\n const half = size / 2;\n const style: React.CSSProperties = {\n position: \"absolute\",\n width: size,\n height: size,\n background: fill,\n transform: \"rotate(45deg)\",\n borderRadius: 2,\n };\n\n // Sit the rotated square half-way over the edge facing the trigger.\n if (side === \"top\") style.bottom = -half;\n if (side === \"bottom\") style.top = -half;\n if (side === \"left\") style.right = -half;\n if (side === \"right\") style.left = -half;\n\n // Position the arrow along the edge to track the chosen alignment.\n const edgePad = Math.max(size, 8);\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.marginLeft = -half;\n } else if (align === \"start\") {\n style.left = edgePad;\n } else {\n style.right = edgePad;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.marginTop = -half;\n } else if (align === \"start\") {\n style.top = edgePad;\n } else {\n style.bottom = edgePad;\n }\n }\n\n return style;\n}\n\nexport function AnimatedTooltip({\n children,\n content,\n side = \"top\",\n align = \"center\",\n background = \"#18181b\",\n color = \"#fafafa\",\n arrowColor,\n arrow = true,\n arrowSize = 8,\n offset = 8,\n delay = 200,\n disabled = false,\n className,\n}: AnimatedTooltipProps) {\n const [open, setOpen] = React.useState(false);\n const tooltipId = React.useId();\n const timerRef = React.useRef | null>(null);\n const prefersReducedMotion = useReducedMotion();\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n }, []);\n\n const show = React.useCallback(() => {\n if (disabled) return;\n clearTimer();\n timerRef.current = setTimeout(() => setOpen(true), delay);\n }, [disabled, delay, clearTimer]);\n\n const hide = React.useCallback(() => {\n clearTimer();\n setOpen(false);\n }, [clearTimer]);\n\n // Clean up any pending show timer on unmount.\n React.useEffect(() => clearTimer, [clearTimer]);\n\n // Dismiss on Escape, per WAI-ARIA tooltip guidance.\n React.useEffect(() => {\n if (!open) return;\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") hide();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [open, hide]);\n\n // Link the trigger to the bubble for assistive tech while it is visible.\n // Event handlers live on the wrapper below — onFocus/onBlur bubble via\n // focusin/focusout, so descendant focus is captured without cloning refs in.\n // Merge with any existing aria-describedby so other descriptions are kept.\n const originalDescribedBy = (\n children.props as React.HTMLAttributes\n )[\"aria-describedby\"];\n const mergedDescribedBy = open\n ? originalDescribedBy\n ? `${originalDescribedBy} ${tooltipId}`\n : tooltipId\n : originalDescribedBy;\n const trigger = React.cloneElement(children, {\n \"aria-describedby\": mergedDescribedBy,\n } as React.HTMLAttributes);\n\n const directional =\n side === \"top\"\n ? { y: 4 }\n : side === \"bottom\"\n ? { y: -4 }\n : side === \"left\"\n ? { x: 4 }\n : { x: -4 };\n\n const motionProps = prefersReducedMotion\n ? {\n initial: { opacity: 0 },\n animate: { opacity: 1 },\n exit: { opacity: 0 },\n transition: { duration: 0.12 },\n }\n : {\n initial: { opacity: 0, scale: 0.92, ...directional },\n animate: { opacity: 1, scale: 1, x: 0, y: 0 },\n exit: { opacity: 0, scale: 0.92, ...directional },\n transition: { type: \"spring\" as const, stiffness: 500, damping: 30 },\n };\n\n return (\n \n {trigger}\n \n {open && (\n \n \n {content}\n {arrow && (\n \n )}\n \n \n )}\n \n \n );\n}\n", + "type": "registry:component", + "target": "components/ui/animated-tooltip.tsx" + } + ] +} diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index 947c257..449889e 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -1216,6 +1216,24 @@ "target": "components/ui/hero-radial-burst.tsx" } ] + }, + { + "name": "animated-tooltip", + "type": "registry:ui", + "title": "Animated Tooltip", + "description": "An accessible, spring-animated tooltip with a customizable pointing arrow. Background, text, and arrow colours are independently configurable, and it follows WAI-ARIA guidance — hover/focus triggers, role=\"tooltip\", aria-describedby, and Escape to dismiss.", + "dependencies": [ + "motion", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "components/ui/animated-tooltip.tsx", + "type": "registry:component", + "target": "components/ui/animated-tooltip.tsx" + } + ] } ] } diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index f001c75..b351249 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -384,8 +384,8 @@ "floating-dock", "limelight-nav", "radial-menu", - "drawer-slide", - "morphing-modal" + "animated-tooltip", + "drawer-slide" ], "motion": { "reducedMotion": "none" @@ -700,6 +700,7 @@ "relatedSlugs": [ "morphing-card-stack", "animated-tabs", + "animated-tooltip", "drawer-slide", "multi-step-auth-card", "notification-stack" @@ -1342,6 +1343,7 @@ }, "relatedSlugs": [ "animated-tabs", + "animated-tooltip", "morphing-modal", "multi-step-auth-card", "notification-stack" @@ -1406,6 +1408,7 @@ "relatedSlugs": [ "morphing-card-stack", "animated-tabs", + "animated-tooltip", "drawer-slide", "morphing-modal", "multi-step-auth-card" @@ -2336,6 +2339,7 @@ }, "relatedSlugs": [ "animated-tabs", + "animated-tooltip", "drawer-slide", "morphing-modal", "notification-stack" @@ -4184,5 +4188,77 @@ "motion": { "reducedMotion": "full" } + }, + { + "name": "animated-tooltip", + "dependencies": [ + "motion", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "animated-tooltip/component.tsx", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\";\ntype Align = \"start\" | \"center\" | \"end\";\n\nexport interface AnimatedTooltipProps {\n /** The trigger element. Must be a single focusable React element. */\n children: React.ReactElement;\n /** Content rendered inside the tooltip bubble. */\n content: React.ReactNode;\n /** Which side of the trigger the tooltip appears on. */\n side?: Side;\n /** Alignment along the trigger's edge. */\n align?: Align;\n /** Background colour of the tooltip bubble. */\n background?: string;\n /** Text colour inside the tooltip. */\n color?: string;\n /** Arrow fill colour. Defaults to `background` so it always matches. */\n arrowColor?: string;\n /** Whether to render the pointing arrow. */\n arrow?: boolean;\n /** Arrow edge length in px. */\n arrowSize?: number;\n /** Gap between trigger and tooltip in px. */\n offset?: number;\n /** Delay before the tooltip appears, in ms. */\n delay?: number;\n /** Disable the tooltip entirely (trigger still renders). */\n disabled?: boolean;\n /** Extra classes merged onto the tooltip bubble. */\n className?: string;\n}\n\nfunction getLayoutStyle(\n side: Side,\n align: Align,\n offset: number,\n): React.CSSProperties {\n const style: React.CSSProperties = {};\n const gap = `calc(100% + ${offset}px)`;\n\n if (side === \"top\") style.bottom = gap;\n if (side === \"bottom\") style.top = gap;\n if (side === \"left\") style.right = gap;\n if (side === \"right\") style.left = gap;\n\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.transform = \"translateX(-50%)\";\n } else if (align === \"start\") {\n style.left = 0;\n } else {\n style.right = 0;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.transform = \"translateY(-50%)\";\n } else if (align === \"start\") {\n style.top = 0;\n } else {\n style.bottom = 0;\n }\n }\n\n return style;\n}\n\nfunction getArrowStyle(\n side: Side,\n align: Align,\n size: number,\n fill: string,\n): React.CSSProperties {\n const half = size / 2;\n const style: React.CSSProperties = {\n position: \"absolute\",\n width: size,\n height: size,\n background: fill,\n transform: \"rotate(45deg)\",\n borderRadius: 2,\n };\n\n // Sit the rotated square half-way over the edge facing the trigger.\n if (side === \"top\") style.bottom = -half;\n if (side === \"bottom\") style.top = -half;\n if (side === \"left\") style.right = -half;\n if (side === \"right\") style.left = -half;\n\n // Position the arrow along the edge to track the chosen alignment.\n const edgePad = Math.max(size, 8);\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.marginLeft = -half;\n } else if (align === \"start\") {\n style.left = edgePad;\n } else {\n style.right = edgePad;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.marginTop = -half;\n } else if (align === \"start\") {\n style.top = edgePad;\n } else {\n style.bottom = edgePad;\n }\n }\n\n return style;\n}\n\nexport function AnimatedTooltip({\n children,\n content,\n side = \"top\",\n align = \"center\",\n background = \"#18181b\",\n color = \"#fafafa\",\n arrowColor,\n arrow = true,\n arrowSize = 8,\n offset = 8,\n delay = 200,\n disabled = false,\n className,\n}: AnimatedTooltipProps) {\n const [open, setOpen] = React.useState(false);\n const tooltipId = React.useId();\n const timerRef = React.useRef | null>(null);\n const prefersReducedMotion = useReducedMotion();\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n }, []);\n\n const show = React.useCallback(() => {\n if (disabled) return;\n clearTimer();\n timerRef.current = setTimeout(() => setOpen(true), delay);\n }, [disabled, delay, clearTimer]);\n\n const hide = React.useCallback(() => {\n clearTimer();\n setOpen(false);\n }, [clearTimer]);\n\n // Clean up any pending show timer on unmount.\n React.useEffect(() => clearTimer, [clearTimer]);\n\n // Dismiss on Escape, per WAI-ARIA tooltip guidance.\n React.useEffect(() => {\n if (!open) return;\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") hide();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [open, hide]);\n\n // Link the trigger to the bubble for assistive tech while it is visible.\n // Event handlers live on the wrapper below — onFocus/onBlur bubble via\n // focusin/focusout, so descendant focus is captured without cloning refs in.\n // Merge with any existing aria-describedby so other descriptions are kept.\n const originalDescribedBy = (\n children.props as React.HTMLAttributes\n )[\"aria-describedby\"];\n const mergedDescribedBy = open\n ? originalDescribedBy\n ? `${originalDescribedBy} ${tooltipId}`\n : tooltipId\n : originalDescribedBy;\n const trigger = React.cloneElement(children, {\n \"aria-describedby\": mergedDescribedBy,\n } as React.HTMLAttributes);\n\n const directional =\n side === \"top\"\n ? { y: 4 }\n : side === \"bottom\"\n ? { y: -4 }\n : side === \"left\"\n ? { x: 4 }\n : { x: -4 };\n\n const motionProps = prefersReducedMotion\n ? {\n initial: { opacity: 0 },\n animate: { opacity: 1 },\n exit: { opacity: 0 },\n transition: { duration: 0.12 },\n }\n : {\n initial: { opacity: 0, scale: 0.92, ...directional },\n animate: { opacity: 1, scale: 1, x: 0, y: 0 },\n exit: { opacity: 0, scale: 0.92, ...directional },\n transition: { type: \"spring\" as const, stiffness: 500, damping: 30 },\n };\n\n return (\n \n {trigger}\n \n {open && (\n \n \n {content}\n {arrow && (\n \n )}\n \n \n )}\n \n \n );\n}\n", + "type": "registry:ui", + "integrity": "sha384-fWsn//5bUatXZHbpJCR4tEj6ecYJg7S/LM7eDX6aBbfpl4ZYAg8XiWje7qxh0j61" + }, + { + "path": "utils/cn.ts", + "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", + "type": "registry:util", + "integrity": "sha384-PFsmUDxsGyTpithwRHqXHK4J46ePXMjvbm5/78jCSJEH1Dsygh4anV8Y/45UrHos" + } + ], + "meta": { + "version": "1.0.1" + }, + "changelog": [ + { + "version": "1.0.1", + "date": "2026-06-09", + "changes": [ + "Preserve the trigger's existing aria-describedby when linking the tooltip." + ] + }, + { + "version": "1.0.0", + "date": "2026-06-08", + "changes": [ + "Initial release." + ] + } + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "tags": [ + "overlay", + "tooltip", + "accessibility" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "audited", + "screenReaderNotes": "Bubble uses role=\"tooltip\" and is linked to the trigger via aria-describedby while visible. Shows on hover and keyboard focus, dismisses on Escape and blur. The decorative arrow is aria-hidden." + }, + "relatedSlugs": [ + "animated-tabs", + "drawer-slide", + "morphing-modal", + "multi-step-auth-card", + "notification-stack" + ], + "motion": { + "reducedMotion": "full", + "performanceNotes": "Spring scale/translate on enter/exit only; falls back to an opacity-only fade when prefers-reduced-motion is set." + } } ] diff --git a/apps/www/public/registry/animated-tabs.json b/apps/www/public/registry/animated-tabs.json index ade299b..8af3c79 100644 --- a/apps/www/public/registry/animated-tabs.json +++ b/apps/www/public/registry/animated-tabs.json @@ -55,8 +55,8 @@ "floating-dock", "limelight-nav", "radial-menu", - "drawer-slide", - "morphing-modal" + "animated-tooltip", + "drawer-slide" ], "motion": { "reducedMotion": "none" diff --git a/apps/www/public/registry/animated-tooltip.json b/apps/www/public/registry/animated-tooltip.json new file mode 100644 index 0000000..d042bf9 --- /dev/null +++ b/apps/www/public/registry/animated-tooltip.json @@ -0,0 +1,72 @@ +{ + "name": "animated-tooltip", + "dependencies": [ + "motion", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "animated-tooltip/component.tsx", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\";\ntype Align = \"start\" | \"center\" | \"end\";\n\nexport interface AnimatedTooltipProps {\n /** The trigger element. Must be a single focusable React element. */\n children: React.ReactElement;\n /** Content rendered inside the tooltip bubble. */\n content: React.ReactNode;\n /** Which side of the trigger the tooltip appears on. */\n side?: Side;\n /** Alignment along the trigger's edge. */\n align?: Align;\n /** Background colour of the tooltip bubble. */\n background?: string;\n /** Text colour inside the tooltip. */\n color?: string;\n /** Arrow fill colour. Defaults to `background` so it always matches. */\n arrowColor?: string;\n /** Whether to render the pointing arrow. */\n arrow?: boolean;\n /** Arrow edge length in px. */\n arrowSize?: number;\n /** Gap between trigger and tooltip in px. */\n offset?: number;\n /** Delay before the tooltip appears, in ms. */\n delay?: number;\n /** Disable the tooltip entirely (trigger still renders). */\n disabled?: boolean;\n /** Extra classes merged onto the tooltip bubble. */\n className?: string;\n}\n\nfunction getLayoutStyle(\n side: Side,\n align: Align,\n offset: number,\n): React.CSSProperties {\n const style: React.CSSProperties = {};\n const gap = `calc(100% + ${offset}px)`;\n\n if (side === \"top\") style.bottom = gap;\n if (side === \"bottom\") style.top = gap;\n if (side === \"left\") style.right = gap;\n if (side === \"right\") style.left = gap;\n\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.transform = \"translateX(-50%)\";\n } else if (align === \"start\") {\n style.left = 0;\n } else {\n style.right = 0;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.transform = \"translateY(-50%)\";\n } else if (align === \"start\") {\n style.top = 0;\n } else {\n style.bottom = 0;\n }\n }\n\n return style;\n}\n\nfunction getArrowStyle(\n side: Side,\n align: Align,\n size: number,\n fill: string,\n): React.CSSProperties {\n const half = size / 2;\n const style: React.CSSProperties = {\n position: \"absolute\",\n width: size,\n height: size,\n background: fill,\n transform: \"rotate(45deg)\",\n borderRadius: 2,\n };\n\n // Sit the rotated square half-way over the edge facing the trigger.\n if (side === \"top\") style.bottom = -half;\n if (side === \"bottom\") style.top = -half;\n if (side === \"left\") style.right = -half;\n if (side === \"right\") style.left = -half;\n\n // Position the arrow along the edge to track the chosen alignment.\n const edgePad = Math.max(size, 8);\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.marginLeft = -half;\n } else if (align === \"start\") {\n style.left = edgePad;\n } else {\n style.right = edgePad;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.marginTop = -half;\n } else if (align === \"start\") {\n style.top = edgePad;\n } else {\n style.bottom = edgePad;\n }\n }\n\n return style;\n}\n\nexport function AnimatedTooltip({\n children,\n content,\n side = \"top\",\n align = \"center\",\n background = \"#18181b\",\n color = \"#fafafa\",\n arrowColor,\n arrow = true,\n arrowSize = 8,\n offset = 8,\n delay = 200,\n disabled = false,\n className,\n}: AnimatedTooltipProps) {\n const [open, setOpen] = React.useState(false);\n const tooltipId = React.useId();\n const timerRef = React.useRef | null>(null);\n const prefersReducedMotion = useReducedMotion();\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n }, []);\n\n const show = React.useCallback(() => {\n if (disabled) return;\n clearTimer();\n timerRef.current = setTimeout(() => setOpen(true), delay);\n }, [disabled, delay, clearTimer]);\n\n const hide = React.useCallback(() => {\n clearTimer();\n setOpen(false);\n }, [clearTimer]);\n\n // Clean up any pending show timer on unmount.\n React.useEffect(() => clearTimer, [clearTimer]);\n\n // Dismiss on Escape, per WAI-ARIA tooltip guidance.\n React.useEffect(() => {\n if (!open) return;\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") hide();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [open, hide]);\n\n // Link the trigger to the bubble for assistive tech while it is visible.\n // Event handlers live on the wrapper below — onFocus/onBlur bubble via\n // focusin/focusout, so descendant focus is captured without cloning refs in.\n // Merge with any existing aria-describedby so other descriptions are kept.\n const originalDescribedBy = (\n children.props as React.HTMLAttributes\n )[\"aria-describedby\"];\n const mergedDescribedBy = open\n ? originalDescribedBy\n ? `${originalDescribedBy} ${tooltipId}`\n : tooltipId\n : originalDescribedBy;\n const trigger = React.cloneElement(children, {\n \"aria-describedby\": mergedDescribedBy,\n } as React.HTMLAttributes);\n\n const directional =\n side === \"top\"\n ? { y: 4 }\n : side === \"bottom\"\n ? { y: -4 }\n : side === \"left\"\n ? { x: 4 }\n : { x: -4 };\n\n const motionProps = prefersReducedMotion\n ? {\n initial: { opacity: 0 },\n animate: { opacity: 1 },\n exit: { opacity: 0 },\n transition: { duration: 0.12 },\n }\n : {\n initial: { opacity: 0, scale: 0.92, ...directional },\n animate: { opacity: 1, scale: 1, x: 0, y: 0 },\n exit: { opacity: 0, scale: 0.92, ...directional },\n transition: { type: \"spring\" as const, stiffness: 500, damping: 30 },\n };\n\n return (\n \n {trigger}\n \n {open && (\n \n \n {content}\n {arrow && (\n \n )}\n \n \n )}\n \n \n );\n}\n", + "type": "registry:ui", + "integrity": "sha384-fWsn//5bUatXZHbpJCR4tEj6ecYJg7S/LM7eDX6aBbfpl4ZYAg8XiWje7qxh0j61" + }, + { + "path": "utils/cn.ts", + "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", + "type": "registry:util", + "integrity": "sha384-PFsmUDxsGyTpithwRHqXHK4J46ePXMjvbm5/78jCSJEH1Dsygh4anV8Y/45UrHos" + } + ], + "meta": { + "version": "1.0.1" + }, + "changelog": [ + { + "version": "1.0.1", + "date": "2026-06-09", + "changes": [ + "Preserve the trigger's existing aria-describedby when linking the tooltip." + ] + }, + { + "version": "1.0.0", + "date": "2026-06-08", + "changes": [ + "Initial release." + ] + } + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "tags": [ + "overlay", + "tooltip", + "accessibility" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "audited", + "screenReaderNotes": "Bubble uses role=\"tooltip\" and is linked to the trigger via aria-describedby while visible. Shows on hover and keyboard focus, dismisses on Escape and blur. The decorative arrow is aria-hidden." + }, + "relatedSlugs": [ + "animated-tabs", + "drawer-slide", + "morphing-modal", + "multi-step-auth-card", + "notification-stack" + ], + "motion": { + "reducedMotion": "full", + "performanceNotes": "Spring scale/translate on enter/exit only; falls back to an opacity-only fade when prefers-reduced-motion is set." + } +} diff --git a/apps/www/public/registry/changelogs.json b/apps/www/public/registry/changelogs.json index 1e46cf1..bd1abcd 100644 --- a/apps/www/public/registry/changelogs.json +++ b/apps/www/public/registry/changelogs.json @@ -701,5 +701,21 @@ "Initial release." ] } + ], + "animated-tooltip": [ + { + "version": "1.0.1", + "date": "2026-06-09", + "changes": [ + "Preserve the trigger's existing aria-describedby when linking the tooltip." + ] + }, + { + "version": "1.0.0", + "date": "2026-06-08", + "changes": [ + "Initial release." + ] + } ] } diff --git a/apps/www/public/registry/drawer-slide.json b/apps/www/public/registry/drawer-slide.json index c0fef1a..1f48be2 100644 --- a/apps/www/public/registry/drawer-slide.json +++ b/apps/www/public/registry/drawer-slide.json @@ -52,6 +52,7 @@ }, "relatedSlugs": [ "animated-tabs", + "animated-tooltip", "morphing-modal", "multi-step-auth-card", "notification-stack" diff --git a/apps/www/public/registry/index.json b/apps/www/public/registry/index.json index 1bda826..6eb27fa 100644 --- a/apps/www/public/registry/index.json +++ b/apps/www/public/registry/index.json @@ -64,6 +64,7 @@ "hero-terminal", "hero-flow-field", "magnetic-text", - "hero-radial-burst" + "hero-radial-burst", + "animated-tooltip" ] } diff --git a/apps/www/public/registry/morphing-modal.json b/apps/www/public/registry/morphing-modal.json index 5719171..59958b1 100644 --- a/apps/www/public/registry/morphing-modal.json +++ b/apps/www/public/registry/morphing-modal.json @@ -53,6 +53,7 @@ "relatedSlugs": [ "morphing-card-stack", "animated-tabs", + "animated-tooltip", "drawer-slide", "multi-step-auth-card", "notification-stack" diff --git a/apps/www/public/registry/multi-step-auth-card.json b/apps/www/public/registry/multi-step-auth-card.json index 1b59766..917d08b 100644 --- a/apps/www/public/registry/multi-step-auth-card.json +++ b/apps/www/public/registry/multi-step-auth-card.json @@ -55,6 +55,7 @@ }, "relatedSlugs": [ "animated-tabs", + "animated-tooltip", "drawer-slide", "morphing-modal", "notification-stack" diff --git a/apps/www/public/registry/notification-stack.json b/apps/www/public/registry/notification-stack.json index 4a34495..3754db8 100644 --- a/apps/www/public/registry/notification-stack.json +++ b/apps/www/public/registry/notification-stack.json @@ -54,6 +54,7 @@ "relatedSlugs": [ "morphing-card-stack", "animated-tabs", + "animated-tooltip", "drawer-slide", "morphing-modal", "multi-step-auth-card" diff --git a/registry.json b/registry.json index f001c75..b351249 100644 --- a/registry.json +++ b/registry.json @@ -384,8 +384,8 @@ "floating-dock", "limelight-nav", "radial-menu", - "drawer-slide", - "morphing-modal" + "animated-tooltip", + "drawer-slide" ], "motion": { "reducedMotion": "none" @@ -700,6 +700,7 @@ "relatedSlugs": [ "morphing-card-stack", "animated-tabs", + "animated-tooltip", "drawer-slide", "multi-step-auth-card", "notification-stack" @@ -1342,6 +1343,7 @@ }, "relatedSlugs": [ "animated-tabs", + "animated-tooltip", "morphing-modal", "multi-step-auth-card", "notification-stack" @@ -1406,6 +1408,7 @@ "relatedSlugs": [ "morphing-card-stack", "animated-tabs", + "animated-tooltip", "drawer-slide", "morphing-modal", "multi-step-auth-card" @@ -2336,6 +2339,7 @@ }, "relatedSlugs": [ "animated-tabs", + "animated-tooltip", "drawer-slide", "morphing-modal", "notification-stack" @@ -4184,5 +4188,77 @@ "motion": { "reducedMotion": "full" } + }, + { + "name": "animated-tooltip", + "dependencies": [ + "motion", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "animated-tooltip/component.tsx", + "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport * as React from \"react\";\n\ntype Side = \"top\" | \"bottom\" | \"left\" | \"right\";\ntype Align = \"start\" | \"center\" | \"end\";\n\nexport interface AnimatedTooltipProps {\n /** The trigger element. Must be a single focusable React element. */\n children: React.ReactElement;\n /** Content rendered inside the tooltip bubble. */\n content: React.ReactNode;\n /** Which side of the trigger the tooltip appears on. */\n side?: Side;\n /** Alignment along the trigger's edge. */\n align?: Align;\n /** Background colour of the tooltip bubble. */\n background?: string;\n /** Text colour inside the tooltip. */\n color?: string;\n /** Arrow fill colour. Defaults to `background` so it always matches. */\n arrowColor?: string;\n /** Whether to render the pointing arrow. */\n arrow?: boolean;\n /** Arrow edge length in px. */\n arrowSize?: number;\n /** Gap between trigger and tooltip in px. */\n offset?: number;\n /** Delay before the tooltip appears, in ms. */\n delay?: number;\n /** Disable the tooltip entirely (trigger still renders). */\n disabled?: boolean;\n /** Extra classes merged onto the tooltip bubble. */\n className?: string;\n}\n\nfunction getLayoutStyle(\n side: Side,\n align: Align,\n offset: number,\n): React.CSSProperties {\n const style: React.CSSProperties = {};\n const gap = `calc(100% + ${offset}px)`;\n\n if (side === \"top\") style.bottom = gap;\n if (side === \"bottom\") style.top = gap;\n if (side === \"left\") style.right = gap;\n if (side === \"right\") style.left = gap;\n\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.transform = \"translateX(-50%)\";\n } else if (align === \"start\") {\n style.left = 0;\n } else {\n style.right = 0;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.transform = \"translateY(-50%)\";\n } else if (align === \"start\") {\n style.top = 0;\n } else {\n style.bottom = 0;\n }\n }\n\n return style;\n}\n\nfunction getArrowStyle(\n side: Side,\n align: Align,\n size: number,\n fill: string,\n): React.CSSProperties {\n const half = size / 2;\n const style: React.CSSProperties = {\n position: \"absolute\",\n width: size,\n height: size,\n background: fill,\n transform: \"rotate(45deg)\",\n borderRadius: 2,\n };\n\n // Sit the rotated square half-way over the edge facing the trigger.\n if (side === \"top\") style.bottom = -half;\n if (side === \"bottom\") style.top = -half;\n if (side === \"left\") style.right = -half;\n if (side === \"right\") style.left = -half;\n\n // Position the arrow along the edge to track the chosen alignment.\n const edgePad = Math.max(size, 8);\n if (side === \"top\" || side === \"bottom\") {\n if (align === \"center\") {\n style.left = \"50%\";\n style.marginLeft = -half;\n } else if (align === \"start\") {\n style.left = edgePad;\n } else {\n style.right = edgePad;\n }\n } else {\n if (align === \"center\") {\n style.top = \"50%\";\n style.marginTop = -half;\n } else if (align === \"start\") {\n style.top = edgePad;\n } else {\n style.bottom = edgePad;\n }\n }\n\n return style;\n}\n\nexport function AnimatedTooltip({\n children,\n content,\n side = \"top\",\n align = \"center\",\n background = \"#18181b\",\n color = \"#fafafa\",\n arrowColor,\n arrow = true,\n arrowSize = 8,\n offset = 8,\n delay = 200,\n disabled = false,\n className,\n}: AnimatedTooltipProps) {\n const [open, setOpen] = React.useState(false);\n const tooltipId = React.useId();\n const timerRef = React.useRef | null>(null);\n const prefersReducedMotion = useReducedMotion();\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n }, []);\n\n const show = React.useCallback(() => {\n if (disabled) return;\n clearTimer();\n timerRef.current = setTimeout(() => setOpen(true), delay);\n }, [disabled, delay, clearTimer]);\n\n const hide = React.useCallback(() => {\n clearTimer();\n setOpen(false);\n }, [clearTimer]);\n\n // Clean up any pending show timer on unmount.\n React.useEffect(() => clearTimer, [clearTimer]);\n\n // Dismiss on Escape, per WAI-ARIA tooltip guidance.\n React.useEffect(() => {\n if (!open) return;\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") hide();\n };\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [open, hide]);\n\n // Link the trigger to the bubble for assistive tech while it is visible.\n // Event handlers live on the wrapper below — onFocus/onBlur bubble via\n // focusin/focusout, so descendant focus is captured without cloning refs in.\n // Merge with any existing aria-describedby so other descriptions are kept.\n const originalDescribedBy = (\n children.props as React.HTMLAttributes\n )[\"aria-describedby\"];\n const mergedDescribedBy = open\n ? originalDescribedBy\n ? `${originalDescribedBy} ${tooltipId}`\n : tooltipId\n : originalDescribedBy;\n const trigger = React.cloneElement(children, {\n \"aria-describedby\": mergedDescribedBy,\n } as React.HTMLAttributes);\n\n const directional =\n side === \"top\"\n ? { y: 4 }\n : side === \"bottom\"\n ? { y: -4 }\n : side === \"left\"\n ? { x: 4 }\n : { x: -4 };\n\n const motionProps = prefersReducedMotion\n ? {\n initial: { opacity: 0 },\n animate: { opacity: 1 },\n exit: { opacity: 0 },\n transition: { duration: 0.12 },\n }\n : {\n initial: { opacity: 0, scale: 0.92, ...directional },\n animate: { opacity: 1, scale: 1, x: 0, y: 0 },\n exit: { opacity: 0, scale: 0.92, ...directional },\n transition: { type: \"spring\" as const, stiffness: 500, damping: 30 },\n };\n\n return (\n \n {trigger}\n \n {open && (\n \n \n {content}\n {arrow && (\n \n )}\n \n \n )}\n \n \n );\n}\n", + "type": "registry:ui", + "integrity": "sha384-fWsn//5bUatXZHbpJCR4tEj6ecYJg7S/LM7eDX6aBbfpl4ZYAg8XiWje7qxh0j61" + }, + { + "path": "utils/cn.ts", + "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", + "type": "registry:util", + "integrity": "sha384-PFsmUDxsGyTpithwRHqXHK4J46ePXMjvbm5/78jCSJEH1Dsygh4anV8Y/45UrHos" + } + ], + "meta": { + "version": "1.0.1" + }, + "changelog": [ + { + "version": "1.0.1", + "date": "2026-06-09", + "changes": [ + "Preserve the trigger's existing aria-describedby when linking the tooltip." + ] + }, + { + "version": "1.0.0", + "date": "2026-06-08", + "changes": [ + "Initial release." + ] + } + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "tags": [ + "overlay", + "tooltip", + "accessibility" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "audited", + "screenReaderNotes": "Bubble uses role=\"tooltip\" and is linked to the trigger via aria-describedby while visible. Shows on hover and keyboard focus, dismisses on Escape and blur. The decorative arrow is aria-hidden." + }, + "relatedSlugs": [ + "animated-tabs", + "drawer-slide", + "morphing-modal", + "multi-step-auth-card", + "notification-stack" + ], + "motion": { + "reducedMotion": "full", + "performanceNotes": "Spring scale/translate on enter/exit only; falls back to an opacity-only fade when prefers-reduced-motion is set." + } } ] diff --git a/registry/animated-tooltip/component.tsx b/registry/animated-tooltip/component.tsx new file mode 100644 index 0000000..4e3b8fb --- /dev/null +++ b/registry/animated-tooltip/component.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +type Side = "top" | "bottom" | "left" | "right"; +type Align = "start" | "center" | "end"; + +export interface AnimatedTooltipProps { + /** The trigger element. Must be a single focusable React element. */ + children: React.ReactElement; + /** Content rendered inside the tooltip bubble. */ + content: React.ReactNode; + /** Which side of the trigger the tooltip appears on. */ + side?: Side; + /** Alignment along the trigger's edge. */ + align?: Align; + /** Background colour of the tooltip bubble. */ + background?: string; + /** Text colour inside the tooltip. */ + color?: string; + /** Arrow fill colour. Defaults to `background` so it always matches. */ + arrowColor?: string; + /** Whether to render the pointing arrow. */ + arrow?: boolean; + /** Arrow edge length in px. */ + arrowSize?: number; + /** Gap between trigger and tooltip in px. */ + offset?: number; + /** Delay before the tooltip appears, in ms. */ + delay?: number; + /** Disable the tooltip entirely (trigger still renders). */ + disabled?: boolean; + /** Extra classes merged onto the tooltip bubble. */ + className?: string; +} + +function getLayoutStyle( + side: Side, + align: Align, + offset: number, +): React.CSSProperties { + const style: React.CSSProperties = {}; + const gap = `calc(100% + ${offset}px)`; + + if (side === "top") style.bottom = gap; + if (side === "bottom") style.top = gap; + if (side === "left") style.right = gap; + if (side === "right") style.left = gap; + + if (side === "top" || side === "bottom") { + if (align === "center") { + style.left = "50%"; + style.transform = "translateX(-50%)"; + } else if (align === "start") { + style.left = 0; + } else { + style.right = 0; + } + } else { + if (align === "center") { + style.top = "50%"; + style.transform = "translateY(-50%)"; + } else if (align === "start") { + style.top = 0; + } else { + style.bottom = 0; + } + } + + return style; +} + +function getArrowStyle( + side: Side, + align: Align, + size: number, + fill: string, +): React.CSSProperties { + const half = size / 2; + const style: React.CSSProperties = { + position: "absolute", + width: size, + height: size, + background: fill, + transform: "rotate(45deg)", + borderRadius: 2, + }; + + // Sit the rotated square half-way over the edge facing the trigger. + if (side === "top") style.bottom = -half; + if (side === "bottom") style.top = -half; + if (side === "left") style.right = -half; + if (side === "right") style.left = -half; + + // Position the arrow along the edge to track the chosen alignment. + const edgePad = Math.max(size, 8); + if (side === "top" || side === "bottom") { + if (align === "center") { + style.left = "50%"; + style.marginLeft = -half; + } else if (align === "start") { + style.left = edgePad; + } else { + style.right = edgePad; + } + } else { + if (align === "center") { + style.top = "50%"; + style.marginTop = -half; + } else if (align === "start") { + style.top = edgePad; + } else { + style.bottom = edgePad; + } + } + + return style; +} + +export function AnimatedTooltip({ + children, + content, + side = "top", + align = "center", + background = "#18181b", + color = "#fafafa", + arrowColor, + arrow = true, + arrowSize = 8, + offset = 8, + delay = 200, + disabled = false, + className, +}: AnimatedTooltipProps) { + const [open, setOpen] = React.useState(false); + const tooltipId = React.useId(); + const timerRef = React.useRef | null>(null); + const prefersReducedMotion = useReducedMotion(); + + const clearTimer = React.useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const show = React.useCallback(() => { + if (disabled) return; + clearTimer(); + timerRef.current = setTimeout(() => setOpen(true), delay); + }, [disabled, delay, clearTimer]); + + const hide = React.useCallback(() => { + clearTimer(); + setOpen(false); + }, [clearTimer]); + + // Clean up any pending show timer on unmount. + React.useEffect(() => clearTimer, [clearTimer]); + + // Dismiss on Escape, per WAI-ARIA tooltip guidance. + React.useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") hide(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, hide]); + + // Link the trigger to the bubble for assistive tech while it is visible. + // Event handlers live on the wrapper below — onFocus/onBlur bubble via + // focusin/focusout, so descendant focus is captured without cloning refs in. + // Merge with any existing aria-describedby so other descriptions are kept. + const originalDescribedBy = ( + children.props as React.HTMLAttributes + )["aria-describedby"]; + const mergedDescribedBy = open + ? originalDescribedBy + ? `${originalDescribedBy} ${tooltipId}` + : tooltipId + : originalDescribedBy; + const trigger = React.cloneElement(children, { + "aria-describedby": mergedDescribedBy, + } as React.HTMLAttributes); + + const directional = + side === "top" + ? { y: 4 } + : side === "bottom" + ? { y: -4 } + : side === "left" + ? { x: 4 } + : { x: -4 }; + + const motionProps = prefersReducedMotion + ? { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.12 }, + } + : { + initial: { opacity: 0, scale: 0.92, ...directional }, + animate: { opacity: 1, scale: 1, x: 0, y: 0 }, + exit: { opacity: 0, scale: 0.92, ...directional }, + transition: { type: "spring" as const, stiffness: 500, damping: 30 }, + }; + + return ( + + {trigger} + + {open && ( + + + {content} + {arrow && ( + + + )} + + + ); +} diff --git a/registry/animated-tooltip/demo.tsx b/registry/animated-tooltip/demo.tsx new file mode 100644 index 0000000..3bf9a92 --- /dev/null +++ b/registry/animated-tooltip/demo.tsx @@ -0,0 +1,122 @@ +// Demo fragment — merged into apps/www/config/demos.tsx by `pnpm build:registry`. +// Imports come from registry/demos/shared.tsx. + +export const demoEntries = { + "animated-tooltip": ({ theme = "dark" }) => { + const isDark = theme === "dark"; + const triggerClass = cn( + "rounded-md px-4 py-2 text-sm font-medium transition-colors", + isDark + ? "bg-neutral-800 text-neutral-100 hover:bg-neutral-700" + : "bg-neutral-100 text-neutral-900 hover:bg-neutral-200", + ); + + return ( +
+ {/* Placement on every side */} +
+ + + + + + + + + + + + +
+ + {/* Independent colour control */} +
+ + + + + + + + + +
+ + {/* Rich content + non-button triggers */} +
+ +

Keyboard shortcut

+

+ Press {"⌘"}K to open the command palette from anywhere. +

+
+ } + > + + + + + + + + + + Link trigger + + +
+ + ); + } +} as const; diff --git a/registry/components/animated-tooltip.json b/registry/components/animated-tooltip.json new file mode 100644 index 0000000..4b889a5 --- /dev/null +++ b/registry/components/animated-tooltip.json @@ -0,0 +1,169 @@ +{ + "slug": "animated-tooltip", + "registry": { + "dependencies": [ + "motion", + "clsx", + "tailwind-merge" + ], + "files": [ + { + "path": "animated-tooltip/component.tsx", + "type": "registry:ui" + } + ] + }, + "docs": { + "name": "Animated Tooltip", + "description": "An accessible, spring-animated tooltip with a customizable pointing arrow. Background, text, and arrow colours are independently configurable, and it follows WAI-ARIA guidance — hover/focus triggers, role=\"tooltip\", aria-describedby, and Escape to dismiss.", + "icon": "MessageSquare", + "category": "Navigation & Overlays", + "props": [ + { + "name": "children", + "type": "React.ReactElement", + "description": "The trigger element. Must be a single focusable React element (e.g. a button or link)." + }, + { + "name": "content", + "type": "React.ReactNode", + "description": "Content rendered inside the tooltip bubble." + }, + { + "name": "side", + "type": "\"top\" | \"bottom\" | \"left\" | \"right\"", + "default": "\"top\"", + "description": "Which side of the trigger the tooltip appears on." + }, + { + "name": "align", + "type": "\"start\" | \"center\" | \"end\"", + "default": "\"center\"", + "description": "Alignment along the trigger's edge." + }, + { + "name": "background", + "type": "string", + "default": "\"#18181b\"", + "description": "Background colour of the tooltip bubble." + }, + { + "name": "color", + "type": "string", + "default": "\"#fafafa\"", + "description": "Text colour inside the tooltip." + }, + { + "name": "arrowColor", + "type": "string", + "description": "Arrow fill colour. Defaults to `background` so it always matches." + }, + { + "name": "arrow", + "type": "boolean", + "default": "true", + "description": "Whether to render the pointing arrow." + }, + { + "name": "arrowSize", + "type": "number", + "default": "8", + "description": "Arrow edge length in px." + }, + { + "name": "offset", + "type": "number", + "default": "8", + "description": "Gap between the trigger and the tooltip in px." + }, + { + "name": "delay", + "type": "number", + "default": "200", + "description": "Delay before the tooltip appears, in ms." + }, + { + "name": "disabled", + "type": "boolean", + "default": "false", + "description": "Disable the tooltip entirely (the trigger still renders)." + }, + { + "name": "className", + "type": "string", + "description": "Extra Tailwind classes merged onto the tooltip bubble." + } + ], + "usageCode": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function Example() {\n return (\n \n \n \n );\n}", + "docs": { + "overview": "AnimatedTooltip wraps any single focusable element and shows a floating label on hover and keyboard focus. It follows the WAI-ARIA tooltip pattern: the bubble carries role=\"tooltip\", the trigger is linked via aria-describedby while open, and pressing Escape dismisses it. The bubble and its pointing arrow are styled through independent props, so you can recolour the background, the text, and the arrow separately. Enter/exit uses a spring and automatically falls back to a plain fade when the user prefers reduced motion.", + "scenarios": [ + { + "title": "Recolour background, text, and arrow independently", + "description": "Set `background`, `color`, and `arrowColor` to fully theme the tooltip. The arrow defaults to the background colour but can be overridden for a contrasting tip.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function ThemedTooltip() {\n return (\n
\n \n \n \n\n \n \n \n
\n );\n}" + }, + { + "title": "Placement on any side", + "description": "Use `side` and `align` to position the tooltip — the arrow tracks the chosen edge and alignment automatically.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function PlacedTooltips() {\n const btn = \"rounded-md bg-neutral-800 px-4 py-2 text-sm text-white\";\n return (\n
\n \n \n \n \n
\n );\n}" + }, + { + "title": "No arrow with a custom delay", + "description": "Set `arrow={false}` for a flat label and tune `delay` to control how quickly it appears.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function MinimalTooltip() {\n return (\n
\n \n \n \n
\n );\n}" + }, + { + "title": "Rich, multi-line content", + "description": "`content` accepts any React node, so you can render a heading, body copy, or a keyboard shortcut. Use `className` to widen the bubble.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\n\nexport default function RichTooltip() {\n return (\n
\n \n

Keyboard shortcut

\n

\n Press {\"\\u2318\"}K to open the command palette from anywhere.\n

\n
\n }\n >\n \n \n \n );\n}" + }, + { + "title": "Any trigger — icon buttons and links", + "description": "The trigger can be any single focusable element. Hover and keyboard focus both work because the tooltip listens via bubbling focus events.", + "code": "\"use client\";\nimport { AnimatedTooltip } from \"@/components/ui/animated-tooltip\";\nimport { Info } from \"lucide-react\";\n\nexport default function TriggerVariants() {\n return (\n
\n \n \n \n \n \n\n \n \n Documentation\n \n \n
\n );\n}" + } + ] + } + }, + "tags": [ + "overlay", + "tooltip", + "accessibility" + ], + "peerDependencies": [ + "react", + "react-dom" + ], + "compatibility": { + "react": "18+", + "next": "14+", + "tailwind": "3+|4+", + "rsc": false, + "ssr": true + }, + "accessibility": { + "status": "audited", + "screenReaderNotes": "Bubble uses role=\"tooltip\" and is linked to the trigger via aria-describedby while visible. Shows on hover and keyboard focus, dismisses on Escape and blur. The decorative arrow is aria-hidden." + }, + "motion": { + "reducedMotion": "full", + "performanceNotes": "Spring scale/translate on enter/exit only; falls back to an opacity-only fade when prefers-reduced-motion is set." + }, + "changelog": [ + { + "version": "1.0.1", + "date": "2026-06-09", + "changes": [ + "Preserve the trigger's existing aria-describedby when linking the tooltip." + ] + }, + { + "version": "1.0.0", + "date": "2026-06-08", + "changes": [ + "Initial release." + ] + } + ] +} diff --git a/registry/demos/demo-key-order.json b/registry/demos/demo-key-order.json index 6a7548b..d417497 100644 --- a/registry/demos/demo-key-order.json +++ b/registry/demos/demo-key-order.json @@ -82,5 +82,6 @@ "hero-magnetic-letters", "hero-terminal", "hero-flow-field", - "hero-radial-burst" + "hero-radial-burst", + "animated-tooltip" ] diff --git a/registry/demos/shared.tsx b/registry/demos/shared.tsx index af73336..7c7b38c 100644 --- a/registry/demos/shared.tsx +++ b/registry/demos/shared.tsx @@ -76,6 +76,7 @@ import { MagneticLettersHero } from "@/components/ui/hero-magnetic-letters"; import { TerminalHero } from "@/components/ui/hero-terminal"; import { FlowFieldHero } from "@/components/ui/hero-flow-field"; import { RadialBurstHero } from "@/components/ui/hero-radial-burst"; +import { AnimatedTooltip } from "@/components/ui/animated-tooltip"; import { motion } from "motion/react"; import { useRef, useState } from "react"; import { @@ -98,6 +99,7 @@ import { Clock, Link2, Share2, + Info, } from "lucide-react"; import { cn } from "@/lib/utils"; diff --git a/registry/manifest.json b/registry/manifest.json index 8a56362..708c50e 100644 --- a/registry/manifest.json +++ b/registry/manifest.json @@ -67,7 +67,8 @@ "hero-terminal", "hero-flow-field", "magnetic-text", - "hero-radial-burst" + "hero-radial-burst", + "animated-tooltip" ], "docsOrder": [ "animated-glowing-text-outline", @@ -134,6 +135,7 @@ "hero-magnetic-letters", "hero-terminal", "hero-flow-field", - "hero-radial-burst" + "hero-radial-burst", + "animated-tooltip" ] }