Navigation
Command menu
A command palette: a modal search over grouped commands, filtered as you type and run with Enter.
- Built on
<dialog>Invoker commandsclosedby:has()- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.4 kB
- Dependencies
- React
Code
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { CommandItem, CommandMenu } from "@/components/equip/command-menu";
export default function Demo() {
const [mod, setMod] = useState("Ctrl ");
const [ran, setRan] = useState("");
useEffect(() => {
if (/Mac|iPhone|iPad/.test(navigator.platform)) setMod("⌘");
}, []);
return (
<div style={{ display: "grid", gap: 8, justifyItems: "center" }}>
<Button
variant="outline"
command="show-modal"
commandfor="demo-command-menu"
trail={
<kbd style={{ fontFamily: "var(--eq-font-mono)", fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-3)" }}>
{mod}K
</kbd>
}
>
Search commands
</Button>
<p style={{ margin: 0, minHeight: "1.5em", fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }} aria-live="polite">
{ran && `Ran ${ran}`}
</p>
<CommandMenu id="demo-command-menu" label="Commands">
<CommandItem onSelect={() => setRan("New project")} keywords="create add">
New project
</CommandItem>
<CommandItem onSelect={() => setRan("Invite teammate")} keywords="member people share">
Invite teammate
</CommandItem>
<CommandItem onSelect={() => setRan("Switch theme")} keywords="dark light appearance">
Switch theme
</CommandItem>
<CommandItem onSelect={() => setRan("Open settings")} keywords="preferences">
Open settings
</CommandItem>
</CommandMenu>
</div>
);
}Examples
Groups and shortcuts
Groups with no matching commands disappear while you type. Shortcuts are labels only; bind them yourself.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { CommandGroup, CommandItem, CommandMenu } from "@/components/equip/command-menu";
export default function Example() {
const [ran, setRan] = useState("");
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<Button variant="outline" command="show-modal" commandfor="example-command-groups">
Open command menu
</Button>
{ran && <span style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Ran {ran}</span>}
<CommandMenu id="example-command-groups" label="Commands" placeholder="Search files, actions and people">
<CommandGroup heading="Actions">
<CommandItem onSelect={() => setRan("New file")} shortcut="⌘N" keywords="create">
New file
</CommandItem>
<CommandItem onSelect={() => setRan("Deploy to production")} shortcut="⌘⇧D" keywords="release ship">
Deploy to production
</CommandItem>
<CommandItem onSelect={() => setRan("Copy share link")} shortcut="⌘L" keywords="url">
Copy share link
</CommandItem>
</CommandGroup>
<CommandGroup heading="Go to">
<CommandItem onSelect={() => setRan("Billing")} keywords="invoices payment plan">
Billing
</CommandItem>
<CommandItem onSelect={() => setRan("Audit log")} keywords="history events security">
Audit log
</CommandItem>
</CommandGroup>
<CommandGroup heading="People">
<CommandItem onSelect={() => setRan("Ana Lima")} keywords="design">
Ana Lima
</CommandItem>
<CommandItem onSelect={() => setRan("Kwame Mensah")} keywords="engineering">
Kwame Mensah
</CommandItem>
</CommandGroup>
</CommandMenu>
</div>
);
}Disabled command and empty text
Arrow keys skip the disabled command, but it still shows up in results so people learn it exists.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { CommandItem, CommandMenu } from "@/components/equip/command-menu";
export default function Example() {
const [ran, setRan] = useState("");
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
<Button variant="outline" command="show-modal" commandfor="example-command-disabled">
Branch actions
</Button>
{ran && <span style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Ran {ran}</span>}
<CommandMenu
id="example-command-disabled"
label="Branch actions"
placeholder="Search actions for main"
emptyText="No action matches. Try merge, rebase or protect."
>
<CommandItem onSelect={() => setRan("Create pull request")} keywords="pr review">
Create pull request
</CommandItem>
<CommandItem onSelect={() => setRan("Rebase on upstream")} keywords="sync update">
Rebase on upstream
</CommandItem>
<CommandItem onSelect={() => setRan("Delete branch")} disabled keywords="remove">
Delete branch (protected)
</CommandItem>
<CommandItem onSelect={() => setRan("Protect branch")} keywords="rules lock">
Protect branch
</CommandItem>
</CommandMenu>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Trigger buttons point at it with commandfor. |
label | string | none | Accessible name of the dialog, the search field and the list. |
placeholder | string | "Search commands" | Placeholder of the search field. |
emptyText | string | "No matching commands" | Shown and announced when nothing matches. |
hotkey | string | none | A letter that opens and closes this menu with Cmd or Ctrl, such as k. |
...rest | ComponentProps<"dialog"> | none | Every native dialog attribute, including onClose and ref. |
CommandGroup heading | string | none | Visible group name. The group is labelled by it. |
CommandItem onSelect | () => void | none | Runs after the menu closes, on click or Enter. |
CommandItem keywords | string | none | Extra words that match this command, separated by spaces. |
CommandItem shortcut | string | none | Shown at the end of the row. Display only. |
CommandItem disabled | boolean | false | Shown dimmed, skipped by the arrow keys and never run. |
Anatomy
| root | .eq-command-menu | The native dialog, near the top of the viewport. |
|---|---|---|
| search | .eq-command-menu-search | |
| icon | .eq-command-menu-icon | |
| input | .eq-command-menu-input | role="combobox" with aria-activedescendant. |
| list | .eq-command-menu-list | role="listbox". |
| group | .eq-command-menu-group | Hidden when none of its commands match. |
| heading | .eq-command-menu-heading | |
| item | .eq-command-menu-item | role="option"; aria-selected marks the highlighted one. |
| label | .eq-command-menu-label | |
| shortcut | .eq-command-menu-shortcut | |
| empty | .eq-command-menu-empty | A status region, empty until nothing matches. |
Keyboard
| Cmd+KCtrl+K | Opens or closes the menu when hotkey is k. |
|---|---|
| DownUp | Moves the highlight, wrapping at the ends and skipping disabled commands. |
| HomeEnd | Moves the highlight to the first or last command. |
| Enter | Runs the highlighted command and closes the menu. |
| Escape | Closes the menu and returns focus to the trigger. |
Notes
- Focus stays in the search field; the highlighted command is conveyed with aria-activedescendant.
- Typing matches command text and keywords, ignoring case; every word typed has to match.
- The query clears whenever the menu closes, so it always opens on the full list.
- A command runs after the menu has closed, so focus is back on the trigger when it opens anything else.
- The hotkey listener is on window and only toggles this menu; it ignores a keypress another handler already took, so when two menus share a hotkey the first mounted wins.
- Shortcuts are labels only; bind them yourself.
- Invoker commands need Chrome 135, Firefox 144 or Safari 26.2; on older engines one document click listener opens the menu for buttons whose commandfor matches its id. Where closedby is missing, a click handler closes it when a click lands outside its box.
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/command-menu.tsx309 lines
"use client";
import {
createContext,
isValidElement,
useContext,
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type ReactNode,
type SyntheticEvent,
} from "react";
import "./command-menu.css";
// React 19 does not type invoker commands yet. This lets any button take them.
declare module "react" {
interface ButtonHTMLAttributes<T> {
command?: string;
commandfor?: string;
}
}
type MenuContext = {
terms: string[];
activeId: string | null;
setActiveId: (id: string) => void;
close: () => void;
};
const Menu = createContext<MenuContext | null>(null);
function textOf(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(textOf).join(" ");
if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);
return "";
}
export type CommandMenuProps = ComponentProps<"dialog"> & {
/** Required. Triggers point at it with commandfor. */
id: string;
/** Accessible name of the dialog, the search field and the list. */
label: string;
placeholder?: string;
/** Shown when no command matches the query. */
emptyText?: string;
/** A letter that opens and closes this menu with Cmd or Ctrl, e.g. "k". */
hotkey?: string;
};
export function CommandMenu({
id,
label,
placeholder = "Search commands",
emptyText = "No matching commands",
hotkey,
className,
children,
onClose,
...rest
}: CommandMenuProps) {
const listId = useId();
const input = useRef<HTMLInputElement>(null);
const list = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState<string | null>(null);
const [empty, setEmpty] = useState(false);
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
const dialog = () => input.current?.closest("dialog") ?? null;
function options() {
return Array.from(
list.current?.querySelectorAll<HTMLElement>('[role="option"]:not([hidden]):not([aria-disabled="true"])') ?? [],
);
}
// After every filter pass: highlight the first match and note whether anything is left.
useEffect(() => {
const visible = list.current?.querySelectorAll('[role="option"]:not([hidden])').length ?? 0;
setEmpty(visible === 0);
setActiveId(options()[0]?.id ?? null);
}, [query]);
useEffect(() => {
if (activeId) document.getElementById(activeId)?.scrollIntoView({ block: "nearest" });
}, [activeId]);
// Invoker commands fallback for engines without them.
useEffect(() => {
if ("command" in HTMLButtonElement.prototype) return;
function invoke(event: MouseEvent) {
if (!(event.target instanceof Element)) return;
const invoker = event.target.closest("button[commandfor]");
if (!invoker || invoker.getAttribute("commandfor") !== id) return;
const target = dialog();
const command = invoker.getAttribute("command");
if (command === "show-modal" && target && !target.open) target.showModal();
if ((command === "close" || command === "request-close") && target?.open) target.close();
}
document.addEventListener("click", invoke);
return () => document.removeEventListener("click", invoke);
}, [id]);
useEffect(() => {
if (!hotkey) return;
const key = hotkey.toLowerCase();
function toggle(event: globalThis.KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.shiftKey) return;
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== key) return;
const target = dialog();
if (!target) return;
event.preventDefault();
if (target.open) target.close();
else {
target.showModal();
input.current?.focus();
}
}
window.addEventListener("keydown", toggle);
return () => window.removeEventListener("keydown", toggle);
}, [hotkey]);
function close() {
dialog()?.close();
}
function handleClose(event: SyntheticEvent<HTMLDialogElement>) {
setQuery("");
setActiveId(options()[0]?.id ?? null);
onClose?.(event);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.nativeEvent.isComposing) return;
const all = options();
const index = all.findIndex((option) => option.id === activeId);
let next: HTMLElement | undefined;
switch (event.key) {
case "ArrowDown":
next = all[index + 1] ?? all[0];
break;
case "ArrowUp":
next = all[index - 1] ?? all[all.length - 1];
break;
case "Home":
next = all[0];
break;
case "End":
next = all[all.length - 1];
break;
case "Enter":
event.preventDefault();
event.stopPropagation();
if (activeId) document.getElementById(activeId)?.click();
return;
default:
return;
}
event.preventDefault();
event.stopPropagation();
if (next) setActiveId(next.id);
}
return (
<dialog
closedby="any"
aria-label={label}
{...rest}
id={id}
className={["eq-command-menu", className].filter(Boolean).join(" ")}
onClose={handleClose}
onClick={(event) => {
rest.onClick?.(event);
if (event.defaultPrevented || event.target !== event.currentTarget) return;
if ("closedBy" in HTMLDialogElement.prototype) return;
const box = event.currentTarget.getBoundingClientRect();
const inside =
event.clientX >= box.left &&
event.clientX <= box.right &&
event.clientY >= box.top &&
event.clientY <= box.bottom;
if (!inside) event.currentTarget.close();
}}
>
<div className="eq-command-menu-search">
<svg
className="eq-command-menu-icon"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="7" cy="7" r="4.5" />
<path d="m10.5 10.5 3 3" />
</svg>
<input
ref={input}
className="eq-command-menu-input"
type="text"
role="combobox"
aria-label={label}
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeId ?? undefined}
autoComplete="off"
spellCheck={false}
placeholder={placeholder}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<Menu.Provider value={{ terms, activeId, setActiveId, close }}>
<div
ref={list}
id={listId}
role="listbox"
aria-label={label}
className="eq-command-menu-list"
onMouseDown={(event) => event.preventDefault()}
>
{children}
</div>
</Menu.Provider>
<p className="eq-command-menu-empty" role="status">
{empty ? emptyText : ""}
</p>
</dialog>
);
}
export type CommandGroupProps = ComponentProps<"div"> & {
/** Visible group name. The group is labelled by it. */
heading: string;
};
/** Hides itself, through CSS, when none of its items match. */
export function CommandGroup({ heading, className, children, ...rest }: CommandGroupProps) {
const headingId = useId();
return (
<div
role="group"
aria-labelledby={headingId}
{...rest}
className={["eq-command-menu-group", className].filter(Boolean).join(" ")}
>
<div id={headingId} className="eq-command-menu-heading">
{heading}
</div>
{children}
</div>
);
}
export type CommandItemProps = Omit<ComponentProps<"div">, "onSelect"> & {
/** Runs after the menu closes, on click or Enter. */
onSelect: () => void;
/** Extra words that match this item, separated by spaces. */
keywords?: string;
/** Displayed only. Bind the shortcut yourself. */
shortcut?: string;
disabled?: boolean;
};
export function CommandItem({
onSelect,
keywords,
shortcut,
disabled = false,
className,
children,
...rest
}: CommandItemProps) {
const id = useId();
const menu = useContext(Menu);
const haystack = `${textOf(children)} ${keywords ?? ""}`.toLowerCase();
const match = menu ? menu.terms.every((term) => haystack.includes(term)) : true;
const active = menu?.activeId === id;
return (
<div
role="option"
{...rest}
id={id}
aria-selected={active}
aria-disabled={disabled || undefined}
hidden={!match}
className={["eq-command-menu-item", className].filter(Boolean).join(" ")}
onPointerMove={() => {
if (!disabled && !active) menu?.setActiveId(id);
}}
onClick={() => {
if (disabled) return;
menu?.close();
onSelect();
}}
>
<span className="eq-command-menu-label">{children}</span>
{shortcut && <kbd className="eq-command-menu-shortcut">{shortcut}</kbd>}
</div>
);
}components/equip/command-menu.css220 lines
@layer equip {
.eq-command-menu {
box-sizing: border-box;
width: min(520px, calc(100vw - 2 * var(--eq-space-4)));
max-width: none;
max-height: min(440px, calc(88dvh - var(--eq-space-5)));
margin: 12dvh auto auto;
padding: var(--eq-space-4);
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);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
opacity: 0;
translate: 0 -4px;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
/* Only when open: a display on the closed dialog would override the UA's display: none. */
.eq-command-menu[open] {
display: flex;
flex-direction: column;
gap: var(--eq-space-3);
opacity: 1;
translate: none;
}
@starting-style {
.eq-command-menu[open] {
opacity: 0;
translate: 0 -4px;
}
}
.eq-command-menu:focus-visible {
outline: none;
}
.eq-command-menu::backdrop {
background-color: transparent;
transition: background-color var(--eq-dur) var(--eq-ease);
}
@supports (overlay: auto) {
.eq-command-menu {
transition:
opacity 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-command-menu::backdrop {
transition:
background-color var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
.eq-command-menu[open]::backdrop {
background-color: var(--eq-backdrop);
}
@starting-style {
.eq-command-menu[open]::backdrop {
background-color: transparent;
}
}
.eq-command-menu-search {
position: relative;
flex: none;
}
.eq-command-menu-icon {
position: absolute;
inset-block: 0;
inset-inline-start: var(--eq-space-3);
width: 16px;
height: 16px;
margin-block: auto;
color: var(--eq-ink-3);
pointer-events: none;
}
.eq-command-menu-input {
box-sizing: border-box;
width: 100%;
height: var(--eq-h-m);
padding-inline: calc(var(--eq-space-3) + 16px + var(--eq-space-2)) var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
}
.eq-command-menu-input::placeholder {
color: var(--eq-ink-3);
}
.eq-command-menu-input:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-command-menu-list {
display: grid;
gap: var(--eq-space-2);
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-block: var(--eq-space-1);
}
.eq-command-menu-list:not(:has(.eq-command-menu-item:not([hidden]))) {
display: none;
}
.eq-command-menu-group {
display: grid;
}
.eq-command-menu-group:not(:has(.eq-command-menu-item:not([hidden]))) {
display: none;
}
.eq-command-menu-heading {
padding: var(--eq-space-1) var(--eq-space-2);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
.eq-command-menu-item {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
cursor: pointer;
user-select: none;
}
.eq-command-menu-item[hidden] {
display: none;
}
.eq-command-menu-item[aria-selected="true"] {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-command-menu-item[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-command-menu-label {
display: flex;
flex: 1;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-command-menu-label > svg {
flex: none;
width: 16px;
height: 16px;
}
/* Plain text, not a keycap: this resets any page-wide kbd style (a raised face would stay
light on the solid active row while the text turns to solid ink). */
.eq-command-menu-shortcut {
flex: none;
min-width: 0;
height: auto;
padding: 0;
border: 0;
background: none;
box-shadow: none;
color: var(--eq-ink-3);
font-weight: var(--eq-weight);
line-height: inherit;
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-size: var(--eq-text-xs);
}
.eq-command-menu-item[aria-selected="true"] .eq-command-menu-shortcut {
color: color-mix(in srgb, var(--eq-solid-ink) 72%, transparent);
}
.eq-command-menu-empty {
margin: 0;
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
text-align: center;
}
/* Kept in the DOM as a live region; this cancels the dialog gap while it is silent. */
.eq-command-menu-empty:empty {
margin-top: calc(-1 * var(--eq-space-3));
}
.eq-command-menu-empty:not(:empty) {
padding-block: var(--eq-space-4);
}
}