# Placard — Animated UI Component for React

> An animated tooltip / hover-card that springs up on hover or focus. The dark-glass card follows the pointer with a subtle velocity-keyed 3D tilt, scales in from 90%, flips on collision, and carries a single cool accent hairline. Fully accessible — role=tooltip, focus + Escape/blur dismissal, aria-describedby wiring — and idle-silent until engaged; reduced motion falls back to an anchored fade.

- Page: https://crucibleui.com/components/utility/placard
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/placard
```

Registry JSON: https://crucibleui.com/r/placard.json

Import from `@/components/crucible/placard`.

## Props (`PlacardProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactElement` | — | The trigger — a single focusable React element (button, link, avatar). Hovering or focusing it reveals the card. It receives `aria-describedby` wiring automatically. |
| `content` | `React.ReactNode` | — | Content rendered inside the floating card — name/role, rich preview, etc. |
| `placement?` | `Placement` | `"top"` | Preferred side of the trigger/pointer the card opens toward. Flips to the opposite side automatically when it would collide with the viewport edge. |
| `follow?` | `boolean` | `true` | When true the card follows the pointer while hovering (with a subtle velocity-keyed tilt). Keyboard focus always anchors to the trigger instead. |
| `tilt?` | `number` | `8` | Peak 3D lean, in degrees, applied from pointer velocity while following. Dialed low so the card reads as a physical surface catching light. |
| `spring?` | `PlacardSpring` | `{ stiffness: 300, damping: 26, mass: 0.7 }` | Spring driving position, scale, and tilt. |
| `openDelay?` | `number` | `120` | Delay before opening, in ms. |
| `closeDelay?` | `number` | `120` | Delay before closing, in ms. |
| `arrow?` | `boolean` | `true` | Render the little pointing arrow on the anchored edge. |
| `collisionPadding?` | `number` | `8` | Minimum gap kept between the card and the viewport edge, in px. |
| `accentColor?` | `string` | `"#38bdf8"` | Color of the single cool accent hairline at the card's top edge. |
| `className?` | `string` | — | Classes merged onto the floating card. |

## Usage example

```tsx
import { Placard } from "@/components/crucible/placard";

interface Person {
  initials: string;
  name: string;
  role: string;
  hue: string;
}

const PEOPLE: Person[] = [
  { initials: "AC", name: "Ava Chen", role: "Product Designer", hue: "from-sky-500/30 to-sky-500/5" },
  { initials: "RD", name: "Rafael Diaz", role: "Staff Engineer", hue: "from-violet-500/30 to-violet-500/5" },
  { initials: "MK", name: "Mira Kaur", role: "Design Systems", hue: "from-emerald-500/30 to-emerald-500/5" },
  { initials: "JO", name: "Jonah Okafor", role: "Motion & Prototyping", hue: "from-amber-500/30 to-amber-500/5" },
];

