Inputs & Forms

Multi Select

A popover multi-select with checkbox rows, an overflowing-chip summary with a rolling +N counter, and an apply/clear footer.

Install

npx shadcn@latest add @paragon/multi-select

Also installs: button

multi-select.tsx

"use client";

import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export interface MultiSelectOption {
  value: string;
  label: string;
}

export interface MultiSelectProps {
  options: MultiSelectOption[];
  /** Controlled committed selection. */
  value?: string[];
  /** Initial selection when uncontrolled. */
  defaultValue?: string[];
  /** Fires when the selection is applied. */
  onValueChange?: (values: string[]) => void;
  placeholder?: string;
  /** How many chips to show before collapsing into "+N more". */
  maxVisible?: number;
  disabled?: boolean;
  /** Applied to the trigger for `htmlFor` wiring. */
  id?: string;
  /** Form field name; renders hidden inputs for each selected value. */
  name?: string;
  className?: string;
}

/** The overflow counter digit, rolling vertically when the count changes. */
function RollingCount({ value }: { value: number }) {
  const reduced = useReducedMotion();
  const prev = React.useRef(value);
  const dir = value >= prev.current ? 1 : -1;
  React.useEffect(() => {
    prev.current = value;
  });
  const chars = String(value).split("");
  return (
    <span className="inline-flex overflow-hidden tabular-nums">
      {chars.map((ch, i) => (
        <span
          key={`${chars.length}-${i}`}
          className="relative inline-flex justify-center"
        >
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={ch}
              initial={
                reduced
                  ? { opacity: 0 }
                  : { y: dir > 0 ? "100%" : "-100%", opacity: 0.25 }
              }
              animate={{ y: "0%", opacity: 1 }}
              exit={
                reduced
                  ? { opacity: 0, transition: { duration: 0.1 } }
                  : {
                      y: dir > 0 ? "-100%" : "100%",
                      opacity: 0.25,
                      transition: { duration: 0.15, ease: EASE_EXIT },
                    }
              }
              transition={{ duration: 0.15, ease: EASE_OUT }}
              className="inline-block"
            >
              {ch}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}

/**
 * A multi-select in a popover with checkbox rows. Selected items summarize
 * as chips on the trigger with a rolling "+N more" counter, and changes
 * commit through an apply/clear footer.
 */
export function MultiSelect({
  options,
  value: valueProp,
  defaultValue,
  onValueChange,
  placeholder = "Select options…",
  maxVisible = 2,
  disabled = false,
  id,
  name,
  className,
}: MultiSelectProps) {
  const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const [open, setOpen] = React.useState(false);
  const [uncontrolled, setUncontrolled] = React.useState<string[]>(
    defaultValue ?? [],
  );
  const committed = valueProp ?? uncontrolled;
  const [draft, setDraft] = React.useState<string[]>(committed);

  const labelFor = (v: string) =>
    options.find((opt) => opt.value === v)?.label ?? v;

  const visible = committed.slice(0, maxVisible);
  const overflow = committed.length - visible.length;

  const apply = () => {
    if (valueProp === undefined) setUncontrolled(draft);
    onValueChange?.(draft);
    setOpen(false);
  };

  return (
    <PopoverPrimitive.Root
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (next) setDraft(committed);
      }}
    >
      <PopoverPrimitive.Trigger asChild>
        <button
          type="button"
          id={id}
          disabled={disabled}
          className={cn(
            "flex h-9 w-64 items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-3 text-sm transition-[background-color,border-color] duration-150 ease-out hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50",
            className,
          )}
        >
          {committed.length === 0 ? (
            <span className="truncate text-muted-foreground">
              {placeholder}
            </span>
          ) : (
            <span className="flex min-w-0 items-center gap-1">
              {visible.map((v) => (
                <span
                  key={v}
                  className="truncate rounded-md bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"
                >
                  {labelFor(v)}
                </span>
              ))}
              {overflow > 0 && (
                <span className="flex shrink-0 items-center gap-0.5 rounded-md bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground">
                  +<RollingCount value={overflow} /> more
                </span>
              )}
            </span>
          )}
          <ChevronsUpDown
            className="size-4 shrink-0 text-muted-foreground"
            aria-hidden
          />
        </button>
      </PopoverPrimitive.Trigger>
      {/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
          child node, and the Radix Portal enforces a single child. */}
      <style href={`paragon-multi-select-${uid}`} precedence="paragon">{`
        @keyframes msel-in-${uid} { from { opacity: 0; transform: scale(0.97); } }
        @keyframes msel-out-${uid} { to { opacity: 0; transform: scale(0.99); } }
        [data-msel="${uid}"][data-state="open"] { animation: msel-in-${uid} 150ms var(--ease-out); }
        [data-msel="${uid}"][data-state="closed"] { animation: msel-out-${uid} 100ms var(--ease-exit) forwards; }
        @media (prefers-reduced-motion: reduce) {
          @keyframes msel-in-${uid} { from { opacity: 0; } }
          @keyframes msel-out-${uid} { to { opacity: 0; } }
        }
      `}</style>
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Content
          data-msel={uid}
          align="start"
          sideOffset={6}
          className="z-50 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-overlay"
          style={{
            transformOrigin: "var(--radix-popover-content-transform-origin)",
          }}
        >
          <div className="max-h-64 overflow-y-auto p-1" role="group" aria-label="Options">
            {options.map((opt) => {
              const checked = draft.includes(opt.value);
              return (
                <label
                  key={opt.value}
                  className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors duration-100 hover:bg-accent"
                >
                  <CheckboxPrimitive.Root
                    checked={checked}
                    onCheckedChange={(next) =>
                      setDraft((prev) =>
                        next === true
                          ? [...prev, opt.value]
                          : prev.filter((v) => v !== opt.value),
                      )
                    }
                    className="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-transparent transition-[background-color,border-color] duration-100 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
                  >
                    <CheckboxPrimitive.Indicator className="transition-[scale,opacity] duration-150 ease-out starting:scale-50 starting:opacity-0 motion-reduce:transition-none">
                      <Check className="size-3" aria-hidden />
                    </CheckboxPrimitive.Indicator>
                  </CheckboxPrimitive.Root>
                  <span className="truncate">{opt.label}</span>
                </label>
              );
            })}
          </div>
          <div className="flex items-center justify-between gap-2 border-t p-2">
            <Button
              variant="ghost"
              size="sm"
              onClick={() => setDraft([])}
              disabled={draft.length === 0}
            >
              Clear
            </Button>
            <Button size="sm" onClick={apply}>
              Apply
              {draft.length > 0 && (
                <span className="tabular-nums">({draft.length})</span>
              )}
            </Button>
          </div>
        </PopoverPrimitive.Content>
      </PopoverPrimitive.Portal>
      {name &&
        committed.map((v) => (
          <input key={v} type="hidden" name={name} value={v} />
        ))}
    </PopoverPrimitive.Root>
  );
}