Command Palette

A palette that arrives as a modal over the page discards the one piece of context people had — where the search box was, and what it was part of.

NavigationProductivitySearchDashboardadvanced
  • Keyboard
  • Touch
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/command-palette.json

The problem

A command palette that arrives as a modal over the page throws away the one piece of context people had: where the search box was, and what it belonged to. The overlay appears from nowhere, dims everything, and has to be dismissed before the page can be seen again.

Growing the field itself into the panel keeps the origin. The list reads as the field having more to say rather than as a new surface arriving on top of the old one — and closing puts you back exactly where you started.

Use cases

  • App-wide search and actions behind ⌘K
  • A dashboard's "what can I do here" affordance, discoverable without a shortcut
  • Any searchable action list of roughly ten to fifty items

Not for: navigation with a handful of destinations — a menu is cheaper. Not for search that returns rich results with previews and pagination; this list is one line per item. Not as a modal replacement: it deliberately does not trap focus or dim the page, which is what makes it feel inline.

Installation

bash
npm install motion lucide-react

Then copy CommandPalette.tsx. It depends only on @/lib/cn.

Usage

tsx
import CommandPalette from "@/patterns/command-palette/CommandPalette";
import { Calendar, UserPlus } from "lucide-react";

<CommandPalette
  commands={[
    {
      id: "schedule",
      label: "Create new schedule",
      icon: <Calendar />,
      shortcut: ["⌘", "S"],
      onSelect: () => createSchedule(),
    },
    {
      id: "customer",
      label: "Add new customer",
      icon: <UserPlus />,
      onSelect: addCustomer,
    },
  ]}
/>;

API

PropTypeDefaultDescription
commandsreadonly Command[]—The full list, filtered as you type.
collapsedLabelstring"Search for anything"Resting placeholder and the trigger's name.
placeholderstring"Search commands…"Placeholder once open.
groupLabelstring"Suggestions"Heading above the results.
hotkeybooleantrueBind ⌘K / Ctrl+K on the document.
classNamestring—Merged onto the root.

Command: { id: string; label: string; icon: ReactNode; shortcut?: readonly string[]; onSelect?: () => void }.

Turn hotkey off when more than one palette can exist on a page — otherwise every instance answers the same chord at once.

Keyboard

KeyAction
⌘K / Ctrl+KOpen from anywhere
↑ / ↓Move the highlight, wrapping
Home / EndFirst or last result
EnterRun the highlighted command
EscapeClose, clear the query, focus back on the trigger

The field keeps focus the whole time and the highlight moves through aria-activedescendant. Moving real DOM focus into the list would take it off the input, and in a palette you have to be able to keep typing.

Accessibility

None of the commands did anything. Every row was a button with no click handler and no onSelect — the list filtered, highlighted and responded to hover, and selecting an item was impossible for anyone. They run now, by click or by Enter.

There was no way to move through the list. A command palette whose arrow keys do nothing is the pattern's central interaction missing: you could type to filter but not reach what you filtered to. Arrows, Home, End and Enter all work, against an APG combobox contract — role="combobox" on the field with aria-expanded, aria-controls and aria-activedescendant, and a role="listbox" of role="option" rows carrying aria-selected.

The close button was empty. It rendered as <button></button> — no glyph, no label, no content at all — so it was an invisible control sitting in the tab order that announced as "button". It has an icon and a name now.

The trigger declares its shortcut with aria-keyshortcuts, so the ⌘K binding is discoverable rather than folklore. Focus follows the surface: into the field on open, back to the trigger on close.

The highlight is clamped rather than reset in an effect — filtering can shorten the list underneath a highlight that has already moved past the new end.

Under prefers-reduced-motion the shared-layout growth still runs (it is position and size, not travel) while the row fades and the dismiss button's entrance delay are dropped.

Known limitation: this does not trap focus or dim the page, so it is not a modal. That is deliberate — it is what makes the palette read as the field expanding rather than as an overlay — but it means Tab can walk out of the open panel into the page behind it. If you need containment, wrap it in a real dialog and lose the inline feel.

Performance

The two states share a layoutId, so collapsed-to-expanded is one shared-layout projection rather than a crossfade between two boxes. The ids are scoped with useId, so several palettes on a page do not animate into each other.

Filtering is useMemo over the source list — derived state, not state synchronised in an effect, which would cost a render per keystroke and show a stale list for one frame.

The bottom fade is sticky, not absolute. Positioned absolutely inside the scroll container it scrolls away with the content it is supposed to be fading.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

CommandPalette.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Search, X } from "lucide-react";

import { cn } from "@/lib/cn";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface Command {
  id: string;
  label: string;
  icon: React.ReactNode;
  /** Rendered as `<kbd>` chips, e.g. `["⌘", "S"]`. */
  shortcut?: readonly string[];
  onSelect?: () => void;
}

