Menu
All-in-one vertical menu. Apply menu({ items }) to a wrapper element (typically a <div>) — it sets role="menu" on the wrapper and generates <button> role="menuitem" children from the items array, each wired with keyboard navigation (Arrow / Home / End move focus, Enter / Space activate). Selection is tracked via activeKey unless selectable: false. Escape hatch: pass items: [] to keep the wrapper's own children — only the container styling and role="menu" semantics apply then.
When used as a floating dropdown (e.g. inside a popover), the wrapper carries a "border-strong" outline plus a medium elevation() box-shadow.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | MenuItem[] | [] | Item definitions { label, key?, onClick? }. label is a plain string (auto-wrapped) or any DomphyElement (e.g. icon + text); key defaults to the item's zero-based index; onClick runs when the item is clicked. [] keeps the wrapper's own children. |
activeKey | ValueOrState<number | string | null> | null | Currently selected item key. Accepts a plain value or a reactive State. |
selectable | boolean | true | Whether items track and update the active selection on click. |
color | ThemeColor | "neutral" | Background color tone for the menu. |
accentColor | ThemeColor | "primary" | Accent color for the active/focus item. |
Customization
Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.
There are four levels of customization, in increasing order of effort:
- Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
- Context attributes. Use
dataTone,dataSize, anddataDensityon a container to shift tone, size, or density for an entire subtree without touching individual elements. - Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
- Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas
Unit - U = fontSize / 4 - convert final values with themeSpacing(n).
Size - n = intrinsic text lines, w = wrapping level, d = density factor:
height = (n * 6 + 2 * d * w) * U
paddingBlock = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius = d * w * UBase density d = 1.5:
| U | w=0 | w=1 | w=2 | w=3 |
|---|---|---|---|---|
height (n = 1) | 6 | 9 | 12 | 15 |
| paddingBlock | 0 | 1.5 | 3 | 4.5 |
| paddingInline | 3 | 4.5 | 6 | 4.5 |
| radius | 0 | 1.5 | 3 | 4.5 |
Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.
| Role | Shift | n=0 |
|---|---|---|
| Background | parent +/- n | 0 |
| Text | bg + K | 6 |
| Border | bg + K/2 | 3 |
| Hover | bg + 2K/3 | 4 |
| Selected / Focus | above +/- K/3 | 2-4 |
State shift range: K/3 <= delta <= 2K/3.
import {
type DomphyElement,
type Listener,
type PartialElement,
toState,
type ValueOrState,
} from "@domphy/core";
import {
type ThemeColor,
themeColor,
themeDensity,
themeSize,
themeSpacing,
} from "@domphy/theme";
import { elevation } from "../utils/elevation.js";
import { focusRing } from "../utils/focusRing.js";
/** One item inside a menu. */
type MenuItem = {
/** Button label — plain string (auto-wrapped) or any DomphyElement (e.g. icon + text). */
label: string | DomphyElement;
/** Stable key. Defaults to the item's zero-based index. */
key?: string | number;
/** Called when the item is clicked. */
onClick?: () => void;
};
/**
* All-in-one vertical menu. Generates `<button>` `[role=menuitem]` elements
* from the `items` array with keyboard navigation (Arrow/Home/End/Enter/Space).
* Apply to any wrapper element (`div`, `ul`, …).
*
* @param props.items - Item definitions `{ label, key?, onClick? }`. Pass `[]`
* to keep the wrapper's own children (escape hatch for fully custom rows —
* only the menu container styling and `role="menu"` semantics apply then).
* @param props.activeKey - Currently selected key (value or State). Defaults to `null`.
* @param props.selectable - Whether items track and update the active selection. Defaults to `true`.
* @param props.color - Background color tone for the menu. Defaults to `"neutral"`.
* @param props.accentColor - Accent color for the active/focus item. Defaults to `"primary"`.
* @example
* { div: null, $: [menu({ items: [
* { label: "Profile", key: "profile", onClick: () => navigate("/profile") },
* { label: "Settings", key: "settings", onClick: () => navigate("/settings") },
* ] })] }
*/
function menu(
props: {
items: MenuItem[];
activeKey?: ValueOrState<number | string | null>;
selectable?: boolean;
color?: ThemeColor;
accentColor?: ThemeColor;
} = { items: [] },
): PartialElement {
const {
items = [],
selectable = true,
color = "neutral",
accentColor = "primary",
} = props;
const activeKey = toState(props.activeKey ?? null);
return {
role: "menu",
dataTone: "shift-0",
_onSchedule: (node, element) => {
// Empty items = the caller renders its own rows; leave children alone.
if (items.length === 0) return;
const id = node.nodeId;
const buttons: DomphyElement<"button">[] = items.map((item, index) => {
const key = item.key ?? index;
return {
button:
typeof item.label === "string"
? [{ span: item.label } as DomphyElement<"span">]
: [item.label],
_key: key,
type: "button",
id: `menuitem${id}${key}`,
role: "menuitem",
...(selectable
? {
ariaCurrent: (l: Listener) =>
activeKey.get(l) === key || undefined,
}
: {}),
onClick: () => {
if (selectable) activeKey.set(key);
item.onClick?.();
},
onKeyDown: (e: Event) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") {
e.preventDefault();
(e.target as HTMLElement).click();
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(k)) return;
e.preventDefault();
const keys = items.map((it, i) => it.key ?? i);
const idx = keys.indexOf(key);
let next = idx;
if (k === "ArrowDown") next = (idx + 1) % keys.length;
else if (k === "ArrowUp")
next = (idx - 1 + keys.length) % keys.length;
else if (k === "Home") next = 0;
else if (k === "End") next = keys.length - 1;
(
document.getElementById(
`menuitem${id}${keys[next]}`,
) as HTMLElement
)?.focus();
},
style: {
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: themeSpacing(2),
width: "100%",
fontSize: (l: Listener) => themeSize(l, "inherit"),
height: (l: Listener) => themeSpacing(6 + themeDensity(l) * 2),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 3),
border: "none",
outline: "none",
// Menu panel is a light (shift-0) surface: "text" resolves to a
// dark-on-light reading tone in both light and dark themes.
color: (l: Listener) => themeColor(l, "text", color),
backgroundColor: (l: Listener) => themeColor(l, "inherit", color),
transition:
"background-color 140ms ease, box-shadow 140ms ease, color 140ms ease",
"&:hover:not([disabled]):not([aria-current=true])": {
backgroundColor: (l: Listener) => themeColor(l, "hover", color),
},
"&[aria-current=true]": {
backgroundColor: (l: Listener) =>
themeColor(l, "shift-3", accentColor),
color: (l: Listener) => themeColor(l, "shift-13", accentColor),
},
"&:focus-visible": {
boxShadow: (l: Listener) => focusRing(l, accentColor),
},
},
} as DomphyElement<"button">;
});
(element as any)[node.tagName] = buttons;
},
style: {
display: "flex",
flexDirection: "column",
paddingBlock: (l: Listener) => themeSpacing(themeDensity(l) * 2),
paddingInline: (l: Listener) => themeSpacing(themeDensity(l) * 2),
fontSize: (l: Listener) => themeSize(l, "inherit"),
backgroundColor: (l: Listener) => themeColor(l, "inherit", color),
color: (l: Listener) => themeColor(l, "text", color),
borderRadius: (l: Listener) => themeSpacing(themeDensity(l) * 2),
outline: (l: Listener) =>
`1px solid ${themeColor(l, "border-strong", color)}`,
outlineOffset: "-1px",
boxShadow: elevation("medium"),
},
};
}
export { menu };
export type { MenuItem };