export default function PlacardDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center gap-10 overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.04),transparent_70%)]"
      />

      <div className="relative z-10 flex flex-col items-center gap-4">
        <div className="flex items-center -space-x-2">
          {PEOPLE.map((p) => (
            <Placard
              key={p.initials}
              placement="top"
              content={
                <div className="flex items-center gap-3 px-3.5 py-2.5">
                  <div
                    className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${p.hue} text-[11px] font-semibold text-white/90 ring-1 ring-white/10`}
                  >
                    {p.initials}
                  </div>
                  <div className="min-w-0">
                    <p className="text-sm font-semibold leading-tight tracking-tight">{p.name}</p>
                    <p className="text-xs leading-tight text-white/55">{p.role}</p>
                  </div>
                </div>
              }
            >
              <button
                type="button"
                aria-label={`${p.name}, ${p.role}`}
                className={`flex h-11 w-11 items-center justify-center rounded-full bg-gradient-to-br ${p.hue} text-xs font-semibold text-white/90 ring-2 ring-neutral-950 outline-none transition hover:scale-105 focus-visible:ring-2 focus-visible:ring-sky-400`}
              >
                {p.initials}
              </button>
            </Placard>
          ))}
        </div>
        <p className="text-sm text-neutral-500">Hover or focus an avatar — the card follows your cursor.</p>
      </div>

      <div className="relative z-10 flex flex-col items-center gap-4">
        <p className="max-w-sm text-center text-sm leading-relaxed text-neutral-400">
          Design systems turn scattered decisions into a shared language. Read the field guide from{" "}
          <Placard
            placement="bottom"
            follow={false}
            content={
              <div className="w-64 px-4 py-3">
                <div className="flex items-center gap-2.5">
                  <div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-sky-500/30 to-sky-500/5 text-[10px] font-semibold text-white/90 ring-1 ring-white/10">
                    AC
                  </div>
                  <div>
                    <p className="text-sm font-semibold leading-tight">Ava Chen</p>
                    <p className="text-xs leading-tight text-white/55">@avachen · Design Systems</p>
                  </div>
                </div>
                <p className="mt-2.5 text-xs leading-relaxed text-white/65">
                  Ships small, polished interfaces and writes about the process. 12 essays, updated weekly.
                </p>
              </div>
            }
          >
            <button
              type="button"
              className="rounded-sm font-medium text-sky-300 underline decoration-sky-400/40 decoration-1 underline-offset-2 outline-none transition hover:decoration-sky-400 focus-visible:ring-2 focus-visible:ring-sky-400"
            >
              Ava Chen
            </button>
          </Placard>
          .
        </p>
        <p className="text-sm text-neutral-500">Tab to the link and it opens anchored, with an arrow.</p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`placard.tsx`)

```tsx
"use client";

import {
  AnimatePresence,
  motion,
  useMotionValue,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";

import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { cn } from "@/lib/utils";

type Placement = "top" | "bottom" | "left" | "right";

export interface PlacardSpring {
  /** @default 300 */
  stiffness?: number;
  /** @default 26 */
  damping?: number;
  /** @default 0.7 */
  mass?: number;
}

export interface PlacardProps {
  /**
   * The trigger — a single focusable React element (button, link, avatar).
   * Hovering or focusing it reveals the card. It receives `aria-describedby`
   * wiring automatically.
   */
  children: React.ReactElement;
  /** Content rendered inside the floating card — name/role, rich preview, etc. */
  content: React.ReactNode;
  /**
   * Preferred side of the trigger/pointer the card opens toward. Flips to the
   * opposite side automatically when it would collide with the viewport edge.
   * @default "top"
   */
  placement?: Placement;
  /**
   * When true the card follows the pointer while hovering (with a subtle
   * velocity-keyed tilt). Keyboard focus always anchors to the trigger instead.
   * @default true
   */
  follow?: boolean;
  /**
   * Peak 3D lean, in degrees, applied from pointer velocity while following.
   * Dialed low so the card reads as a physical surface catching light.
   * @default 8
   */
  tilt?: number;
  /** Spring driving position, scale, and tilt. @default { stiffness: 300, damping: 26, mass: 0.7 } */
  spring?: PlacardSpring;
  /** Delay before opening, in ms. @default 120 */
  openDelay?: number;
  /** Delay before closing, in ms. @default 120 */
  closeDelay?: number;
  /** Render the little pointing arrow on the anchored edge. @default true */
  arrow?: boolean;
  /** Minimum gap kept between the card and the viewport edge, in px. @default 8 */
  collisionPadding?: number;
  /** Color of the single cool accent hairline at the card's top edge. @default "#38bdf8" */
  accentColor?: string;
  /** Classes merged onto the floating card. */
  className?: string;
}

const DEFAULT_SPRING = { stiffness: 300, damping: 26, mass: 0.7 } as const;
const GAP = 10;
const ARROW = 7;
const TILT_FACTOR = 0.008;

interface Rect {
  left: number;
  right: number;
  top: number;
  bottom: number;
  cx: number;
  cy: number;
}

interface Solved {
  x: number;
  y: number;
  place: Placement;
  arrow: number;
}

const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);

function solve(
  rect: Rect,
  w: number,
  h: number,
  placement: Placement,
  hasArrow: boolean,
  pad: number
): Solved {
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  const gap = GAP + (hasArrow ? ARROW : 0);
  let place = placement;

  // Collision flip toward the opposite side when there isn't room.
  if (place === "top" && rect.top - gap - h < pad && rect.bottom + gap + h + pad <= vh) {
    place = "bottom";
  } else if (place === "bottom" && rect.bottom + gap + h > vh - pad && rect.top - gap - h >= pad) {
    place = "top";
  } else if (place === "left" && rect.left - gap - w < pad && rect.right + gap + w + pad <= vw) {
    place = "right";
  } else if (place === "right" && rect.right + gap + w > vw - pad && rect.left - gap - w >= pad) {
    place = "left";
  }

  let x: number;
  let y: number;
  if (place === "top") {
    x = rect.cx - w / 2;
    y = rect.top - gap - h;
  } else if (place === "bottom") {
    x = rect.cx - w / 2;
    y = rect.bottom + gap;
  } else if (place === "left") {
    x = rect.left - gap - w;
    y = rect.cy - h / 2;
  } else {
    x = rect.right + gap;
    y = rect.cy - h / 2;
  }

  // Keep fully on screen.
  x = clamp(x, pad, Math.max(pad, vw - w - pad));
  y = clamp(y, pad, Math.max(pad, vh - h - pad));

  // Arrow slides along the anchored edge so it keeps pointing at the anchor
  // even after the card is clamped.
  const arrow =
    place === "top" || place === "bottom"
      ? clamp(rect.cx - x, 14, w - 14)
      : clamp(rect.cy - y, 14, h - 14);

  return { x, y, place, arrow };
}

const ORIGIN: Record<Placement, string> = {
  top: "center bottom",
  bottom: "center top",
  left: "right center",
  right: "left center",
};

/**
 * Placard — an animated tooltip / hover-card. Hovering or focusing the trigger
 * springs up a neutral dark-glass card with a hairline border and a single cool
 * accent line along its top edge. While the pointer moves, the card follows it
 * and leans in 3D by an amount keyed to cursor velocity, so it reads as a
 * physical card catching light; it settles flat and scales in from 90%.
 *
 * Fully accessible: opens on focus as well as hover, exposes `role="tooltip"`
 * with `aria-describedby` wiring on the trigger, and dismisses on blur or
 * Escape. Idle-silent — no listeners, portal, or animation frames run until the
 * trigger is engaged. Under reduced motion it becomes a plain anchored fade
 * with no follow and no tilt.
 */
export function Placard({
  children,
  content,
  placement = "top",
  follow = true,
  tilt = 8,
  spring,
  openDelay = 120,
  closeDelay = 120,
  arrow = true,
  collisionPadding = 8,
  accentColor = "#38bdf8",
  className,
}: PlacardProps) {
  const reducedMotion = useReducedMotion();
  const springConfig = { ...DEFAULT_SPRING, ...spring };

  const [mounted, setMounted] = React.useState(false);
  const [open, setOpen] = React.useState(false);
  const [place, setPlace] = React.useState<Placement>(placement);

  const triggerRef = React.useRef<HTMLElement | null>(null);
  const cardRef = React.useRef<HTMLDivElement | null>(null);

  const hoverRef = React.useRef(false);
  const focusRef = React.useRef(false);
  const escapedRef = React.useRef(false);
  const openRef = React.useRef(false);
  const placeRef = React.useRef<Placement>(placement);
  const pointerRef = React.useRef({ x: 0, y: 0 });
  const openTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const closeTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  const tooltipId = React.useId();

  // Target position; the springs below chase it for a subtle follow lag.
  const xt = useMotionValue(0);
  const yt = useMotionValue(0);
  const xs = useSpring(xt, springConfig);
  const ys = useSpring(yt, springConfig);
  const arrowPos = useMotionValue(0);

  // Pointer position feeds velocity, which drives the tilt.
  const px = useMotionValue(0);
  const py = useMotionValue(0);
  const vx = useVelocity(px);
  const vy = useVelocity(py);
  const rotYTarget = useTransform(vx, (v) => clamp(v * TILT_FACTOR, -tilt, tilt));
  const rotXTarget = useTransform(vy, (v) => clamp(-v * TILT_FACTOR, -tilt, tilt));
  const rotateY = useSpring(rotYTarget, springConfig);
  const rotateX = useSpring(rotXTarget, springConfig);

  React.useEffect(() => setMounted(true), []);
  React.useEffect(() => {
    openRef.current = open;
  }, [open]);

  const isFollowing = React.useCallback(
    () => follow && hoverRef.current && !reducedMotion,
    [follow, reducedMotion]
  );

  const reposition = React.useCallback(() => {
    const card = cardRef.current;
    if (!card) return;
    const w = card.offsetWidth;
    const h = card.offsetHeight;

    let rect: Rect;
    if (isFollowing()) {
      const p = pointerRef.current;
      rect = { left: p.x, right: p.x, top: p.y, bottom: p.y, cx: p.x, cy: p.y };
    } else {
      const t = triggerRef.current?.getBoundingClientRect();
      if (!t) return;
      rect = {
        left: t.left,
        right: t.right,
        top: t.top,
        bottom: t.bottom,
        cx: t.left + t.width / 2,
        cy: t.top + t.height / 2,
      };
    }

    const r = solve(rect, w, h, placement, arrow, collisionPadding);
    xt.set(r.x);
    yt.set(r.y);
    arrowPos.set(r.arrow);
    if (r.place !== placeRef.current) {
      placeRef.current = r.place;
      setPlace(r.place);
    }
  }, [isFollowing, placement, arrow, collisionPadding, xt, yt, arrowPos]);

  const sync = React.useCallback(() => {
    if (openTimer.current) clearTimeout(openTimer.current);
    if (closeTimer.current) clearTimeout(closeTimer.current);
    const wantOpen = (hoverRef.current || focusRef.current) && !escapedRef.current;
    if (wantOpen) {
      openTimer.current = setTimeout(() => setOpen(true), openDelay);
    } else {
      closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
    }
  }, [openDelay, closeDelay]);

  // Position instantly (no spring travel) the first frame the card mounts.
  // Re-place on the next frame too: on the very first open the card ref/layout
  // may not be measurable yet, so reposition() bails and the springs would sit
  // at the stale (0,0) — which is what stranded keyboard-focus opens in the
  // corner (no pointer-move to correct them). The card fades in from opacity 0,
  // so the one-frame correction isn't visible.
  React.useLayoutEffect(() => {
    if (!open) return;
    const place = () => {
      reposition();
      xs.jump(xt.get());
      ys.jump(yt.get());
    };
    place();
    const id = requestAnimationFrame(place);
    return () => cancelAnimationFrame(id);
  }, [open, reposition, xs, ys, xt, yt]);

  // Reposition on scroll/resize while open (only work when engaged).
  React.useEffect(() => {
    if (!open) return;
    const onScroll = () => reposition();
    window.addEventListener("scroll", onScroll, true);
    window.addEventListener("resize", onScroll);
    const card = cardRef.current;
    const ro =
      card && typeof ResizeObserver !== "undefined"
        ? new ResizeObserver(() => reposition())
        : null;
    if (ro && card) ro.observe(card);
    return () => {
      window.removeEventListener("scroll", onScroll, true);
      window.removeEventListener("resize", onScroll);
      ro?.disconnect();
    };
  }, [open, reposition]);

  // Escape dismisses from anywhere while open — a hover-opened card must be
  // dismissable without moving the pointer or focusing the trigger.
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      escapedRef.current = true;
      if (openTimer.current) clearTimeout(openTimer.current);
      setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  React.useEffect(
    () => () => {
      if (openTimer.current) clearTimeout(openTimer.current);
      if (closeTimer.current) clearTimeout(closeTimer.current);
    },
    []
  );

  const handlePointerEnter = (e: React.PointerEvent) => {
    if (e.pointerType === "touch") return;
    hoverRef.current = true;
    escapedRef.current = false;
    pointerRef.current = { x: e.clientX, y: e.clientY };
    // Seat the velocity sources at the entry point — otherwise the first move
    // reads as a jump from (0,0) and the card lurches to full tilt.
    px.jump(e.clientX);
    py.jump(e.clientY);
    sync();
  };

  const handlePointerMove = (e: React.PointerEvent) => {
    if (e.pointerType === "touch") return;
    pointerRef.current = { x: e.clientX, y: e.clientY };
    if (isFollowing()) {
      px.set(e.clientX);
      py.set(e.clientY);
      if (openRef.current) reposition();
    }
  };

  const handlePointerLeave = () => {
    hoverRef.current = false;
    escapedRef.current = false;
    sync();
    if (openRef.current) reposition();
  };

  const handleFocus = () => {
    focusRef.current = true;
    escapedRef.current = false;
    sync();
    if (openRef.current) reposition();
  };

  const handleBlur = () => {
    focusRef.current = false;
    escapedRef.current = false;
    sync();
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Escape" && openRef.current) {
      escapedRef.current = true;
      if (openTimer.current) clearTimeout(openTimer.current);
      setOpen(false);
    }
  };

  const childProps = children.props as { "aria-describedby"?: string };
  const describedBy =
    [childProps["aria-describedby"], open ? tooltipId : null].filter(Boolean).join(" ") ||
    undefined;

  const trigger = (
    <span
      ref={(node) => {
        triggerRef.current = node;
      }}
      className="inline-flex"
      onPointerEnter={handlePointerEnter}
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      onFocus={handleFocus}
      onBlur={handleBlur}
      onKeyDown={handleKeyDown}
    >
      {React.cloneElement(children as React.ReactElement<{ "aria-describedby"?: string }>, {
        "aria-describedby": describedBy,
      })}
    </span>
  );

  const arrowSideClass =
    place === "top"
      ? "left-0 bottom-0 translate-y-1/2 border-b border-r"
      : place === "bottom"
        ? "left-0 top-0 -translate-y-1/2 border-t border-l"
        : place === "left"
          ? "top-0 right-0 translate-x-1/2 border-t border-r"
          : "top-0 left-0 -translate-x-1/2 border-b border-l";

  const overlay = open ? (
    <motion.div
      style={{
        position: "fixed",
        top: 0,
        left: 0,
        zIndex: 9999,
        x: xs,
        y: ys,
        pointerEvents: "none",
        transformOrigin: ORIGIN[place],
      }}
      initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
      animate={reducedMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
      exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
      transition={
        reducedMotion
          ? { duration: 0.14, ease: "easeOut" }
          : { type: "spring", ...springConfig, opacity: { duration: 0.12 } }
      }
    >
      <motion.div
        ref={cardRef}
        role="tooltip"
        id={tooltipId}
        data-crucible="placard"
        data-placement={place}
        style={{
          rotateX: reducedMotion ? 0 : rotateX,
          rotateY: reducedMotion ? 0 : rotateY,
          transformPerspective: reducedMotion ? undefined : 800,
        }}
        className={cn(
          "relative max-w-xs overflow-hidden rounded-xl border border-white/10 bg-neutral-900/85 text-white shadow-2xl shadow-black/60 backdrop-blur-xl",
          className
        )}
      >
        {/* Cool accent hairline along the top edge. */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-x-0 top-0 h-px"
          style={{
            background: `linear-gradient(90deg, transparent, ${accentColor}, transparent)`,
            opacity: 0.7,
          }}
        />
        {/* Faint sheen so the tilt reads as a surface catching light. */}
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0 rounded-[inherit]"
          style={{
            background:
              "radial-gradient(120% 80% at 30% 0%, rgba(255,255,255,0.08), transparent 60%)",
          }}
        />
        <div className="relative z-10">{content}</div>
        {arrow ? (
          <motion.span
            aria-hidden
            className={cn(
              "pointer-events-none absolute h-2.5 w-2.5 rotate-45 border-white/10 bg-neutral-900/85",
              arrowSideClass
            )}
            style={
              place === "top" || place === "bottom"
                ? { x: arrowPos, marginLeft: -5 }
                : { y: arrowPos, marginTop: -5 }
            }
          />
        ) : null}
      </motion.div>
    </motion.div>
  ) : null;

  return (
    <>
      {trigger}
      {mounted
        ? createPortal(<AnimatePresence>{overlay}</AnimatePresence>, document.body)
        : null}
    </>
  );
}
```
