Skip to content
txbrendan/ui

Command Menu

beta

Search everything from anywhere: ⌘K opens it over the page, results come grouped, and the arrows and Enter pick one.

Preview

Effect

over the page

Nothing chosen yet

inline: in a card or a docs page

Sam CarterDesign lead, Mobile app
Design18 people
Demo daySat 3 Oct

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/command-menu

Use it for

  • One per app, to jump to people, teams, events, pages, and actions from anywhere.
  • Keep open in state. Set it in onOpen, which ⌘K (Ctrl+K) calls, and clear it in onClose.
  • Put a Search button on screen that opens it too. Teach ⌘K in that button's tooltip, never in the layout.
  • Give each item a group, like "People" or "Actions". Groups show in the order they first appear, five results each.
  • Mark a few items recent. They show before anything is typed.
  • Use inline to show it open in a card or a docs page.

Avoid it for

  • Picking a value for a form. Use Combobox, which keeps the choice in the field.
  • A short list of actions on one thing. Use Menu.
  • Two command menus on one page with shortcut on. Only one should listen for ⌘K.

Props

  • items

    The results: { id, person?, avatar?, label?, icon?, group?, meta?, keywords?, recent?, onSelect? }. A person has a name and an avatar element. Anything else has a label and an icon element.

    CommandItem[]

  • open

    Whether it is open over the page. Not used with inline.

    boolean · default false

  • onOpen

    ⌘K (Ctrl+K) was pressed while it was closed.

    () => void

  • onClose

    Esc, a press outside, ⌘K, or a choice closed it.

    () => void

  • onSelect

    Runs after a result is chosen. It closes first, then the item's own onSelect runs, then this.

    (item: CommandItem) => void

  • placeholder

    The field's placeholder.

    string · default "Search people, teams, and pages"

  • label

    Names the dialog and the field for screen readers.

    string · default "Search"

  • shortcut

    Listen for ⌘K on the page. A rich text editor keeps ⌘K for its links.

    boolean · default true

  • inline

    Open in the page with no scrim. Choosing a result or Esc clears the words.

    boolean · default false

  • effect

    How the menu and its backdrop open and close. rise fades in and drops into place. grow fades in and grows in place, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. Not used with inline.

    "rise" | "grow" · default "rise"

  • className

    Extra classes on the menu. Later classes win.

    string

Accessibility

  • It is a modal dialog named by label. Focus goes to the field, Tab stays inside even when pressed fast, and the page behind is inert.
  • When it closes, focus goes back to what opened it.
  • The field is a combobox. The arrows move the highlight and Enter chooses. Focus stays in the field.
  • Esc clears typed words first. The next Esc closes it.
  • Results are grouped under their group's name, which screen readers hear.
  • Avatars and icons are hidden from screen readers, since the name says who or what it is.

Examples

Open it with ⌘K or a button

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

const ITEMS = [
  { id: "sam", person: "Sam Carter", group: "People", meta: "Design lead, Mobile app", recent: true },
  { id: "design", label: "Design", group: "Teams" },
  { id: "fees", label: "Fees", group: "Pages" },
];

export function Search() {
  const [open, setOpen] = useState(false);
  const router = useRouter();
  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>Search</button>
      <CommandMenu
        items={ITEMS}
        open={open}
        onOpen={() => setOpen(true)}
        onClose={() => setOpen(false)}
        onSelect={(item) => router.push(`/${item.id}`)}
      />
    </>
  );
}

Open in the page

<CommandMenu
  inline
  items={[
    { id: "new", label: "New event", group: "Actions", recent: true },
    { id: "fees", label: "Fees", group: "Pages" },
  ]}
/>

Grows in place

"use client";

import { useState } from "react";

const ITEMS = [
  { id: "design", label: "Design", group: "Teams" },
  { id: "fees", label: "Fees", group: "Pages" },
];

export function Search() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>Search</button>
      <CommandMenu items={ITEMS} open={open} onOpen={() => setOpen(true)} onClose={() => setOpen(false)} effect="grow" />
    </>
  );
}