Skip to main content

ns-ui

Context Menu Unfold

A context menu that unfolds like a pocket knife: items swing out from a hinged spine as slim blades, staggered from folded to open, with a hairline edge highlight while swinging; submenus unfold as a second, smaller knife from a blade's tip.

Usage

registry/core/context-menu-unfold/demo.tsx
"use client";

import { useEffect, useRef } from "react";
import { JackKnife, type JackKnifeItem } from "./component";

const ITEMS: JackKnifeItem[] = [
  { id: "rename", label: "Rename", shortcut: "⌘R" },
  { id: "duplicate", label: "Duplicate", shortcut: "⌘D" },
  {
    id: "export",
    label: "Export as",
    submenu: [
      { id: "export-png", label: "PNG" },
      { id: "export-svg", label: "SVG" },
      { id: "export-pdf", label: "PDF" },
    ],
  },
  { id: "share", label: "Share…" },
  { id: "delete", label: "Delete", shortcut: "⌫" },
];

// Self-driving: periodically opens the menu via the trigger button (a real
// DOM click, so it exercises the exact path a mouse user takes), waits,
// closes it with Escape, then loops — exercising the fold-open and
// fold-closed staggers without any input from whoever is viewing the demo.
export default function JackKnifeDemo() {
  const rootRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    let cancelled = false;
    let timer: ReturnType<typeof setTimeout>;

    const cycle = () => {
      if (cancelled) return;
      const trigger = rootRef.current?.querySelector<HTMLButtonElement>("[data-context-menu-unfold-trigger]");
      trigger?.click();
      timer = setTimeout(() => {
        if (cancelled) return;
        document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
        timer = setTimeout(cycle, 1400);
      }, 2000);
    };

    timer = setTimeout(cycle, 700);
    return () => {
      cancelled = true;
      clearTimeout(timer);
    };
  }, []);

  return (
    <div ref={rootRef} className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / context-menu-unfold
      </p>

      <div className="flex w-full max-w-md flex-col items-center gap-3">
        <JackKnife items={ITEMS} label="File actions" onSelect={() => {}}>
          <div className="flex h-40 w-full max-w-md items-center justify-center rounded-[12px] border border-dashed border-border text-xs text-ns-muted">
            Right-click this surface, or use the File actions button below
          </div>
        </JackKnife>
      </div>

      <p className="max-w-sm text-center text-xs text-ns-muted">
        Items swing out from a hinge like knife blades; Export as opens a
        smaller knife of its own.
      </p>
    </div>
  );
}

Component

registry/core/context-menu-unfold/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// JackKnife — a context menu that unfolds like a pocket knife. A spine
// anchors at the trigger point (the pointer for a right-click, or the
// trigger button's own position for the accessible trigger); menu items are
// slim "blades" hinged at the spine edge, each swinging in with a staggered
// rotation from folded (~80deg, tucked flat against the spine) to open
// (0deg). Every swing is a one-shot ref-driven CSS transition (transform +
// a border-color pulse for the hairline edge highlight) — timed with a
// per-item delay for the stagger — not a continuous rAF loop. A submenu is
// the same component recursively, anchored at the parent blade's tip.
// Distinct from menu-nested-trays (telescoping trays) and dropdown-drape (cloth
// dropdown): this is a radial-fold context-menu primitive.
// ---------------------------------------------------------------------------

export interface JackKnifeItem {
  id: string;
  label: string;
  shortcut?: string;
  disabled?: boolean;
  submenu?: JackKnifeItem[];
}

