Skip to content

Chips that fit the field with a rolling +N, search, select all and clear.

Pickersmotion@base-ui/react@number-flow/react

01Preview

#2481 · feat/usage-billing

Metered usage on the billing page

Reviewers
4 selected: Mina Okafor, Tom Lindqvist, Adeola Bankole, Priya Raman

Jonah is out until Monday and can’t be requested.

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react @number-flow/react

03Usage

import { MultiSelect } from "@/components/ui/multi-select";

<MultiSelect
  label="Reviewers"
  options={[
    { value: "mina", label: "Mina Okafor", description: "Design systems" },
    { value: "tom", label: "Tom Lindqvist", description: "Frontend" },
  ]}
  defaultValue={["mina"]}
  placeholder="Add reviewers"
  noun="people"
/>

04Source

"use client";
import { Combobox } from "@base-ui/react/combobox";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { ChevronsUpDown, Search, X } from "@/lib/icons";
import { spring } from "@/lib/motion";

export type MultiSelectOption = {
  value: string;
  label: string;
  /** Decorative: an avatar, a flag, a status dot. Shown in the list and on the chip. */
  icon?: React.ReactNode;
  description?: string;
  disabled?: boolean;
};

// The "Select all" row is a real list item so arrow keys reach it; it never stays in the value.
const ALL: MultiSelectOption = { value: "\u0000all", label: "Select all" };

export type MultiSelectProps = {
  options: MultiSelectOption[];
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  /** Visible label. Without one, pass aria-label. */
  label?: React.ReactNode;
  "aria-label"?: string;
  placeholder?: string;
  searchPlaceholder?: string;
  /** Noun for the count and the empty state, e.g. "people". */
  noun?: string;
  /** Show the "Select all" row, which acts on what the search currently matches. */
  selectAll?: boolean;
  disabled?: boolean;
  invalid?: boolean;
  name?: string;
  /** Portal target for the popup. Defaults to document.body. */
  container?: Combobox.Portal.Props["container"];
  className?: string;
};

const normalize = (s: string) => s.normalize("NFD").replace(/\p{M}/gu, "").toLocaleLowerCase();

