bottega

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-tooltip

Usage

Usagetsx
<AnimatedTooltip content={undefined}>
  {/* children */}
</AnimatedTooltip>

Props

PropTypeDefaultDescription
children*React.ReactNodeThe trigger element — can be any focusable node.
content*React.ReactNodeTooltip content; accepts string or JSX for rich labels.
sideenumtopPreferred placement; Radix handles collision detection. Default "top".
sideOffsetnumber8Gap in px between trigger and tooltip. Default 8.
delayDurationnumber300Hover open delay in ms passed to Radix provider. Default 300.
asChildbooleantrueForward as-child to Radix trigger for custom trigger elements. Default true.
variantenumprimaryInverted "primary" bubble (default) or bordered "outline" surface.
classNamestringAdditional 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