Feedback

Animated Tooltip

A self-contained hover/focus tooltip with a spring pop-in and a reduced-motion fallback — no Radix or floating-ui required.

Preview in your theme

Loading preview…

Dependencies
motion
Registry dependencies
utils
"use client"

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

export interface AnimatedTooltipProps {
  /** Tooltip bubble content. */
  content: React.ReactNode
  /** Which side of the trigger the bubble appears on. */
  side?: "top" | "bottom"
  /** The trigger — a single focusable element (button, link, or a tabIndex'd
   * element). The tooltip's id is injected into it via aria-describedby so
   * screen readers announce the bubble from the element that actually holds

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/animated-tooltip.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "AnimatedTooltip" component using motion
(Framer Motion successor) for the reveal animation. Self-contained — no
Radix/shadcn Tooltip primitive, no floating-ui.

Contract
- Export a component AnimatedTooltip with props: content (ReactNode, the bubble
  body), side = "top" | "bottom" (default "top"), children (a single
  focusable ReactElement, the trigger — wrapped in a positioning span; not
  text, since it is cloned), className (merged onto the bubble, not the
  trigger).

Behavior
- Track hover and focus as two separate booleans (hovered, focused); open =
  hovered || focused, so a pointer leaving mid-keyboard-focus never hides a
  tooltip that's still keyboard-focused, and vice versa. Wire
  onMouseEnter/onMouseLeave and onFocus/onBlur on the outer wrapper span —
  React's focus/blur bubble like native focusin/focusout, so focusing any
  focusable child of the wrapper (button, link, tabIndex'd element) opens it.
  This gives "focus-within" behavior; the wrapper itself is not focusable.
- Escape key (onKeyDown on the wrapper) force-closes both hovered and focused.
- Mount/unmount the bubble with AnimatePresence so exit animations play. Read
  useReducedMotion(); when true, initial/animate only touch opacity (plain
  fade, no y or scale) — the tooltip's meaning must survive with motion off.
  When false: initial { opacity: 0, y: side === "top" ? 8 : -8, scale: 0.9 },
  animate to { opacity: 1, y: 0, scale: 1 } with a spring transition
  (stiffness 300, damping 18) for a slight overshoot pop. Exit is always a
  plain ~150ms fade, regardless of reduced motion.
- The bubble carries role="tooltip" and a React.useId()-generated id;
  aria-describedby pointing at that id is cloned INTO the trigger element
  itself (the node that actually receives focus — ARIA tooltip pattern; on a
  wrapper span screen readers never announce it), and only while open (the
  id doesn't exist in the DOM once unmounted, so the reference must not
  dangle while closed). Any aria-describedby the trigger already carries (a
  field hint, an error id) is kept: the tooltip id is appended to it, space
  separated, and removed again on close. Children must therefore be a single
  focusable element.
- Positioning is pure CSS: the bubble is absolute, centered horizontally
  (left-1/2 -translate-x-1/2) and offset above (bottom-full mb-2) or below
  (top-full mt-2) the trigger depending on side. There is no collision
  detection or auto-flip — if the trigger sits at a viewport edge the bubble
  can overflow. That's an intentional scope cut (see Customization levers).
- A small arrow is a 2x2 square rotated 45deg (bg-foreground, same color as
  the bubble), aria-hidden, positioned at the bubble's near edge so it points
  back at the trigger.

Rendering & styling
- Semantic tokens only: bubble is bg-foreground text-background (an inverted
  color scheme that stays correct in both light and dark themes since the two
  tokens flip together), text-xs, rounded-md, px-2.5 py-1, shadow-md. cn()
  merges the consumer className onto the bubble only — never onto the trigger
  wrapper.
- pointer-events-none on the bubble so it never steals hover/click from page
  content beneath it.

Customization levers
- Spring feel: raise stiffness for a snappier pop, raise damping to kill the
  overshoot entirely, or lower stiffness for a slower drift-in.
- Inverted vs. subtle skin: swap bg-foreground/text-background for bg-card
  text-card-foreground border shadow-sm for a lighter, less contrasty bubble.
- Reveal delay: wrap the open boolean behind a short setTimeout (with cleanup)
  before it flips true for a deliberate hover-intent delay; keep close instant.
- Collision-aware positioning: this component intentionally skips edge-flip
  and scroll-container clamping to stay a zero-dependency drop-in. Swapping
  the CSS positioning for floating-ui's useFloating (with shift/flip
  middleware) is the straightforward upgrade path once a page needs it.

Concepts

  • Hover-focus parity — hovered and focused are tracked as two independent booleans (open = hovered || focused), so a mouse leaving while the trigger is still keyboard-focused never hides the tooltip, and Tab-focusing works identically to hovering.
  • Spring pop-in with overshoot — stiffness: 300, damping: 18 gives the bubble a small, snappy overshoot rather than a linear ease — it feels like it "pops" into place.
  • Reduced-motion honesty — under prefers-reduced-motion, the position/scale animation is dropped entirely and only opacity fades; the tooltip still appears and disappears, just without motion.
  • CSS-only positioning, no collision flip — the bubble is absolute, centered on the trigger and offset above/below by side; there's no viewport-edge detection or auto-flip, an intentional scope cut for a zero-dependency component (see the floating-ui lever above).
  • Arrow via rotated square — a small bg-foreground square rotated 45° sits at the bubble's near edge, reusing the bubble's own color instead of a separate border trick.

On This Page