← Componentes

Command

Un buscador de acciones, no de datos — a diferencia de Combobox, que resuelve un valor. Se compone con piezas (CommandInput, CommandGroup, CommandItem…) en vez de un array de opciones, así cada ítem lleva su propio ícono, atajo o estado. CommandDialog lo envuelve en un overlay tipo ⌘K; sin overlay, Command es un panel más.

Paleta de comandos (⌘K)

La variante insignia: anclada cerca del encabezado, grupos con atajos, un ítem deshabilitado y una barra inferior con la leyenda de teclado. hotkey="k" registra ⌘K/Ctrl+K mientras el trigger de abajo está montado — probalo.

<CommandDialog open={open} onOpenChange={setOpen} hotkey="k" label="Buscar en Kenza UI">
  <CommandInput placeholder="Escribe un comando o busca…" />
  <CommandList>
    <CommandEmpty>No encontramos ese comando.</CommandEmpty>
    <CommandGroup heading="Navegación">
      <CommandItem value="Ir a Componentes" onSelect={() => router.push("/components")}>
        <GridIcon className="h-4 w-4" />
        Ir a Componentes
        <CommandShortcut>⌘1</CommandShortcut>
      </CommandItem>
    </CommandGroup>
    <CommandSeparator />
    <CommandGroup heading="Acciones">
      <CommandItem value="Nueva plantilla" onSelect={() => crearPlantilla()}>
        <PlusIcon className="h-4 w-4" />
        Nueva plantilla
        <CommandShortcut>⌘N</CommandShortcut>
      </CommandItem>
    </CommandGroup>
  </CommandList>
</CommandDialog>
{/* hotkey="k" registra ⌘K / Ctrl+K solo mientras CommandDialog está montado — sin
    ese prop no escucha nada global, ningún listener sorpresa */}

Variante centrada, con selección

variant="center" da un panel más grande y centrado — mejor para pocas opciones con más peso visual, como un selector de tema. selected en CommandItem pinta el check de la opción activa, independiente de cuál esté resaltada por teclado.

Tema activo: Obsidiana

<CommandDialog open={open} onOpenChange={setOpen} variant="center" showHints={false}>
  <CommandInput placeholder="Buscar un tema…" />
  <CommandList>
    {temas.map((tema) => (
      <CommandItem
        key={tema.value}
        value={tema.label}
        selected={temaActivo === tema.value}
        onSelect={() => setTemaActivo(tema.value)}
      >
        {tema.label}
      </CommandItem>
    ))}
  </CommandList>
</CommandDialog>
{/* selected pinta el check — es un estado tuyo (como un radio), no la
    selección de teclado que ya maneja Command con activeValue */}

En línea, sin overlay

Command no exige CommandDialog — usado solo, es un panel de acciones rápidas embebido en una página o un sidebar.

Esta plantilla

Duplicar sección
Vista previa
Publicar cambios
<div className="overflow-hidden rounded-md border border-border">
  <Command>
    <CommandInput placeholder="Acciones rápidas…" />
    <CommandList>
      <CommandEmpty>Sin resultados.</CommandEmpty>
      <CommandGroup heading="Esta plantilla">
        <CommandItem value="Duplicar sección" onSelect={duplicar}>Duplicar sección</CommandItem>
        <CommandItem value="Vista previa" onSelect={previsualizar}>Vista previa</CommandItem>
      </CommandGroup>
    </CommandList>
  </Command>
</div>
{/* sin CommandDialog alrededor, Command es un panel más — un widget de
    acciones rápidas dentro de una página, no un overlay */}

Buscador y teclado

El filtro ignora acentos y mayúsculas, igual que Combobox. Cada CommandItem declara su propio value — no se infiere del texto visible, así los íconos y atajos no ensucian el match. keywords suma términos invisibles (por ejemplo ["ajustes"] para un ítem que dice "Configuración"). Flechas mueven el ítem activo, Enter lo selecciona, Esc cierra el diálogo — la misma mecánica de foco que ya usan Dialog y AlertDialog.

Código

"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { useDialogTransition } from "./lib/use-dialog-transition";

function SearchIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="10.5" cy="10.5" r="6.5" />
      <path d="M20 20l-4.35-4.35" />
    </svg>
  );
}

function CheckIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M5 12.5l4.5 4.5L19 7.5" />
    </svg>
  );
}

function Kbd({ children }: { children: React.ReactNode }) {
  return (
    <span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-[3px] border border-border bg-muted px-1 font-mono text-[10px] text-muted-foreground">
      {children}
    </span>
  );
}

// sin acentos y sin mayúsculas — mismo criterio que el buscador de
// Combobox, "comando" tiene que encontrar "Cómando" sin que haga falta
// teclear el acento
function normalize(str: string) {
  return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}

function matches(value: string, keywords: string[] | undefined, query: string) {
  if (!query) return true;
  const q = normalize(query);
  if (normalize(value).includes(q)) return true;
  return keywords?.some((k) => normalize(k).includes(q)) ?? false;
}

type NavEntry = { value: string; onSelect?: () => void };

interface CommandContextValue {
  query: string;
  setQuery: (query: string) => void;
  activeValue: string | null;
  setActiveValue: (value: string | null) => void;
  navRef: React.MutableRefObject<NavEntry[]>;
  listboxId: string;
}

const CommandContext = React.createContext<CommandContextValue | null>(null);

function useCommandContext(component: string) {
  const ctx = React.useContext(CommandContext);
  if (!ctx) throw new Error(`${component} debe usarse dentro de Command`);
  return ctx;
}

export interface CommandProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Command es no controlado por defecto, igual que Combobox — este es solo el valor inicial del buscador */
  defaultQuery?: string;
}

export function Command({ defaultQuery = "", className, children, ...props }: CommandProps) {
  const [query, setQuery] = React.useState(defaultQuery);
  const [activeValue, setActiveValue] = React.useState<string | null>(null);
  const navRef = React.useRef<NavEntry[]>([]);
  const listboxId = React.useId();

  const value = React.useMemo(
    () => ({ query, setQuery, activeValue, setActiveValue, navRef, listboxId }),
    [query, activeValue, listboxId],
  );

  return (
    <CommandContext.Provider value={value}>
      <div className={cn("flex flex-col", className)} {...props}>
        {children}
      </div>
    </CommandContext.Provider>
  );
}

export interface CommandInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange"> {}

export const CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(
  ({ className, placeholder = "Buscar…", onKeyDown, ...props }, ref) => {
    const ctx = useCommandContext("CommandInput");

    const moveActive = (direction: 1 | -1) => {
      const nav = ctx.navRef.current;
      if (nav.length === 0) return;
      const currentIndex = nav.findIndex((entry) => entry.value === ctx.activeValue);
      const nextIndex = Math.min(Math.max(currentIndex + direction, 0), nav.length - 1);
      ctx.setActiveValue(nav[nextIndex]?.value ?? null);
    };

    return (
      <div className="flex items-center gap-2 border-b border-border px-4">
        <SearchIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
        <input
          ref={ref}
          role="combobox"
          aria-autocomplete="list"
          aria-expanded="true"
          aria-controls={ctx.listboxId}
          aria-activedescendant={ctx.activeValue ? `${ctx.listboxId}-${ctx.activeValue}` : undefined}
          value={ctx.query}
          onChange={(e) => ctx.setQuery(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "ArrowDown") {
              e.preventDefault();
              moveActive(1);
            } else if (e.key === "ArrowUp") {
              e.preventDefault();
              moveActive(-1);
            } else if (e.key === "Enter") {
              e.preventDefault();
              ctx.navRef.current.find((entry) => entry.value === ctx.activeValue)?.onSelect?.();
            }
            onKeyDown?.(e);
          }}
          placeholder={placeholder}
          className={cn(
            "h-12 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground",
            className,
          )}
          {...props}
        />
      </div>
    );
  },
);
CommandInput.displayName = "CommandInput";

export interface CommandEmptyProps extends React.HTMLAttributes<HTMLDivElement> {}

// nunca se monta directamente — CommandList intercepta este nodo al
// filtrar y renderiza sus children solo cuando la búsqueda no encuentra
// nada. Vive como componente (no como prop de string) para poder llevar
// contenido rico, igual que cualquier otro hijo de Command.
export function CommandEmpty(_props: CommandEmptyProps): null {
  return null;
}

export interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {
  heading?: string;
}

export function CommandGroup({ heading, className, children, ...props }: CommandGroupProps) {
  const headingId = React.useId();
  return (
    <div role="group" aria-labelledby={heading ? headingId : undefined} className={cn("px-1 py-1.5", className)} {...props}>
      {heading && (
        <p id={headingId} className="px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground/70">
          {heading}
        </p>
      )}
      {children}
    </div>
  );
}

export function CommandSeparator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  return <div role="separator" className={cn("my-1 h-px bg-border", className)} {...props} />;
}

export const CommandShortcut = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(
  ({ className, ...props }, ref) => (
    <span
      ref={ref}
      className={cn(
        "ml-auto flex h-5 items-center justify-center gap-0.5 rounded-[3px] border border-border bg-muted px-1.5 font-mono text-[10px] tracking-wide text-muted-foreground",
        className,
      )}
      {...props}
    />
  ),
);
CommandShortcut.displayName = "CommandShortcut";

export interface CommandItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
  value: string;
  /** términos adicionales que hacen match aunque no aparezcan en el texto visible (ej. "ajustes" para un ítem que dice "Configuración") */
  keywords?: string[];
  disabled?: boolean;
  /** check visual para ítems tipo toggle (ej. selector de tema) — no confundir con "activo por teclado" */
  selected?: boolean;
  onSelect?: () => void;
}

export const CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(
  ({ value, keywords, disabled = false, selected, onSelect, className, children, ...props }, ref) => {
    const ctx = useCommandContext("CommandItem");
    const isActive = ctx.activeValue === value;

    return (
      <div
        ref={ref}
        id={`${ctx.listboxId}-${value}`}
        data-command-value={value}
        role="option"
        aria-selected={isActive}
        aria-disabled={disabled}
        onMouseEnter={() => !disabled && ctx.setActiveValue(value)}
        onClick={() => !disabled && onSelect?.()}
        className={cn(
          "relative flex cursor-pointer items-center gap-2 rounded-sm py-2 pl-3 pr-2 text-sm text-foreground outline-none transition-colors duration-100",
          disabled && "pointer-events-none opacity-40",
          isActive && "bg-muted",
          className,
        )}
        {...props}
      >
        {/* filo dorado que se despliega en el ítem activo — el mismo tipo
            de gesto que las marcas de esquina de Dialog, aplicado acá como
            una arista que se revela en vez de aparecer de golpe */}
        <span
          aria-hidden="true"
          className={cn(
            "absolute inset-y-1.5 left-0 w-0.5 origin-center scale-y-0 bg-primary transition-transform duration-150",
            EASE,
            isActive && "scale-y-100",
          )}
        />
        {selected !== undefined && (
          <span className={cn("flex h-3.5 w-3.5 shrink-0 items-center justify-center text-primary", !selected && "opacity-0")}>
            <CheckIcon className="h-3.5 w-3.5" />
          </span>
        )}
        <span className="flex flex-1 items-center gap-2 truncate">{children}</span>
      </div>
    );
  },
);
CommandItem.displayName = "CommandItem";

interface WalkAcc {
  nav: NavEntry[];
  emptyContent: React.ReactNode;
}

