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
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/animated-tooltip.jsonPrompt
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: 18gives 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 byside; 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-foregroundsquare rotated 45° sits at the bubble's near edge, reusing the bubble's own color instead of a separate border trick.