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…

"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 (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 overshootstiffness: 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