Placard
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.
motionfree
"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 "@/registry/default/hooks/use-reduced-motion/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]);
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 };
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}
</>
);
}
Installation
CLI
npx shadcn@latest add @crucible/placardManual — install dependencies, then copy the source
npm install motionProps
| 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. |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.