primitives
Animated Tooltip
A spring-anchored tooltip that blooms upward from its trigger — scaling 0.85→1 with a vertical fade and a micro-tilt toward the cursor's entry side, settling to rest via a physics-tuned spring (stiffness 120, damping 14). Wraps Radix UI Tooltip so keyboard, focus, Escape and ARIA are handled by the primitive; the Motion layer rides on top via AnimatePresence. Depth comes from an inset ring-glow instead of a drop-shadow, keeping paint cheap. Inverted 'primary' bubble by default with a bordered 'outline' variant.
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/animated-tooltipUsage
Usagetsx
<AnimatedTooltip content={undefined}>
{/* children */}
</AnimatedTooltip>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | React.ReactNode | — | The trigger element — can be any focusable node. |
| content* | React.ReactNode | — | Tooltip content; accepts string or JSX for rich labels. |
| side | enum | top | Preferred placement; Radix handles collision detection. Default "top". |
| sideOffset | number | 8 | Gap in px between trigger and tooltip. Default 8. |
| delayDuration | number | 300 | Hover open delay in ms passed to Radix provider. Default 300. |
| asChild | boolean | true | Forward as-child to Radix trigger for custom trigger elements. Default true. |
| variant | enum | primary | Inverted "primary" bubble (default) or bordered "outline" surface. |
| className | string | Additional classes merged onto the tooltip content element. |
Source
animated-tooltip.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { Variants } from "motion/react";
export type AnimatedTooltipSide = "top" | "bottom" | "left" | "right";
export interface AnimatedTooltipProps {
/** The trigger element — can be any focusable node. */
children: React.ReactNode;
/** Tooltip content; accepts string or JSX for rich labels. */
content: React.ReactNode;
/** Preferred placement; Radix handles collision detection. Default "top". */
side?: AnimatedTooltipSide;
/** Gap in px between trigger and tooltip. Default 8. */
sideOffset?: number;
/** Hover open delay in ms passed to Radix provider. Default 300. */
delayDuration?: number;
/** Forward as-child to Radix trigger for custom trigger elements. Default true. */
asChild?: boolean;
/** Inverted "primary" bubble (default) or bordered "outline" surface. */
variant?: "primary" | "outline";
/** Additional classes merged onto the tooltip content element. */
className?: string;
}
// Physics-tuned spring: tight enough to feel instant, loose enough for one
// gentle scale overshoot with no oscillation on translateY.
// Motion JS transition timings are SECONDS / spring config — NOT the
// --duration/--ease CSS tokens (those drive ~300ms CSS UI transitions).
const ENTER_SPRING = { type: "spring" as const, stiffness: 120, damping: 14, mass: 0.8 };
const EXIT_TWEEN = { duration: 0.12, ease: "linear" as const };
// Reduced-motion: opacity only, no transform, 150ms linear both directions.
const REDUCED_TWEEN = { duration: 0.15, ease: "linear" as const };
function springVariants(tilt: number): Variants {
return {
initial: { opacity: 0, scale: 0.85, y: 4, rotate: tilt },
animate: {
opacity: 1,
scale: 1,
y: 0,
rotate: 0,
transition: ENTER_SPRING,
},
exit: {
opacity: 0,
scale: 0.92,
y: 3,
rotate: 0,
transition: EXIT_TWEEN,
},
};
}
const reducedVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: REDUCED_TWEEN },
exit: { opacity: 0, transition: REDUCED_TWEEN },
};
export function AnimatedTooltip({
children,
content,
side = "top",
sideOffset = 8,
delayDuration = 300,
asChild = true,
variant = "primary",
className = "",
}: AnimatedTooltipProps) {
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
// Sign of the entry micro-tilt, derived from the cursor's X relative to the
// trigger center on the pointer-enter that opens the tooltip.
const tiltRef = useRef(0);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
// null (SSR / first paint) => treat as reduced; only animate after mount.
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
function handlePointerEnter(e: React.PointerEvent) {
if (!animate) {
tiltRef.current = 0;
return;
}
const el = triggerRef.current;
if (!el) {
tiltRef.current = 0;
return;
}
const rect = el.getBoundingClientRect();
const center = rect.left + rect.width / 2;
// Tilt toward the cursor's entry side: ±3deg.
tiltRef.current = e.clientX < center ? -3 : 3;
}
const isPrimary = variant === "primary";
const surfaceClasses = isPrimary
? [
"bg-primary text-primary-foreground",
// Inner-glow border: a faint 1px ring inside the frame for depth
// without a drop-shadow (no paint layer / no layout). Uses --ring.
"shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--ring)_30%,transparent)]",
]
: ["bg-background text-foreground border border-border"];
const variants = animate ? springVariants(tiltRef.current) : reducedVariants;
return (
<TooltipPrimitive.Provider delayDuration={delayDuration}>
<TooltipPrimitive.Root open={open} onOpenChange={setOpen}>
<TooltipPrimitive.Trigger
ref={triggerRef}
asChild={asChild}
onPointerEnter={handlePointerEnter}
// Guarantee a keyboard-focus ring from --ring regardless of the
// child trigger's own styling. With asChild, Radix merges this
// className onto the child so focus visibility never depends on
// the consumer remembering to add one.
className="rounded-[calc(var(--radius)-2px)] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{children}
</TooltipPrimitive.Trigger>
<AnimatePresence>
{open ? (
<TooltipPrimitive.Portal forceMount>
<TooltipPrimitive.Content
asChild
side={side}
sideOffset={sideOffset}
// Tooltip content must NOT receive focus — interactive content
// belongs in a Popover. Escape / blur handled by Radix.
>
<motion.div
variants={variants}
initial="initial"
animate="animate"
exit="exit"
style={{ transformOrigin: "var(--radix-tooltip-content-transform-origin)" }}
className={[
"z-50 select-none",
"px-3 py-1.5",
"text-xs font-medium tracking-wide",
// Slightly tighter than card-level radius.
"rounded-[calc(var(--radius)-2px)]",
"will-change-transform",
...surfaceClasses,
className,
]
.filter(Boolean)
.join(" ")}
>
{content}
</motion.div>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
) : null}
</AnimatePresence>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
}
Dependencies
- motion
- @radix-ui/react-tooltip
- @bottega/tokens