export function MultiSelect({
  options,
  value,
  defaultValue = [],
  onValueChange,
  label,
  "aria-label": ariaLabel,
  placeholder = "Select…",
  searchPlaceholder = "Search",
  noun = "items",
  selectAll = true,
  disabled,
  invalid,
  name,
  container,
  className,
}: MultiSelectProps) {
  const reduce = !!useReducedMotion();
  const [inner, setInner] = useState(defaultValue);
  const controlled = value !== undefined;
  const ids = controlled ? value : inner;
  const setIds = (next: string[]) => {
    if (!controlled) setInner(next);
    onValueChange?.(next);
  };

  const byValue = useMemo(() => new Map(options.map((o) => [o.value, o])), [options]);
  const selected = useMemo(() => ids.map((id) => byValue.get(id)).filter((o): o is MultiSelectOption => !!o), [ids, byValue]);
  const [query, setQuery] = useState("");
  const q = normalize(query.trim());
  const matches = useMemo(() => (q ? options.filter((o) => normalize(o.label).includes(q) || normalize(o.description ?? "").includes(q)) : options), [options, q]);
  const pickable = matches.filter((o) => !o.disabled);
  const pickedInMatches = pickable.filter((o) => ids.includes(o.value)).length;
  const allState = pickable.length > 0 && pickedInMatches === pickable.length ? "checked" : pickedInMatches > 0 ? "mixed" : "unchecked";
  const filtered = matches.length ? (selectAll && pickable.length > 1 ? [ALL, ...matches] : matches) : [];

  const describedBy = useId();
  const { rowRef, measureRef, fit } = useFit(selected.map((o) => o.value).join("|"));
  const shown = selected.slice(0, fit);
  const overflow = selected.length - shown.length;
  const { setList, y, height, opacity } = useGlide(reduce);

  // The resting state is the same either way so server and client markup agree; only the travel differs.
  const chipMotion = {
    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: "blur(2px)" },
    animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
    exit: reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.8, filter: "blur(2px)", transition: { duration: 0.12 } },
    transition: reduce ? { duration: 0.12 } : spring.snappy,
  };

  return (
    <Combobox.Root<MultiSelectOption, true>
      multiple
      items={selectAll ? [ALL, ...options] : options}
      filteredItems={filtered}
      value={selected}
      onValueChange={(next) => {
        if (next.includes(ALL)) {
          const pickableIds = pickable.map((o) => o.value);
          setIds(allState === "checked" ? ids.filter((id) => !pickableIds.includes(id)) : [...ids, ...pickableIds.filter((id) => !ids.includes(id))]);
          return;
        }
        setIds(next.map((o) => o.value));
      }}
      inputValue={query}
      onInputValueChange={(next, details) => {
        // Keep the search after a pick so several results can be chosen from one query.
        if (details.reason === "input-clear" && details.isItemPress) return details.cancel();
        setQuery(next);
      }}
      onOpenChangeComplete={(open) => !open && setQuery("")}
      isItemEqualToValue={(a, b) => a.value === b.value}
      itemToStringLabel={(o) => o.label}
      itemToStringValue={(o) => o.value}
      disabled={disabled}
      name={name}
    >
      <div className={cn("flex w-full min-w-0 flex-col gap-1.5", className)}>
        {label && <Combobox.Label className="w-fit cursor-default text-[12.5px] font-medium text-fg-2 select-none">{label}</Combobox.Label>}
        <div className="group/field relative">
          <Combobox.Trigger
            aria-label={label ? undefined : ariaLabel}
            aria-describedby={selected.length ? describedBy : undefined}
            aria-invalid={invalid || undefined}
            className={cn(
              "flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-line-2 bg-raised pr-2 pl-1 text-left text-[13px] text-fg shadow-[var(--shadow)] select-none",
              "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
              "transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.99] active:duration-75",
              "hover:border-fg-4 data-popup-open:border-fg-4",
              "aria-invalid:border-danger/70 data-disabled:pointer-events-none data-disabled:opacity-50",
              "pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5",
              selected.length ? "pr-[3.25rem]" : "pl-2.5",
            )}
          >
            <span ref={rowRef} className="relative flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden">
              {selected.length === 0 && <span className="truncate text-fg-4">{placeholder}</span>}
              <AnimatePresence initial={false} mode="popLayout">
                {shown.map((o, i) => (
                  <motion.span key={o.value} layout={!reduce} {...chipMotion} className={cn(i === 0 ? "min-w-0 shrink" : "shrink-0", "flex")}>
                    <Chip option={o} />
                  </motion.span>
                ))}
                {overflow > 0 && (
                  <motion.span key="more" layout={!reduce} {...chipMotion} className="flex h-[22px] shrink-0 items-center rounded-[5px] bg-line px-1.5 text-[12px] text-fg-2">
                    +<NumberFlow value={overflow} className="tabular" />
                  </motion.span>
                )}
              </AnimatePresence>
              {/* Every chip laid out off-screen, so the row knows how many fit before it shows them. */}
              <span ref={measureRef} aria-hidden className="pointer-events-none invisible absolute top-0 left-0 flex h-full items-center gap-1">
                {selected.map((o) => (
                  <span key={o.value} className="flex shrink-0">
                    <Chip option={o} />
                  </span>
                ))}
                <span className="flex h-[22px] shrink-0 items-center rounded-[5px] px-1.5 text-[12px] tabular">+{selected.length}</span>
              </span>
            </span>
            <ChevronsUpDown size={16} className="absolute right-2 shrink-0 text-fg-3 transition-colors group-hover/field:text-fg-2" />
          </Combobox.Trigger>
          <span id={describedBy} className="sr-only">
            {selected.length} selected: {selected.map((o) => o.label).join(", ")}
          </span>

          <AnimatePresence initial={false}>
            {selected.length > 0 && !disabled && (
              <motion.button
                type="button"
                aria-label={`Clear ${noun}`}
                onClick={() => setIds([])}
                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}
                transition={spring.pop}
                className={cn(
                  "absolute top-1/2 right-7 -mt-2.5 grid size-5 place-items-center rounded-md text-fg-3 hover:bg-hover hover:text-fg",
                  "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
                  "transition-colors active:scale-[0.9]",
                  "after:absolute after:-inset-2.5 after:content-['']",
                )}
              >
                <X size={14} />
              </motion.button>
            )}
          </AnimatePresence>
        </div>
      </div>

      <Combobox.Portal container={container}>
        <Combobox.Positioner align="start" sideOffset={6} collisionPadding={8} className="z-(--z-popover) outline-none">
          <Combobox.Popup
            aria-label={typeof label === "string" ? label : ariaLabel}
            className={cn(
              "flex w-(--anchor-width) max-w-(--available-width) min-w-64 origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
              "transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out",
              "data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0",
              "motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100",
            )}
          >
            <div className="flex h-10 shrink-0 items-center gap-2 border-b border-line px-3">
              <Search size={14} className="shrink-0 text-fg-3" />
              <Combobox.Input
                aria-label={searchPlaceholder}
                placeholder={searchPlaceholder}
                onKeyDown={(e) => {
                  // Backspace in an empty search takes back the last pick.
                  if (e.key === "Backspace" && query === "" && ids.length) setIds(ids.slice(0, -1));
                }}
                className="h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]"
              />
            </div>

            <Combobox.Empty className="empty:hidden">
              <div className="flex flex-col items-center gap-2 px-3 py-5 text-center text-[12.5px] text-fg-3">
                <p className="max-w-full truncate">
                  No {noun} match <span className="text-fg-2">{query.trim()}</span>
                </p>
                <button
                  type="button"
                  onClick={() => setQuery("")}
                  className="h-6 rounded-md border border-line-2 px-2 text-[12px] font-medium text-fg-2 transition-[background-color,color,scale] duration-150 outline-none hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 active:scale-[0.96]"
                >
                  Clear search
                </button>
              </div>
            </Combobox.Empty>

            <Combobox.List ref={setList} className="relative max-h-[min(calc(var(--available-height)-6rem),16.5rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none empty:hidden">
              <motion.div aria-hidden style={{ y, height, opacity }} className="pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line" />
              <Combobox.Collection>
              {(o: MultiSelectOption) =>
                o === ALL ? (
                  <Combobox.Item key={o.value} value={o} className={cn(itemClass, "mb-1 after:absolute after:inset-x-1 after:-bottom-[3px] after:h-px after:bg-line")}>
                    <Box state={allState} reduce={reduce} />
                    <span className="min-w-0 flex-1 truncate">{allState === "checked" ? "Deselect all" : q ? `Select all ${pickable.length} matches` : "Select all"}</span>
                    <span className="font-mono text-2xs text-fg-4 tabular">{pickable.length}</span>
                  </Combobox.Item>
                ) : (
                  <Combobox.Item key={o.value} value={o} disabled={o.disabled} className={itemClass}>
                    <Box state={ids.includes(o.value) ? "checked" : "unchecked"} reduce={reduce} />
                    {o.icon && <span className="flex shrink-0 text-fg-3">{o.icon}</span>}
                    <span className="flex min-w-0 flex-1 items-baseline gap-2">
                      <span className="truncate">{o.label}</span>
                      {o.description && <span className="min-w-0 truncate text-[12px] text-fg-3">{o.description}</span>}
                    </span>
                  </Combobox.Item>
                )
              }
              </Combobox.Collection>
            </Combobox.List>

            <div className="flex h-9 shrink-0 items-center justify-between gap-2 border-t border-line pr-1.5 pl-3 text-[12px] text-fg-3">
              <span className="tabular">
                <NumberFlow value={selected.length} className="text-fg-2" /> of {options.length} selected
              </span>
              <button
                type="button"
                disabled={!selected.length}
                onClick={() => setIds([])}
                className={cn(
                  "h-6 rounded-md px-2 text-[12px] font-medium text-fg-2 hover:bg-hover hover:text-fg disabled:pointer-events-none disabled:opacity-40",
                  "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
                  "transition-[background-color,color,scale] duration-150 active:scale-[0.96]",
                )}
              >
                Clear
              </button>
            </div>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  );
}