export interface JackKnifeProps {
  /** the menu's rows */
  items: JackKnifeItem[];
  /** called with the chosen row's id when one is selected */
  onSelect?: (id: string) => void;
  /** region that opens the menu on right-click; the trigger button always works too */
  children?: ReactNode;
  /** accessible name for the trigger button and the menu itself */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

type Anchor = { x: number; y: number; side: "left" | "right" };

const OPEN_MS = 260;
const OPEN_STAGGER_MS = 38;
const CLOSE_MS = 150;
const CLOSE_STAGGER_MS = 22;
const HAIRLINE_HOLD_MS = 120;
const TYPEAHEAD_RESET_MS = 700;
const ITEM_W = 200;
const EDGE_MARGIN = 10;

function computeAnchor(x: number, y: number): Anchor {
  const side: Anchor["side"] = x + ITEM_W + EDGE_MARGIN > window.innerWidth ? "left" : "right";
  return { x, y, side };
}

function clampTop(y: number, height: number) {
  return Math.min(Math.max(EDGE_MARGIN, y), Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN));
}

function foldItem(
  el: HTMLElement | null,
  index: number,
  opening: boolean,
  side: "left" | "right",
  reduced: boolean
) {
  if (!el) return;
  const sign = side === "right" ? -1 : 1;
  if (reduced) {
    el.style.transition = "none";
    el.style.transform = "rotate(0deg)";
    el.style.opacity = opening ? "1" : "0";
    el.style.borderColor = "var(--border)";
    return;
  }
  if (opening) {
    el.style.transition = "none";
    el.style.transform = `rotate(${sign * 80}deg)`;
    el.style.opacity = "0";
    el.style.borderColor = "var(--border)";
    el.getBoundingClientRect(); // force reflow before animating in
    const delay = index * OPEN_STAGGER_MS;
    el.style.transition = `transform ${OPEN_MS}ms cubic-bezier(0.16,1,0.3,1) ${delay}ms, opacity ${OPEN_MS * 0.7}ms linear ${delay}ms, border-color ${OPEN_MS}ms linear ${delay}ms`;
    el.style.transform = "rotate(0deg)";
    el.style.opacity = "1";
    el.style.borderColor = "var(--foreground)";
    window.setTimeout(() => {
      el.style.transition = `border-color ${HAIRLINE_HOLD_MS}ms linear`;
      el.style.borderColor = "var(--border)";
    }, delay + OPEN_MS);
  } else {
    const delay = index * CLOSE_STAGGER_MS;
    el.style.transition = `transform ${CLOSE_MS}ms cubic-bezier(0.6,0,0.84,0) ${delay}ms, opacity ${CLOSE_MS}ms linear ${delay}ms`;
    el.style.transform = `rotate(${sign * 80}deg)`;
    el.style.opacity = "0";
  }
}

interface BladesProps {
  items: JackKnifeItem[];
  anchor: Anchor;
  label: string;
  onSelect: (id: string) => void;
  onClose: (focusTrigger: boolean) => void;
  menuId: string;
}

