Dropdown Drape

Menu

Dropdown whose panel is a live verlet cloth pinned to the trigger — it falls and drapes like an awning, labels ride the weave, the cursor billows the fabric, and close yanks it back up.

Install
npx shadcn add https://design.helpmarq.com/r/dropdown-drape.json
Source
registry/core/dropdown-drape/component.tsx
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from "react";

// DrapeMenu — a dropdown whose panel is a live verlet cloth pinned along the
// trigger's bottom edge. On open the cloth falls under gravity and drapes like
// an awning; the menu items are real DOM nodes (role=menuitem) riding
// designated vertex rows, so they sway and settle with the fabric. Cursor
// proximity billows the weave upward; close yanks every vertex back to the
// trigger on a critically damped spring and unmounts at sub-pixel rest.
// Canvas 2D underneath draws per-quad fold shading from token-derived grays.
// Direct-DOM rAF loop — no React state on the hot path, sleeps when settled.

export type DrapeMenuItem = {
  id: string;
  label: string;
  /** rendered right-aligned in font-mono, e.g. "⌘N" */
  shortcut?: string;
  icon?: ReactNode;
  /** draws a separator row above this item */
  separatorBefore?: boolean;
};

const COLS = 10;
const ROWS = 14;
const GRAVITY = 2400; // px/s²
const VERLET_DAMP = 0.99; // velocity retention per step
const RELAX_ITERS = 3; // structural-constraint relaxation passes per frame
const BILLOW_F = 900; // px/s² peak upward force at zero distance
const BILLOW_SIGMA = 60; // gaussian falloff radius, px
const BILLOW_RANGE2 = 150 * 150; // px² — beyond this the force is skipped
const SPRING_K = 120; // s⁻² — close retract stiffness
const SPRING_C = 2 * Math.sqrt(SPRING_K); // ζ = 1.0, critically damped
const SLEEP_EPS = 0.02; // px/frame — loop sleeps below this displacement
const DONE_EPS = 0.5; // px — retract unmounts below this displacement
const ARM_V = 24; // px/s — row must first exceed this to arm its entrance
const REVEAL_V = 8; // px/s — armed row reveals its label below this
const ITEM_H = 38;
const SEP_H = 13;
const PAD_TOP = 10;
const PAD_BOT = 12;
const INSET = 10; // px between cloth edge and item edge
const MARGIN_X = 80; // canvas bleed for sway/billow
const MARGIN_TOP = 16;
const MARGIN_BOT = 64;
const MAX_TILT = (6 * Math.PI) / 180; // item rotation clamp, ±6°
const DT_MAX = 0.032; // s — clamp tab-switch jumps

type Entry =
  | { kind: "item"; item: DrapeMenuItem; y0: number; h: number }
  | { kind: "sep"; y0: number; h: number };

type Sim = {
  posX: Float32Array;
  posY: Float32Array;
  prevX: Float32Array;
  prevY: Float32Array;
  velX: Float32Array;
  velY: Float32Array;
};

type Geom = { tw: number; clothW: number; dx0: number; dy0: number; dpr: number };

type RGB = [number, number, number];

function Glyph({ d }: { d: string }) {
  return (
    <svg
      viewBox="0 0 16 16"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
    >
      <path d={d} />
    </svg>
  );
}

const DEFAULT_ITEMS: DrapeMenuItem[] = [
  { id: "new-project", label: "New project", shortcut: "⌘N", icon: <Glyph d="M8 3v10M3 8h10" /> },
  {
    id: "invite-member",
    label: "Invite member",
    shortcut: "⌘I",
    icon: <Glyph d="M10 13.5v-1a3 3 0 0 0-3-3H4.5a3 3 0 0 0-3 3v1M5.75 7a2.25 2.25 0 1 0 0-4.5 2.25 2.25 0 0 0 0 4.5M12.5 5v4M14.5 7h-4" />,
  },
  {
    id: "switch-workspace",
    label: "Switch workspace",
    shortcut: "⌘K",
    icon: <Glyph d="M5 3 2 6l3 3M2 6h9M11 13l3-3-3-3M14 10H5" />,
  },
  {
    id: "settings",
    label: "Workspace settings",
    shortcut: "⌘,",
    separatorBefore: true,
    icon: <Glyph d="M2 5h12M10 3v4M2 11h12M6 9v4" />,
  },
  {
    id: "usage",
    label: "Usage & billing",
    icon: <Glyph d="M2 13.5h12M4 13.5v-3M8 13.5v-8M12 13.5v-5.5" />,
  },
  {
    id: "sign-out",
    label: "Sign out",
    shortcut: "⇧⌘Q",
    icon: <Glyph d="M6.5 2H3v12h3.5M10.5 11l3-3-3-3M13.5 8h-7" />,
  },
];

