A filterable command list on cmdk, inline or in a Cmd/Ctrl+K dialog, with groups, shortcuts, descriptions, loading state and page-safe scrolling.
import { Calendar, Mail, Settings, Smile, User } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ballmac/command";
export default function CommandDemo() {
return (
<Command className="w-full max-w-md rounded-xl border shadow-md" label="Quick actions">
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>
<Calendar aria-hidden="true" /> Schedule meeting
</CommandItem>
<CommandItem>
<Smile aria-hidden="true" /> Search emoji
</CommandItem>
<CommandItem disabled>
<Mail aria-hidden="true" /> Compose email (offline)
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem>
<User aria-hidden="true" /> Profile <CommandShortcut>⌘P</CommandShortcut>
</CommandItem>
<CommandItem>
<Settings aria-hidden="true" /> Preferences <CommandShortcut>⌘,</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/commandInstall the dependencies.
$ pnpm add cmdk@^1 radix-ui lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/command.tsx// Ballmac UI: Command. https://ui.ballmac.com/components/command // Based on shadcn/ui Command (MIT, Copyright (c) 2023 shadcn) on cmdk (MIT, Copyright (c) 2022 Dip), adding a page-safe scroll fix, an item description, a loading row, and a hotkey dialog. "use client"; import * as React from "react"; import { Command as Primitive } from "cmdk"; import { Check, Search } from "lucide-react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; /** * cmdk scrolls the highlighted row into view with scrollIntoView, which also scrolls the page when * the command menu is off-screen (an inline demo below the fold). Route it to the list only. */ function scrollWithinList(this: HTMLElement) { const list = this.closest<HTMLElement>("[cmdk-list]"); if (!list) return; const bounds = list.getBoundingClientRect(); const rect = this.getBoundingClientRect(); if (rect.top < bounds.top) list.scrollTop -= bounds.top - rect.top + 8; else if (rect.bottom > bounds.bottom) list.scrollTop += rect.bottom - bounds.bottom + 8; } function keepScrollInList(el: HTMLElement | null) { if (el) el.scrollIntoView = scrollWithinList; } function setRef<T>(ref: React.Ref<T> | undefined, value: T | null) { if (typeof ref === "function") ref(value); else if (ref) (ref as React.RefObject<T | null>).current = value; } type CommandProps = React.ComponentProps<typeof Primitive>; /** Root. Accepts cmdk props such as `filter`, `shouldFilter`, `value`, `onValueChange` and `loop`. */ function Command({ className, loop = true, label, ...props }: CommandProps) { const msg = useMessages() label ??= msg("command.label", "Command menu") return ( <Primitive data-slot="command" loop={loop} label={label} className={cn( "flex h-full w-full flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground", className, )} {...props} /> ); } type CommandDialogProps = Omit<CommandProps, "title"> & { /** Controlled open state. */ open?: boolean; /** Initial open state when uncontrolled. */ defaultOpen?: boolean; /** Called when the dialog opens or closes. */ onOpenChange?: (open: boolean) => void; /** Key that toggles the dialog together with ⌘ (Ctrl on Windows and Linux). `false` turns the shortcut off. */ hotkey?: string | false; /** Accessible title of the dialog (visually hidden). */ title?: string; /** Accessible description of the dialog (visually hidden). */ description?: string; }; /** The command menu in a modal dialog near the top of the screen. Toggled with ⌘K by default; Escape closes it. */ function CommandDialog({ open: openProp, defaultOpen = false, onOpenChange, hotkey = "k", title, description, className, children, ...props }: CommandDialogProps) { const msg = useMessages() title ??= msg("command.title", "Command menu") description ??= msg("command.description", "Type a command or search. Use the arrow keys to move and Enter to run.") const [uncontrolled, setUncontrolled] = React.useState(defaultOpen); const open = openProp ?? uncontrolled; const setOpen = React.useCallback( (next: boolean) => { if (openProp === undefined) setUncontrolled(next); onOpenChange?.(next); }, [openProp, onOpenChange], ); React.useEffect(() => { if (!hotkey) return; const onKeyDown = (event: KeyboardEvent) => { if ( (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === hotkey.toLowerCase() ) { event.preventDefault(); setOpen(!open); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [hotkey, open, setOpen]); return ( <DialogPrimitive.Root open={open} onOpenChange={setOpen}> <DialogPrimitive.Portal> <DialogPrimitive.Overlay data-slot="command-overlay" className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 motion-reduce:animate-none" /> <DialogPrimitive.Content data-slot="command-dialog" className="fixed top-[14dvh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2 overflow-hidden rounded-xl border bg-popover shadow-[0_24px_60px_-12px_rgb(0_0_0/0.4)] outline-none duration-150 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 motion-reduce:animate-none" > <DialogPrimitive.Title className="sr-only">{title}</DialogPrimitive.Title> <DialogPrimitive.Description className="sr-only"> {description} </DialogPrimitive.Description> <Command className={className} {...props}> {children} </Command> </DialogPrimitive.Content> </DialogPrimitive.Portal> </DialogPrimitive.Root> ); } type CommandInputProps = React.ComponentProps<typeof Primitive.Input>; function CommandInput({ className, ...props }: CommandInputProps) { return ( <div data-slot="command-input-wrapper" className="flex h-12 shrink-0 items-center gap-2 border-b px-3.5" > <Search aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> <Primitive.Input data-slot="command-input" className={cn( "h-full w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", className, )} {...props} /> </div> ); } type CommandListProps = React.ComponentProps<typeof Primitive.List>; function CommandList({ className, ...props }: CommandListProps) { return ( <Primitive.List data-slot="command-list" className={cn( "max-h-[min(20rem,55dvh)] scroll-py-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1", className, )} {...props} /> ); } type CommandEmptyProps = React.ComponentProps<typeof Primitive.Empty>; function CommandEmpty({ className, ...props }: CommandEmptyProps) { return ( <Primitive.Empty data-slot="command-empty" className={cn("py-8 text-center text-sm text-muted-foreground", className)} {...props} /> ); } type CommandLoadingProps = React.ComponentProps<typeof Primitive.Loading>; /** Shown while results load. Renders `role="progressbar"` with the given `progress` (0 to 100). */ function CommandLoading({ className, ...props }: CommandLoadingProps) { return ( <Primitive.Loading data-slot="command-loading" className={cn("py-6 text-center text-sm text-muted-foreground", className)} {...props} /> ); } type CommandGroupProps = React.ComponentProps<typeof Primitive.Group>; function CommandGroup({ ref, className, ...props }: CommandGroupProps) { return ( <Primitive.Group ref={(el: HTMLDivElement | null) => { keepScrollInList(el?.querySelector<HTMLElement>("[cmdk-group-heading]") ?? null); setRef(ref, el); }} data-slot="command-group" className={cn( "overflow-hidden text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", className, )} {...props} /> ); } type CommandSeparatorProps = React.ComponentProps<typeof Primitive.Separator>; function CommandSeparator({ className, ...props }: CommandSeparatorProps) { return ( <Primitive.Separator data-slot="command-separator" aria-hidden="true" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} /> ); } type CommandItemProps = React.ComponentProps<typeof Primitive.Item> & { /** Leading icon. Use this instead of putting the icon in `children` when the item has a `description`. */ icon?: React.ReactNode; /** Second line of muted text under the title. */ description?: React.ReactNode; /** Show a check at the end, for the currently chosen option in a picker. */ selected?: boolean; }; function CommandItem({ ref, className, icon, description, selected, children, ...props }: CommandItemProps) { return ( <Primitive.Item ref={(el: HTMLDivElement | null) => { keepScrollInList(el); setRef(ref, el); }} data-slot="command-item" data-checked={selected ? "true" : undefined} className={cn( "relative flex min-h-9 cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className, )} {...props} > {icon} {description ? ( <span className="grid min-w-0 flex-1 gap-0.5"> <span className="truncate">{children}</span> <span className="truncate text-xs text-muted-foreground">{description}</span> </span> ) : ( children )} {selected && <Check aria-hidden="true" className="ms-auto size-4 !text-foreground" />} </Primitive.Item> ); } type CommandShortcutProps = React.ComponentProps<"span">; function CommandShortcut({ className, ...props }: CommandShortcutProps) { return ( <span data-slot="command-shortcut" aria-hidden="true" className={cn( "ms-auto font-mono text-xs tracking-wide text-muted-foreground", className, )} {...props} /> ); } export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandLoading, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, type CommandProps, type CommandDialogProps, type CommandInputProps, type CommandListProps, type CommandEmptyProps, type CommandLoadingProps, type CommandGroupProps, type CommandItemProps, type CommandShortcutProps, type CommandSeparatorProps, };Update the import paths to match your project setup.
Usage
import { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandLoading, CommandGroup, CommandItem, CommandShortcut, CommandSeparator } from "@/components/ballmac/command"The full example is in the Code tab above.
Examples
Cmd+K dialog
Last command: Nothing run yet
"use client";
import * as React from "react";
import { FilePlus, FolderOpen, Search } from "lucide-react";
import { buttonVariants } from "@/components/ballmac/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ballmac/command";
import { Kbd } from "@/components/ballmac/kbd";
export default function CommandDialogExample() {
const [open, setOpen] = React.useState(false);
const [last, setLast] = React.useState("Nothing run yet");
const run = (name: string) => {
setLast(name);
setOpen(false);
};
return (
<div className="grid w-full max-w-sm justify-items-start gap-3">
<button type="button" onClick={() => setOpen(true)} className={buttonVariants({ variant: "outline" })}>
<Search aria-hidden="true" /> Search commands <Kbd>⌘K</Kbd>
</button>
<p className="text-sm text-muted-foreground" aria-live="polite">
Last command: {last}
</p>
<CommandDialog open={open} onOpenChange={setOpen} title="Command menu">
<CommandInput placeholder="Search files and actions…" />
<CommandList>
<CommandEmpty>No matching commands.</CommandEmpty>
<CommandGroup heading="Actions">
<CommandItem onSelect={() => run("New file")}>
<FilePlus aria-hidden="true" /> New file <CommandShortcut>⌘N</CommandShortcut>
</CommandItem>
<CommandItem onSelect={() => run("Open folder")}>
<FolderOpen aria-hidden="true" /> Open folder <CommandShortcut>⌘O</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
);
}API reference
<CommandDialog>
| Prop | Type | Default |
|---|---|---|
openControlled open state. | boolean | — |
defaultOpenInitial open state when uncontrolled. | boolean | false |
onOpenChangeCalled when the dialog opens or closes. | (open: boolean) => void | — |
hotkeyKey that toggles the dialog together with ⌘ (Ctrl on Windows and Linux). `false` turns the shortcut off. | string | false | "k" |
titleAccessible title of the dialog (visually hidden). | string | — |
descriptionAccessible description of the dialog (visually hidden). | string | — |
<CommandItem>
| Prop | Type | Default |
|---|---|---|
iconLeading icon. Use this instead of putting the icon in `children` when the item has a `description`. | React.ReactNode | — |
descriptionSecond line of muted text under the title. | React.ReactNode | — |
selectedShow a check at the end, for the currently chosen option in a picker. | boolean | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Type | Filters the list |
| ArrowUp / ArrowDown | Moves the highlighted item |
| Enter | Runs the highlighted item |
| Cmd/Ctrl+K | Toggles CommandDialog |
| Escape | Closes the dialog |
Use with AI
Compose Command, CommandInput, CommandList, CommandGroup and CommandItem. CommandDialog adds the modal and the ⌘K hotkey. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Command (@ballmac/command) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Command palettes and quick switchers
- Searchable action lists
- The list inside a combobox
Not for
- macOS-style launcher visuals; use spotlight-search
- A simple select; use select
Registry JSON: https://ui.ballmac.com/r/command.json
Credits
Based on shadcn/ui Command, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Popover
A focus-managed compact surface for controls or details, with collision handling, mobile-safe width, and optional close action.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
Kbd
Keyboard key and shortcut display built on the semantic <kbd> element, in three sizes, with KbdGroup for combinations like ⌘ ⇧ K. Adapts inside tooltips.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.