const itemClass = cn(
  "group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-10",
  "data-disabled:text-fg-4 data-disabled:[&_span]:text-fg-4",
);

function Chip({ option }: { option: MultiSelectOption }) {
  return (
    <span className="flex h-[22px] min-w-0 items-center gap-1 rounded-[5px] border border-line-2 bg-frame px-1.5 text-[12px] leading-none text-fg">
      {option.icon && <span className="flex shrink-0 [&>*]:size-3.5">{option.icon}</span>}
      <span className="min-w-0 truncate">{option.label}</span>
    </span>
  );
}

// A checkbox that squashes a little on press and draws its tick when it turns on.
function Box({ state, reduce }: { state: "checked" | "unchecked" | "mixed"; reduce: boolean }) {
  const on = state !== "unchecked";
  return (
    <span
      aria-hidden
      data-state={state}
      className={cn(
        "grid size-3.5 shrink-0 place-items-center rounded-[4px] border transition-[background-color,border-color,scale] duration-150 ease-out group-active/item:scale-[0.88]",
        on ? "border-fg bg-fg text-frame" : "border-line-2 bg-frame group-data-highlighted/item:border-fg-4",
      )}
    >
      <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
        {state === "mixed" ? (
          <path d="M2.5 5h5" />
        ) : (
          <motion.path
            d="M2 5.2 4.1 7.2 8 2.8"
            initial={false}
            animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
            transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.2, ease: [0.16, 1, 0.3, 1] }, opacity: { duration: 0.05 } }}
          />
        )}
      </svg>
    </span>
  );
}

