FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Button
  • Toggle
  • ToggleGroup
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Context menu
  • Toolbar
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • NumberField
  • Field & Form
  • Progress
  • Meter

Resources

  • Roadmap & changelog
  • GitHub ↗

Context menu

A context menu is a Menu opened from a different trigger. Menu.ContextTrigger is a region (any element) whose contextmenu event (right-click, Ctrl+click on macOS, Shift+F10 or the ContextMenu key) or touch long-press opens the Root's Menu.Popup at the pointer. Everything inside the popup is Menu's: items whose action runs with data-pending, checkbox and radio items, submenus, typeahead, Escape and Tab. The library fills the three gaps the platform leaves: touch on iOS (which never fires contextmenu), placing a popover at a point, and the light dismiss that would close a just-opened menu in Firefox and Safari.

Three rules for an accessible context menu

  1. A context menu is a shortcut, never the only path to an action. iOS (with or without VoiceOver) may never open it, nothing announces it, and users do not discover it. Every item must also be reachable through a visible control: a Menu.Trigger in the same Root (the first example), a toolbar (the second), or a keyboard shortcut.
  2. Targets must be focusable for keyboard and screen-reader users: the browser fires contextmenu at the focused element, so a region with no focusable content cannot be opened from the keyboard. Windows screen readers pass the Applications key and Shift+F10 through to the page.
  3. Name the menu with aria-label on the Popup when the Root has no Menu.Trigger (the Popup's aria-labelledby points at the Trigger's id).
NameSizeActions
Report.pdf1.2 MB
Budget.xlsx84 KB
Notes.md3 KB

Right-click a row, or use its ⋯ button.

