React + MotionFilters

Multi-select dropdown

A dropdown with a search box at the top where you tick several options and see how many are picked.

Settings

6

Code

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 = ["Design", "Code", "Audio", "Video", "3D", "Data"],
  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` : "Select..."}
        </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="Search..."
                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]">No results</p>
              )}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt for AI

Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.

More in Filters

Where to use it

When there are too many options for chips, a dropdown keeps the page tidy. Think of choosing languages in a translator’s profile, tags for a blog post or teammates to assign a task to. The search box filters as you type, so this multi-select dropdown still feels quick with a long list.

The button shows a count rather than every name, which keeps it compact. If people need to see their choices at a glance, show them as chips under the field. And swap the placeholder for something specific, like “Pick languages”.