Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 259 additions & 0 deletions apps/www/components/ui/animated-tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
"use client";

import { cn } from "@/lib/utils";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";

type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";

export interface AnimatedTooltipProps {
/** The trigger element. Must be a single focusable React element. */
children: React.ReactElement;
/** Content rendered inside the tooltip bubble. */
content: React.ReactNode;
/** Which side of the trigger the tooltip appears on. */
side?: Side;
/** Alignment along the trigger's edge. */
align?: Align;
/** Background colour of the tooltip bubble. */
background?: string;
/** Text colour inside the tooltip. */
color?: string;
/** Arrow fill colour. Defaults to `background` so it always matches. */
arrowColor?: string;
/** Whether to render the pointing arrow. */
arrow?: boolean;
/** Arrow edge length in px. */
arrowSize?: number;
/** Gap between trigger and tooltip in px. */
offset?: number;
/** Delay before the tooltip appears, in ms. */
delay?: number;
/** Disable the tooltip entirely (trigger still renders). */
disabled?: boolean;
/** Extra classes merged onto the tooltip bubble. */
className?: string;
}

function getLayoutStyle(
side: Side,
align: Align,
offset: number,
): React.CSSProperties {
const style: React.CSSProperties = {};
const gap = `calc(100% + ${offset}px)`;

if (side === "top") style.bottom = gap;
if (side === "bottom") style.top = gap;
if (side === "left") style.right = gap;
if (side === "right") style.left = gap;

if (side === "top" || side === "bottom") {
if (align === "center") {
style.left = "50%";
style.transform = "translateX(-50%)";
} else if (align === "start") {
style.left = 0;
} else {
style.right = 0;
}
} else {
if (align === "center") {
style.top = "50%";
style.transform = "translateY(-50%)";
} else if (align === "start") {
style.top = 0;
} else {
style.bottom = 0;
}
}

return style;
}

function getArrowStyle(
side: Side,
align: Align,
size: number,
fill: string,
): React.CSSProperties {
const half = size / 2;
const style: React.CSSProperties = {
position: "absolute",
width: size,
height: size,
background: fill,
transform: "rotate(45deg)",
borderRadius: 2,
};

// Sit the rotated square half-way over the edge facing the trigger.
if (side === "top") style.bottom = -half;
if (side === "bottom") style.top = -half;
if (side === "left") style.right = -half;
if (side === "right") style.left = -half;

// Position the arrow along the edge to track the chosen alignment.
const edgePad = Math.max(size, 8);
if (side === "top" || side === "bottom") {
if (align === "center") {
style.left = "50%";
style.marginLeft = -half;
} else if (align === "start") {
style.left = edgePad;
} else {
style.right = edgePad;
}
} else {
if (align === "center") {
style.top = "50%";
style.marginTop = -half;
} else if (align === "start") {
style.top = edgePad;
} else {
style.bottom = edgePad;
}
}

return style;
}

export function AnimatedTooltip({
children,
content,
side = "top",
align = "center",
background = "#18181b",
color = "#fafafa",
arrowColor,
arrow = true,
arrowSize = 8,
offset = 8,
delay = 200,
disabled = false,
className,
}: AnimatedTooltipProps) {
const [open, setOpen] = React.useState(false);
const tooltipId = React.useId();
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const prefersReducedMotion = useReducedMotion();

const clearTimer = React.useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);

const show = React.useCallback(() => {
if (disabled) return;
clearTimer();
timerRef.current = setTimeout(() => setOpen(true), delay);
}, [disabled, delay, clearTimer]);

const hide = React.useCallback(() => {
clearTimer();
setOpen(false);
}, [clearTimer]);

// Clean up any pending show timer on unmount.
React.useEffect(() => clearTimer, [clearTimer]);

// Dismiss on Escape, per WAI-ARIA tooltip guidance.
React.useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") hide();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open, hide]);

// Link the trigger to the bubble for assistive tech while it is visible.
// Event handlers live on the wrapper below — onFocus/onBlur bubble via
// focusin/focusout, so descendant focus is captured without cloning refs in.
// Merge with any existing aria-describedby so other descriptions are kept.
const originalDescribedBy = (
children.props as React.HTMLAttributes<HTMLElement>
)["aria-describedby"];
const mergedDescribedBy = open
? originalDescribedBy
? `${originalDescribedBy} ${tooltipId}`
: tooltipId
: originalDescribedBy;
const trigger = React.cloneElement(children, {
"aria-describedby": mergedDescribedBy,
} as React.HTMLAttributes<HTMLElement>);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 (
<span
className="relative inline-flex"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{trigger}
<AnimatePresence>
{open && (
<span
className="pointer-events-none absolute z-50"
style={getLayoutStyle(side, align, offset)}
>
<motion.span
role="tooltip"
id={tooltipId}
{...motionProps}
style={{
background,
color,
transformOrigin: "center",
}}
className={cn(
"relative block w-max max-w-xs rounded-md px-2.5 py-1.5 text-sm font-medium leading-snug shadow-lg shadow-black/20",
className,
)}
>
{content}
{arrow && (
<span
aria-hidden="true"
style={getArrowStyle(
side,
align,
arrowSize,
arrowColor ?? background,
)}
/>
)}
</motion.span>
</span>
)}
</AnimatePresence>
</span>
);
}
86 changes: 86 additions & 0 deletions apps/www/config/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
LucideWand2,
LucideWaves,
LucideWind,
MessageSquare,
Terminal,
} from "lucide-react";

Expand Down Expand Up @@ -113,6 +114,7 @@ const iconMap = {
LucideWand2,
LucideWaves,
LucideWind,
MessageSquare,
Terminal,
} satisfies Record<string, ElementType>;

Expand Down Expand Up @@ -3456,6 +3458,90 @@ const componentDefinitions = [
}
],
"usageCode": "import { RadialBurstHero } from \"@/components/ui/hero-radial-burst\";\n\nexport default function Hero() {\n return (\n <RadialBurstHero\n defaultTheme=\"night\"\n title={\n <>\n The backbone\n <br />\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 <AnimatedTooltip content=\"Copy to clipboard\" side=\"top\">\n <button className=\"rounded-md bg-neutral-800 px-4 py-2 text-sm text-white\">\n Copy\n </button>\n </AnimatedTooltip>\n );\n}"
}
] satisfies ComponentDefinition[];

Expand Down
Loading
Loading