Show code
ContextMenuFiles.tsx
"use client";
import { startTransition, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { fakeSave } from "./fake-api.js";

interface File {
  readonly id: string;
  readonly name: string;
  readonly size: string;
}

const initialFiles: readonly File[] = [
  { id: "1", name: "Report.pdf", size: "1.2 MB" },
  { id: "2", name: "Budget.xlsx", size: "84 KB" },
  { id: "3", name: "Notes.md", size: "3 KB" },
];

// One Menu.Root per row with two ways in: the "⋯" button opens the menu anchored below it, a
// right-click anywhere on the row (or Shift+F10 on the button, or a long-press on touch) opens
// the same menu at the pointer. The button is also the required alternate path: a context menu
// is never the only way to an action. Archive is a fake 1.2 s server call: the item shows
// "Archiving…" (data-pending) and the menu closes when it settles.
export function ContextMenuFiles() {
  const [files, setFiles] = useState(initialFiles);
  const [log, setLog] = useState("Right-click a row, or use its ⋯ button.");
  const remove = (id: string) =>
    startTransition(() => setFiles((f) => f.filter((x) => x.id !== id)));
  const rename = (id: string) =>
    startTransition(() =>
      setFiles((f) => f.map((x) => (x.id === id ? { ...x, name: `Renamed ${x.name}` } : x))),
    );

  return (
    <div className="demo-stack">
      <table className="demo-files">
        <thead>
          <tr>
            <th scope="col">Name</th>
            <th scope="col">Size</th>
            <th scope="col">
              <span className="demo-cm-hidden">Actions</span>
            </th>
          </tr>
        </thead>
        <tbody>
          {files.map((file) => (
            <Menu.Root
              key={file.id}
              openChangeAction={(open, { reason }) => {
                if (open) setLog(`Opened the menu of ${file.name} (${reason}).`);
              }}
            >
              <Menu.ContextTrigger render={<tr className="demo-file-row" />}>
                <td>{file.name}</td>
                <td>{file.size}</td>
                <td>
                  <Menu.Trigger
                    className="demo-icon-button"
                    aria-label={`Actions for ${file.name}`}
                  >
                    {({ pending }) =>
                      pending ? <span className="demo-spinner" aria-hidden /> : "⋯"
                    }
                  </Menu.Trigger>
                  {/* In a table the Popup goes inside a cell (a <div> cannot sit next to a <tr>);
                      its clicks and keys then bubble to the row: check event.target there. */}
                  <Menu.Popup className="demo-menu" align="end">
                    <Menu.Item action={() => rename(file.id)}>Rename</Menu.Item>
                    <Menu.Item
                      action={async () => {
                        await fakeSave("the archive", { ms: 1200 });
                        remove(file.id);
                        startTransition(() => setLog(`Archived ${file.name}.`));
                      }}
                    >
                      {({ pending }) => (pending ? "Archiving…" : "Archive")}
                    </Menu.Item>
                    <Menu.Separator />
                    <Menu.Item className="demo-menu-danger" action={() => remove(file.id)}>
                      Delete
                    </Menu.Item>
                  </Menu.Popup>
                </td>
              </Menu.ContextTrigger>
            </Menu.Root>
          ))}
        </tbody>
      </table>
      {files.length === 0 && (
        <button type="button" className="demo-button" onClick={() => setFiles(initialFiles)}>
          Restore the files
        </button>
      )}
      <p className="demo-readout">{log}</p>
    </div>
  );
}
context-menu.css
/* Context menu: the region (Menu.ContextTrigger) carries `data-open` while its menu is open and
   `data-pending` while the open is in flight. The popup uses the Menu styles (menu.css); a
   context open is `[data-context]` and is placed at the pointer by the library, so it gets no
   anchored gap (menu.css only adds one for `[data-side]`). */

.demo-files {
  inline-size: 100%;
  max-inline-size: 28rem;
  border-collapse: collapse;
}
.demo-files th,
.demo-files td {
  padding: 6px 10px;
  border-block-end: 1px solid var(--color-border);
  text-align: start;
}
.demo-files td:last-child {
  inline-size: 1%;
  white-space: nowrap;
}
/* The row whose menu is open. */
.demo-file-row[data-open] {
  background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.demo-file-row[data-pending] {
  cursor: progress;
}
/* Touch: no text selection or loupe during the long-press. A trade-off (the row's text is not
   selectable by touch), so it is the page's choice, not a library default. */
.demo-file-row {
  -webkit-user-select: none;
  user-select: none;
}
.demo-cm-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.demo-canvas {
  display: block;
  max-inline-size: 100%;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background: var(--color-bg);
  -webkit-user-select: none;
  user-select: none;
}
.demo-canvas:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}
.demo-canvas[data-open] {
  border-color: var(--color-primary);
}

(nothing exported yet)

Show code
ContextMenuCanvas.tsx
"use client";
import { startTransition, useEffect, useRef, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { sleep } from "./fake-api.js";

// A focusable canvas as the region: right-click it, long-press it on touch, or focus it (Tab)
// and press Shift+F10 / the ContextMenu key. The checkbox and radio items keep the menu open and
// show data-pending while their (fake) save runs; "Export" is a submenu. The same options sit in
// the toolbar above: a context menu is never the only way to an action. A second right-click
// elsewhere on the canvas moves the open menu there.
export function ContextMenuCanvas() {
  const [grid, setGrid] = useState(true);
  const [zoom, setZoom] = useState("fit");
  const [exported, setExported] = useState("(nothing exported yet)");
  const canvas = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const element = canvas.current;
    const context = element?.getContext("2d");
    if (!element || !context) return;
    const { width, height } = element;
    context.clearRect(0, 0, width, height);
    const step = zoom === "fit" ? 20 : 40;
    if (grid) {
      context.strokeStyle = "rgb(128 128 128 / 0.35)";
      for (let x = 0; x <= width; x += step) {
        context.beginPath();
        context.moveTo(x, 0);
        context.lineTo(x, height);
        context.stroke();
      }
      for (let y = 0; y <= height; y += step) {
        context.beginPath();
        context.moveTo(0, y);
        context.lineTo(width, y);
        context.stroke();
      }
    }
    context.fillStyle = "rgb(99 102 241)";
    const size = step * 3;
    context.fillRect(width / 2 - size / 2, height / 2 - size / 2, size, size);
  }, [grid, zoom]);

  const saveGrid = async (next: boolean) => {
    await sleep(500);
    startTransition(() => setGrid(next));
  };
  const saveZoom = async (next: string) => {
    await sleep(500);
    startTransition(() => setZoom(next));
  };
  const exportAs = (format: string) => async () => {
    await sleep(800);
    startTransition(() => setExported(`Exported diagram.${format}`));
  };

  return (
    <div className="demo-stack">
      <div className="demo-row" role="group" aria-label="View">
        <label className="demo-row">
          <input
            type="checkbox"
            checked={grid}
            onChange={(e) => startTransition(() => setGrid(e.target.checked))}
          />
          Grid
        </label>
        <select
          aria-label="Zoom"
          value={zoom}
          onChange={(e) => startTransition(() => setZoom(e.target.value))}
        >
          <option value="fit">Fit</option>
          <option value="100">100%</option>
        </select>
      </div>
      <Menu.Root>
        <Menu.ContextTrigger
          render={
            <canvas ref={canvas} width={320} height={160} tabIndex={0} aria-label="Diagram" />
          }
          className="demo-canvas"
        />
        <Menu.Popup
          className="demo-menu"
          aria-label="Diagram options"
          transition={{
            "fsui-menu-open": "menu-in",
            "fsui-menu-close": "menu-out",
            default: "none",
          }}
        >
          <Menu.CheckboxItem checked={grid} checkedChangeAction={saveGrid}>
            Show grid
          </Menu.CheckboxItem>
          <Menu.RadioGroup value={zoom} valueChangeAction={saveZoom}>
            <Menu.GroupLabel className="demo-menu-label">Zoom</Menu.GroupLabel>
            <Menu.RadioItem value="fit">Fit</Menu.RadioItem>
            <Menu.RadioItem value="100">100%</Menu.RadioItem>
          </Menu.RadioGroup>
          <Menu.Separator />
          <Menu.Root>
            <Menu.SubmenuTrigger>Export</Menu.SubmenuTrigger>
            <Menu.Popup className="demo-menu">
              <Menu.Item action={exportAs("svg")}>
                {({ pending }) => (pending ? "Exporting…" : "SVG")}
              </Menu.Item>
              <Menu.Item action={exportAs("png")}>
                {({ pending }) => (pending ? "Exporting…" : "PNG")}
              </Menu.Item>
            </Menu.Popup>
          </Menu.Root>
        </Menu.Popup>
      </Menu.Root>
      <p className="demo-readout">{exported}</p>
    </div>
  );
}
context-menu.css
/* Context menu: the region (Menu.ContextTrigger) carries `data-open` while its menu is open and
   `data-pending` while the open is in flight. The popup uses the Menu styles (menu.css); a
   context open is `[data-context]` and is placed at the pointer by the library, so it gets no
   anchored gap (menu.css only adds one for `[data-side]`). */

