# Select Multi Chips — React Select Component

> A multi-select whose picks become chips that spring into the field and reflow when removed; type to filter, Enter toggles, Backspace removes the last chip.

- Category: [Selects](https://pixelpapa.com/components/selects.md)
- Tags: select, multi-select, chips, listbox, filter
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/selects/select-multi-chips

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/select-multi-chips.json"
```

The file is written to `components/pixelpapa/select-multi-chips.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Check, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type MultiOption = { value: string; label: string; /** Tailwind background class for the dot. */ color?: string };

type MultiSelectProps = {
  options: MultiOption[];
  value?: string[];
  defaultValue?: string[];
  onChange?: (value: string[]) => void;
  placeholder?: string;
  label: string;
  className?: string;
};

const spring = { type: "spring", stiffness: 500, damping: 32 } as const;

export function MultiSelect({
  options,
  value,
  defaultValue = [],
  onChange,
  placeholder = "Select…",
  label,
  className,
}: MultiSelectProps) {
  const [inner, setInner] = useState(defaultValue);
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const root = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const uid = useId();
  const picked = value ?? inner;
  const q = query.trim().toLowerCase();
  const results = q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;

  useEffect(() => {
    if (!open) return;
    const close = (e: PointerEvent) => {
      if (!root.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", close);
    return () => document.removeEventListener("pointerdown", close);
  }, [open]);

  function set(next: string[]) {
    if (value === undefined) setInner(next);
    onChange?.(next);
  }
  const toggle = (v: string) => set(picked.includes(v) ? picked.filter((p) => p !== v) : [...picked, v]);

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    const last = results.length - 1;
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      if (!open) return setOpen(true);
      setActive((a) => (e.key === "ArrowDown" ? Math.min(last, a + 1) : Math.max(0, a - 1)));
    } else if (e.key === "Home" && open) {
      e.preventDefault();
      setActive(0);
    } else if (e.key === "End" && open) {
      e.preventDefault();
      setActive(last);
    } else if (e.key === "Enter") {
      e.preventDefault();
      if (!open) return setOpen(true);
      if (results[active]) {
        toggle(results[active].value);
        setQuery("");
      }
    } else if (e.key === "Escape" && open) {
      e.preventDefault();
      setOpen(false);
    } else if (e.key === "Backspace" && !query && picked.length) {
      set(picked.slice(0, -1));
    } else if (e.key === "Tab") setOpen(false);
  }

  return (
    <div ref={root} className={cn("relative w-full", className)}>
      <div
        onClick={() => {
          input.current?.focus();
          setOpen(true);
        }}
        className={cn(
          "flex min-h-11 cursor-text flex-wrap items-center gap-1.5 rounded-xl border bg-background p-1.5 shadow-xs transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/15",
          open ? "border-ring" : "border-input",
        )}
      >
        <AnimatePresence mode="popLayout" initial={false}>
          {picked.map((v) => {
            const o = options.find((opt) => opt.value === v);
            if (!o) return null;
            return (
              <motion.span
                key={v}
                layout
                initial={{ opacity: 0, scale: 0.6 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.6 }}
                transition={spring}
                className="inline-flex h-7 items-center gap-1.5 rounded-lg bg-secondary pr-1 pl-2 text-sm font-medium text-secondary-foreground"
              >
                {o.color && <span className={cn("size-2 rounded-full", o.color)} />}
                {o.label}
                <button
                  type="button"
                  aria-label={`Remove ${o.label}`}
                  onClick={(e) => {
                    e.stopPropagation();
                    toggle(v);
                  }}
                  className="flex size-5 items-center justify-center rounded-md opacity-60 transition hover:bg-foreground/10 hover:opacity-100"
                >
                  <X className="size-3" strokeWidth={2.5} />
                </button>
              </motion.span>
            );
          })}
        </AnimatePresence>
        <motion.div layout="position" transition={spring} className="min-w-20 flex-1">
          <input
            ref={input}
            role="combobox"
            aria-label={label}
            aria-expanded={open}
            aria-controls={`${uid}-list`}
            aria-autocomplete="list"
            aria-activedescendant={open && results[active] ? `${uid}-opt-${active}` : undefined}
            autoComplete="off"
            value={query}
            placeholder={picked.length ? "Add more…" : placeholder}
            onChange={(e) => {
              setQuery(e.target.value);
              setActive(0);
              setOpen(true);
            }}
            onKeyDown={onKeyDown}
            className="h-7 w-full bg-transparent px-1.5 text-sm text-foreground outline-none placeholder:text-muted-foreground"
          />
        </motion.div>
      </div>

      <AnimatePresence>
        {open && (
          <motion.ul
            id={`${uid}-list`}
            role="listbox"
            aria-label={label}
            aria-multiselectable
            initial={{ opacity: 0, y: -8, scale: 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: -4, scale: 0.98, transition: { duration: 0.12 } }}
            transition={{ type: "spring", stiffness: 460, damping: 30 }}
            onPointerDown={(e) => e.preventDefault()}
            className="absolute top-full left-0 z-20 mt-2 max-h-56 w-full origin-top overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-xl shadow-black/10"
          >
            {results.map((o, i) => {
              const on = picked.includes(o.value);
              return (
                <li
                  key={o.value}
                  id={`${uid}-opt-${i}`}
                  role="option"
                  aria-selected={on}
                  onPointerMove={() => i !== active && setActive(i)}
                  onClick={() => toggle(o.value)}
                  className="relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm text-popover-foreground"
                >
                  {i === active && (
                    <motion.span
                      layoutId={`${uid}-active`}
                      transition={{ type: "spring", stiffness: 620, damping: 42 }}
                      className="absolute inset-0 rounded-lg bg-accent"
                    />
                  )}
                  <span
                    className={cn(
                      "relative flex size-4 items-center justify-center rounded-[5px] border transition-colors",
                      on ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
                    )}
                  >
                    <motion.span initial={false} animate={{ scale: on ? 1 : 0 }} transition={{ type: "spring", stiffness: 520, damping: 24 }}>
                      <Check className="size-3" strokeWidth={3.5} />
                    </motion.span>
                  </span>
                  {o.color && <span className={cn("relative size-2 rounded-full", o.color)} />}
                  <span className="relative flex-1 truncate">{o.label}</span>
                </li>
              );
            })}
            {results.length === 0 && (
              <li role="presentation" className="px-3 py-5 text-center text-sm text-muted-foreground">
                No label called “{query.trim()}”
              </li>
            )}
          </motion.ul>
        )}
      </AnimatePresence>
    </div>
  );
}

const labels: MultiOption[] = [
  { value: "bug", label: "Bug", color: "bg-red-500" },
  { value: "design", label: "Design", color: "bg-violet-500" },
  { value: "frontend", label: "Frontend", color: "bg-sky-500" },
  { value: "backend", label: "Backend", color: "bg-emerald-500" },
  { value: "docs", label: "Docs", color: "bg-amber-500" },
  { value: "performance", label: "Performance", color: "bg-orange-500" },
  { value: "accessibility", label: "Accessibility", color: "bg-teal-500" },
];

export default function SelectMultiChipsDemo() {
  const [picked, setPicked] = useState(["design", "frontend"]);

  return (
    <div className="flex h-[410px] w-full max-w-sm flex-col">
      <div className="rounded-2xl border border-border bg-card p-6 shadow-sm">
        <p className="text-xs font-medium text-muted-foreground">ISSUE #482</p>
        <h3 className="mt-1 text-lg font-semibold tracking-tight text-card-foreground">Settings page jumps on save</h3>
        <p className="mt-4 mb-1.5 text-sm font-medium text-card-foreground">Labels</p>
        <MultiSelect label="Labels" options={labels} value={picked} onChange={setPicked} placeholder="Add labels…" />
        <p className="mt-2 px-1 text-xs text-muted-foreground">
          {picked.length} selected · Backspace removes the last one
        </p>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Select Multi Chips" component from PixelPapa UI to this project.

What it is: A multi-select whose picks become chips that spring into the field and reflow when removed; type to filter, Enter toggles, Backspace removes the last chip.
Category: Selects. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/select-multi-chips.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/select-multi-chips.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/select-multi-chips" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More selects

- [Select Animated Dropdown](https://pixelpapa.com/components/selects/select-animated-dropdown.md): A custom select whose menu springs open with staggered rows and a highlight that glides between options; arrow keys, Home/End, Enter, Escape and typeahead all work.
- [Select Avatar People](https://pixelpapa.com/components/selects/select-avatar-people.md) (Pro): An assignee picker with gradient initials avatars and presence dots; the chosen avatar pops into the trigger and the list filters from a built-in search.
- [Select Cascading Menu](https://pixelpapa.com/components/selects/select-cascading-menu.md) (Pro): A two-level select: pick a group on the left and its children slide in on the right; arrow keys move within a column and Left/Right step between levels.
- [Select Color Swatches](https://pixelpapa.com/components/selects/select-color-swatches.md): A radio group of colour swatches where a ring springs from one swatch to the next and the colour name rolls over; arrow keys move the selection.
- [Select Combobox Search](https://pixelpapa.com/components/selects/select-combobox-search.md) (Pro): A searchable combobox that filters as you type, bolds the matching letters, slides a highlight under the active row and restores the selection on Escape.