// única pasada sobre los children de CommandList: decide qué se ve según
// la búsqueda, arma en simultáneo la lista navegable por teclado
// (excluyendo los disabled) y guarda el contenido de CommandEmpty si
// aparece — evita un store de registro aparte, los children ya son la
// fuente de verdad.
function walkChildren(children: React.ReactNode, query: string, acc: WalkAcc): { nodes: React.ReactNode[]; count: number } {
  const nodes: React.ReactNode[] = [];
  let count = 0;

  // los nodos se vuelven a montar en un array propio (nodes), así que cada
  // uno necesita su propia key — se preserva la del autor si la puso, o se
  // cae a algo estable (value/heading/índice) si no.
  React.Children.forEach(children, (child, index) => {
    if (!React.isValidElement(child)) {
      if (child !== null && child !== undefined && typeof child !== "boolean") {
        nodes.push(<React.Fragment key={`n-${index}`}>{child}</React.Fragment>);
      }
      return;
    }

    if (child.type === CommandEmpty) {
      acc.emptyContent = (child.props as CommandEmptyProps).children ?? null;
      return;
    }

    if (child.type === CommandItem) {
      const itemProps = child.props as CommandItemProps;
      if (!matches(itemProps.value, itemProps.keywords, query)) return;
      count += 1;
      if (!itemProps.disabled) acc.nav.push({ value: itemProps.value, onSelect: itemProps.onSelect });
      nodes.push(React.cloneElement(child, { key: child.key ?? itemProps.value }));
      return;
    }

    if (child.type === CommandGroup) {
      const groupProps = child.props as CommandGroupProps;
      const inner = walkChildren(groupProps.children, query, acc);
      if (inner.count === 0) return;
      count += inner.count;
      nodes.push(React.cloneElement(child, { key: child.key ?? groupProps.heading ?? `g-${index}` }, inner.nodes));
      return;
    }

    if (child.type === CommandSeparator) {
      nodes.push(React.cloneElement(child, { key: child.key ?? `s-${index}` }));
      return;
    }

    // envoltorio genérico (Fragment, div propio del consumidor) — se
    // recorre para encontrar ítems adentro, así CommandItem no está
    // forzado a ser hijo directo de CommandGroup/CommandList
    if (child.props && typeof child.props === "object" && "children" in child.props) {
      const inner = walkChildren((child.props as { children?: React.ReactNode }).children, query, acc);
      count += inner.count;
      nodes.push(React.cloneElement(child, { key: child.key ?? `w-${index}` }, inner.nodes));
      return;
    }

    nodes.push(React.cloneElement(child, { key: child.key ?? `e-${index}` }));
  });

  return { nodes, count };
}

