From 8112abcfcc3678ecab86bc42ccfc6ed812bcd4c1 Mon Sep 17 00:00:00 2001 From: pras75299 Date: Mon, 8 Jun 2026 23:45:16 +0530 Subject: [PATCH 1/2] feat(animated-tooltip): add accessible animated tooltip with customizable arrow --- apps/www/components/ui/animated-tooltip.tsx | 250 ++++++++++++++++++ apps/www/config/components.ts | 86 ++++++ apps/www/config/demos.tsx | 120 +++++++++ apps/www/config/docs-scenarios.ts | 31 +++ apps/www/public/r/animated-tooltip.json | 20 ++ apps/www/public/r/registry.json | 18 ++ apps/www/public/registry.json | 73 ++++- apps/www/public/registry/animated-tabs.json | 4 +- .../www/public/registry/animated-tooltip.json | 65 +++++ apps/www/public/registry/changelogs.json | 9 + apps/www/public/registry/drawer-slide.json | 1 + apps/www/public/registry/index.json | 3 +- apps/www/public/registry/morphing-modal.json | 1 + .../public/registry/multi-step-auth-card.json | 1 + .../public/registry/notification-stack.json | 1 + registry.json | 73 ++++- registry/animated-tooltip/component.tsx | 250 ++++++++++++++++++ registry/animated-tooltip/demo.tsx | 122 +++++++++ registry/components/animated-tooltip.json | 162 ++++++++++++ registry/demos/demo-key-order.json | 3 +- registry/demos/shared.tsx | 2 + registry/manifest.json | 6 +- 22 files changed, 1291 insertions(+), 10 deletions(-) create mode 100644 apps/www/components/ui/animated-tooltip.tsx create mode 100644 apps/www/public/r/animated-tooltip.json create mode 100644 apps/www/public/registry/animated-tooltip.json create mode 100644 registry/animated-tooltip/component.tsx create mode 100644 registry/animated-tooltip/demo.tsx create mode 100644 registry/components/animated-tooltip.json diff --git a/apps/www/components/ui/animated-tooltip.tsx b/apps/www/components/ui/animated-tooltip.tsx new file mode 100644 index 00000000..6b2155be --- /dev/null +++ b/apps/www/components/ui/animated-tooltip.tsx @@ -0,0 +1,250 @@ +"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. + const trigger = React.cloneElement(children, { + "aria-describedby": open ? tooltipId : undefined, + } 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 da0a89cc..476cc12d 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 1073eccf..d90fc9d4 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 7624c63c..71137343 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 00000000..583b2f02 --- /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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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 947c2577..449889e4 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 f001c75b..1c820b23 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,70 @@ "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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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-dAXbuvV+OufRhTXzM6Og9S6bD1NiKwFldVK6v4V2HYgGAyMLUHwElTqV7S/gfg1T" + }, + { + "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.0" + }, + "changelog": [ + { + "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 ade299b6..8af3c792 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 00000000..d15a041c --- /dev/null +++ b/apps/www/public/registry/animated-tooltip.json @@ -0,0 +1,65 @@ +{ + "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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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-dAXbuvV+OufRhTXzM6Og9S6bD1NiKwFldVK6v4V2HYgGAyMLUHwElTqV7S/gfg1T" + }, + { + "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.0" + }, + "changelog": [ + { + "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 1e46cf18..ddfe666d 100644 --- a/apps/www/public/registry/changelogs.json +++ b/apps/www/public/registry/changelogs.json @@ -701,5 +701,14 @@ "Initial release." ] } + ], + "animated-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 c0fef1aa..1f48be28 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 1bda826f..6eb27fa2 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 5719171a..59958b1e 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 1b59766c..917d08bc 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 4a34495a..3754db8e 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 f001c75b..1c820b23 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,70 @@ "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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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-dAXbuvV+OufRhTXzM6Og9S6bD1NiKwFldVK6v4V2HYgGAyMLUHwElTqV7S/gfg1T" + }, + { + "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.0" + }, + "changelog": [ + { + "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 00000000..6b2155be --- /dev/null +++ b/registry/animated-tooltip/component.tsx @@ -0,0 +1,250 @@ +"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. + const trigger = React.cloneElement(children, { + "aria-describedby": open ? tooltipId : undefined, + } 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 00000000..3bf9a92a --- /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 00000000..f495a4ef --- /dev/null +++ b/registry/components/animated-tooltip.json @@ -0,0 +1,162 @@ +{ + "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.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 6a7548b7..d4174977 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 af73336a..7c7b38c4 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 8a563627..708c50ed 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" ] } From 895bf09799368421608f68878d0e251ad1e1c5d7 Mon Sep 17 00:00:00 2001 From: pras75299 Date: Tue, 9 Jun 2026 13:07:22 +0530 Subject: [PATCH 2/2] fix(animated-tooltip): preserve existing aria-describedby on trigger --- apps/www/components/ui/animated-tooltip.tsx | 11 ++++++++++- apps/www/public/r/animated-tooltip.json | 2 +- apps/www/public/registry.json | 13 ++++++++++--- apps/www/public/registry/animated-tooltip.json | 13 ++++++++++--- apps/www/public/registry/changelogs.json | 7 +++++++ registry.json | 13 ++++++++++--- registry/animated-tooltip/component.tsx | 11 ++++++++++- registry/components/animated-tooltip.json | 7 +++++++ 8 files changed, 65 insertions(+), 12 deletions(-) diff --git a/apps/www/components/ui/animated-tooltip.tsx b/apps/www/components/ui/animated-tooltip.tsx index 6b2155be..4e3b8fb5 100644 --- a/apps/www/components/ui/animated-tooltip.tsx +++ b/apps/www/components/ui/animated-tooltip.tsx @@ -173,8 +173,17 @@ export function AnimatedTooltip({ // 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": open ? tooltipId : undefined, + "aria-describedby": mergedDescribedBy, } as React.HTMLAttributes); const directional = diff --git a/apps/www/public/r/animated-tooltip.json b/apps/www/public/r/animated-tooltip.json index 583b2f02..36944a67 100644 --- a/apps/www/public/r/animated-tooltip.json +++ b/apps/www/public/r/animated-tooltip.json @@ -12,7 +12,7 @@ "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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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", + "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/registry.json b/apps/www/public/registry.json index 1c820b23..b3512495 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -4199,9 +4199,9 @@ "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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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", + "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-dAXbuvV+OufRhTXzM6Og9S6bD1NiKwFldVK6v4V2HYgGAyMLUHwElTqV7S/gfg1T" + "integrity": "sha384-fWsn//5bUatXZHbpJCR4tEj6ecYJg7S/LM7eDX6aBbfpl4ZYAg8XiWje7qxh0j61" }, { "path": "utils/cn.ts", @@ -4211,9 +4211,16 @@ } ], "meta": { - "version": "1.0.0" + "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", diff --git a/apps/www/public/registry/animated-tooltip.json b/apps/www/public/registry/animated-tooltip.json index d15a041c..d042bf97 100644 --- a/apps/www/public/registry/animated-tooltip.json +++ b/apps/www/public/registry/animated-tooltip.json @@ -8,9 +8,9 @@ "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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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", + "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-dAXbuvV+OufRhTXzM6Og9S6bD1NiKwFldVK6v4V2HYgGAyMLUHwElTqV7S/gfg1T" + "integrity": "sha384-fWsn//5bUatXZHbpJCR4tEj6ecYJg7S/LM7eDX6aBbfpl4ZYAg8XiWje7qxh0j61" }, { "path": "utils/cn.ts", @@ -20,9 +20,16 @@ } ], "meta": { - "version": "1.0.0" + "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", diff --git a/apps/www/public/registry/changelogs.json b/apps/www/public/registry/changelogs.json index ddfe666d..bd1abcd7 100644 --- a/apps/www/public/registry/changelogs.json +++ b/apps/www/public/registry/changelogs.json @@ -703,6 +703,13 @@ } ], "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", diff --git a/registry.json b/registry.json index 1c820b23..b3512495 100644 --- a/registry.json +++ b/registry.json @@ -4199,9 +4199,9 @@ "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 const trigger = React.cloneElement(children, {\n \"aria-describedby\": open ? tooltipId : undefined,\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", + "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-dAXbuvV+OufRhTXzM6Og9S6bD1NiKwFldVK6v4V2HYgGAyMLUHwElTqV7S/gfg1T" + "integrity": "sha384-fWsn//5bUatXZHbpJCR4tEj6ecYJg7S/LM7eDX6aBbfpl4ZYAg8XiWje7qxh0j61" }, { "path": "utils/cn.ts", @@ -4211,9 +4211,16 @@ } ], "meta": { - "version": "1.0.0" + "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", diff --git a/registry/animated-tooltip/component.tsx b/registry/animated-tooltip/component.tsx index 6b2155be..4e3b8fb5 100644 --- a/registry/animated-tooltip/component.tsx +++ b/registry/animated-tooltip/component.tsx @@ -173,8 +173,17 @@ export function AnimatedTooltip({ // 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": open ? tooltipId : undefined, + "aria-describedby": mergedDescribedBy, } as React.HTMLAttributes); const directional = diff --git a/registry/components/animated-tooltip.json b/registry/components/animated-tooltip.json index f495a4ef..4b889a51 100644 --- a/registry/components/animated-tooltip.json +++ b/registry/components/animated-tooltip.json @@ -151,6 +151,13 @@ "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",