.demo-files {
  inline-size: 100%;
  max-inline-size: 28rem;
  border-collapse: collapse;
}
.demo-files th,
.demo-files td {
  padding: 6px 10px;
  border-block-end: 1px solid var(--color-border);
  text-align: start;
}
.demo-files td:last-child {
  inline-size: 1%;
  white-space: nowrap;
}
/* The row whose menu is open. */
.demo-file-row[data-open] {
  background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.demo-file-row[data-pending] {
  cursor: progress;
}
/* Touch: no text selection or loupe during the long-press. A trade-off (the row's text is not
   selectable by touch), so it is the page's choice, not a library default. */
.demo-file-row {
  -webkit-user-select: none;
  user-select: none;
}
.demo-cm-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.demo-canvas {
  display: block;
  max-inline-size: 100%;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  background: var(--color-bg);
  -webkit-user-select: none;
  user-select: none;
}
.demo-canvas:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}
.demo-canvas[data-open] {
  border-color: var(--color-primary);
}

Anatomy

import { Menu } from "@funstack/ui/menu";

<Menu.Root>                                                  {/* no DOM; as Menu */}
  <Menu.ContextTrigger render={<div className="card" />}>   {/* the region: any element, no ARIA */}
    <h3>{file.name}</h3>
    <Menu.Trigger aria-label={`Actions for ${file.name}`}>⋯</Menu.Trigger>  {/* optional: the same menu as a button */}
  </Menu.ContextTrigger>
  <Menu.Popup aria-label="File actions">                     {/* a sibling of the region */}
    <Menu.Item action={rename}>Rename</Menu.Item>
    <Menu.Item action={archive}>Archive</Menu.Item>
    <Menu.Root>
      <Menu.SubmenuTrigger>Move to</Menu.SubmenuTrigger>
      <Menu.Popup>…</Menu.Popup>
    </Menu.Root>
  </Menu.Popup>