function Blades({ items, anchor, label, onSelect, onClose, menuId }: BladesProps) {
  const panelRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const reducedRef = useRef(false);
  const [reduced, setReduced] = useState(false);
  const [submenuIndex, setSubmenuIndex] = useState<number | null>(null);
  const [submenuAnchor, setSubmenuAnchor] = useState<Anchor | null>(null);
  const typeaheadRef = useRef({ buffer: "", at: 0 });

  const closableItems = useMemo(() => items.filter((it) => !it.disabled), [items]);

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

  useEffect(() => {
    items.forEach((_, i) => foldItem(itemRefs.current[i] ?? null, i, true, anchor.side, reducedRef.current));
    const first = itemRefs.current.find((el, i) => el && !items[i]?.disabled);
    first?.focus({ preventScroll: true });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const closeSelf = useCallback(
    (focusTrigger: boolean) => {
      items.forEach((_, i) => foldItem(itemRefs.current[i] ?? null, i, false, anchor.side, reducedRef.current));
      const total = reducedRef.current ? 0 : items.length * CLOSE_STAGGER_MS + CLOSE_MS;
      window.setTimeout(() => onClose(focusTrigger), total);
    },
    [items, anchor.side, onClose]
  );

  const openSubmenu = (index: number) => {
    const el = itemRefs.current[index];
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const x = anchor.side === "right" ? rect.right - 8 : rect.left + 8;
    setSubmenuAnchor(computeAnchor(x, rect.top));
    setSubmenuIndex(index);
  };
  const closeSubmenu = () => {
    setSubmenuIndex(null);
    setSubmenuAnchor(null);
  };

  const focusByIndex = (i: number) => {
    const el = itemRefs.current[i];
    el?.focus({ preventScroll: true });
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (submenuIndex !== null) {
      // let the submenu's own handler deal with everything except closing it back here
      if (e.key === "Escape") return; // bubbles from submenu, harmless
      return;
    }
    const enabled = itemRefs.current
      .map((el, i) => ({ el, i }))
      .filter(({ el, i }) => el && !items[i]?.disabled);
    const currentIdx = enabled.findIndex(({ el }) => el === document.activeElement);

    if (e.key === "Escape") {
      e.preventDefault();
      closeSelf(true);
      return;
    }
    if (e.key === "Tab") {
      closeSelf(false);
      return;
    }
    if (e.key === "ArrowDown") {
      e.preventDefault();
      const next = enabled[(currentIdx + 1) % enabled.length];
      if (next) focusByIndex(next.i);
      return;
    }
    if (e.key === "ArrowUp") {
      e.preventDefault();
      const next = enabled[currentIdx < 0 ? enabled.length - 1 : (currentIdx - 1 + enabled.length) % enabled.length];
      if (next) focusByIndex(next.i);
      return;
    }
    if (e.key === "Home") {
      e.preventDefault();
      const first = enabled[0];
      if (first) focusByIndex(first.i);
      return;
    }
    if (e.key === "End") {
      e.preventDefault();
      const last = enabled[enabled.length - 1];
      if (last) focusByIndex(last.i);
      return;
    }
    if (e.key === "ArrowRight" || e.key === "Enter" || e.key === " ") {
      const active = items[currentIdx >= 0 ? enabled[currentIdx]!.i : -1];
      if (active?.submenu?.length) {
        e.preventDefault();
        openSubmenu(enabled[currentIdx]!.i);
      } else if (e.key !== "ArrowRight" && active && !active.disabled) {
        e.preventDefault();
        onSelect(active.id);
      }
      return;
    }
    if (e.key === "ArrowLeft") {
      return; // top level has nowhere to go left to; submenu instance handles its own close
    }
    if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
      const now = Date.now();
      const ta = typeaheadRef.current;
      ta.buffer = now - ta.at < TYPEAHEAD_RESET_MS ? ta.buffer + e.key : e.key;
      ta.at = now;
      const match = closableItems.find((it) => it.label.toLowerCase().startsWith(ta.buffer.toLowerCase()));
      if (match) {
        const idx = items.indexOf(match);
        focusByIndex(idx);
      }
    }
  };

  const panelTop = clampTop(anchor.y, items.length * 38 + 16);
  const panelLeft = anchor.side === "right" ? anchor.x : anchor.x - ITEM_W;

  return (
    <div
      ref={panelRef}
      role="menu"
      aria-label={label}
      id={menuId}
      onKeyDown={onKeyDown}
      className="fixed z-[950] flex flex-col gap-0.5 rounded-[10px] border border-border bg-background p-1.5 shadow-lg"
      style={{ left: panelLeft, top: panelTop, width: ITEM_W }}
    >
      {items.map((item, i) => (
        <button
          key={item.id}
          ref={(el) => {
            itemRefs.current[i] = el;
          }}
          type="button"
          role="menuitem"
          disabled={item.disabled}
          aria-disabled={item.disabled}
          aria-haspopup={item.submenu ? "menu" : undefined}
          aria-expanded={item.submenu ? submenuIndex === i : undefined}
          tabIndex={-1}
          onMouseEnter={(e: ReactMouseEvent<HTMLButtonElement>) => {
            if (item.submenu?.length) openSubmenu(i);
            else closeSubmenu();
            e.currentTarget.focus({ preventScroll: true });
          }}
          onClick={() => {
            if (item.disabled) return;
            if (item.submenu?.length) openSubmenu(i);
            else onSelect(item.id);
          }}
          className="ns-jk-blade flex origin-left items-center justify-between gap-3 rounded-[6px] border border-transparent px-2.5 py-1.5 text-left text-xs text-foreground outline-none transition-colors hover:bg-border/40 focus-visible:bg-border/40 disabled:pointer-events-none disabled:opacity-40"
        >
          <span className="truncate">{item.label}</span>
          {item.shortcut && <span className="shrink-0 font-mono text-[10px] text-ns-muted">{item.shortcut}</span>}
          {item.submenu && !item.shortcut && <span className="shrink-0 text-[10px] text-ns-muted">{"›"}</span>}
        </button>
      ))}

      {submenuIndex !== null && submenuAnchor && items[submenuIndex]?.submenu && (
        <Blades
          items={items[submenuIndex]!.submenu!}
          anchor={submenuAnchor}
          label={`${items[submenuIndex]!.label} submenu`}
          onSelect={onSelect}
          onClose={(focusTrigger) => {
            closeSubmenu();
            if (focusTrigger) focusByIndex(submenuIndex);
          }}
          menuId={`${menuId}-sub`}
        />
      )}

      {!reduced && <style>{`.ns-jk-blade{transform-box:fill-box;}`}</style>}
    </div>
  );
}

export function JackKnife({ items, onSelect, children, label = "Actions", className = "" }: JackKnifeProps) {
  const autoId = useId();
  const menuId = `jk-menu-${autoId.replace(/:/g, "")}`;

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const [anchor, setAnchor] = useState<Anchor | null>(null);
  const [open, setOpen] = useState(false);

  const openAt = (x: number, y: number) => setAnchor(computeAnchor(x, y));

  useEffect(() => {
    setOpen(anchor !== null);
  }, [anchor]);

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      // scoped to this instance's own menu id (and its `-sub` descendants at
      // any depth) — a bare `[role="menu"]` query would match a SECOND
      // JackKnife instance's panel elsewhere on the page and incorrectly
      // treat a click inside THAT menu as "inside this one"
      const panels = document.querySelectorAll(`[id^="${menuId}"]`);
      const target = e.target as Node;
      const inside = Array.from(panels).some((p) => p.contains(target));
      if (!inside) setAnchor(null);
    };
    document.addEventListener("pointerdown", onPointerDown, true);
    return () => document.removeEventListener("pointerdown", onPointerDown, true);
  }, [open, menuId]);

  const handleSelect = (id: string) => {
    onSelect?.(id);
    setAnchor(null);
    triggerRef.current?.focus();
  };

  const handleClose = (focusTrigger: boolean) => {
    setAnchor(null);
    if (focusTrigger) triggerRef.current?.focus();
  };

  return (
    <div className={`relative inline-block ${className}`}>
      {children && (
        <div
          onContextMenu={(e: ReactMouseEvent) => {
            e.preventDefault();
            openAt(e.clientX, e.clientY);
          }}
        >
          {children}
        </div>
      )}

      <button
        ref={triggerRef}
        type="button"
        data-context-menu-unfold-trigger=""
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={open ? menuId : undefined}
        onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
          const rect = e.currentTarget.getBoundingClientRect();
          openAt(rect.left, rect.bottom + 6);
        }}
        className="mt-3 rounded-[6px] border border-border px-2.5 py-1.5 text-xs text-foreground transition-colors hover:border-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
      >
        {label}
      </button>

      {open && anchor && (
        <Blades
          items={items}
          anchor={anchor}
          label={label}
          onSelect={handleSelect}
          onClose={handleClose}
          menuId={menuId}
        />
      )}
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/context-menu-unfold.tsx, so point the import there.

