Pantograph
Morphing flyout nav where one shared dark-glass panel slides and resizes between triggers instead of closing and reopening — an anticipation-stretch morph with a caret that tracks the active trigger and content that crossfades in from the direction of travel. Hover-intent grace, full disclosure semantics (focus opens, Escape closes, arrow keys move triggers), and instant repositioning under reduced motion.
motionfree
"use client";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
useTransform,
type Variants,
} from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface PantographItem {
/** Unique value identifying this trigger. */
value: string;
/** Trigger label shown in the bar. */
label: React.ReactNode;
/**
* Flyout panel content for this trigger. Omit for a plain trigger that
* navigates (via `href`) without opening a panel.
*/
content?: React.ReactNode;
/** Optional href — renders the trigger as a link. */
href?: string;
}
export interface PantographProps
extends Omit<
React.ComponentPropsWithoutRef<"nav">,
"onChange" | "value" | "defaultValue"
> {
/** Triggers to render, left to right. @default a small generic set */
items?: PantographItem[];
/** How a flyout opens on pointer devices. @default "hover" */
openOn?: "hover" | "click";
/** Show the caret that tracks the active trigger. @default true */
caret?: boolean;
/**
* Grace period in ms before the panel closes after the pointer leaves —
* the hover-intent forgiveness that stops flicker on diagonal travel.
* @default 150
*/
closeDelay?: number;
/**
* Incoming content slides in from the direction of travel (`"directional"`)
* or simply crossfades in place (`"crossfade"`). @default "directional"
*/
contentTransition?: "directional" | "crossfade";
/** Spring physics for the panel morph. */
spring?: { stiffness?: number; damping?: number; mass?: number };
/** Min panel width in px. @default 200 */
minWidth?: number;
/** Max panel width in px. @default 560 */
maxWidth?: number;
/** Controlled active trigger value; `null` means closed. */
value?: string | null;
/** Initial active trigger when uncontrolled. @default null (closed) */
defaultValue?: string | null;
/** Fires whenever the active trigger changes (a value, or `null` on close). */
onValueChange?: (value: string | null) => void;
}
const DEFAULT_SPRING = { stiffness: 440, damping: 40, mass: 0.9 };
const CONTENT_SHIFT = 22;
const CARET_PAD = 20;
function clamp(v: number, lo: number, hi: number): number {
return Math.min(Math.max(v, lo), hi);
}
function LinkList({
links,
}: {
links: { label: string; hint?: string; href?: string }[];
}) {
return (
<ul className="grid w-max min-w-52 gap-0.5">
{links.map((l) => (
<li key={l.label}>
<a
href={l.href ?? "#"}
className="group/link flex flex-col rounded-lg px-3 py-2 transition-colors hover:bg-white/6 focus-visible:bg-white/6 focus-visible:outline-none"
>
<span className="text-sm font-medium text-white/85 transition-colors group-hover/link:text-white">
{l.label}
</span>
{l.hint ? (
<span className="text-xs text-white/45">{l.hint}</span>
) : null}
</a>
</li>
))}
</ul>
);
}
const DEFAULT_ITEMS: PantographItem[] = [
{
value: "products",
label: "Products",
content: (
<LinkList
links={[
{ label: "Overview", hint: "The whole platform at a glance" },
{ label: "Automation", hint: "Rules that run themselves" },
{ label: "Insights", hint: "Live dashboards and reports" },
]}
/>
),
},
{
value: "pricing",
label: "Pricing",
content: (
<LinkList
links={[
{ label: "Plans", hint: "Pick a tier that fits" },
{ label: "Enterprise", hint: "Volume and SSO" },
]}
/>
),
},
{
value: "company",
label: "Company",
content: (
<LinkList
links={[
{ label: "About", hint: "Who we are" },
{ label: "Careers", hint: "Join the team" },
{ label: "Blog", hint: "News and writing" },
{ label: "Contact", hint: "Talk to us" },
]}
/>
),
},
];
/**
* Pantograph — a horizontal nav where hovering a trigger opens a single shared
* flyout that *morphs* to the next trigger instead of closing and reopening:
* the panel slides along the bar and resizes to fit each trigger's content
* while the caret tracks the active trigger and the content crossfades in from
* the direction of travel. The signature is an *anticipation stretch* — the
* panel's leading edge extends toward the new trigger a beat before the body
* follows and the trailing edge snaps in, so it reads as one window panning
* over a continuous surface. Hover-intent grace prevents flicker on diagonal
* travel. Full disclosure semantics: focus opens, Escape closes, arrow keys
* move between triggers, `aria-expanded` reflects state. On coarse pointers it
* opens on tap; under `prefers-reduced-motion` the panel repositions instantly
* and the content simply crossfades.
*/
export function Pantograph({
items = DEFAULT_ITEMS,
openOn = "hover",
caret = true,
closeDelay = 150,
contentTransition = "directional",
spring,
minWidth = 200,
maxWidth = 560,
value,
defaultValue = null,
onValueChange,
className,
...props
}: PantographProps) {
const reducedMotion = useReducedMotion();
const baseId = React.useId();
const panelId = `${baseId}-panel`;
const rootRef = React.useRef<HTMLDivElement>(null);
const barRef = React.useRef<HTMLDivElement>(null);
const triggerRefs = React.useRef<Map<string, HTMLElement>>(new Map());
const measureRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const closeTimer = React.useRef<number | null>(null);
const springCfg = { ...DEFAULT_SPRING, ...spring };
// ---- open / active state (controlled or not) -----------------------------
const [internalValue, setInternalValue] = React.useState<string | null>(
defaultValue
);
const activeValue = value !== undefined ? value : internalValue;
const [direction, setDirection] = React.useState(1);
const [focusValue, setFocusValue] = React.useState<string>(
items[0]?.value ?? ""
);
const [finePointer, setFinePointer] = React.useState(true);
const activeItem = items.find(
(i) => i.value === activeValue && i.content != null
);
const open = activeItem != null;
const setActive = React.useCallback(
(next: string | null) => {
if (next && activeValue && next !== activeValue) {
const a = triggerCentersRef.current[activeValue];
const b = triggerCentersRef.current[next];
if (a != null && b != null) setDirection(b > a ? 1 : -1);
}
if (value === undefined) setInternalValue(next);
onValueChange?.(next);
},
[activeValue, value, onValueChange]
);
// ---- layout measurement --------------------------------------------------
const [navWidth, setNavWidth] = React.useState(0);
const [dims, setDims] = React.useState<
Record<string, { w: number; h: number }>
>({});
const triggerCentersRef = React.useRef<Record<string, number>>({});
const measureTriggers = React.useCallback(() => {
const bar = barRef.current;
if (!bar) return;
const barRect = bar.getBoundingClientRect();
setNavWidth(barRect.width);
const centers: Record<string, number> = {};
for (const item of items) {
const el = triggerRefs.current.get(item.value);
if (el) {
const r = el.getBoundingClientRect();
centers[item.value] = r.left - barRect.left + r.width / 2;
}
}
triggerCentersRef.current = centers;
}, [items]);
React.useLayoutEffect(() => {
measureTriggers();
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => measureTriggers());
if (barRef.current) ro.observe(barRef.current);
return () => ro.disconnect();
}, [measureTriggers]);
// Measure each trigger's natural content size (padding included).
React.useLayoutEffect(() => {
if (typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver((entries) => {
setDims((prev) => {
let changed = false;
const next = { ...prev };
for (const entry of entries) {
const key = entry.target.getAttribute("data-measure-key");
if (!key) continue;
const el = entry.target as HTMLElement;
const w = el.offsetWidth;
const h = el.offsetHeight;
if (!next[key] || next[key].w !== w || next[key].h !== h) {
next[key] = { w, h };
changed = true;
}
}
return changed ? next : prev;
});
});
for (const el of measureRefs.current.values()) ro.observe(el);
return () => ro.disconnect();
}, [items]);
// ---- morph motion values -------------------------------------------------
const leftMV = useMotionValue(0);
const rightMV = useMotionValue(0);
const heightMV = useMotionValue(0);
const caretCenterMV = useMotionValue(0);
const widthMV = useTransform([leftMV, rightMV], (latest: number[]) =>
Math.max(0, latest[1] - latest[0])
);
const caretX = useTransform(
[caretCenterMV, leftMV, rightMV],
(latest: number[]) => {
const [c, l, r] = latest;
return clamp(c - l, CARET_PAD, Math.max(CARET_PAD, r - l - CARET_PAD));
}
);
const wasOpenRef = React.useRef(false);
React.useEffect(() => {
if (!open || !activeValue) {
wasOpenRef.current = false;
return;
}
const dim = dims[activeValue];
const center = triggerCentersRef.current[activeValue];
if (!dim || center == null || !navWidth) return;
const w = clamp(dim.w, minWidth, maxWidth);
const h = dim.h;
let targetLeft: number;
if (w >= navWidth) targetLeft = (navWidth - w) / 2;
else targetLeft = clamp(center - w / 2, 0, navWidth - w);
const targetRight = targetLeft + w;
if (!wasOpenRef.current || reducedMotion) {
// Fresh open or reduced motion: seat the panel with no morph theatrics.
leftMV.set(targetLeft);
rightMV.set(targetRight);
heightMV.set(h);
caretCenterMV.set(center);
} else {
// Anticipation stretch: the leading edge (toward the new trigger) springs
// fast and arrives first; the trailing edge follows a beat behind, so the
// panel visibly stretches before it settles.
const movingRight = targetLeft > leftMV.get();
const lead = {
type: "spring" as const,
stiffness: springCfg.stiffness * 1.7,
damping: springCfg.damping * 1.05,
mass: springCfg.mass * 0.85,
};
const trail = {
type: "spring" as const,
stiffness: springCfg.stiffness * 0.85,
damping: springCfg.damping,
mass: springCfg.mass,
delay: 0.05,
};
if (movingRight) {
animate(rightMV, targetRight, lead);
animate(leftMV, targetLeft, trail);
} else {
animate(leftMV, targetLeft, lead);
animate(rightMV, targetRight, trail);
}
animate(heightMV, h, {
type: "spring",
stiffness: springCfg.stiffness,
damping: springCfg.damping,
mass: springCfg.mass,
});
animate(caretCenterMV, center, {
type: "spring",
stiffness: 700,
damping: 44,
});
}
wasOpenRef.current = true;
// springCfg is derived each render; deps below cover its inputs via `spring`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeValue, dims, navWidth, reducedMotion, minWidth, maxWidth]);
// ---- pointer / focus intent ---------------------------------------------
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(pointer: fine)");
const sync = () => setFinePointer(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
React.useEffect(
() => () => {
if (closeTimer.current != null) window.clearTimeout(closeTimer.current);
},
[]
);
const cancelClose = React.useCallback(() => {
if (closeTimer.current != null) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const scheduleClose = React.useCallback(() => {
cancelClose();
closeTimer.current = window.setTimeout(() => setActive(null), closeDelay);
}, [cancelClose, closeDelay, setActive]);
const hoverMode = openOn === "hover" && finePointer;
const handleTriggerEnter = (item: PantographItem) => {
if (!hoverMode) return;
cancelClose();
if (item.content != null) setActive(item.value);
else setActive(null);
};
const handleTriggerClick = (item: PantographItem) => {
if (item.content == null) return; // let the link/button do its thing
if (openOn === "click" || !finePointer) {
setActive(activeValue === item.value ? null : item.value);
}
};
// Click-away for click / touch open.
React.useEffect(() => {
if (!open || hoverMode) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setActive(null);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, hoverMode, setActive]);
// ---- keyboard: roving focus across triggers ------------------------------
const enabledItems = items;
const moveFocus = (fromValue: string, dir: 1 | -1 | "home" | "end") => {
const count = enabledItems.length;
if (count === 0) return;
let idx = enabledItems.findIndex((i) => i.value === fromValue);
if (idx < 0) idx = 0;
let nextIdx: number;
if (dir === "home") nextIdx = 0;
else if (dir === "end") nextIdx = count - 1;
else nextIdx = (idx + dir + count) % count;
const next = enabledItems[nextIdx];
if (!next) return;
setFocusValue(next.value);
triggerRefs.current.get(next.value)?.focus();
if (open) {
if (next.content != null) setActive(next.value);
else setActive(null);
}
};
const handleBarKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const from = focusValue || items[0]?.value || "";
switch (e.key) {
case "ArrowRight":
e.preventDefault();
moveFocus(from, 1);
break;
case "ArrowLeft":
e.preventDefault();
moveFocus(from, -1);
break;
case "Home":
e.preventDefault();
moveFocus(from, "home");
break;
case "End":
e.preventDefault();
moveFocus(from, "end");
break;
case "ArrowDown": {
const item = items.find((i) => i.value === from);
if (item?.content != null) {
e.preventDefault();
setActive(from);
}
break;
}
case "Escape":
if (open) {
e.preventDefault();
setActive(null);
}
break;
default:
break;
}
};
const handleRootBlur = (e: React.FocusEvent<HTMLDivElement>) => {
if (!rootRef.current?.contains(e.relatedTarget as Node)) {
cancelClose();
setActive(null);
}
};
const directionalContent = contentTransition === "directional" && !reducedMotion;
const contentVariants: Variants = {
enter: (dir: number) =>
directionalContent
? { opacity: 0, x: dir * CONTENT_SHIFT, filter: "blur(5px)" }
: { opacity: 0 },
center: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: (dir: number) =>
directionalContent
? { opacity: 0, x: -dir * CONTENT_SHIFT, filter: "blur(5px)" }
: { opacity: 0 },
};
return (
<nav
{...props}
aria-label={props["aria-label"] ?? "Main"}
data-crucible="pantograph"
className={cn("inline-block", className)}
>
<div
ref={rootRef}
className="relative inline-block"
onPointerLeave={hoverMode ? scheduleClose : undefined}
onPointerEnter={hoverMode ? cancelClose : undefined}
onBlur={handleRootBlur}
>
{/* The trigger bar. */}
<div
ref={barRef}
role="menubar"
aria-label={props["aria-label"] ?? "Main"}
onKeyDown={handleBarKeyDown}
className="relative flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/70 p-1.5 backdrop-blur-md"
>
{items.map((item) => {
const isActive = item.value === activeValue && item.content != null;
const hasPanel = item.content != null;
const commonProps = {
ref: (el: HTMLElement | null) => {
if (el) triggerRefs.current.set(item.value, el);
else triggerRefs.current.delete(item.value);
},
role: "menuitem",
tabIndex: item.value === (focusValue || items[0]?.value) ? 0 : -1,
"aria-haspopup": hasPanel ? ("menu" as const) : undefined,
"aria-expanded": hasPanel ? isActive : undefined,
"aria-controls": hasPanel && isActive ? panelId : undefined,
onFocus: () => {
setFocusValue(item.value);
if (hasPanel) setActive(item.value);
else setActive(null);
},
onPointerEnter: () => handleTriggerEnter(item),
className: cn(
"relative rounded-full px-3.5 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
isActive ? "text-white" : "text-white/60 hover:text-white/90"
),
};
const inner = (
<>
{isActive && (
<motion.span
layoutId={`${baseId}-trigger-glow`}
aria-hidden
className="absolute inset-0 -z-10 rounded-full bg-white/[0.07]"
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 620, damping: 34 }
}
/>
)}
<span className="relative">{item.label}</span>
</>
);
return item.href ? (
<a
key={item.value}
href={item.href}
{...commonProps}
onClick={(e) => {
if (hasPanel && (openOn === "click" || !finePointer)) {
e.preventDefault();
handleTriggerClick(item);
}
}}
>
{inner}
</a>
) : (
<button
key={item.value}
type="button"
{...commonProps}
onClick={() => handleTriggerClick(item)}
>
{inner}
</button>
);
})}
</div>
{/* The single shared flyout that morphs between triggers. */}
<AnimatePresence>
{open && activeItem && (
<motion.div
id={panelId}
role="menu"
aria-label={
typeof activeItem.label === "string" ? activeItem.label : undefined
}
key="pantograph-panel"
className="absolute top-full left-0 z-20 mt-3 origin-top"
style={{ x: leftMV, width: widthMV, height: heightMV }}
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96, y: -6 }
}
animate={
reducedMotion
? { opacity: 1 }
: { opacity: 1, scale: 1, y: 0 }
}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.97, y: -6 }
}
transition={
reducedMotion
? { duration: 0.12 }
: { type: "spring", stiffness: 500, damping: 40, mass: 0.7 }
}
onPointerEnter={hoverMode ? cancelClose : undefined}
onPointerLeave={hoverMode ? scheduleClose : undefined}
>
<div className="relative h-full w-full overflow-hidden rounded-2xl border border-white/10 bg-neutral-900/85 shadow-[0_1px_0_rgba(255,255,255,0.06)_inset,0_24px_60px_-20px_rgba(0,0,0,0.8)] backdrop-blur-xl">
{/* Top hairline sheen. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-white/25 to-transparent"
/>
<AnimatePresence
mode="popLayout"
initial={false}
custom={direction}
>
<motion.div
key={activeItem.value}
custom={direction}
variants={contentVariants}
initial="enter"
animate="center"
exit="exit"
transition={
reducedMotion
? { duration: 0.14 }
: {
type: "spring",
stiffness: 520,
damping: 42,
mass: 0.7,
}
}
className="absolute inset-0 p-3"
>
{activeItem.content}
</motion.div>
</AnimatePresence>
</div>
{/* Caret — slides to track the active trigger, never teleports. */}
{caret && (
<motion.span
aria-hidden
className="pointer-events-none absolute -top-1.5 z-10 h-3 w-3 -translate-x-1/2 rotate-45 rounded-tl-[3px] border-t border-l border-white/10 bg-neutral-900/85 backdrop-blur-xl"
style={{ left: caretX }}
/>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Hidden measurers — natural content size drives the morph target. */}
<div
aria-hidden
className="pointer-events-none invisible fixed top-0 left-0 -z-50"
style={{ visibility: "hidden" }}
>
{items.map((item) =>
item.content != null ? (
<div
key={item.value}
data-measure-key={item.value}
ref={(el) => {
if (el) measureRefs.current.set(item.value, el);
else measureRefs.current.delete(item.value);
}}
className="absolute w-max p-3"
>
{item.content}
</div>
) : null
)}
</div>
</nav>
);
}
Installation
CLI
npx shadcn@latest add @crucible/pantographManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| items | PantographItem[] | a small generic set | Triggers to render, left to right. |
| openOn | "hover" | "click" | "hover" | How a flyout opens on pointer devices. |
| caret | boolean | true | Show the caret that tracks the active trigger. |
| closeDelay | number | 150 | Grace period in ms before the panel closes after the pointer leaves — the hover-intent forgiveness that stops flicker on diagonal travel. |
| contentTransition | "directional" | "crossfade" | "directional" | Incoming content slides in from the direction of travel ("directional") or simply crossfades in place ("crossfade"). |
| spring | { stiffness?: number; damping?: number; mass?: number } | Spring physics for the panel morph. | |
| minWidth | number | 200 | Min panel width in px. |
| maxWidth | number | 560 | Max panel width in px. |
| value | string | null | Controlled active trigger value; null means closed. | |
| defaultValue | string | null | null (closed) | Initial active trigger when uncontrolled. |
| onValueChange | (value: string | null) => void | Fires whenever the active trigger changes (a value, or null on close). | |
Also accepts all props of Omit< React.ComponentPropsWithoutRef<"nav">, "onChange" | "value" | "defaultValue" > — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.