// Works out how many chips fit on one line beside a "+N" badge, from an invisible copy of the row.
function useFit(key: string) {
  const rowRef = useRef<HTMLSpanElement>(null);
  const measureRef = useRef<HTMLSpanElement>(null);
  const [fit, setFit] = useState(99);

  useEffect(() => {
    const row = rowRef.current;
    const measure = measureRef.current;
    if (!row || !measure) return;
    let frame = 0;
    const compute = () => {
      const items = [...measure.children] as HTMLElement[];
      const badge = items.pop();
      const available = row.clientWidth;
      const gap = 4;
      let used = 0;
      let n = 0;
      for (let i = 0; i < items.length; i++) {
        const width = items[i].offsetWidth + (i ? gap : 0);
        const reserve = i < items.length - 1 ? gap + (badge?.offsetWidth ?? 0) : 0;
        if (used + width + reserve > available) break;
        used += width;
        n = i + 1;
      }
      // The first chip always shows, truncating if it must, so the field never reads as empty.
      setFit(Math.max(1, n));
    };
    const schedule = () => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(compute);
    };
    const observer = new ResizeObserver(schedule);
    observer.observe(row);
    observer.observe(measure);
    schedule();
    return () => {
      observer.disconnect();
      cancelAnimationFrame(frame);
    };
  }, [key]);

  return { rowRef, measureRef, fit };
}