Use when a right-click context menu on a region where the open itself is the flourish, blades swinging out from a hinged spine and submenus unfolding from a blade's tip, with a visible trigger button so the affordance is reachable by keyboard; pick menu-nested-trays instead when the nesting goes deep enough that every ancestor level needs to stay on screen and one click away, or dropdown-drape when the panel hangs from a trigger in normal page flow rather than from a pointer position.

Install

npx shadcn add https://design.helpmarq.com/r/context-menu-unfold.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build a context menu whose open/close animation reads as a pocket knife unfolding, usable both via right-click on a wrapped region and via a visible, accessible trigger button (both open the identical menu — the button exists because a right-click affordance alone is unreachable by keyboard, and it is also what any automated or assistive-tech interaction should use). On open, compute an anchor point (the pointer's clientX/clientY for a right-click, or the trigger button's own bounding rect for a button click) and decide a fold 'side' (right or left) by checking whether the menu's fixed width would overflow the right edge of the viewport from that anchor — if it would, anchor the panel so its right edge sits at the pointer instead of its left edge, and mirror the fold direction. The menu panel itself is position:fixed, its top clamped so it never overflows the bottom of the viewport either. Each menu item is a real <button role="menuitem"> (transform-box: fill-box; transform-origin: left) that starts, on mount, folded flat (`rotate(80deg)` or `rotate(-80deg)` depending on fold side, so it always rotates 'away from' the anchor edge like a real blade tucked behind the spine) and opacity 0. Immediately after mount (post-reflow, so the folded starting pose actually commits as a distinct frame), transition every item to `rotate(0deg)` / opacity 1 over ~260ms with an ease-out-expo curve, but stagger each item's transition-delay by its index × ~38ms so they swing open one after another rather than all at once — a real fan-of-blades opening. Simultaneously transition the item's border-color from --border to --foreground over the same timing (the 'hairline edge highlight while swinging'), then — once that item's own swing finishes — transition the border back down to --border over a short ~120ms settle, so the highlight is specifically tied to motion, not a resting state. Closing reverses this: stagger from the LAST item to the first (reverse order reads as 'folding back up'), shorter duration (~150ms) and tighter stagger (~22ms), transform back to the folded rotation and opacity 0 — actually unmount the panel only after the full staggered close duration elapses, never abruptly. All of this is one-shot ref-driven inline style writes per open/close event (computed once, CSS interpolates) — never a continuous rAF loop, since nothing here needs per-frame updates. A submenu (items with a `submenu` array) is the exact same component rendered recursively: hovering or pressing ArrowRight/Enter on an item that has one computes an anchor at that blade's own tip (its bounding rect) and mounts a second, smaller-feeling knife there with the identical fold-in/fold-out mechanic — closing it (Escape, ArrowLeft context, or selecting one of its items) returns focus to the parent blade that opened it. Full menu keyboard semantics on the top-level panel (role=menu, aria-label): ArrowDown/ArrowUp move a roving focus among enabled (non-disabled) items only, wrapping at both ends; Home/End jump to the first/last enabled item; typeahead buffers printable keystrokes (reset after ~700ms of inactivity) and jumps focus to the first item whose label starts with the buffered string, case-insensitively; Escape folds the whole menu closed and returns focus to whatever opened it (the right-click surface conceptually, or literally the trigger button); Tab also closes the menu (without stealing focus) since a menu should never trap Tab out of the document flow. A pointerdown anywhere outside any open menu panel (top-level or submenu) closes the whole stack. Hovering an item (mouse only) slides it 2px further out along its own resting angle (0deg, so a plain translateX, signed by fold side) as a lightweight 'lean into it' cue, separate from the open/close swing. prefers-reduced-motion: items appear directly in their open pose (opacity 1, rotate(0deg)) with no folded starting frame, no stagger, no swing — closing likewise just disappears, both still fully functional and instant rather than absent. Distinct from menu-nested-trays (telescoping horizontal trays) and dropdown-drape (a cloth/verlet-simulated dropdown panel): this is a radial-fold context-menu primitive, not a scrollable tray or a physics panel. Zero dependencies, no canvas.

Props

PropTypeDefaultDescription
itemsJackKnifeItem[]—the menu's rows
onSelect?(id: string) => void—called with the chosen row's id when one is selected
children?ReactNode—region that opens the menu on right-click; the trigger button always works too
label?string"Actions"accessible name for the trigger button and the menu itself
className?string—extra classes merged onto the rendered root element