Paleta de comandos (⌘K)
Modal flotante con búsqueda instantánea, navegación con flechas y atajos opcionales.
Paleta de comandos
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
/**
* Paleta de comandos: backdrop borroso, panel con búsqueda instantánea, navegación con flechas
* y selección con Enter. Se invoca con ⌘K o Ctrl+K, se cierra con Escape o clic fuera. Las
* entradas pueden llevar icono y un atajo, y el panel entra y sale con resorte.
*
* <CommandPalette
* open={open}
* onOpenChange={setOpen}
* groups={[
* { title: "Navegación", items: [{ id, label, icon, onSelect }] },
* { title: "Acciones", items: [{ id, label, hint, onSelect }] }
* ]}
* />
*
* O en modo no controlado:
*
* <CommandPalette defaultOpen groups={...} />
*/
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
export type CommandItem = {
id: string;
label: string;
hint?: string;
icon?: ReactNode;
onSelect?: () => void;
};
export type CommandGroup = { title: string; items: CommandItem[] };
export type CommandPaletteProps = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
groups: CommandGroup[];
placeholder?: string;
};
const CSS = `
.gcmd-backdrop { position: fixed; inset: 0; z-index: 80; background: rgba(8, 9, 14, .55); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); opacity: 0; transition: opacity .25s cubic-bezier(.16,1,.3,1), visibility 0s linear .25s; visibility: hidden; }
.gcmd-backdrop[data-open="true"] { opacity: 1; transition: opacity .25s cubic-bezier(.16,1,.3,1); visibility: visible; }
.gcmd-panel { position: fixed; z-index: 81; top: 14vh; left: 50%; display: flex; flex-direction: column; width: min(580px, calc(100vw - 32px)); max-height: 64vh; background: #0e1016; color: #e8eaef; border-radius: 14px; border: 1px solid rgba(255,255,255,.08); box-shadow: 0 24px 64px -16px rgba(0,0,0,.6), inset 0 0 0 1px rgba(255,255,255,.04); opacity: 0; transform: translate(-50%, -10px) scale(.97); transition: transform .42s cubic-bezier(.16,1,.3,1), opacity .25s cubic-bezier(.16,1,.3,1), visibility 0s linear .42s; visibility: hidden; overflow: hidden; font-family: inherit; }
.gcmd-panel[data-open="true"] { opacity: 1; transform: translate(-50%, 0) scale(1); transition: transform .42s cubic-bezier(.16,1,.3,1), opacity .25s cubic-bezier(.16,1,.3,1); visibility: visible; }
.gcmd-input-wrap { display: flex; align-items: center; gap: 14px; padding: 18px 20px; border-bottom: 1px solid rgba(255,255,255,.07); }
.gcmd-input-wrap svg { flex-shrink: 0; opacity: .55; color: #fff; }
.gcmd-input { flex: 1; min-width: 0; background: transparent; border: 0; outline: none; color: #fff; font: inherit; font-size: 17px; letter-spacing: -.01em; padding: 0; }
.gcmd-input::placeholder { color: rgba(255,255,255,.4); }
.gcmd-kbd { font-size: 11px; letter-spacing: .04em; padding: 4px 8px; border-radius: 5px; background: rgba(255,255,255,.08); color: rgba(255,255,255,.6); font-family: inherit; }
.gcmd-list { flex: 1; min-height: 0; overflow-y: auto; padding: 8px 6px; }
.gcmd-group { padding: 6px 0; }
.gcmd-group + .gcmd-group { border-top: 1px solid rgba(255,255,255,.05); }
.gcmd-group-title { padding: 8px 14px; font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: rgba(255,255,255,.4); }
.gcmd-item { display: flex; align-items: center; gap: 14px; padding: 10px 14px; margin: 1px 0; border-radius: 8px; cursor: pointer; transition: background-color .12s ease, color .12s ease; color: rgba(255,255,255,.78); }
.gcmd-item[data-active="true"] { background: rgba(110, 130, 255, .16); color: #fff; }
.gcmd-item-icon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; background: rgba(255,255,255,.06); flex-shrink: 0; color: rgba(255,255,255,.85); }
.gcmd-item-label { flex: 1; min-width: 0; font-size: 14.5px; line-height: 1.3; }
.gcmd-item-label mark { background: transparent; color: #fff; font-weight: 700; }
.gcmd-item-hint { font-size: 11px; padding: 3px 7px; border-radius: 5px; background: rgba(255,255,255,.08); color: rgba(255,255,255,.6); font-family: inherit; letter-spacing: .04em; }
.gcmd-empty { padding: 36px 18px; text-align: center; color: rgba(255,255,255,.4); font-size: 14px; }
.gcmd-foot { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-top: 1px solid rgba(255,255,255,.07); font-size: 11px; color: rgba(255,255,255,.45); letter-spacing: .04em; }
.gcmd-foot kbd { display: inline-block; padding: 1px 6px; margin: 0 2px; border-radius: 4px; background: rgba(255,255,255,.1); font-family: inherit; }
.gcmd-scroll::-webkit-scrollbar { width: 8px; }
.gcmd-scroll::-webkit-scrollbar-track { background: transparent; }
.gcmd-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,.08); border-radius: 4px; }
@media (prefers-reduced-motion: reduce) {
.gcmd-backdrop, .gcmd-panel { transition: opacity .12s ease; transform: translate(-50%, 0); }
.gcmd-backdrop[data-open="true"], .gcmd-panel[data-open="true"] { transform: translate(-50%, 0); }
}
`;
function SearchIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
function Highlight({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>;
const lower = text.toLowerCase();
const i = lower.indexOf(query.toLowerCase());
if (i === -1) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<mark>{text.slice(i, i + query.length)}</mark>
{text.slice(i + query.length)}
</>
);
}
export function CommandPalette({ open, defaultOpen, onOpenChange, groups, placeholder = "Busca acciones, páginas, atajos…" }: CommandPaletteProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const setOpen = (value: boolean) => {
if (!isControlled) setInternalOpen(value);
onOpenChange?.(value);
};
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return groups;
return groups
.map((g) => ({ ...g, items: g.items.filter((item) => item.label.toLowerCase().includes(q)) }))
.filter((g) => g.items.length > 0);
}, [groups, query]);
const flat = useMemo(() => filtered.flatMap((g) => g.items), [filtered]);
useEffect(() => {
if (typeof window === "undefined") return;
function onKey(event: KeyboardEvent) {
const inEditable =
event.target instanceof HTMLElement &&
(event.target.tagName === "INPUT" || event.target.tagName === "TEXTAREA" || event.target.isContentEditable);
if (inEditable) return;
const isMod = event.metaKey || event.ctrlKey;
if (isMod && event.key.toLowerCase() === "k") {
event.preventDefault();
setOpen(!isOpen);
return;
}
if (!isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
} else if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((i) => Math.min(flat.length - 1, i + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((i) => Math.max(0, i - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const item = flat[activeIndex];
if (item) {
item.onSelect?.();
setOpen(false);
}
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, flat, activeIndex, setOpen]);
useEffect(() => {
if (isOpen) {
setQuery("");
setActiveIndex(0);
const t = window.setTimeout(() => inputRef.current?.focus(), 60);
return () => window.clearTimeout(t);
}
return;
}, [isOpen]);
useEffect(() => setActiveIndex(0), [query]);
useEffect(() => {
if (!isOpen) return;
function onPointerDown(event: PointerEvent) {
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [isOpen, setOpen]);
let cursor = 0;
return (
<>
<style>{CSS}</style>
<div className="gcmd-backdrop" data-open={isOpen ? "true" : "false"} aria-hidden="true" />
<div
ref={panelRef}
className="gcmd-panel"
data-open={isOpen ? "true" : "false"}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<h2 id={titleId} style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0,0,0,0)", whiteSpace: "nowrap", border: 0 }}>
Paleta de comandos
</h2>
<div className="gcmd-input-wrap">
<SearchIcon />
<input
ref={inputRef}
className="gcmd-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
aria-label="Buscar comandos"
autoComplete="off"
spellCheck={false}
/>
<span className="gcmd-kbd">Esc</span>
</div>
<div className="gcmd-list gcmd-scroll" role="listbox" aria-label="Resultados">
{flat.length === 0 ? (
<div className="gcmd-empty">Sin resultados para «{query}»</div>
) : (
filtered.map((group) => (
<div key={group.title} className="gcmd-group">
<div className="gcmd-group-title">{group.title}</div>
{group.items.map((item) => {
const active = cursor === activeIndex;
const currentIndex = cursor;
cursor += 1;
return (
<div
key={item.id}
className="gcmd-item"
data-active={active ? "true" : "false"}
role="option"
aria-selected={active}
onMouseEnter={() => setActiveIndex(currentIndex)}
onClick={() => {
item.onSelect?.();
setOpen(false);
}}
>
{item.icon ? <span className="gcmd-item-icon">{item.icon}</span> : null}
<span className="gcmd-item-label">
<Highlight text={item.label} query={query.trim()} />
</span>
{item.hint ? <span className="gcmd-item-hint">{item.hint}</span> : null}
</div>
);
})}
</div>
))
)}
</div>
<div className="gcmd-foot">
<span><kbd>↑</kbd><kbd>↓</kbd> navegar · <kbd>↵</kbd> abrir · <kbd>esc</kbd> cerrar</span>
<span><kbd>⌘K</kbd> invocar</span>
</div>
</div>
</>
);
}
/** Pequeño wrapper para la vitrina: muestra un botón que abre la paleta. */
export function CommandPaletteDemo({ groups }: { groups: CommandGroup[] }) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 10,
padding: "10px 16px",
borderRadius: 10,
background: "rgba(255,255,255,.05)",
color: "#e8eaef",
border: "1px solid rgba(255,255,255,.08)",
font: "inherit",
fontSize: 14,
cursor: "pointer",
}}
>
<SearchIcon />
Busca una acción…
<span style={{ display: "inline-flex", gap: 4, marginLeft: 8 }}>
<kbd style={{ padding: "2px 6px", borderRadius: 4, background: "rgba(255,255,255,.1)", fontSize: 11, fontFamily: "inherit" }}>⌘</kbd>
<kbd style={{ padding: "2px 6px", borderRadius: 4, background: "rgba(255,255,255,.1)", fontSize: 11, fontFamily: "inherit" }}>K</kbd>
</span>
</button>
<CommandPalette open={open} onOpenChange={setOpen} groups={groups} />
</>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/command-paletteEjemplo de uso de Paleta de comandos (⌘K)
Después de copiar command-palette.tsx a tu proyecto, impórtalo y colócalo donde lo necesites. Exporta 2 componentes: CommandPalette, CommandPaletteDemo.
import { CommandPalette } from "./command-palette";
export default function Ejemplo() {
return <CommandPalette placeholder="Ejemplo" open />;
}Propiedades de CommandPalette
CommandPalette acepta 5 propiedades; 1 es obligatoria.
| Propiedad | Tipo | Descripción |
|---|---|---|
open? | boolean | Opcional. |
defaultOpen? | boolean | Opcional. |
onOpenChange? | (open: boolean) => void | Opcional. |
groups | CommandGroup[] | Obligatoria. |
placeholder? | string | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 290 líneas (12 KB) y no necesita @keyframes. Esto es lo que hace Paleta de comandos (⌘K) por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Declara roles ARIA para lectores de pantalla: dialog, listbox, option.
- Responde al teclado, no solo al ratón.
- Usa backdrop-filter para el efecto de vidrio; en navegadores sin soporte el panel se ve sin desenfoque.
- Es un componente de cliente de Next.js: incluye la directiva "use client".
Acerca del producto
Una paleta de comandos sobre backdrop borroso. Se invoca con ⌘K o Ctrl+K, busca en tiempo real con coincidencia difusa simple, navega con flechas y selecciona con Enter. Cada resultado puede llevar icono y un atajo visible. El panel entra y sale con resorte y respeta reduced motion.
Incluye
Un solo archivo React + TypeScript, con los estilos dentro. No usa Tailwind ni bibliotecas externas.
- Atajo global ⌘K / Ctrl+K para abrir y cerrar
- Búsqueda instantánea con resaltado del término
- Navegación con flechas, Enter y Escape
- Cierre al hacer clic fuera del panel
- Soporte de iconos y atajos por entrada
- Cumple prefers-reduced-motion
Cómo usarlo
Pasa open y onOpenChange para controlarlo, o defaultOpen para que se gestione solo. Define groups como un arreglo de { title, items } y en cada item un onSelect.