Skip to content
goodtabs UI
BrowseBrowse components

Command button

A search-styled button that opens a command menu and listens for its shortcut.

npx shadcn@latest add https://ui.goodtabs.co/r/command-button.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/command-button

Looks like a search field, acts like a button. Press the shortcut anywhere to fire it.

In a sidebar

Fills the sidebar width above the navigation, the usual home for a command trigger.

Icon only on small screens

goodtabs

Below the sm breakpoint only the search icon stays. Resize to see it collapse.

Usage

import { CommandButton } from "@/components/ui/command-button"

<CommandButton placeholder="Search docs" shortcut="mod+k" onClick={() => setOpen(true)} />

Props

PropTypeDefaultDescription
placeholderstring"Search..."The text shown inside the field. Doubles as the accessible name.
shortcutstring | null"mod+k"Shortcut that fires onClick from anywhere. Shown as a kbd. null hides both.
onClickMouseEventHandlerOpens your command menu. Also called by the shortcut.
widthstringWidth of the field, any CSS length. Fills its container by default.
collapse"none" | "sm" | "md""none"Collapse to a square icon button below this breakpoint.

Source

command-button/command-button.tsx
"use client";

import * as React from "react";
import { SearchIcon } from "lucide-react";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/utils";

type CommandButtonProps = React.ComponentProps<"button"> & {
  /** The text shown inside the field. Doubles as the accessible name. */
  placeholder?: string;
  /** Shortcut that triggers onClick from anywhere, for example "mod+k". Pass null to hide the hint and the listener. */
  shortcut?: string | null;
  /** Width of the field, any CSS length. Fills its container by default. */
  width?: string;
  /** Collapse to a square icon button below this breakpoint. */
  collapse?: "none" | "sm" | "md";
};

const noop = () => () => {};
const isMac = () => /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);

/** Reads the platform once after mount. The server renders the mac form, so there is nothing to flash on a mac. */
function useIsMac() {
  return React.useSyncExternalStore(noop, isMac, () => true);
}

const macGlyphs: Record<string, string> = { mod: "⌘", meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧" };
const pcGlyphs: Record<string, string> = { mod: "Ctrl", meta: "Win", ctrl: "Ctrl", alt: "Alt", shift: "Shift" };

function parseShortcut(shortcut: string) {
  const parts = shortcut
    .toLowerCase()
    .split("+")
    .map((p) => p.trim());
  return { modifiers: parts.slice(0, -1), key: parts[parts.length - 1] ?? "" };
}

function formatShortcut(shortcut: string, mac: boolean) {
  const { modifiers, key } = parseShortcut(shortcut);
  const glyphs = mac ? macGlyphs : pcGlyphs;
  const label = key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
  const mods = modifiers.map((m) => glyphs[m] ?? m);
  return mac ? [...mods, label].join("") : [...mods, label].join("+");
}

function matchesShortcut(event: KeyboardEvent, shortcut: string, mac: boolean) {
  const { modifiers, key } = parseShortcut(shortcut);
  if (event.key.toLowerCase() !== key) return false;
  const want = {
    meta: modifiers.includes("meta") || (mac && modifiers.includes("mod")),
    ctrl: modifiers.includes("ctrl") || (!mac && modifiers.includes("mod")),
    alt: modifiers.includes("alt"),
    shift: modifiers.includes("shift"),
  };
  return (
    event.metaKey === want.meta &&
    event.ctrlKey === want.ctrl &&
    event.altKey === want.alt &&
    event.shiftKey === want.shift
  );
}

function isTyping(target: EventTarget | null) {
  if (!(target instanceof HTMLElement)) return false;
  return target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName);
}

const collapseClasses = {
  none: { button: "", text: "" },
  sm: { button: "max-sm:w-8 max-sm:justify-center max-sm:px-0", text: "max-sm:hidden" },
  md: { button: "max-md:w-8 max-md:justify-center max-md:px-0", text: "max-md:hidden" },
};

/**
 * A button dressed as a search field that opens a command menu.
 * Shows the shortcut as a kbd and fires onClick when it is pressed anywhere on the page.
 */
export function CommandButton({
  placeholder = "Search...",
  shortcut = "mod+k",
  width,
  collapse = "none",
  className,
  style,
  onClick,
  ...props
}: CommandButtonProps) {
  const ref = React.useRef<HTMLButtonElement>(null);
  const mac = useIsMac();

  React.useEffect(() => {
    if (!shortcut) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.defaultPrevented || event.repeat || isTyping(event.target)) return;
      if (!matchesShortcut(event, shortcut, mac)) return;
      event.preventDefault();
      ref.current?.click();
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [shortcut, mac]);

  const hint = shortcut ? formatShortcut(shortcut, mac) : null;
  const keyshortcuts = shortcut
    ? shortcut
        .split("+")
        .map((p) => {
          const part = p.trim().toLowerCase();
          if (part === "mod") return mac ? "Meta" : "Control";
          if (part === "ctrl") return "Control";
          return part.charAt(0).toUpperCase() + part.slice(1);
        })
        .join("+")
    : undefined;

  return (
    <button
      ref={ref}
      type="button"
      data-slot="command-button"
      aria-label={placeholder}
      aria-keyshortcuts={keyshortcuts}
      onClick={onClick}
      className={cn(
        "inline-flex h-8 w-full items-center gap-2 rounded-md border border-input bg-background px-2.5 text-sm text-muted-foreground shadow-xs transition-colors duration-100 ease-out outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
        width && "w-(--command-button-width)",
        collapseClasses[collapse].button,
        className,
      )}
      style={{ "--command-button-width": width, ...style } as React.CSSProperties}
      {...props}
    >
      <SearchIcon className="size-4 shrink-0" aria-hidden="true" />
      <span data-slot="command-button-text" className={cn("flex-1 truncate text-left", collapseClasses[collapse].text)}>
        {placeholder}
      </span>
      {hint && (
        <Kbd aria-hidden="true" className={cn("shrink-0", collapseClasses[collapse].text)}>
          {hint}
        </Kbd>
      )}
    </button>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/command-button/prompt.md, or the Copy AI prompt button above.

When to use

  • As the trigger for a command palette or search dialog in a header or sidebar
  • When the shortcut should work from anywhere on the page, not only while the field is focused
  • Where a real input would be wrong because the search happens in a dialog

When not to use

  • For inline filtering as the person types: use the search input instead
  • When there is no command menu to open; a plain search field is more honest
  • More than one on a page: the shortcut listeners would compete

Agent notes

  • It takes an onClick function, so the component that renders it must be a client component ("use client")
  • mod means Command on macOS and Control elsewhere; the kbd shows the glyph after mount and the server renders the mac form
  • The shortcut is ignored while focus is in an input, textarea, select or contenteditable
  • Wire the actual command menu yourself: the button only opens it, pair it with shadcn's Command or a Dialog