React + MotionFiltros

Desplegable multiselección

Un desplegable con buscador arriba donde marcas varias opciones y ves cuántas llevas elegidas.

Ajustes

6

Código

import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronDown, Search } from "lucide-react";

const sizeMap = {
  sm: { pad: "px-3 py-1.5", text: "text-xs" },
  md: { pad: "px-4 py-2", text: "text-sm" },
  lg: { pad: "px-5 py-2.5", text: "text-base" },
} as const;

interface MultiSelectProps {
  accent?: string;
  size?: keyof typeof sizeMap;
  options?: string[];
  value?: string[];
  onChange?: (value: string[]) => void;
}

export default function MultiSelect({
  accent = "#0071e3",
  size = "md",
  options = ["Diseño", "Código", "Audio", "Vídeo", "3D", "Datos"],
  value,
  onChange,
}: MultiSelectProps) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [internal, setInternal] = useState<string[]>([]);
  const selected = value ?? internal;
  const s = sizeMap[size];
  const filtered = options.filter((o) => o.toLowerCase().includes(query.toLowerCase()));

  const toggle = (o: string) => {
    const next = selected.includes(o) ? selected.filter((x) => x !== o) : [...selected, o];
    if (value === undefined) setInternal(next);
    onChange?.(next);
  };

  return (
    <div className="relative w-64">
      <button
        type="button"
        aria-expanded={open}
        onClick={() => setOpen((o) => !o)}
        className={`flex w-full items-center justify-between rounded-full border border-black/10 bg-white font-medium dark:border-white/10 dark:bg-[#1c1c1e] ${s.pad} ${s.text}`}
      >
        <span className={selected.length ? "" : "text-[#86868b]"}>
          {selected.length ? `${selected.length} seleccionados` : "Selecciona..."}
        </span>
        <ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
      </button>
      <AnimatePresence>
        {open && (
          <motion.div
            initial={{ opacity: 0, y: -6 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -6 }}
            className="absolute z-20 mt-2 w-full overflow-hidden rounded-2xl border border-black/10 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.03),0_8px_28px_rgba(0,0,0,0.05)] dark:border-white/10 dark:bg-[#1d1d1f]"
          >
            <div className="flex items-center gap-2 border-b border-black/10 px-3 py-2 dark:border-white/10">
              <Search className="h-3.5 w-3.5 text-[#86868b]" />
              <input
                autoFocus
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Buscar..."
                className="w-full bg-transparent text-sm outline-none placeholder:text-[#86868b]"
              />
            </div>
            <div className="max-h-44 overflow-y-auto p-1 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-black/20 dark:[&::-webkit-scrollbar-thumb]:bg-white/20">
              {filtered.map((o) => {
                const on = selected.includes(o);
                return (
                  <button
                    key={o}
                    type="button"
                    aria-pressed={on}
                    onClick={() => toggle(o)}
                    className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/10"
                  >
                    {o}
                    {on && <Check className="h-4 w-4" style={{ color: accent }} />}
                  </button>
                );
              })}
              {!filtered.length && (
                <p className="px-2.5 py-2 text-sm text-[#86868b]">Sin resultados</p>
              )}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Filtros

Dónde usarlo

Cuando hay demasiadas opciones para usar chips, un desplegable mantiene la página ordenada. Piensa en elegir idiomas en el perfil de un traductor, etiquetas para una entrada de blog o personas a las que asignar una tarea. El buscador filtra mientras escribes, así que este select múltiple sigue siendo ágil con listas largas.

El botón muestra un número en vez de todos los nombres, y así ocupa poco. Si hace falta ver lo elegido de un vistazo, enséñalo como chips debajo. Cambia también el texto de ejemplo por algo concreto, como «Elige idiomas».