# Tooltip Rich Card — React Tooltip Component

> A toolbar whose rich tooltip (title, body, shortcut keys) glides from button to button instead of closing and reopening.

- Category: [Tooltips & Popovers](https://pixelpapa.com/components/tooltips.md)
- Tags: tooltip, toolbar, shortcut, kbd, shared
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/tooltips/tooltip-rich-card

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/tooltip-rich-card.json"
```

The file is written to `components/pixelpapa/tooltip-rich-card.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Frame, Hand, MessageSquare, MousePointer2, PenTool, Type, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";

export type RichTool = { id: string; icon: LucideIcon; title: string; body: string; keys: string[] };

type RichTooltipToolbarProps = {
  tools: RichTool[];
  defaultActive?: string;
  /** Index of the tool whose tooltip is visible at rest. */
  defaultOpenIndex?: number;
  /** Milliseconds before the first tooltip opens; moving to a neighbour is instant. */
  delay?: number;
  className?: string;
};

const CARD_W = 248;

export function RichTooltipToolbar({ tools, defaultActive, defaultOpenIndex, delay = 300, className }: RichTooltipToolbarProps) {
  const id = useId();
  const reduce = useReducedMotion();
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const [active, setActive] = useState(defaultActive ?? tools[0].id);
  const [[tip, dir], setTip] = useState<[number | null, number]>([defaultOpenIndex ?? null, 0]);
  const [centres, setCentres] = useState<number[]>([]);

  useEffect(() => {
    setCentres(buttons.current.map((b) => (b ? b.offsetLeft + b.offsetWidth / 2 : 0)));
    return () => clearTimeout(timer.current);
  }, [tools.length]);

  function show(i: number, instant = false) {
    clearTimeout(timer.current);
    const open = () => setTip(([prev]) => [i, prev === null ? 0 : Math.sign(i - prev)]);
    if (tip !== null || instant) open();
    else timer.current = setTimeout(open, delay);
  }

  function hide() {
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setTip([null, 0]), 80);
  }

  const x = tip === null ? 0 : (centres[tip] ?? 0) - CARD_W / 2;
  // Wait for the button centres so the card never slides in from the wrong place.
  const tool = tip === null || !centres.length ? null : tools[tip];

  return (
    <div
      role="toolbar"
      aria-label="Tools"
      onPointerLeave={hide}
      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && hide()}
      onKeyDown={(e) => e.key === "Escape" && setTip([null, 0])}
      className={cn("relative inline-flex gap-1 rounded-2xl border border-border bg-card p-1.5 shadow-lg shadow-black/5", className)}
    >
      {tools.map((t, i) => (
        <button
          key={t.id}
          ref={(el) => {
            buttons.current[i] = el;
          }}
          aria-label={t.title}
          aria-pressed={active === t.id}
          aria-describedby={tip === i ? `${id}-tip` : undefined}
          onClick={() => setActive(t.id)}
          onPointerEnter={() => show(i)}
          onFocus={() => show(i, true)}
          className={cn(
            "relative flex size-10 items-center justify-center rounded-xl transition-colors",
            active === t.id ? "text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
          )}
        >
          {active === t.id && (
            <motion.span
              layoutId={`tooltip-rich-card-active-${id}`}
              className="absolute inset-0 rounded-xl bg-primary shadow-sm shadow-primary/30"
              transition={{ type: "spring", stiffness: 480, damping: 34 }}
            />
          )}
          <t.icon className="relative size-[18px]" />
        </button>
      ))}

      <AnimatePresence>
        {tool && (
          <motion.div
            id={`${id}-tip`}
            role="tooltip"
            initial={{ opacity: 0, y: 6, scale: 0.94, x }}
            animate={{ opacity: 1, y: 0, scale: 1, x }}
            exit={{ opacity: 0, y: 4, scale: 0.96, transition: { duration: 0.12 } }}
            transition={{ type: "spring", stiffness: 420, damping: 32 }}
            style={{ width: CARD_W, transformOrigin: "50% 100%" }}
            className="pointer-events-none absolute bottom-full left-0 z-30 mb-3"
          >
            <div className="overflow-hidden rounded-xl border border-border bg-popover p-3.5 text-popover-foreground shadow-xl shadow-black/10">
              <AnimatePresence mode="popLayout" initial={false} custom={dir}>
                <motion.div
                  key={tool.id}
                  custom={dir}
                  variants={{
                    enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 24 }),
                    center: { opacity: 1, x: 0 },
                    exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -24 }),
                  }}
                  initial="enter"
                  animate="center"
                  exit="exit"
                  transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
                >
                  <div className="flex items-center justify-between gap-3">
                    <p className="text-sm font-semibold">{tool.title}</p>
                    <span className="flex gap-1">
                      {tool.keys.map((k) => (
                        <kbd
                          key={k}
                          className="flex h-5 min-w-5 items-center justify-center rounded-md border border-border border-b-2 bg-muted px-1 font-sans text-[11px] font-medium text-muted-foreground"
                        >
                          {k}
                        </kbd>
                      ))}
                    </span>
                  </div>
                  <p className="mt-1.5 min-h-10 text-[13px] leading-5 text-muted-foreground">{tool.body}</p>
                </motion.div>
              </AnimatePresence>
            </div>
            <span className="absolute -bottom-[5px] left-1/2 size-2.5 -translate-x-1/2 rotate-45 border-r border-b border-border bg-popover" />
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