export function DrapeMenu({
  label = "Workspace",
  items = DEFAULT_ITEMS,
  onSelect,
  minWidth = 264,
  className = "",
}: {
  /** trigger label; also the menu's aria-label */
  label?: string;
  items?: DrapeMenuItem[];
  onSelect?: (id: string) => void;
  /** cloth/panel width floor in px (widens to match a wider trigger) */
  minWidth?: number;
  className?: string;
}) {
  const menuId = useId();
  const [open, setOpen] = useState(false);
  const [mounted, setMounted] = useState(false);
  const [reduced, setReduced] = useState(false);

  const wrapperRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const overlayRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const elsRef = useRef<(HTMLElement | null)[]>([]);

  const simRef = useRef<Sim | null>(null);
  const geomRef = useRef<Geom | null>(null);
  const phaseRef = useRef<"fall" | "retract">("fall");
  const lastDtRef = useRef(1 / 60);
  const openStartRef = useRef(0);
  const revealRef = useRef<{ armed: boolean[]; revealed: boolean[] }>({ armed: [], revealed: [] });
  const wakeRef = useRef<(() => void) | null>(null);
  const closeRef = useRef<(focusTrigger: boolean) => void>(() => {});
  const onSelectRef = useRef(onSelect);
  onSelectRef.current = onSelect;
  const reducedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // Keyed on a content signature rather than the `items` reference itself:
  // consumers commonly pass an inline array literal (`<DrapeMenu items={[...]} />`),
  // which gets a fresh identity on every parent re-render. Keying on `items`
  // directly would tear down and reinit the live cloth sim (see the physics
  // effect below, which depends on `entries`) on every unrelated re-render.
  const itemsSig = items
    .map((it) => `${it.id}|${it.label}|${it.shortcut ?? ""}|${it.separatorBefore ? 1 : 0}`)
    .join("");
  const { list: entries, clothH } = useMemo(() => {
    const list: Entry[] = [];
    let y = PAD_TOP;
    for (const item of items) {
      if (item.separatorBefore && list.length > 0) {
        list.push({ kind: "sep", y0: y, h: SEP_H });
        y += SEP_H;
      }
      list.push({ kind: "item", item, y0: y, h: ITEM_H });
      y += ITEM_H;
    }
    return { list, clothH: y + PAD_BOT };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- itemsSig is the intended dependency
  }, [itemsSig]);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  // clear the reduced-mode close timer on unmount
  useEffect(
    () => () => {
      if (reducedTimerRef.current) clearTimeout(reducedTimerRef.current);
    },
    []
  );

  const openMenu = () => {
    if (reducedTimerRef.current) {
      clearTimeout(reducedTimerRef.current);
      reducedTimerRef.current = null;
    }
    openStartRef.current = performance.now();
    if (!reduced && mounted && phaseRef.current === "retract") {
      // reopen mid-retract: hand spring velocities back to the verlet solver
      const s = simRef.current;
      if (s) {
        const dt = 1 / 60;
        for (let i = 0; i < s.posX.length; i++) {
          s.prevX[i] = s.posX[i] - s.velX[i] * dt;
          s.prevY[i] = s.posY[i] - s.velY[i] * dt;
        }
      }
      phaseRef.current = "fall";
      const rev = revealRef.current;
      rev.armed.fill(false);
      rev.revealed.fill(false);
      for (const el of elsRef.current) if (el) el.style.opacity = "0";
      wakeRef.current?.();
    }
    setMounted(true);
    setOpen(true);
  };

  const closeMenu = (focusTrigger: boolean) => {
    if (!open) return;
    if (reduced) {
      setOpen(false);
      reducedTimerRef.current = setTimeout(() => setMounted(false), 170);
    } else {
      const s = simRef.current;
      if (s) {
        const dt = lastDtRef.current || 1 / 60;
        for (let i = 0; i < s.posX.length; i++) {
          s.velX[i] = (s.posX[i] - s.prevX[i]) / dt;
          s.velY[i] = (s.posY[i] - s.prevY[i]) / dt;
        }
      }
      phaseRef.current = "retract";
      setOpen(false);
      for (const el of elsRef.current) if (el) el.style.opacity = "0";
      wakeRef.current?.();
    }
    if (focusTrigger) triggerRef.current?.focus();
  };
  closeRef.current = closeMenu;

  // close on outside pointerdown while mounted
  useEffect(() => {
    if (!mounted) return;
    const onDown = (e: PointerEvent) => {
      const w = wrapperRef.current;
      if (w && e.target instanceof Node && !w.contains(e.target)) closeRef.current(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [mounted]);

  // move focus to the first item once the menu opens
  useEffect(() => {
    if (!open || !mounted) return;
    const id = requestAnimationFrame(() => {
      for (let i = 0; i < entries.length; i++) {
        if (entries[i].kind === "item") {
          elsRef.current[i]?.focus({ preventScroll: true });
          break;
        }
      }
    });
    return () => cancelAnimationFrame(id);
  }, [open, mounted, entries]);

  // -------------------------------------------------------------------------
  // cloth simulation + canvas fold shading (skipped under reduced motion)
  // -------------------------------------------------------------------------
  useEffect(() => {
    if (!mounted || reduced) return;
    const wrapper = wrapperRef.current;
    const trigger = triggerRef.current;
    const overlay = overlayRef.current;
    const canvas = canvasRef.current;
    if (!wrapper || !trigger || !overlay || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const N = COLS * ROWS;
    let raf = 0;
    let last = 0;
    let pActive = false;
    let pxr = 0;
    let pyr = 0;

    // token-derived draw colors — re-derived live on documentElement class flips
    let col = {
      surface: [23, 23, 23] as RGB,
      border: [46, 46, 46] as RGB,
      lighter: [237, 237, 237] as RGB,
      darker: [10, 10, 10] as RGB,
    };
    const derive = () => {
      const cs = getComputedStyle(document.documentElement);
      const read = (name: string, fb: string): RGB => {
        const raw = cs.getPropertyValue(name).trim();
        ctx.fillStyle = fb;
        if (raw) ctx.fillStyle = raw; // canvas normalizes any CSS color format
        const v = String(ctx.fillStyle);
        if (v.charAt(0) === "#" && v.length >= 7) {
          return [
            parseInt(v.slice(1, 3), 16),
            parseInt(v.slice(3, 5), 16),
            parseInt(v.slice(5, 7), 16),
          ];
        }
        const m = v.match(/[\d.]+/g);
        if (m && m.length >= 3) return [Number(m[0]), Number(m[1]), Number(m[2])];
        return [23, 23, 23];
      };
      const surface = read("--surface", "#171717");
      const border = read("--border", "#2e2e2e");
      const fg = read("--foreground", "#ededed");
      const bg = read("--background", "#0a0a0a");
      const lum = (c: RGB) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
      // sheen goes toward whichever ink is lighter, shadow toward the darker —
      // fold shading stays visible on both themes without hardcoded hexes
      const fgLighter = lum(fg) >= lum(bg);
      col = {
        surface,
        border,
        lighter: fgLighter ? fg : bg,
        darker: fgLighter ? bg : fg,
      };
    };
    const mix = (a: RGB, b: RGB, t: number) =>
      `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(
        a[2] + (b[2] - a[2]) * t
      )})`;
    const rgba = (c: RGB, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;

    const measure = (): boolean => {
      const tRect = trigger.getBoundingClientRect();
      if (tRect.width < 2 || tRect.height < 2) return false; // zero-size guard
      const clothW = Math.max(minWidth, Math.round(tRect.width));
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      const cw = clothW + MARGIN_X * 2;
      const ch = clothH + MARGIN_TOP + MARGIN_BOT;
      canvas.width = Math.max(1, Math.round(cw * dpr));
      canvas.height = Math.max(1, Math.round(ch * dpr));
      canvas.style.width = `${cw}px`;
      canvas.style.height = `${ch}px`;
      geomRef.current = {
        tw: tRect.width,
        clothW,
        dx0: clothW / (COLS - 1),
        dy0: clothH / (ROWS - 1),
        dpr,
      };
      const itemW = clothW - INSET * 2;
      for (const el of elsRef.current) if (el) el.style.width = `${itemW}px`;
      return true;
    };
    if (!measure()) return;

    // vertices start compressed at the trigger's bottom edge, then fall free
    const initCloth = () => {
      const g = geomRef.current;
      if (!g) return;
      const sim: Sim = {
        posX: new Float32Array(N),
        posY: new Float32Array(N),
        prevX: new Float32Array(N),
        prevY: new Float32Array(N),
        velX: new Float32Array(N),
        velY: new Float32Array(N),
      };
      for (let r = 0; r < ROWS; r++) {
        for (let c = 0; c < COLS; c++) {
          const i = r * COLS + c;
          sim.posX[i] = c * g.dx0;
          // fraction of rest length, not an absolute px start: the prior fixed
          // `r * 1.5` compressed rows to ~1/13th of dy0, which this solver's
          // 3-iteration relax can't recover from and folds into a stuck ~14px
          // clump instead of draping (reproduced with a plain click). 0.5 keeps
          // the compressed-then-falls look inside the solver's convergence basin.
          sim.posY[i] = r * g.dy0 * 0.5;
          sim.prevX[i] = sim.posX[i];
          sim.prevY[i] = sim.posY[i];
        }
      }
      simRef.current = sim;
    };
    initCloth();
    phaseRef.current = "fall";
    revealRef.current = {
      armed: new Array<boolean>(entries.length).fill(false),
      revealed: new Array<boolean>(entries.length).fill(false),
    };
    derive();

    const A = { x: 0, y: 0 };
    const B = { x: 0, y: 0 };
    const C = { x: 0, y: 0 };
    const D = { x: 0, y: 0 };

    const sampleInto = (
      X: Float32Array,
      Y: Float32Array,
      u: number,
      v: number,
      out: { x: number; y: number }
    ) => {
      const cf = u * (COLS - 1);
      const rf = v * (ROWS - 1);
      let c0 = Math.floor(cf);
      let r0 = Math.floor(rf);
      if (c0 < 0) c0 = 0;
      else if (c0 > COLS - 2) c0 = COLS - 2;
      if (r0 < 0) r0 = 0;
      else if (r0 > ROWS - 2) r0 = ROWS - 2;
      let fx = cf - c0;
      let fy = rf - r0;
      if (fx < 0) fx = 0;
      else if (fx > 1) fx = 1;
      if (fy < 0) fy = 0;
      else if (fy > 1) fy = 1;
      const i00 = r0 * COLS + c0;
      const i01 = i00 + COLS;
      const x0 = X[i00] + (X[i00 + 1] - X[i00]) * fx;
      const x1 = X[i01] + (X[i01 + 1] - X[i01]) * fx;
      const y0 = Y[i00] + (Y[i00 + 1] - Y[i00]) * fx;
      const y1 = Y[i01] + (Y[i01 + 1] - Y[i01]) * fx;
      out.x = x0 + (x1 - x0) * fy;
      out.y = y0 + (y1 - y0) * fy;
    };

    const updateEntries = (dt: number, now: number) => {
      const g = geomRef.current;
      const s = simRef.current;
      if (!g || !s) return;
      const uL = INSET / g.clothW;
      const uR = 1 - uL;
      const rev = revealRef.current;
      const falling = phaseRef.current === "fall";
      for (let i = 0; i < entries.length; i++) {
        const el = elsRef.current[i];
        if (!el) continue;
        const en = entries[i];
        const v = (en.y0 + en.h / 2) / clothH;
        sampleInto(s.posX, s.posY, uL, v, A);
        sampleInto(s.posX, s.posY, uR, v, B);
        let ang = Math.atan2(B.y - A.y, B.x - A.x);
        if (ang > MAX_TILT) ang = MAX_TILT;
        else if (ang < -MAX_TILT) ang = -MAX_TILT;
        el.style.transform = `translate3d(${A.x.toFixed(2)}px, ${(A.y - en.h / 2).toFixed(
          2
        )}px, 0) rotate(${ang.toFixed(4)}rad)`;
        if (falling && !rev.revealed[i]) {
          // label fades in once its anchor row's vertical velocity settles
          sampleInto(s.posX, s.posY, 0.5, v, C);
          sampleInto(s.prevX, s.prevY, 0.5, v, D);
          const vy = Math.abs(C.y - D.y) / dt;
          if (vy > ARM_V) rev.armed[i] = true;
          if ((rev.armed[i] && vy < REVEAL_V) || now - openStartRef.current > 900) {
            rev.revealed[i] = true;
            el.style.opacity = "1";
          }
        }
      }
    };

    const draw = () => {
      const g = geomRef.current;
      const s = simRef.current;
      if (!g || !s) return;
      const { posX, posY } = s;
      ctx.setTransform(g.dpr, 0, 0, g.dpr, 0, 0);
      ctx.clearRect(0, 0, canvas.width / g.dpr, canvas.height / g.dpr);
      ctx.translate(MARGIN_X, MARGIN_TOP);
      ctx.lineJoin = "round";
      // per-quad fold shading: brightness from horizontal stretch (taut =
      // sheen toward the lighter ink) and lateral lean (fold shadow toward
      // the darker ink) — a cheap vertex-normal approximation
      for (let r = 0; r < ROWS - 1; r++) {
        for (let c = 0; c < COLS - 1; c++) {
          const i00 = r * COLS + c;
          const i10 = i00 + 1;
          const i01 = i00 + COLS;
          const i11 = i01 + 1;
          const ex =
            (Math.hypot(posX[i10] - posX[i00], posY[i10] - posY[i00]) +
              Math.hypot(posX[i11] - posX[i01], posY[i11] - posY[i01])) /
            (2 * g.dx0);
          const lean = (posX[i01] - posX[i00] + (posX[i11] - posX[i10])) / (2 * g.dy0);
          let sh = 2.4 * (ex - 1) + 1.1 * lean;
          if (sh > 1) sh = 1;
          else if (sh < -1) sh = -1;
          const fill =
            sh >= 0
              ? mix(col.surface, col.lighter, 0.06 + 0.3 * sh)
              : mix(col.surface, col.darker, 0.05 + 0.32 * -sh);
          ctx.beginPath();
          ctx.moveTo(posX[i00], posY[i00]);
          ctx.lineTo(posX[i10], posY[i10]);
          ctx.lineTo(posX[i11], posY[i11]);
          ctx.lineTo(posX[i01], posY[i01]);
          ctx.closePath();
          ctx.fillStyle = fill;
          ctx.fill();
          ctx.strokeStyle = fill; // 1px self-stroke kills seams between quads
          ctx.lineWidth = 1;
          ctx.stroke();
        }
      }
      // weave ribs
      ctx.strokeStyle = rgba(col.border, 0.45);
      ctx.lineWidth = 1;
      for (let r = 1; r < ROWS - 1; r++) {
        ctx.beginPath();
        ctx.moveTo(posX[r * COLS], posY[r * COLS]);
        for (let c = 1; c < COLS; c++) ctx.lineTo(posX[r * COLS + c], posY[r * COLS + c]);
        ctx.stroke();
      }
      // hem
      ctx.strokeStyle = rgba(col.border, 0.9);
      ctx.beginPath();
      ctx.moveTo(posX[0], posY[0]);
      for (let c = 1; c < COLS; c++) ctx.lineTo(posX[c], posY[c]);
      for (let r = 1; r < ROWS; r++) ctx.lineTo(posX[r * COLS + COLS - 1], posY[r * COLS + COLS - 1]);
      for (let c = COLS - 2; c >= 0; c--)
        ctx.lineTo(posX[(ROWS - 1) * COLS + c], posY[(ROWS - 1) * COLS + c]);
      for (let r = ROWS - 2; r > 0; r--) ctx.lineTo(posX[r * COLS], posY[r * COLS]);
      ctx.closePath();
      ctx.stroke();
    };

    const relax = (s: Sim, i: number, j: number, rest: number) => {
      const dx = s.posX[j] - s.posX[i];
      const dy = s.posY[j] - s.posY[i];
      const dist = Math.hypot(dx, dy);
      if (dist < 1e-6) return; // degenerate-pair guard
      const f = ((dist - rest) / dist) * 0.5;
      const ox = dx * f;
      const oy = dy * f;
      s.posX[i] += ox;
      s.posY[i] += oy;
      s.posX[j] -= ox;
      s.posY[j] -= oy;
    };

    const finishClose = () => {
      simRef.current = null;
      phaseRef.current = "fall";
      setMounted(false);
    };

    const loop = (now: number) => {
      raf = 0;
      const g = geomRef.current;
      const s = simRef.current;
      if (!g || !s) return;
      const dt = last === 0 ? 1 / 60 : Math.min(DT_MAX, Math.max(0.001, (now - last) / 1000));
      last = now;
      lastDtRef.current = dt;
      let sleep = false;

      if (phaseRef.current === "fall") {
        // verlet step: gravity + gaussian cursor billow, 0.99 damping/step
        const dt2 = dt * dt;
        for (let i = COLS; i < N; i++) {
          const x = s.posX[i];
          const y = s.posY[i];
          let ay = GRAVITY;
          if (pActive) {
            const ddx = x - pxr;
            const ddy = y - pyr;
            const d2 = ddx * ddx + ddy * ddy;
            if (d2 < BILLOW_RANGE2)
              ay -= BILLOW_F * Math.exp(-d2 / (2 * BILLOW_SIGMA * BILLOW_SIGMA));
          }
          s.posX[i] = x + (x - s.prevX[i]) * VERLET_DAMP;
          s.posY[i] = y + (y - s.prevY[i]) * VERLET_DAMP + ay * dt2;
          s.prevX[i] = x;
          s.prevY[i] = y;
        }
        for (let it = 0; it < RELAX_ITERS; it++) {
          for (let r = 0; r < ROWS; r++) {
            const row = r * COLS;
            for (let c = 0; c < COLS - 1; c++) relax(s, row + c, row + c + 1, g.dx0);
          }
          for (let r = 0; r < ROWS - 1; r++) {
            for (let c = 0; c < COLS; c++) relax(s, r * COLS + c, (r + 1) * COLS + c, g.dy0);
          }
          // re-pin the top row along the trigger's bottom edge
          for (let c = 0; c < COLS; c++) {
            s.posX[c] = c * g.dx0;
            s.posY[c] = 0;
            s.prevX[c] = s.posX[c];
            s.prevY[c] = 0;
          }
        }
        let maxDp = 0;
        for (let i = COLS; i < N; i++) {
          const dpx = Math.abs(s.posX[i] - s.prevX[i]);
          const dpy = Math.abs(s.posY[i] - s.prevY[i]);
          if (dpx > maxDp) maxDp = dpx;
          if (dpy > maxDp) maxDp = dpy;
        }
        updateEntries(dt, now);
        draw();
        // Hard elapsed-time cap: the drape is visually at rest well before the
        // velocity epsilon trips (documented drape time is 380-500ms), but
        // sub-visual verlet jitter can keep the loop at 60fps for several extra
        // seconds. Force-sleep with margin past that so we're not burning
        // frames on imperceptible motion.
        if (maxDp < SLEEP_EPS || now - openStartRef.current > 1200) sleep = true;
      } else {
        // retract: every vertex on a critically damped spring to the trigger
        const tx = g.tw / 2;
        let maxDisp = 0;
        for (let i = 0; i < N; i++) {
          let vx = s.velX[i];
          let vy = s.velY[i];
          vx += (-SPRING_K * (s.posX[i] - tx) - SPRING_C * vx) * dt;
          vy += (-SPRING_K * s.posY[i] - SPRING_C * vy) * dt;
          s.velX[i] = vx;
          s.velY[i] = vy;
          s.posX[i] += vx * dt;
          s.posY[i] += vy * dt;
          s.prevX[i] = s.posX[i];
          s.prevY[i] = s.posY[i];
          const dx = Math.abs(s.posX[i] - tx);
          const dy = Math.abs(s.posY[i]);
          if (dx > maxDisp) maxDisp = dx;
          if (dy > maxDisp) maxDisp = dy;
        }
        updateEntries(dt, now);
        draw();
        if (maxDisp < DONE_EPS) {
          finishClose();
          return;
        }
      }

      if (sleep) last = 0;
      else raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (!raf) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    wakeRef.current = wake;
    wake();

    const onMove = (e: PointerEvent) => {
      const oRect = overlay.getBoundingClientRect();
      pxr = e.clientX - oRect.left;
      pyr = e.clientY - oRect.top;
      pActive = true;
      wake();
    };
    const onLeave = () => {
      pActive = false;
      wake();
    };
    wrapper.addEventListener("pointermove", onMove);
    wrapper.addEventListener("pointerleave", onLeave);

    const ro = new ResizeObserver(() => {
      if (measure()) wake();
    });
    ro.observe(trigger);

    const mo = new MutationObserver(() => {
      derive();
      wake();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    return () => {
      cancelAnimationFrame(raf);
      raf = 0;
      wakeRef.current = null;
      ro.disconnect();
      mo.disconnect();
      wrapper.removeEventListener("pointermove", onMove);
      wrapper.removeEventListener("pointerleave", onLeave);
    };
  }, [mounted, reduced, entries, clothH, minWidth]);

  // -------------------------------------------------------------------------
  // keyboard menu semantics
  // -------------------------------------------------------------------------
  const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const btns: HTMLElement[] = [];
    for (let i = 0; i < entries.length; i++) {
      if (entries[i].kind === "item") {
        const el = elsRef.current[i];
        if (el) btns.push(el);
      }
    }
    if (e.key === "Escape") {
      e.preventDefault();
      closeMenu(true);
      return;
    }
    if (e.key === "Tab") {
      closeMenu(false);
      return;
    }
    if (btns.length === 0) return;
    const idx = btns.indexOf(document.activeElement as HTMLElement);
    let next = -1;
    if (e.key === "ArrowDown") next = (idx + 1) % btns.length;
    else if (e.key === "ArrowUp") next = idx < 0 ? btns.length - 1 : (idx - 1 + btns.length) % btns.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = btns.length - 1;
    if (next >= 0) {
      e.preventDefault();
      btns[next].focus({ preventScroll: true });
    }
  };

  const onTriggerKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
    if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
      e.preventDefault();
      openMenu();
    } else if (open && e.key === "Escape") {
      e.preventDefault();
      closeMenu(true);
    }
  };

  const select = (id: string) => {
    onSelectRef.current?.(id);
    closeMenu(true);
  };

  const setEl = (i: number) => (el: HTMLElement | null) => {
    elsRef.current[i] = el;
  };

  const itemClass =
    "group flex items-center gap-2.5 rounded-sm px-3 text-left text-sm text-foreground outline-none " +
    "hover:bg-surface focus-visible:bg-surface focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent";
  const rail = (
    <span
      aria-hidden
      className="absolute bottom-[7px] left-0 top-[7px] w-0.5 rounded-full bg-accent opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
    />
  );

  const itemInner = (item: DrapeMenuItem) => (
    <>
      {rail}
      {item.icon && (
        <span className="shrink-0 text-muted transition-colors duration-150 group-hover:text-foreground group-focus-visible:text-foreground">
          {item.icon}
        </span>
      )}
      <span className="min-w-0 flex-1 truncate">{item.label}</span>
      {item.shortcut && <span className="shrink-0 font-mono text-xs text-muted">{item.shortcut}</span>}
    </>
  );

  return (
    <div ref={wrapperRef} className={`relative inline-block ${className}`}>
      <button
        ref={triggerRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={mounted ? menuId : undefined}
        onClick={() => (open ? closeMenu(true) : openMenu())}
        onKeyDown={onTriggerKeyDown}
        className="inline-flex items-center gap-2 rounded-sm border border-border bg-surface px-3.5 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
      >
        {label}
        <svg
          viewBox="0 0 16 16"
          className={`h-3.5 w-3.5 text-muted transition-transform duration-200 ${open ? "rotate-180" : ""}`}
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden
        >
          <path d="m4 6 4 4 4-4" />
        </svg>
      </button>

      {mounted && !reduced && (
        <div ref={overlayRef} className="absolute left-0 top-full z-50">
          <canvas
            ref={canvasRef}
            aria-hidden
            className="pointer-events-none absolute"
            style={{ left: -MARGIN_X, top: -MARGIN_TOP }}
          />
          <div
            id={menuId}
            role="menu"
            aria-label={label}
            onKeyDown={onMenuKeyDown}
            className="relative"
          >
            {entries.map((en, i) =>
              en.kind === "sep" ? (
                <div
                  key={`sep-${i}`}
                  ref={setEl(i)}
                  role="separator"
                  aria-orientation="horizontal"
                  className="absolute left-0 top-0 flex items-center px-2.5 will-change-transform"
                  style={{
                    height: SEP_H,
                    opacity: 0,
                    transition: "opacity 120ms linear",
                    transformOrigin: "0 50%",
                  }}
                >
                  <span className="h-px w-full bg-border" />
                </div>
              ) : (
                <button
                  key={en.item.id}
                  ref={setEl(i)}
                  type="button"
                  role="menuitem"
                  tabIndex={-1}
                  onClick={() => select(en.item.id)}
                  className={`absolute left-0 top-0 will-change-transform ${itemClass}`}
                  style={{
                    height: ITEM_H,
                    opacity: 0,
                    transition: "opacity 120ms linear",
                    transformOrigin: "0 50%",
                  }}
                >
                  {itemInner(en.item)}
                </button>
              )
            )}
          </div>
        </div>
      )}

      {mounted && reduced && (
        <div
          id={menuId}
          role="menu"
          aria-label={label}
          onKeyDown={onMenuKeyDown}
          data-state={open ? "open" : "closed"}
          className="absolute left-0 top-full z-50 mt-2 origin-top rounded-md border border-border bg-surface p-1.5 shadow-lg transition-[opacity,transform] duration-150 data-[state=closed]:pointer-events-none data-[state=closed]:scale-[0.98] data-[state=closed]:opacity-0"
          style={{ width: minWidth, animation: "ns-drape-in 150ms ease-out" }}
        >
          {entries.map((en, i) =>
            en.kind === "sep" ? (
              <div
                key={`sep-${i}`}
                role="separator"
                aria-orientation="horizontal"
                className="mx-1 my-1.5 h-px bg-border"
              />
            ) : (
              <button
                key={en.item.id}
                ref={setEl(i)}
                type="button"
                role="menuitem"
                tabIndex={-1}
                onClick={() => select(en.item.id)}
                className={`relative w-full py-2 ${itemClass}`}
              >
                {itemInner(en.item)}
              </button>
            )
          )}
        </div>
      )}

      <style>{`@keyframes ns-drape-in{from{opacity:0;transform:scale(.98)}}`}</style>
    </div>
  );
}
Build spec

Build a dropdown menu whose panel is a live 2D verlet cloth pinned along its top edge to the trigger's bottom edge. Simulate a 10x14 point grid with structural constraints only and 3 relaxation iterations per frame; gravity 2400 px/s^2, velocity damping 0.99 per step. On open the vertices start compressed at the trigger's bottom edge and fall freely, draping taut in roughly 380-500 ms — emergent from the physics, not tweened. A Canvas 2D layer underneath draws per-quad fold shading from a vertex-normal approximation (horizontal stretch = sheen toward the lighter ink, lateral lean = shadow toward the darker ink), with every drawn color derived from getComputedStyle CSS tokens (--surface/--border/--foreground/--background) at mount and re-derived live via a MutationObserver on the documentElement class so both themes shade correctly. Menu items are real DOM nodes (role=menu/menuitem) anchored to designated vertex rows: each item's transform is its anchor vertex translate plus a rotation from the local row slope clamped to +/-6 degrees, and each label fades in over 120 ms once its anchor row's vertical velocity drops below 8 px/s. Cursor proximity billows the weave: an upward force F = 900 * exp(-d^2 / (2*60^2)) px/s^2 on vertices within ~150 px of the pointer. Close yanks every vertex back to the trigger point on a per-vertex critically damped spring (k = 120 s^-2, zeta = 1.0) and unmounts once max displacement is under 0.5 px. Hot path is refs-only direct-DOM in a single rAF loop that sleeps when max per-frame displacement is under 0.02 px, or unconditionally 1200 ms after open (a backstop for imperceptible verlet jitter that can otherwise keep the epsilon from tripping), and wakes on pointermove/open/close; ResizeObserver re-derives cloth width from the trigger rect with a zero-size guard, and all listeners, observers, and the canvas are torn down on close/unmount. Full menu semantics: aria-expanded trigger, arrow-key navigation, Home/End, Enter selects, Esc closes and returns focus to the trigger, outside click closes; hovered/focused items get a token-relative bg-surface highlight plus an accent left rail and accent focus-visible ring. Under prefers-reduced-motion render a plain dropdown with a 150 ms opacity/scale-98% fade and no cloth or canvas.

Tags
menudropdownclothphysicsverletcursornav