Command
Componente Command de shadcn — paleta de comandos, búsqueda y navegación con teclado impulsada por cmdk.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
npx shadcn@latest add command dialogimport {
Command, CommandDialog, CommandEmpty, CommandGroup,
CommandInput, CommandItem, CommandList, CommandSeparator,
} from "@/components/ui/command";
// Menú de comandos en línea
<Command>
<CommandInput placeholder="Buscar..." />
<CommandList>
<CommandEmpty>No se encontraron resultados.</CommandEmpty>
<CommandGroup heading="Acciones">
<CommandItem onSelect={() => console.log("new")}>Archivo nuevo</CommandItem>
<CommandItem onSelect={() => console.log("save")}>Guardar</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Configuración">
<CommandItem>Perfil</CommandItem>
<CommandItem>Preferencias</CommandItem>
</CommandGroup>
</CommandList>
</Command>Cuándo usarlo: Cuando quieres una paleta de búsqueda o acciones prioritaria para el teclado — navegación estilo Cmd+K, búsqueda tipo spotlight o selección con combobox.
Ejemplo funcional
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
CommandDialog, CommandEmpty, CommandGroup,
CommandInput, CommandItem, CommandList, CommandSeparator,
} from "@/components/ui/command";
import {
FileText, Settings, User, Search, Plus, Moon, Sun, LogOut,
} from "lucide-react";
type CommandAction = {
id: string;
label: string;
icon: React.ReactNode;
shortcut?: string;
action: () => void;
};
export function CommandPalette() {
const [open, setOpen] = useState(false);
const router = useRouter();
// Cmd+K para abrir
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((o) => !o);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
const runAndClose = useCallback(
(fn: () => void) => {
fn();
setOpen(false);
},
[]
);
const pages: CommandAction[] = [
{ id: "home", label: "Inicio", icon: <FileText className="mr-2 h-4 w-4" />, action: () => router.push("/") },
{ id: "dashboard", label: "Panel", icon: <FileText className="mr-2 h-4 w-4" />, action: () => router.push("/dashboard") },
{ id: "settings", label: "Configuración", icon: <Settings className="mr-2 h-4 w-4" />, shortcut: "Cmd+,", action: () => router.push("/settings") },
{ id: "profile", label: "Perfil", icon: <User className="mr-2 h-4 w-4" />, action: () => router.push("/profile") },
];
const actions: CommandAction[] = [
{ id: "new-doc", label: "Documento nuevo", icon: <Plus className="mr-2 h-4 w-4" />, shortcut: "Cmd+N", action: () => console.log("new doc") },
{ id: "search", label: "Buscar en todo", icon: <Search className="mr-2 h-4 w-4" />, shortcut: "Cmd+F", action: () => console.log("search") },
];
const theme: CommandAction[] = [
{ id: "light", label: "Modo claro", icon: <Sun className="mr-2 h-4 w-4" />, action: () => document.documentElement.classList.remove("dark") },
{ id: "dark", label: "Modo oscuro", icon: <Moon className="mr-2 h-4 w-4" />, action: () => document.documentElement.classList.add("dark") },
];
return (
<>
{/* Botón de activación */}
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted"
>
<Search className="h-4 w-4" />
<span>Buscar...</span>
<kbd className="ml-4 rounded bg-muted px-1.5 py-0.5 text-xs font-mono">Cmd+K</kbd>
</button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Escribe un comando o busca..." />
<CommandList>
<CommandEmpty>No se encontraron resultados.</CommandEmpty>
<CommandGroup heading="Páginas">
{pages.map((item) => (
<CommandItem key={item.id} onSelect={() => runAndClose(item.action)}>
{item.icon}
<span>{item.label}</span>
{item.shortcut && (
<kbd className="ml-auto text-xs text-muted-foreground">{item.shortcut}</kbd>
)}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Acciones">
{actions.map((item) => (
<CommandItem key={item.id} onSelect={() => runAndClose(item.action)}>
{item.icon}
<span>{item.label}</span>
{item.shortcut && (
<kbd className="ml-auto text-xs text-muted-foreground">{item.shortcut}</kbd>
)}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Tema">
{theme.map((item) => (
<CommandItem key={item.id} onSelect={() => runAndClose(item.action)}>
{item.icon}
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Cuenta">
<CommandItem onSelect={() => runAndClose(() => console.log("logout"))}>
<LogOut className="mr-2 h-4 w-4" />
<span>Cerrar sesión</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</>
);
}Qué demuestra esto:
- Atajo de teclado
Cmd+Kpara abrir - Envoltorio
CommandDialog(renderiza en un Dialog) - Comandos agrupados con iconos y atajos de teclado
- Manejadores
onSelectque navegan o disparan acciones - Comportamiento de cerrar al seleccionar
- Campo de búsqueda con filtrado automático
Profundización
Cómo funciona
- Command está construido sobre
cmdkde Paco Coursey — un menú de comandos composable headless CommandInputproporciona un cuadro de búsqueda que filtra automáticamente los hijosCommandItempor contenido de textoCommandItemes navegable con teclado — las flechas se mueven entre ítems, Enter seleccionaCommandDialogcombinaCommandcon elDialogde shadcn para la superposición modalCommandGroupproporciona agrupación visual con un encabezado opcionalCommandEmptyse renderiza cuando ningún ítem coincide con la consulta de búsqueda- El filtrado es del lado del cliente por defecto usando el contenido de texto del ítem
Variaciones
Búsqueda asíncrona con carga:
function AsyncCommand() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) { setResults([]); return; }
setLoading(true);
const timer = setTimeout(async () => {
const data = await fetch(`/api/search?q=${query}`).then((r) => r.json());
setResults(data);
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [query]);
return (
<Command shouldFilter={false}>
<CommandInput value={query} onValueChange={setQuery} />
<CommandList>
{loading && <CommandEmpty>Buscando...</CommandEmpty>}
{!loading && results.length === 0 && <CommandEmpty>Sin resultados.</CommandEmpty>}
{results.map((r) => (
<CommandItem key={r.id} value={r.id} onSelect={() => navigate(r)}>
{r.title}
</CommandItem>
))}
</CommandList>
</Command>
);
}Combobox (select con búsqueda):
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
function Combobox({ options, value, onSelect }: {
options: { value: string; label: string }[];
value: string;
onSelect: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}>
{options.find((o) => o.value === value)?.label ?? "Seleccionar..."}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Buscar..." />
<CommandList>
<CommandEmpty>Sin coincidencias.</CommandEmpty>
<CommandGroup>
{options.map((opt) => (
<CommandItem key={opt.value} value={opt.value} onSelect={() => { onSelect(opt.value); setOpen(false); }}>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}Notas de TypeScript
// onSelect de CommandItem recibe el string value en minúsculas
<CommandItem
value="unique-id"
onSelect={(value: string) => {
// value es la versión en minúsculas del texto del ítem o de la prop value explícita
}}
>
// Tipado de acciones de comando
interface CommandAction {
id: string;
label: string;
icon: React.ReactNode;
shortcut?: string;
action: () => void;
keywords?: string[]; // términos de búsqueda adicionales
}Errores comunes
-
El value de
onSelectestá en minúsculas — cmdk convierte el value a minúsculas para la coincidencia. Solución: Usa una propvalueseparada y busca los datos originales por ID. -
shouldFilter={false}para asíncrono — Si obtienes resultados de una API, desactiva el filtro integrado. Solución: EstableceshouldFilter={false}y maneja el filtrado en el servidor. -
Los ítems deben tener contenido de texto — cmdk filtra por el contenido de texto de
CommandItem. Si tu ítem solo tiene iconos, el filtrado no funcionará. Solución: Añade texto visible o usa la propvaluepara filtrado personalizado. -
z-index del Dialog —
CommandDialogusa el mismo z-index que Dialog. Si tienes otras superposiciones, pueden entrar en conflicto. Solución: Ajusta el z-index medianteclassNameenCommandDialog. -
Conflictos de atajos de teclado —
Cmd+Kpuede entrar en conflicto con atajos del navegador o del editor. Solución: Comprueba conflictos y documenta el atajo para los usuarios.
Alternativas
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| cmdk (bare) | Quieres la librería headless sin estilos de shadcn | Ya usas shadcn |
| Algolia DocSearch | Necesitas búsqueda de texto completo en documentación | Necesitas una paleta de comandos general |
| kbar | Quieres una paleta de comandos alternativa con otra API | cmdk cubre tus necesidades |
<datalist> nativo | Necesitas un autocompletado simple nativo del navegador | Quieres navegación rica con teclado y agrupación |
FAQs
¿Cuál es la diferencia entre Command y CommandDialog?
Commandrenderiza un menú de comandos en línea incrustado en la páginaCommandDialogenvuelveCommanddentro de unDialogde shadcn para una superposición modal- Usa
CommandDialogpara paletas estilo Cmd+K; usaCommandpara comboboxes en línea
¿Cómo conectas un atajo de teclado Cmd+K para abrir la paleta de comandos?
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((o) => !o);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);¿Qué librería subyacente impulsa el componente Command de shadcn?
cmdkde Paco Coursey — una librería de menú de comandos composable headless- Proporciona navegación con teclado (flechas, Enter para seleccionar) y filtrado de texto integrado
Trampa: ¿Por qué onSelect devuelve un value en minúsculas?
- cmdk convierte internamente el string value a minúsculas para coincidencia sin distinguir mayúsculas
- Solución: usa una prop
valueseparada enCommandItemy busca los datos originales por ID en lugar de depender del string deonSelect
¿Cómo construyes un combobox (select con búsqueda) usando Command?
- Envuelve
Commanddentro de unPopoveren lugar de un Dialog - Usa
PopoverTriggercomo botón yPopoverContentconteniendo el Command - Cierra el popover al seleccionar un ítem con
setOpen(false)
¿Cuándo debes establecer shouldFilter={false} en Command?
- Cuando obtienes resultados de una API (búsqueda asíncrona/del lado del servidor)
- El filtro integrado del lado del cliente ocultaría resultados que aún no se han cargado
- Maneja el filtrado en el servidor y pasa los resultados directamente a
CommandItem
¿Cómo implementas búsqueda asíncrona con una llamada API con debounce?
useEffect(() => {
if (!query) { setResults([]); return; }
setLoading(true);
const timer = setTimeout(async () => {
const data = await fetch(`/api/search?q=${query}`).then(r => r.json());
setResults(data);
setLoading(false);
}, 300);
return () => clearTimeout(timer);
}, [query]);¿Qué muestra CommandEmpty y cuándo?
- Renderiza sus hijos cuando ningún elemento
CommandItemcoincide con la consulta de búsqueda actual - Útil para mensajes como "No se encontraron resultados" o "Buscando..."
¿Cómo tipas una acción de comando para usarla en una lista dinámica de comandos?
interface CommandAction {
id: string;
label: string;
icon: React.ReactNode;
shortcut?: string;
action: () => void;
keywords?: string[];
}Trampa: ¿Por qué el filtrado podría no funcionar si un CommandItem solo contiene un icono?
- cmdk filtra por el contenido de texto de cada
CommandItem - Si el ítem no tiene texto visible, el filtro no tiene nada con qué coincidir
- Solución: añade texto visible o establece la prop
valueexplícitamente para filtrado personalizado
¿Cómo cierras la paleta de comandos después de seleccionar un ítem?
const runAndClose = useCallback((fn: () => void) => {
fn();
setOpen(false);
}, []);
<CommandItem onSelect={() => runAndClose(item.action)}>¿Qué tipo de TypeScript espera onSelect en un CommandItem?
onSelectrecibe un callback de tipo(value: string) => void- El parámetro
valuees el contenido de texto en minúsculas o la propvalueexplícita
Relacionado
- Dialog — CommandDialog usa Dialog internamente
- Button — botones de activación para la paleta
- Data Table — UI de filtrado para tablas
- Setup — instalación