</Menu.Root>
  • One new part in the Menu namespace. There is no ContextMenu namespace: Root, Popup, items and submenus are Menu's.
  • The region is a plain element: no role (a tr, li or canvas through render keeps its own), no aria-haspopup (not allowed on generic elements) and no aria-expanded. ARIA has no way to say "this has a context menu". tabIndex is yours (rule 2).
  • Render the Popup next to the region, so the reading order is natural. In a table row (render={<tr />}) put it inside a cell instead: a <div> cannot be a sibling of a <tr>. Right-clicks and long-presses inside a menu (this one, or any other Menu.Popup inside the region) are never the region's. Other events inside the menu do bubble to the row, in the DOM and in React: a row onClick (selection) also sees item clicks, and selectors such as .row:hover * reach the menu. Check event.target in row handlers.
  • A right-click inside a popup that is not a menu (a Popover.Popup, a Select picker) rendered inside the region is the region's: its menu opens and the other popup closes, as any popover="auto" would. Keep such popups outside the region, or give them onContextMenu={(e) => e.preventDefault()}, which keeps the region quiet there.
  • A Menu.ContextTrigger belongs to a top-level Root; inside a Menu.Popup a Root is a submenu (development warning).

Opening

InputWhereWhat opens
Right-click; Ctrl+click on macOSthe regionThe menu at the pointer, first item focused without a focus ring
Shift+F10, the ContextMenu key; Ctrl+Return on macOS; VO+Shift+Ma focused element in the regionThe menu inside the focused element (the browser puts the event there), first item focused with the ring
Long-press (touch, pen)the regionAfter longPressDelay (500 ms) held within 10 px: the menu at the touch point, first item focused quietly
  • The open is Menu's: openChangeAction(true, { reason: "activate" }) with details.event the contextmenu event (the pointerdown of a long-press), in the Root's transition. Items that suspend hold the open with data-pending on the region (and on a Trigger in the same Root); a rejection rolls data-open back.
  • The long-press runs on every platform for touch and pen pointers, because iOS fires no contextmenu. Where the platform fires its own (Android, Windows pen), whichever comes first opens once. The page stays scrollable: a pan cancels the press. The one click a release may synthesise is swallowed.
  • Before hydration the browser's own menu shows. Nothing is half-open and nothing is replayed after hydration; the custom menu works from then on.

Placement

The menu opens with its top-left corner at the point and flips when it does not fit: upward at the bottom edge, leftward at the right edge, both in the corner. CSS anchor positioning anchors to elements, not points, so the Popup renders the point inline (--menu-point-x, --menu-point-y), puts left / top there and names three @position-try options that mirror it. The menu lives in the top layer, so a transformed or filtered ancestor of the region does not move it.

Those options are the library's only shipped CSS: a <style> React hoists into <head> once per page on the first context open (data-href="@funstack/ui/menu-point"). It is functional only: three named at-rules, no selectors, nothing visual.

@position-try --fsui-menu-up      { top: auto; bottom: calc(100% - var(--menu-point-y)); }
@position-try --fsui-menu-left    { left: auto; right: calc(100% - var(--menu-point-x)); }
@position-try --fsui-menu-up-left { top: auto; bottom: calc(100% - var(--menu-point-y));
                                    left: auto; right: calc(100% - var(--menu-point-x)); }
  • Adjust with the Popup's style, which wins over the inline placement (your own position-try-fallbacks, a margin).
  • Opt out with side={null} on the Popup: no inline placement and no <style> on either path; a context open still sets --menu-point-x / --menu-point-y and data-context for your stylesheet.
  • CSP: the inline <style> falls under the same style-src directive as the inline style attributes every anchored popup already uses, so nothing new is required.
  • While the menu is open the page scrolls under it (the point is in viewport coordinates); native menus block scrolling instead. Placement is physically bottom-right of the point, also under direction: rtl.
<Menu.Popup side={null} className="menu">…</Menu.Popup>
.menu[data-context] {
  inset: auto;
  left: var(--menu-point-x);
  top: var(--menu-point-y);
  position-try-fallbacks: --mine;
}
@position-try --mine { top: auto; bottom: calc(100% - var(--menu-point-y)); }
.menu:not([data-context]) { position-area: bottom span-right; }

Moving and closing

InputWhat happensreason
Right-click elsewhere in the region while openThe menu moves to the new point: no openChangeAction, items keep their state, a pending item action keeps running, the first item is focused again–
Right-click or middle-click outside the menuCloses at once, for every menu (a Menu opened from its button too): the browser's own light dismiss ignores those buttons in Chromium, and native menus close on any click"dismiss"
Right-click inside the menuNothing: the browser's menu is suppressed, the menu stays where it is–
An item's activation, Escape, Tab, a left click outsideAs Menu"activate" / "dismiss"

Focus after a close is the platform's: the element focused when the menu opened. A right-click on a region that cannot take focus leaves focus on <body> (the right-button press already moved it there, as with native menus); with a Menu.Trigger in the same Root, focus goes to that button instead.

