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
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | string | "Search..." | The text shown inside the field. Doubles as the accessible name. |
| shortcut | string | null | "mod+k" | Shortcut that fires onClick from anywhere. Shown as a kbd. null hides both. |
| onClick | MouseEventHandler | Opens your command menu. Also called by the shortcut. | |
| width | string | Width 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