const tools: RichTool[] = [
  { id: "move", icon: MousePointer2, title: "Move", body: "Select, drag and resize layers. Double-click to enter a group.", keys: ["V"] },
  { id: "frame", icon: Frame, title: "Frame", body: "Draw a frame, or pick a device preset from the right panel.", keys: ["F"] },
  { id: "pen", icon: PenTool, title: "Pen", body: "Place points to draw a path. Hold Shift to snap to 45° angles.", keys: ["P"] },
  { id: "text", icon: Type, title: "Text", body: "Click for auto-width text, or drag to set a fixed text box.", keys: ["T"] },
  { id: "comment", icon: MessageSquare, title: "Comment", body: "Pin feedback to the canvas and mention teammates with @.", keys: ["C"] },
  { id: "hand", icon: Hand, title: "Hand", body: "Pan around the canvas. Hold Space to borrow it temporarily.", keys: ["H"] },
];

export default function TooltipRichCardDemo() {
  return (
    <div className="flex h-64 w-full max-w-md items-end justify-center pb-6">
      <RichTooltipToolbar tools={tools} defaultActive="move" defaultOpenIndex={2} />
    </div>
  );
}
```

## AI prompt

```text
Add the "Tooltip Rich Card" component from PixelPapa UI to this project.

What it is: A toolbar whose rich tooltip (title, body, shortcut keys) glides from button to button instead of closing and reopening.
Category: Tooltips & Popovers. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/tooltip-rich-card.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/tooltip-rich-card.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/tooltip-rich-card" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More tooltips & popovers

- [Hover Card Link Preview](https://pixelpapa.com/components/tooltips/hover-card-link-preview.md): Inline links that open a hover card with a drawn miniature of the destination page, which slowly scrolls while you read the summary.
- [Popover Color Picker Mini](https://pixelpapa.com/components/tooltips/popover-color-picker-mini.md) (Pro): A compact colour popover with a draggable saturation field, hue slider, swatches and hex input that springs out of its swatch trigger.
- [Popover Share Menu](https://pixelpapa.com/components/tooltips/popover-share-menu.md) (Pro): A share popover with a public-link switch, a copy button that flips to a confirmed state with a sweep across the URL, and quick share targets.
- [Tooltip Spring Follow](https://pixelpapa.com/components/tooltips/tooltip-spring-follow.md): A tooltip that trails the cursor on a spring and tilts with its velocity, like a tag swinging from the pointer.