// One highlight for the list: glides after the pointer, jumps for arrow keys.
function useGlide(reduce: boolean) {
  const [list, setList] = useState<HTMLDivElement | null>(null);
  const y = useMotionValue(0);
  const height = useMotionValue(32);
  const opacity = useMotionValue(0);

  useEffect(() => {
    if (!list) return;
    let keyboard = false;
    let shown = false;
    let running: AnimationPlaybackControls[] = [];
    const stop = () => {
      running.forEach((c) => c.stop());
      running = [];
    };
    const place = () => {
      const el = list.querySelector<HTMLElement>("[data-highlighted]");
      stop();
      if (!el) {
        shown = false;
        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));
        return;
      }
      const target = el.hasAttribute("data-disabled") ? 0.45 : 1;
      let top = 0;
      for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;
      if (!shown || keyboard || reduce) {
        y.jump(top);
        height.jump(el.offsetHeight);
        if (keyboard || reduce) opacity.jump(target);
        else running.push(animate(opacity, target, { duration: 0.08 }));
      } else {
        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));
        opacity.jump(target);
      }
      shown = true;
    };
    const onKey = () => (keyboard = true);
    const onPointer = () => (keyboard = false);
    const observer = new MutationObserver(place);
    observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-highlighted"] });
    document.addEventListener("keydown", onKey, true);
    list.addEventListener("pointermove", onPointer);
    place();
    return () => {
      observer.disconnect();
      document.removeEventListener("keydown", onKey, true);
      list.removeEventListener("pointermove", onPointer);
      stop();
    };
  }, [list, reduce, y, height, opacity]);

  return { setList, y, height, opacity };
}

05Props

PropTypeDefaultDescription
options*{ value: string; label: string; icon?; description?; disabled? }[]Everything that can be picked. Icons show in the list and on chips.
valuestring[]Chosen values in the order they were picked, when controlled.
defaultValuestring[][]Initial values, when uncontrolled.
onValueChange(value: string[]) => voidCalled with the full list after every pick, removal, select all or clear.
labelReactNodeVisible label for the field. Without one, pass aria-label.
placeholderstring"Select…"Shown in the field while nothing is picked.
searchPlaceholderstring"Search"Placeholder and accessible name of the search box in the popup.
nounstring"items"Used in the empty state and the clear button's label: “No people match…”, “Clear people”.
selectAllbooleantrueAdds a Select all row that acts on what the search currently matches.
disabledbooleanfalseDims the field and ignores input.
invalidbooleanfalseDanger border and aria-invalid.
namestringSubmits each value with a surrounding form.
containerHTMLElement | RefObjectWhere to portal the popup. Defaults to document.body.

06Notes

Behavior

  • An invisible copy of the chip row is measured on every change and resize, so the field shows as many whole chips as fit and a +N for the rest. The first chip always shows, truncating if it has to.
  • The search survives each pick, so several results can be chosen from one query; it resets when the popup closes. Select all follows the search (“Select all 4 matches”), skips disabled options and turns into Deselect all.
  • Backspace in an empty search takes back the last pick. No matches shows the query with a Clear search action.
  • Disabled options stay visible and reachable so people can see who can't be picked, and are never added by Select all.

Motion

  • Chips pop in from scale 0.8 with a 2px blur on the snappy spring (520/38) and their neighbors slide into place with layout animation; exits are 120ms.
  • The +N and the footer count roll their digits instead of swapping.
  • Checkboxes squash to 0.88 while pressed and draw their tick in 200ms on the expo ease-out. The list highlight glides after the pointer and jumps on arrow keys.
  • Reduced motion keeps short opacity fades and drops scale, blur, layout travel and the glide.

Accessibility

  • The field is Base UI's combobox trigger with a search input inside the popup; options are a multi-select listbox with aria-selected.
  • The trigger is described by a hidden summary (“4 selected: Mina Okafor, …”), since chips truncate and overflow into +N.
  • Arrows move through Select all and the options, Enter or click toggles, Escape closes and returns focus to the field. Clear is a separate labeled button with a 40px hit area.