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…
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 (ReactNode, the
trigger — wrapped in a positioning span), 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). 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.