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

New project
Invite teammate
Switch theme
Open settings

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.

Actions
New file⌘N
Deploy to production⌘⇧D
Copy share link⌘L
Go to
Billing
Audit log
People
Ana Lima
Kwame Mensah

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.

Create pull request
Rebase on upstream
Delete branch (protected)
Protect branch

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

PropTypeDefaultWhat it does
idstringnoneRequired. Trigger buttons point at it with commandfor.
labelstringnoneAccessible name of the dialog, the search field and the list.
placeholderstring"Search commands"Placeholder of the search field.
emptyTextstring"No matching commands"Shown and announced when nothing matches.
hotkeystringnoneA letter that opens and closes this menu with Cmd or Ctrl, such as k.
...restComponentProps<"dialog">noneEvery native dialog attribute, including onClose and ref.
CommandGroup headingstringnoneVisible group name. The group is labelled by it.
CommandItem onSelect() => voidnoneRuns after the menu closes, on click or Enter.
CommandItem keywordsstringnoneExtra words that match this command, separated by spaces.
CommandItem shortcutstringnoneShown at the end of the row. Display only.
CommandItem disabledbooleanfalseShown dimmed, skipped by the arrow keys and never run.

Anatomy

root.eq-command-menuThe native dialog, near the top of the viewport.
search.eq-command-menu-search
icon.eq-command-menu-icon
input.eq-command-menu-inputrole="combobox" with aria-activedescendant.
list.eq-command-menu-listrole="listbox".
group.eq-command-menu-groupHidden when none of its commands match.
heading.eq-command-menu-heading
item.eq-command-menu-itemrole="option"; aria-selected marks the highlighted one.
label.eq-command-menu-label
shortcut.eq-command-menu-shortcut
empty.eq-command-menu-emptyA status region, empty until nothing matches.

Keyboard

Cmd+KCtrl+KOpens or closes the menu when hotkey is k.
DownUpMoves the highlight, wrapping at the ends and skipping disabled commands.
HomeEndMoves the highlight to the first or last command.
EnterRuns the highlighted command and closes the menu.
EscapeCloses 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index