export interface CommandPaletteProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "onSelect"
> {
  commands: readonly Command[];
  /** Resting placeholder. @default "Search for anything" */
  collapsedLabel?: string;
  /** Placeholder once open. @default "Search commands…" */
  placeholder?: string;
  /** Heading above the results. @default "Suggestions" */
  groupLabel?: string;
  /** Bind ⌘K / Ctrl+K on the document. @default true */
  hotkey?: boolean;
}

/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original                                    */
/* -------------------------------------------------------------------------- */

const RADIUS = 30;
/** The dismiss button waits for the container to finish widening. */
const CLOSE_DELAY = 0.19;
const ITEM = { duration: 0.15 } as const;

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A search field that becomes a command list.
 *
 * A command palette that arrives as a modal over the page throws away the one
 * piece of context people had — where the search box was. Growing the field
 * itself into the panel keeps the origin, so the list reads as the field
 * having more to say rather than as a new surface.
 */
export function CommandPalette({
  commands,
  collapsedLabel = "Search for anything",
  placeholder = "Search commands…",
  groupLabel = "Suggestions",
  hotkey = true,
  className,
  ...rest
}: CommandPaletteProps) {
  const reduce = useReducedMotion();
  const uid = useId();
  const listId = `${uid}-list`;

  const rootRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const interacted = useRef(false);

  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);

  // Derived, not synchronised. Mirroring this into state via an effect costs a
  // render per keystroke and shows a stale list for one frame.
  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return commands;
    return commands.filter((c) => c.label.toLowerCase().includes(q));
  }, [commands, query]);

  // Clamped rather than reset in an effect: filtering can shorten the list
  // under a highlight that has already moved past the new end.
  const active = Math.min(activeIndex, Math.max(0, results.length - 1));

  /*
   * `AnimatePresence mode="wait"` holds the incoming element back until the
   * outgoing one has finished exiting, so at the moment `open` flips the
   * element we want to focus does not exist yet. Focusing from an effect keyed
   * on `open` therefore focuses `null` and drops focus to the body. These
   * flags let the ref callbacks below claim focus as each element mounts.
   */
  const focusTriggerOnMount = useRef(false);
  const focusFieldOnMount = useRef(false);

  const close = useCallback(() => {
    interacted.current = true;
    focusTriggerOnMount.current = true;
    setOpen(false);
    setQuery("");
    setActiveIndex(0);
  }, []);

  const openPalette = useCallback(() => {
    interacted.current = true;
    focusFieldOnMount.current = true;
    setOpen(true);
  }, []);

  const run = useCallback(
    (command: Command) => {
      command.onSelect?.();
      close();
    },
    [close],
  );

  useEffect(() => {
    if (!hotkey) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
        event.preventDefault();
        openPalette();
      }
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [hotkey, openPalette]);

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) close();
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open, close]);

  /**
   * APG combobox: the field keeps focus throughout and the highlight moves via
   * `aria-activedescendant`. Moving real DOM focus into the list instead would
   * take it off the input, and you have to keep typing.
   */
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLInputElement>) => {
      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          setActiveIndex((i) =>
            results.length ? (i + 1) % results.length : 0,
          );
          return;
        case "ArrowUp":
          event.preventDefault();
          setActiveIndex((i) =>
            results.length ? (i - 1 + results.length) % results.length : 0,
          );
          return;
        case "Home":
          event.preventDefault();
          setActiveIndex(0);
          return;
        case "End":
          event.preventDefault();
          setActiveIndex(Math.max(0, results.length - 1));
          return;
        case "Enter": {
          event.preventDefault();
          const command = results[active];
          if (command) run(command);
          return;
        }
        case "Escape":
          event.preventDefault();
          close();
          return;
        default:
          return;
      }
    },
    [active, close, results, run],
  );

  return (
    <div
      ref={rootRef}
      className={cn("flex w-full min-w-0 justify-center", className)}
      {...rest}
    >
      <AnimatePresence mode="wait">
        {!open ? (
          <motion.div
            key="collapsed"
            layoutId={`${uid}-shell`}
            style={{ borderRadius: RADIUS }}
            className="w-full max-w-[350px] bg-surface"
          >
            <button
              ref={(node) => {
                triggerRef.current = node;
                // Focus as it mounts, which is the first moment it exists.
                if (node && focusTriggerOnMount.current) {
                  focusTriggerOnMount.current = false;
                  node.focus();
                }
              }}
              type="button"
              onClick={openPalette}
              aria-label={collapsedLabel}
              aria-keyshortcuts="Meta+K Control+K"
              className={cn(
                "focus-ring flex w-full cursor-text items-center gap-2 px-4 py-3",
                "rounded-[30px] border border-border bg-surface shadow-resting",
                "transition-shadow duration-200 hover:shadow-floating",
              )}
            >
              <motion.span
                layoutId={`${uid}-field`}
                className="inline-flex items-center gap-2"
              >
                <Search
                  className="size-4 shrink-0 text-content-subtle"
                  aria-hidden="true"
                />
                <span className="truncate text-sm text-content-subtle">
                  {collapsedLabel}
                </span>
              </motion.span>
            </button>
          </motion.div>
        ) : (
          <motion.div
            key="expanded"
            layoutId={`${uid}-shell`}
            style={{ borderRadius: RADIUS }}
            className="w-full max-w-[640px] overflow-hidden bg-surface shadow-overlay"
          >
            <motion.div
              layout
              className="mx-3 flex items-center gap-3 border-b border-border px-4 py-3"
            >
              <motion.span layoutId={`${uid}-field`} className="inline-flex">
                <Search
                  className="size-4 shrink-0 text-content-subtle"
                  aria-hidden="true"
                />
              </motion.span>

              <input
                ref={(node) => {
                  inputRef.current = node;
                  if (node && focusFieldOnMount.current) {
                    focusFieldOnMount.current = false;
                    node.focus();
                  }
                }}
                type="text"
                role="combobox"
                aria-expanded
                aria-controls={listId}
                aria-label={placeholder}
                aria-autocomplete="list"
                aria-activedescendant={
                  results[active]
                    ? `${uid}-opt-${results[active].id}`
                    : undefined
                }
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value);
                  setActiveIndex(0);
                }}
                onKeyDown={handleKeyDown}
                placeholder={placeholder}
                className="min-w-0 flex-1 bg-transparent text-sm text-content placeholder-content-subtle outline-none"
              />

              {/*
                The original rendered this button with no children and no
                label — an invisible, unnameable control sitting in the tab
                order. It has a glyph and a name now.
              */}
              <motion.button
                type="button"
                onClick={close}
                aria-label="Close command palette"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                transition={
                  reduce ? { duration: 0.01 } : { delay: CLOSE_DELAY }
                }
                className="focus-ring shrink-0 rounded p-1 text-content-subtle transition-colors hover:bg-surface-subtle hover:text-content"
              >
                <X className="size-4" aria-hidden="true" />
              </motion.button>
            </motion.div>

            <div className="relative max-h-96 overflow-y-auto">
              <div className="px-3 py-2">
                <motion.div
                  layout="position"
                  id={`${uid}-group`}
                  className="px-3 py-1.5 text-xs font-medium tracking-wider text-content-subtle uppercase"
                >
                  {groupLabel}
                </motion.div>

                <div
                  role="listbox"
                  id={listId}
                  aria-labelledby={`${uid}-group`}
                >
                  <AnimatePresence mode="popLayout">
                    {results.length === 0 ? (
                      <motion.p
                        key="empty"
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        className="px-3 py-8 text-center text-sm text-content-subtle"
                      >
                        No commands found
                      </motion.p>
                    ) : (
                      <motion.div
                        layout="preserve-aspect"
                        className="min-h-10 space-y-0.5"
                      >
                        {results.map((command, index) => (
                          <motion.button
                            key={command.id}
                            id={`${uid}-opt-${command.id}`}
                            type="button"
                            role="option"
                            aria-selected={index === active}
                            tabIndex={-1}
                            onClick={() => run(command)}
                            onPointerMove={() => setActiveIndex(index)}
                            initial={{ opacity: 0 }}
                            animate={{ opacity: 1 }}
                            exit={{ opacity: 0 }}
                            transition={reduce ? { duration: 0.01 } : ITEM}
                            className={cn(
                              "flex w-full cursor-pointer items-center justify-between gap-3",
                              "rounded-md px-3 py-2 transition-colors duration-200",
                              index === active && "bg-surface-subtle",
                            )}
                          >
                            <span className="flex flex-1 items-center gap-3">
                              <span
                                aria-hidden="true"
                                className="shrink-0 text-content-subtle [&>svg]:size-4"
                              >
                                {command.icon}
                              </span>
                              <span className="text-sm font-medium text-content">
                                {command.label}
                              </span>
                            </span>

                            {command.shortcut?.length ? (
                              <span className="flex shrink-0 items-center gap-1">
                                {command.shortcut.map((key) => (
                                  <kbd
                                    key={key}
                                    className={cn(
                                      "inline-flex size-4 items-center justify-center rounded",
                                      "border border-border bg-surface-subtle text-xs",
                                      "font-medium text-content-muted",
                                    )}
                                  >
                                    {key}
                                  </kbd>
                                ))}
                              </span>
                            ) : null}
                          </motion.button>
                        ))}
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              </div>

              {/*
                Sticky, not absolute. Absolutely positioned against the
                scrolling box it would scroll away with the content it is
                meant to be fading.
              */}
              <div
                aria-hidden="true"
                className="pointer-events-none sticky bottom-0 h-8 bg-gradient-to-t from-surface to-transparent"
              />
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

export default CommandPalette;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react), layoutId
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original design by ydwndr. Rebuilt with a props API, working commands, arrow-key navigation, a combobox contract, a dismiss button that exists, and reduced-motion handling. The shared-layout growth, the 30px radius, the 190ms delay before the dismiss button appears and the 150ms row fade are unchanged.