Nested regions and editable targets

  • The innermost region wins. A region opens only when the contextmenu event reaches it not yet defaultPrevented, and opening prevents it, so an outer region stays quiet. A disabled region lets the event through to an outer one. The library never calls stopPropagation(), so your document-level listeners still see the event.
  • Your own handler on an element inside the region decides for that element: preventDefault() keeps both the browser's menu and the region quiet (a canvas that draws its own); stopPropagation() gives the browser's menu (and leaves an open menu of the region open under it).
  • Text fields keep the browser's menu (copy, paste, spell-check): a right-click or long-press on a text-taking <input>, a <textarea> or editable content opens nothing custom. An editor whose menu has its own clipboard items sets editableTargets="menu".
<Menu.ContextTrigger editableTargets="menu" render={<div contentEditable />} />

Sharing items with a Menu button

One Root may hold a Menu.Trigger and a Menu.ContextTrigger: the button opens the menu anchored below it (side / align), a right-click opens the same menu at the pointer. It is one menu: while it is open from a right-click the button shows aria-expanded="true" and data-open, and a click on it closes the menu. That is the recommended way to satisfy rule 1, and it gives keyboard users a focusable target inside the region. Use one Root per target (a row, a card); several ContextTriggers in one Root are allowed but all show data-open together.

Props

Menu.ContextTrigger

PropTypeDefaultDescription
disabledbooleanfalseThe region does nothing: the browser's menu shows, or an outer region opens. data-disabled.
editableTargets"native" | "menu""native"What a right-click or long-press on a text-taking <input>, a <textarea> or editable content inside the region opens: the browser's menu, or this one.
longPressDelaynumber500Milliseconds a touch or pen pointer must stay down (within 10 px) to open. A development warning when negative.
classNamestring | ((state: MenuContextTriggerState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MenuContextTriggerState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MenuContextTriggerState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((props, state: MenuContextTriggerState) => ReactNode)–Replace the default <div> (element form) or take over rendering (function form).

MenuContextTriggerState: { open, pending, disabled } (open optimistic, the Root's). Emits data-open, data-pending, data-disabled and inline -webkit-touch-callout: none (your style wins). Owns onContextMenu, onPointerDown, onPointerMove, onPointerUp and onPointerCancel; onKeyDown and onClick pass through. No id, tabIndex or ARIA. Every prop is serialisable, so a Server Component can render the region.

Additions to Menu.Popup and Menu.Root

WhereWhatMeaning
Menu.Popupdata-context, state.contextThe current open came from a ContextTrigger and is placed at a point (committed). data-side / data-align are absent then, and state.side is null.
Menu.Popup--menu-point-x, --menu-point-yThe point, inline, in viewport pixels (also with side={null}).
Menu.Popupside, alignApply to opens from a Menu.Trigger; ignored for a context open. side={null} opts out of the inline placement on both paths.
Menu.PopuponContextMenu (owned)Prevents the browser's menu over the custom one.
Menu.Rootstate.contextThe current open (optimistic) came from a ContextTrigger.

Styling

.row[data-open] { background: var(--accent-weak); }   /* the region of the open menu */
.row[data-pending] { cursor: progress; }
.row { -webkit-user-select: none; user-select: none; } /* touch: optional, see below */
.menu[data-context] { margin: 0; }                    /* point-placed: no anchored gap */
  • The Menu styles apply as they are (margin: 0; inset: auto on the popup is what a point-placed menu wants too). Enter and exit CSS and transition as Menu: the open and library-driven closes animate with transition; dismissals and the outside right-click with CSS; a move is instant.
  • On iOS a long-press over text starts a selection and the loupe. Only a user-select: none that applies before the touch starts prevents it, and then the text cannot be selected by touch, so it is your choice, not a library default. The region already suppresses the link and image callout inline.

Browser notes

  • iOS Safari never fires contextmenu for touch; the library's long-press opens the menu there. VoiceOver on iOS cannot open it (rule 1). On iPadOS with a trackpad, Safari shows its own menu over the custom one (WebKit bug 260722).
  • Firefox: Shift+right-click forces the browser's menu and fires no event, a built-in escape hatch.
  • Windows fires contextmenu after the button is released; Linux and macOS on the press. The library captures the pointer on the popup in the latter case, so the release does not light-dismiss the new menu in Firefox and Safari.
  • Safari does not fire a keyboard contextmenu on a target with border-radius (WebKit bug 317496): keep a square focus target, or offer the Menu button.