export function CommandList({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const ctx = useCommandContext("CommandList");
  const listRef = React.useRef<HTMLDivElement>(null);

  const { nodes, count, emptyContent, nav } = React.useMemo(() => {
    const acc: WalkAcc = { nav: [], emptyContent: null };
    const walked = walkChildren(children, ctx.query, acc);
    return { nodes: walked.nodes, count: walked.count, emptyContent: acc.emptyContent, nav: acc.nav };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- ctx.query es el único valor de ctx que debe disparar un re-filtrado
  }, [children, ctx.query]);

  React.useEffect(() => {
    ctx.navRef.current = nav;
    if (!nav.some((entry) => entry.value === ctx.activeValue)) {
      ctx.setActiveValue(nav[0]?.value ?? null);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- solo debe correr cuando cambia la lista visible, no en cada render por activeValue
  }, [nav]);

  React.useEffect(() => {
    if (!ctx.activeValue) return;
    listRef.current?.querySelector<HTMLElement>(`[data-command-value="${CSS.escape(ctx.activeValue)}"]`)?.scrollIntoView({ block: "nearest" });
  }, [ctx.activeValue]);

  return (
    <div ref={listRef} role="listbox" id={ctx.listboxId} className={cn("max-h-80 overflow-y-auto overflow-x-hidden p-1", className)} {...props}>
      {count === 0 ? (
        <div className="px-3 py-8 text-center text-sm text-muted-foreground">{emptyContent ?? "Sin resultados."}</div>
      ) : (
        nodes
      )}
    </div>
  );
}

// marca de esquina compartida con Dialog (ver dialog.tsx) — se duplica acá
// en vez de importarse porque es un detalle privado de implementación de
// cada overlay, no una pieza pública de la librería
const corners = [
  { position: "-left-2 -top-2", border: "border-l border-t", offset: "translate(-6px,-6px)" },
  { position: "-right-2 -top-2", border: "border-r border-t", offset: "translate(6px,-6px)" },
  { position: "-left-2 -bottom-2", border: "border-l border-b", offset: "translate(-6px,6px)" },
  { position: "-right-2 -bottom-2", border: "border-r border-b", offset: "translate(6px,6px)" },
] as const;

const COMMAND_DIALOG_EXIT_MS = 200;

export type CommandDialogVariant = "top" | "center";

export interface CommandDialogProps extends CommandProps {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** top: anclado cerca del encabezado, compacto, tipo Linear/Raycast. center: panel más grande, centrado */
  variant?: CommandDialogVariant;
  /** tecla que junto con ⌘/Ctrl abre y cierra el palette — sin este prop, CommandDialog no escucha nada global */
  hotkey?: string;
  /** barra inferior con la leyenda de teclado */
  showHints?: boolean;
  label?: string;
}

export function CommandDialog({
  open: controlledOpen,
  defaultOpen = false,
  onOpenChange,
  variant = "top",
  hotkey,
  showHints = true,
  label = "Paleta de comandos",
  className,
  children,
  ...commandProps
}: CommandDialogProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
  const isControlled = controlledOpen !== undefined;
  const open = isControlled ? controlledOpen : uncontrolledOpen;

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (!isControlled) setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [isControlled, onOpenChange],
  );

  React.useEffect(() => {
    if (!hotkey) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey.toLowerCase()) {
        e.preventDefault();
        setOpen(!open);
      }
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [hotkey, open, setOpen]);

  const { portalReady, rendered, entered, panelRef } = useDialogTransition(open, setOpen, COMMAND_DIALOG_EXIT_MS);

  if (!portalReady || !rendered) return null;

  return createPortal(
    <div className={cn("fixed inset-0 z-50", variant === "center" && "flex items-center justify-center p-4")}>
      <div
        aria-hidden="true"
        onClick={() => setOpen(false)}
        className={cn(
          "absolute inset-0 bg-background/80 transition-opacity",
          EASE,
          entered ? "opacity-100 duration-300" : "opacity-0 duration-200",
        )}
      />
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-label={label}
        tabIndex={-1}
        className={cn(
          "relative flex w-full flex-col rounded-md border border-border bg-background transition-all",
          EASE,
          variant === "top" && "fixed left-1/2 top-[14%] max-w-xl -translate-x-1/2",
          variant === "center" && "max-w-2xl",
          entered ? "opacity-100 scale-100 translate-y-0 duration-300" : "opacity-0 scale-95 translate-y-2 duration-200",
          className,
        )}
      >
        {corners.map((corner) => (
          <span
            key={corner.position}
            aria-hidden="true"
            className={cn(
              "pointer-events-none absolute h-4 w-4 border-primary/70 transition-all",
              EASE,
              corner.position,
              corner.border,
              entered ? "opacity-100 duration-300 delay-150" : "opacity-0 duration-200",
            )}
            style={{ transform: entered ? "translate(0,0)" : corner.offset }}
          />
        ))}
        {/* el recorte de esquinas vive acá, no en el panel de afuera — así
            no tapa las marcas de esquina, que quedan fuera de este div */}
        <div className="flex flex-col overflow-hidden rounded-md">
          <Command {...commandProps}>{children}</Command>
          {showHints && (
            <div className="flex items-center gap-4 border-t border-border px-4 py-2.5 text-xs text-muted-foreground">
              <span className="inline-flex items-center gap-1">
                <Kbd>↑</Kbd>
                <Kbd>↓</Kbd> navegar
              </span>
              <span className="inline-flex items-center gap-1">
                <Kbd>↵</Kbd> seleccionar
              </span>
              <span className="ml-auto inline-flex items-center gap-1">
                <Kbd>esc</Kbd> cerrar
              </span>
            </div>
          )}
        </div>
      </div>
    </div>,
    document.body,
  );
}

El filtrado no usa un registro de estado aparte — CommandList recorre sus propios children una vez por búsqueda, arma la lista navegable por teclado y decide qué grupos sobreviven, todo en la misma pasada.

API

ComponentePropTipoDefault
CommanddefaultQuerystring""
CommandInput—atributos nativos de input, salvo value/onChange
CommandList—atributos nativos de div
CommandGroupheadingstring—
CommandItemvaluestring—
CommandItemkeywordsstring[]—
CommandItemdisabledbooleanfalse
CommandItemselectedboolean—
CommandItemonSelect() => void—
CommandEmpty—contenido a mostrar cuando la búsqueda no encuentra nada
CommandSeparator—hairline entre grupos
CommandShortcut—placa de atajo — hijo de CommandItem
CommandDialogopen / defaultOpen / onOpenChangeboolean / …—
CommandDialogvarianttop | centertop
CommandDialoghotkeystring—
CommandDialogshowHintsbooleantrue
CommandDialoglabelstring"Paleta de comandos"