Overlays
Menu
A button that opens a list of actions, for commands that do not each deserve a button.
- Built on
Popover APIpopovertargetCSS anchor positioning@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.5 kB
- Dependencies
- React
Code
import { Menu, MenuItem, MenuSeparator } from "@/components/equip/menu";
export default function Demo() {
return (
<Menu label="Deploy">
<MenuItem shortcut="⇧⌘S">Deploy to staging</MenuItem>
<MenuItem shortcut="⇧⌘P">Deploy to production</MenuItem>
<MenuItem disabled>Promote preview</MenuItem>
<MenuSeparator />
<MenuItem variant="danger">Cancel running deploy</MenuItem>
</Menu>
);
}Examples
Icon trigger, aligned to the end
An icon-only trigger needs an aria-label, which is passed straight to the button.
atlas-web
Code
import { Menu, MenuItem, MenuSeparator } from "@/components/equip/menu";
export default function Example() {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span>atlas-web</span>
<Menu
aria-label="Actions for atlas-web"
placement="bottom-end"
label={
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<circle cx="3.5" cy="8" r="1" />
<circle cx="8" cy="8" r="1" />
<circle cx="12.5" cy="8" r="1" />
</svg>
}
>
<MenuItem>Open settings</MenuItem>
<MenuItem shortcut="⌘D">Duplicate</MenuItem>
<MenuItem>Transfer to team</MenuItem>
<MenuSeparator />
<MenuItem variant="danger">Delete project</MenuItem>
</Menu>
</div>
);
}Acting on a choice
onClick runs, then the menu closes and focus goes back to the trigger. Type a letter to jump to an item.
Code
"use client";
import { useState } from "react";
import { Menu, MenuItem } from "@/components/equip/menu";
const regions = ["eu-west-1", "us-east-1", "ap-southeast-2"];
export default function Example() {
const [region, setRegion] = useState(regions[0]);
return (
<Menu label={`Region: ${region}`}>
{regions.map((name) => (
<MenuItem key={name} onClick={() => setRegion(name)}>
{name}
</MenuItem>
))}
</Menu>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Content of the trigger button. |
placement | "bottom-start" | "bottom-end" | "top-start" | "top-end" | "bottom-start" | Side of the trigger the menu opens on and the edge it lines up with. Flips when it does not fit. |
children | ReactNode | none | MenuItem and MenuSeparator elements. |
...rest | ComponentProps<"button"> | none | Every native button attribute, applied to the trigger, including aria-label and disabled. |
MenuItem variant | "default" | "danger" | "default" | danger colours the row for a destructive action. |
MenuItem shortcut | string | none | A shortcut hint at the end of the row. Display only; it does not bind the key. |
MenuItem disabled | boolean | none | Sets aria-disabled. The item stays focusable but does nothing. |
MenuItem ...rest | ComponentProps<"button"> | none | Every native button attribute, including onClick. |
Anatomy
| root | .eq-menu | The trigger button. |
|---|---|---|
| label | .eq-menu-label | |
| chevron | .eq-menu-chevron | |
| list | .eq-menu-list | The popover with role="menu". |
| item | .eq-menu-item | |
| item label | .eq-menu-item-label | Type-ahead matches against this text. |
| shortcut | .eq-menu-shortcut | |
| separator | .eq-menu-separator |
Keyboard
| EnterSpace | On the trigger, opens the menu and focuses the first item. On an item, activates it and closes the menu. |
|---|---|
| ArrowDown | On the trigger, opens the menu on the first item. In the menu, moves to the next item, wrapping. |
| ArrowUp | On the trigger, opens the menu on the last item. In the menu, moves to the previous item, wrapping. |
| HomeEnd | Moves to the first or last item. |
| A-Z | Moves to the next item whose label starts with that letter. |
| Escape | Closes the menu and returns focus to the trigger. |
| Tab | Closes the menu and moves focus on from the trigger. |
Notes
- The trigger has aria-haspopup="menu", aria-expanded and aria-controls; the list is labelled by the trigger.
- Items use roving focus: only the item under focus is reachable, and arrows move between them.
- Disabled items stay focusable so they can be discovered, as the menu pattern recommends, but cannot be activated.
- Pointer movement moves focus too, so the highlighted row is always the one Enter would activate.
- Pass onClick from a client component; call event.preventDefault() in it to keep the menu open.
- CSS anchor positioning is used inside @supports when the engine has anchor-name, position-area and position-try-fallbacks (Chromium 125, Safari 26 and Firefox 147 on, each listed as partial support). Elsewhere the menu is placed with fixed coordinates from the trigger's box when it opens and flipped once its size is known; it does not follow scrolling in that path. The exit fade needs transition of overlay, which only Chromium has; elsewhere the menu fades in but closes at once.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/menu.tsx281 lines
"use client";
import {
createContext,
useContext,
useId,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type ReactNode,
type ToggleEvent,
} from "react";
import "./menu.css";
export type MenuPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
export type MenuProps = Omit<ComponentProps<"button">, "children"> & {
/** Content of the trigger button. */
label: ReactNode;
/** Side of the trigger the menu opens on, and the edge it lines up with. */
placement?: MenuPlacement;
/** MenuItem and MenuSeparator elements. */
children: ReactNode;
};
const CloseMenu = createContext<() => void>(() => {});
/* Must match the @supports condition in menu.css exactly. */
const ANCHOR_SUPPORT =
"(anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block)";
function anchorSupported() {
return typeof CSS !== "undefined" && CSS.supports(ANCHOR_SUPPORT);
}
/** Fallback for engines without anchor positioning: fixed coordinates from the trigger's box. */
function place(trigger: Element, surface: HTMLElement, placement: MenuPlacement) {
const box = trigger.getBoundingClientRect();
const viewWidth = document.documentElement.clientWidth;
const viewHeight = document.documentElement.clientHeight;
const gap = parseFloat(getComputedStyle(surface).marginTop) || 0;
let top = placement.startsWith("top");
let end = placement.endsWith("end");
// Size is only known once shown; flip to the other side when this one does not fit.
const height = surface.offsetHeight + gap;
const width = surface.offsetWidth;
if (surface.offsetHeight > 0) {
if (!top && box.bottom + height > viewHeight && box.top - height >= 0) top = true;
else if (top && box.top - height < 0 && box.bottom + height <= viewHeight) top = false;
if (!end && box.left + width > viewWidth && box.right - width >= 0) end = true;
else if (end && box.right - width < 0 && box.left + width <= viewWidth) end = false;
}
const style = surface.style;
style.top = top ? "auto" : `${box.bottom}px`;
style.bottom = top ? `${viewHeight - box.top}px` : "auto";
style.left = end ? "auto" : `${box.left}px`;
style.right = end ? `${viewWidth - box.right}px` : "auto";
}
export function Menu({
label,
placement = "bottom-start",
id,
className,
style,
children,
onKeyDown,
...rest
}: MenuProps) {
const auto = useId();
const triggerId = id ?? `${auto}trigger`;
const menuId = `${auto}menu`;
const anchorName = `--eq-menu-${auto.replace(/[^\w-]/g, "")}`;
const menuRef = useRef<HTMLDivElement>(null);
const focusOnOpen = useRef<"first" | "last">("first");
const [open, setOpen] = useState(false);
function trigger() {
return document.getElementById(triggerId);
}
function items() {
return Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
}
function hide() {
const menu = menuRef.current;
if (menu?.matches(":popover-open")) menu.hidePopover();
}
function close() {
hide();
trigger()?.focus();
}
function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
focusOnOpen.current = event.key === "ArrowUp" ? "last" : "first";
if (menuRef.current?.matches(":popover-open")) {
const all = items();
all[focusOnOpen.current === "last" ? all.length - 1 : 0]?.focus();
} else {
// A click, not showPopover(), so the trigger stays the popover's invoker
// and a later click on it closes the menu instead of reopening it.
event.currentTarget.click();
}
}
function handleBeforeToggle(event: ToggleEvent<HTMLDivElement>) {
const button = trigger();
if (event.newState === "open" && button && !anchorSupported()) {
place(button, event.currentTarget, placement);
}
}
function handleToggle(event: ToggleEvent<HTMLDivElement>) {
const isOpen = event.newState === "open";
setOpen(isOpen);
if (!isOpen) return;
const button = trigger();
if (button && !anchorSupported()) place(button, event.currentTarget, placement);
const all = items();
all[focusOnOpen.current === "last" ? all.length - 1 : 0]?.focus();
focusOnOpen.current = "first";
}
function handleMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const all = items();
if (all.length === 0) return;
const current = all.findIndex((item) => item === document.activeElement);
let next: number | undefined;
switch (event.key) {
case "ArrowDown":
next = current < 0 ? 0 : (current + 1) % all.length;
break;
case "ArrowUp":
next = current < 0 ? all.length - 1 : (current - 1 + all.length) % all.length;
break;
case "Home":
next = 0;
break;
case "End":
next = all.length - 1;
break;
case "Escape":
event.preventDefault();
close();
return;
case "Tab":
// Focus goes back to the trigger first, so the browser's Tab moves on from there.
trigger()?.focus();
hide();
return;
default: {
if (event.key.length !== 1 || event.key === " ") return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const letter = event.key.toLowerCase();
for (let step = 1; step <= all.length; step++) {
const index = (current + step + all.length) % all.length;
const text = all[index].querySelector(".eq-menu-item-label")?.textContent ?? "";
if (text.trim().toLowerCase().startsWith(letter)) {
next = index;
break;
}
}
}
}
if (next === undefined) return;
event.preventDefault();
all[next].focus();
}
return (
<>
<button
type="button"
{...rest}
id={triggerId}
className={["eq-menu", className].filter(Boolean).join(" ")}
style={{ anchorName, ...style }}
popoverTarget={menuId}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
onKeyDown={handleTriggerKeyDown}
>
<span className="eq-menu-label">{label}</span>
<svg
className="eq-menu-chevron"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 6l4 4 4-4" />
</svg>
</button>
<div
ref={menuRef}
id={menuId}
popover="auto"
role="menu"
aria-labelledby={triggerId}
className="eq-menu-list"
data-placement={placement}
style={{ positionAnchor: anchorName }}
onBeforeToggle={handleBeforeToggle}
onToggle={handleToggle}
onKeyDown={handleMenuKeyDown}
>
<CloseMenu.Provider value={close}>{children}</CloseMenu.Provider>
</div>
</>
);
}
export type MenuItemProps = ComponentProps<"button"> & {
variant?: "default" | "danger";
/** A shortcut hint shown at the end of the row, e.g. "⌘D". Display only. */
shortcut?: string;
};
export function MenuItem({
variant = "default",
shortcut,
disabled,
className,
children,
onClick,
onPointerMove,
...rest
}: MenuItemProps) {
const close = useContext(CloseMenu);
return (
<button
type="button"
{...rest}
role="menuitem"
tabIndex={-1}
// aria-disabled keeps the item focusable, as the menu pattern asks, but inert to activation.
aria-disabled={disabled || undefined}
className={["eq-menu-item", className].filter(Boolean).join(" ")}
data-variant={variant}
onPointerMove={(event) => {
onPointerMove?.(event);
if (document.activeElement !== event.currentTarget) {
event.currentTarget.focus({ preventScroll: true });
}
}}
onClick={(event) => {
if (disabled) return;
onClick?.(event);
if (!event.defaultPrevented) close();
}}
>
<span className="eq-menu-item-label">{children}</span>
{shortcut && <kbd className="eq-menu-shortcut">{shortcut}</kbd>}
</button>
);
}
export function MenuSeparator({ className, ...rest }: ComponentProps<"div">) {
return (
<div
role="separator"
{...rest}
className={["eq-menu-separator", className].filter(Boolean).join(" ")}
/>
);
}components/equip/menu.css249 lines
@layer equip {
.eq-menu {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--eq-space-2);
height: var(--eq-h-m);
padding: 0 var(--eq-space-2) 0 var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-m) / 1 var(--eq-font);
letter-spacing: -0.005em;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-menu[aria-expanded="true"] {
background: var(--eq-tint);
}
@media (hover: hover) {
.eq-menu:hover:not(:disabled) {
background: var(--eq-tint);
}
}
.eq-menu:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-menu:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-menu:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-menu-label {
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
}
.eq-menu-label > svg {
width: 1.15em;
height: 1.15em;
}
.eq-menu-chevron {
flex: none;
width: 14px;
height: 14px;
color: var(--eq-ink-2);
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-menu[aria-expanded="true"] .eq-menu-chevron {
rotate: 180deg;
}
.eq-menu-list {
--_shift: -4px;
box-sizing: border-box;
/* Undo the UA's centring: the menu is placed next to its trigger. */
position: fixed;
inset: auto;
margin: var(--eq-space-1) 0;
min-width: 200px;
max-width: calc(100vw - 2 * var(--eq-space-4));
max-height: calc(100dvh - 2 * var(--eq-space-4));
overflow: auto;
/* Rows carry their own padding, so the surface keeps only room for the focus ring. */
padding: var(--eq-space-1);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
/* Grows out of the corner nearest its trigger. Leaving is quick, on the plain ease. */
transform-origin: top left;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
.eq-menu-list[data-placement^="top"] {
--_shift: 4px;
}
.eq-menu-list[data-placement="bottom-end"] {
transform-origin: top right;
}
.eq-menu-list[data-placement="top-start"] {
transform-origin: bottom left;
}
.eq-menu-list[data-placement="top-end"] {
transform-origin: bottom right;
}
.eq-menu-list:popover-open {
display: flex;
flex-direction: column;
opacity: 1;
scale: none;
translate: none;
/* Arriving: the spring carries it in and lets it settle. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-menu-list:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
}
}
.eq-menu-item {
display: flex;
align-items: center;
gap: var(--eq-space-3);
width: 100%;
min-height: var(--eq-h-s);
flex: none;
padding: 0 var(--eq-space-2);
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.3 var(--eq-font);
text-align: start;
white-space: nowrap;
cursor: pointer;
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-menu-item:active:not([aria-disabled="true"]) {
scale: var(--eq-press);
}
.eq-menu-item[data-variant="danger"] {
color: var(--eq-danger);
}
.eq-menu-item:focus {
outline: none;
background: var(--eq-tint);
}
.eq-menu-item:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-menu-item[data-variant="danger"]:focus {
background: color-mix(in srgb, var(--eq-danger) 10%, transparent);
}
.eq-menu-item[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-menu-item[aria-disabled="true"]:focus {
background: transparent;
}
.eq-menu-item-label {
flex: 1;
}
.eq-menu-shortcut {
margin-inline-start: auto;
color: var(--eq-ink-2);
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-xs);
}
.eq-menu-separator {
flex: none;
height: 1px;
margin: var(--eq-space-1) calc(-1 * var(--eq-space-1));
background: var(--eq-line);
}
/* Keeping the surface in the top layer while it fades out needs overlay, which only some
engines can transition. Elsewhere it closes at once rather than fade outside the top layer. */
@supports (overlay: auto) {
.eq-menu-list {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-menu-list:popover-open {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
/* Anchor positioning where the engine has it. menu.tsx uses the same condition. */
@supports (anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block) {
.eq-menu-list {
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
.eq-menu-list[data-placement="bottom-end"] {
position-area: block-end span-inline-start;
}
.eq-menu-list[data-placement="top-start"] {
position-area: block-start span-inline-end;
}
.eq-menu-list[data-placement="top-end"] {
position-area: block-start span-inline-start;
}
}
}