Ir al contenido
Evaluar mi proyecto
Componentes · MicrointeraccionesDisponible

Paleta de comandos (⌘K)

Modal flotante con búsqueda instantánea, navegación con flechas y atajos opcionales.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

command-palette.tsx290 líneas
Descargar
"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 &laquo;{query}&raquo;</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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/command-palette

Ejemplo 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.

ejemplo.tsx5 líneas
import { CommandPalette } from "./command-palette";

export default function Ejemplo() {
  return <CommandPalette placeholder="Ejemplo" open />;
}

Propiedades de CommandPalette

CommandPalette acepta 5 propiedades; 1 es obligatoria.

PropiedadTipoDescripción
open?booleanOpcional.
defaultOpen?booleanOpcional.
onOpenChange?(open: boolean) => voidOpcional.
groupsCommandGroup[]Obligatoria.
placeholder?stringOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React · Next.js (quita la directiva use client fuera de Next.js) · Sin Tailwind

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.