Menu
A dropdown that pops from its corner on a spring, rows staggering in. One highlight slides row to row following hover and the arrow keys.
Code
1. Install
npm i framer-motion
2. Files
"use client";
// Menu: a dropdown that pops from its corner on a spring, rows staggering in. One highlight slides row to row
// following hover and the arrow keys. Escape, an outside press, Tab or a pick closes it and refocuses the trigger.
import { cloneElement, useEffect, useId, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent, type ReactElement, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
export type MenuCue = "unfold" | "fold" | "push";
export type MenuEntry =
| { type?: "item"; label: ReactNode; icon?: ReactNode; kbd?: string; danger?: boolean; disabled?: boolean; onSelect?: () => void }
| { type: "separator" }
| { type: "label"; label: ReactNode };
export type MenuProps = {
/** Rows of the card: items, separators and small group labels. */
items?: MenuEntry[];
/** The element that opens the menu. It gets the aria wiring and handlers itself, so it must be one focusable element (a button). */
trigger?: ReactElement<HTMLAttributes<HTMLElement>>;
/** Which edge of the trigger the card lines up with. */
align?: "start" | "end";
/** Open below or above the trigger. */
side?: "bottom" | "top";
/** Starts open, without moving focus or a sound. */
defaultOpen?: boolean;
/** Sound hook: "unfold" on open, "fold" on close, "push" on a pick. */
onSound?: (cue: MenuCue) => void;
className?: string;
style?: CSSProperties;
};
export const DEFAULT_MENU_ITEMS: MenuEntry[] = [
{ label: "Duplicate", kbd: "⌘D" },
{ label: "Share" },
{ label: "Move to…" },
{ type: "separator" },
{ label: "Delete", danger: true },
];
const EXIT = [0.4, 0, 1, 1] as const;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.6 } as const;
const POP = { type: "spring", stiffness: 500, damping: 18, mass: 0.6 } as const;
const CARD = {
hidden: { opacity: 0, scale: 0.9, y: -6 },
show: { opacity: 1, scale: 1, y: 0, transition: { ...POP, opacity: { duration: 0.14 }, delayChildren: 0.02, staggerChildren: 0.025 } },
};
const ROW = { hidden: { opacity: 0, y: -6 }, show: (off: boolean) => ({ opacity: off ? 0.4 : 1, y: 0, transition: { ...SNAP, opacity: { duration: 0.16 } } }) };
export function Menu({ items = DEFAULT_MENU_ITEMS, trigger = <button type="button" className="mnu-btn">Actions</button>, align = "start", side = "bottom", defaultOpen = false, onSound, className, style }: MenuProps) {
const id = useId();
const [open, setOpenRaw] = useState(defaultOpen);
const [hot, setHot] = useState(-1);
const woke = useRef(!defaultOpen); // false while resting (defaultOpen, untouched)
const root = useRef<HTMLDivElement>(null);
const card = useRef<HTMLDivElement>(null);
const snd = useRef(onSound);
snd.current = onSound;
const setOpen = (v: boolean, quiet = false) => {
if (v === open) return;
woke.current = true;
if (!quiet) snd.current?.(v ? "unfold" : "fold");
setOpenRaw(v);
if (!v) setHot(-1);
// closing from inside the card (Escape, a pick) hands focus back to the trigger
if (!v && card.current?.contains(document.activeElement)) root.current?.querySelector<HTMLElement>("[data-menu-trigger]")?.focus();
};
const closeRef = useRef(setOpen);
closeRef.current = setOpen;
useEffect(() => {
if (!open) return;
const off = (e: PointerEvent) => (root.current?.contains(e.target as Node) ? (woke.current = true) : woke.current && closeRef.current(false));
const key = (e: globalThis.KeyboardEvent) => e.key === "Escape" && (woke.current || root.current?.contains(document.activeElement)) && closeRef.current(false);
document.addEventListener("pointerdown", off);
document.addEventListener("keydown", key);
return () => {
document.removeEventListener("pointerdown", off);
document.removeEventListener("keydown", key);
};
}, [open]);
// keyboard focus follows `hot`
useEffect(() => {
if (!open || hot < 0) return;
const rows = [...(card.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") ?? [])];
rows[Math.min(hot, rows.length - 1)]?.focus();
}, [open, hot]);
const p = trigger.props;
const wired = cloneElement(trigger, {
"data-menu-trigger": "",
"aria-haspopup": "menu",
"aria-expanded": open,
"aria-controls": `${id}-menu`,
onClick: (e: MouseEvent<HTMLElement>) => {
p.onClick?.(e);
setOpen(!open);
if (!open && e.detail === 0) setHot(0); // keyboard click (Enter/Space): land on the first item
},
onKeyDown: (e: KeyboardEvent<HTMLElement>) => {
p.onKeyDown?.(e);
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
setOpen(true);
setHot(e.key === "ArrowDown" ? 0 : Number.MAX_SAFE_INTEGER);
},
} as HTMLAttributes<HTMLElement>);
const onKey = (e: KeyboardEvent) => {
const rows = [...(card.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") ?? [])];
const i = rows.indexOf(document.activeElement as HTMLButtonElement);
const jump: Record<string, number | undefined> = { ArrowDown: (i + 1) % rows.length, ArrowUp: (i - 1 + rows.length) % rows.length, Home: 0, End: rows.length - 1 };
const target = jump[e.key];
if (target !== undefined) {
e.preventDefault();
setHot(target);
}
if (e.key === "Tab") {
// close back onto the trigger; the exiting card is still in the DOM
e.preventDefault();
setOpen(false);
}
if (e.key.length === 1 && /\S/.test(e.key)) {
const starts = (el: HTMLElement) => el.textContent?.trim().toLowerCase().startsWith(e.key.toLowerCase());
const j = rows.findIndex((r, k) => k > i && starts(r));
const k = j >= 0 ? j : rows.findIndex(starts);
if (k >= 0) setHot(k);
}
};
let n = -1; // index among the rows that can be focused
return (
<div ref={root} className={className ? `mnu-root ${className}` : "mnu-root"} data-align={align} data-side={side} style={style}>
<span className="mnu-trigger">{wired}</span>
<AnimatePresence>
{open && (
<motion.div
ref={card}
id={`${id}-menu`}
role="menu"
className="mnu-card"
variants={CARD}
initial="hidden"
animate="show"
exit={{ opacity: 0, scale: 0.96, y: -4, transition: { duration: 0.14, ease: EXIT } }}
onPointerLeave={() => setHot(-1)}
onKeyDown={onKey}
>
{items.map((it, k) => {
if (it.type === "separator") return <hr key={k} className="mnu-sep" />;
if (it.type === "label") return <div key={k} className="mnu-label">{it.label}</div>;
const i = it.disabled ? -1 : ++n;
const lit = i >= 0 && hot === i;
return (
<motion.button
key={k}
type="button"
role="menuitem"
disabled={it.disabled}
className="mnu-item"
data-danger={it.danger || undefined}
data-hot={lit || undefined}
variants={ROW}
custom={!!it.disabled}
onPointerEnter={() => i >= 0 && setHot(i)}
onFocus={() => i >= 0 && setHot(i)}
onClick={() => {
snd.current?.("push");
it.onSelect?.();
setOpen(false, true);
}}
>
{lit && <motion.span className="mnu-hl" layoutId={`${id}-hl`} transition={{ ...SNAP, mass: 0.5 }} />}
{it.icon && <span className="mnu-ico">{it.icon}</span>}
<span className="mnu-text">{it.label}</span>
{it.kbd && <kbd className="mnu-kbd">{it.kbd}</kbd>}
</motion.button>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/* Menu. Prefix mnu-. */
.mnu-root,
.mnu-root * {
box-sizing: border-box;
}
:where(.mnu-root) button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
}
.mnu-root {
--mnu-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: inline-block;
font-family: var(--ts-font, system-ui, sans-serif);
}
.mnu-trigger {
display: inline-block;
}
/* default trigger (no `trigger` prop) */
.mnu-btn {
height: 40px;
padding: 0 16px;
border-radius: 999px;
background: var(--ts-raised);
box-shadow: var(--ts-edge);
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
cursor: pointer;
transition: scale 0.22s var(--mnu-ease), background 0.16s var(--mnu-ease);
}
.mnu-btn:hover {
background: rgba(var(--ts-fg-rgb), 0.05);
}
.mnu-btn:active {
scale: 0.96;
}
.mnu-btn:focus-visible {
outline: 2px solid var(--ts-focus);
outline-offset: 2px;
}
.mnu-card {
position: absolute;
z-index: 30;
top: calc(100% + 6px);
left: 0;
min-width: 200px;
padding: 6px;
border-radius: var(--ts-r-pop);
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
transform-origin: top left;
display: flex;
flex-direction: column;
gap: 1px;
}
.mnu-root[data-align="end"] .mnu-card {
left: auto;
right: 0;
transform-origin: top right;
}
.mnu-root[data-side="top"] .mnu-card {
top: auto;
bottom: calc(100% + 6px);
transform-origin: bottom left;
}
.mnu-root[data-side="top"][data-align="end"] .mnu-card {
transform-origin: bottom right;
}
.mnu-item {
position: relative;
height: 40px;
padding: 0 10px;
border-radius: calc(var(--ts-r-pop) - 6px);
color: var(--ts-fg);
font-size: 13.5px;
letter-spacing: -0.01em;
display: flex;
align-items: center;
gap: 10px;
text-align: left;
cursor: pointer;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: color 0.16s var(--mnu-ease), scale 0.22s var(--mnu-ease);
}
.mnu-item:active:not(:disabled) {
scale: 0.98;
}
.mnu-item:disabled {
opacity: 0.4;
cursor: default;
}
/* the sliding .mnu-hl is the focus indicator inside the card */
.mnu-item:focus-visible {
outline: 0;
}
.mnu-item[data-danger] {
color: var(--ts-danger);
}
.mnu-hl {
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(var(--ts-fg-rgb), 0.07);
}
.mnu-item[data-danger] .mnu-hl {
background: color-mix(in srgb, var(--ts-danger) 10%, transparent);
}
.mnu-ico,
.mnu-text,
.mnu-kbd {
position: relative;
}
.mnu-ico {
display: grid;
place-items: center;
color: rgba(var(--ts-fg-rgb), 0.6);
}
.mnu-item[data-danger] .mnu-ico {
color: inherit;
}
.mnu-ico svg {
width: 15px;
height: 15px;
}
.mnu-text {
flex: 1;
}
.mnu-kbd {
font: 11px var(--ts-font, system-ui, sans-serif);
color: rgba(var(--ts-fg-rgb), 0.45);
letter-spacing: 0.04em;
}
.mnu-sep {
border: 0;
height: 1px;
margin: 4px 6px;
background: var(--ts-border);
}
.mnu-label {
padding: 6px 10px 4px;
font-size: 12px;
font-weight: 500;
color: rgba(var(--ts-fg-rgb), 0.45);
letter-spacing: 0.04em;
text-transform: uppercase;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Menu } from "./Menu";
import "./Menu.css";
export default function Demo() {
return <Menu />;
}