Teseria
All elements

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

Terminal
npm i framer-motion

2. Files

Menu.tsx185 lines
"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.css153 lines
/* 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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Menu } from "./Menu";
import "./Menu.css";

export default function Demo() {
  return <Menu />;
}