Components

Getting started

Components154

More

Several choices from a short list: the pill names them ("Design, Sales +2"), the list rises off it with ticks that draw, and it stays open while you pick.

Used forTable filtersNotification settingsAssignmentForms

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/multi-select.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Table filters

    Status: Open, Pending +1.

  • Notification settings

    Which channels to be told on.

  • Assignment

    Teams that can see a project.

  • Forms

    Interests or regions in a profile.

What it handles for you

Chips are good for a few values and bad for a filter field that must stay one line; a summary with a rolling count stays put.

Instead of wiring upreact-select (multi)checkbox dropdownfilter menu
  1. One-line summary

    First names, then a rolling +N.

  2. Stays open

    Pick several; Done or Escape closes it.

  3. Filter for long lists

    Appears past seven options.

  4. Listbox semantics

    aria-multiselectable, aria-selected, active descendant.

Overview

Multi-selects usually fill their field with chips that wrap onto three lines, or close after every pick. This one keeps the pill to one line: it names the first choices and rolls a "+N" for the rest, while the words morph as you tick. The list rises off the pill and stays open; ticks draw themselves, a filter appears for longer lists, and Select all / Clear sit under a count that rolls.

States

closed
Names of the first two choices and a +N that rolls.
open
The list rises off the pill (320ms); the highlight glides between rows.
tick
The box fills and its tick draws (260ms); the pill's words morph.

Usage

usage.tsx
import { MultiSelect } from "@/components/ui/multi-select";<MultiSelect label="Status" defaultValue={["open"]} options={[  { value: "open", label: "Open" },  { value: "pending", label: "Pending" },  { value: "closed", label: "Closed" },]} />

Works with

Props

PropTypeDescription
optionsMultiOption[]value, label, description, disabled.
value / defaultValuestring[]Chosen values, kept in the options' order.
onValueChange(value: string[]) => voidCalled on every tick.
labelstringShown above; accessible name.
searchablebooleandefault More than 7 optionsShow the filter.
maxShownnumberdefault 2Labels named before +N.
hint / error / placeholderReactNodeAs in Input.

Accessibility and motion

  • Placement, presence and dismissal come from floating.ts, shared with tooltip, popover and the menus.
  • For long or remote lists searched first, use combobox.
  • Reduced motion: the list and ticks change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll, Text morph, ui-interaction-motion.

Source

components/multi-select.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { Field, useFieldIds } from "./field";import { enterStyle, themeVars, useDismiss, useFloating, usePresence } from "./floating";import { NumberRoll } from "./number-roll";import { TextMorph } from "./text-morph";import { EASE_OUT, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * MULTI-SELECT: several choices from a short list * *   pill     says what's chosen in words ("Design, Sales"); past *            two it keeps the first and a "+3" that rolls as the *            count moves; the words morph as you tick *   list     rises off the pill; each row's tick draws itself, the *            highlight glides between rows, and the list stays open *            while you pick several *   find     past seven options a filter field sits on top and *            narrows the rows as you type *   bulk     Select all / Clear, and a count that rolls * * For long or remote lists that you search first, use combobox. * ───────────────────────────────────────────────────────── */export interface MultiOption {  value: string;  label: string;  description?: string;  disabled?: boolean;}export interface MultiSelectProps {  options: MultiOption[];  value?: string[];  defaultValue?: string[];  onValueChange?: (value: string[]) => void;  /** Shown above; also the accessible name. */  label: string;  hideLabel?: boolean;  hint?: React.ReactNode;  error?: React.ReactNode;  placeholder?: string;  /** Show the filter field (defaults to more than seven options). */  searchable?: boolean;  /** How many labels the pill names before "+N". */  maxShown?: number;  id?: string;  disabled?: boolean;  className?: string;}function Tick({ on, reduced }: { on: boolean; reduced: boolean }) {  return (    <span      className={`relative flex size-4 shrink-0 items-center justify-center rounded-[5px] transition-[background-color,box-shadow] duration-200 ${on ? "bg-primary text-primary-foreground" : "bg-background shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_22%,var(--border))]"}`}    >      <svg viewBox="0 0 16 16" fill="none" className="size-3">        <path          d="M3.5 8.5 6.5 11.5 12.5 4.5"          stroke="currentColor"          strokeWidth="2"          strokeLinecap="round"          strokeLinejoin="round"          pathLength={1}          strokeDasharray={1}          style={{ strokeDashoffset: on ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset ${on ? 260 : 120}ms ${EASE_OUT}` }}        />      </svg>    </span>  );}export function MultiSelect({  options,  value,  defaultValue = [],  onValueChange,  label,  hideLabel,  hint,  error,  placeholder = "Choose",  searchable,  maxShown = 2,  id,  disabled,  className = "",}: MultiSelectProps) {  const reduced = useInteractionReducedMotion();  const ids = useFieldIds(id);  const listId = React.useId();  const [own, setOwn] = React.useState(defaultValue);  const chosen = value ?? own;  const [open, setOpen] = React.useState(false);  const [query, setQuery] = React.useState("");  const [active, setActive] = React.useState<string | null>(null);  const [mark, setMark] = React.useState<{ y: number; h: number } | null>(null);  const anchor = React.useRef<HTMLDivElement>(null);  const surface = React.useRef<HTMLDivElement>(null);  const list = React.useRef<HTMLDivElement>(null);  const { mounted, shown } = usePresence(open, 180);  const place = useFloating({ open: mounted, anchor, floating: surface, side: "bottom", align: "start", offset: 6 });  const [vars, setVars] = React.useState<React.CSSProperties>({});  const [width, setWidth] = React.useState(240);  const find = searchable ?? options.length > 7;  const visible = options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()));  const pickable = visible.filter((o) => !o.disabled);  const commit = (next: string[]) => {    // Keep the options' own order, whatever order they were ticked in.    const ordered = options.map((o) => o.value).filter((v) => next.includes(v));    if (value === undefined) setOwn(ordered);    onValueChange?.(ordered);  };  const flip = (v: string) => commit(chosen.includes(v) ? chosen.filter((x) => x !== v) : [...chosen, v]);  const close = (refocus: boolean) => {    setOpen(false);    setQuery("");    if (refocus) document.getElementById(ids.id)?.focus();  };  useDismiss(open, (reason) => close(reason === "escape"), [anchor, surface]);  React.useLayoutEffect(() => {    if (!mounted) return;    setVars(themeVars(anchor.current));    setWidth(Math.max(240, anchor.current?.offsetWidth ?? 0));  }, [mounted]);  React.useEffect(() => {    if (!open || !shown) return;    (find ? surface.current?.querySelector<HTMLElement>("input") : list.current)?.focus({ preventScroll: true });  }, [open, shown, find]);  React.useLayoutEffect(() => {    const l = list.current;    if (!l) return;    const measure = () => {      const el = active ? l.querySelector<HTMLElement>(`[data-option="${CSS.escape(active)}"]`) : null;      setMark((p) => (el ? (p && p.y === el.offsetTop && p.h === el.offsetHeight ? p : { y: el.offsetTop, h: el.offsetHeight }) : null));      el?.scrollIntoView({ block: "nearest" });    };    measure();    const ro = new ResizeObserver(measure);    ro.observe(l);    return () => ro.disconnect();  }, [active, query, mounted]);  const onKeyDown = (e: React.KeyboardEvent) => {    const i = pickable.findIndex((o) => o.value === active);    if (e.key === "ArrowDown" || e.key === "ArrowUp") {      e.preventDefault();      const next = pickable[e.key === "ArrowDown" ? (i + 1) % pickable.length : (i - 1 + pickable.length) % pickable.length];      if (next) setActive(next.value);    } else if ((e.key === "Enter" || (e.key === " " && !find)) && i >= 0) {      e.preventDefault();      flip(pickable[i].value);    } else if (e.key === "Tab") close(false);  };  const names = options.filter((o) => chosen.includes(o.value)).map((o) => o.label);  const extra = Math.max(0, names.length - maxShown);  const summary = names.slice(0, maxShown).join(", ");  const allOn = pickable.length > 0 && pickable.every((o) => chosen.includes(o.value));  return (    <Field id={ids.id} label={hideLabel ? undefined : label} hint={hint} error={error} className={className}>      <div ref={anchor}>        <button          id={ids.id}          type="button"          disabled={disabled}          aria-haspopup="listbox"          aria-expanded={open}          aria-controls={open ? listId : undefined}          aria-label={`${label}: ${names.length ? names.join(", ") : "none chosen"}`}          onClick={() => (open ? close(false) : (setActive(null), setOpen(true)))}          onKeyDown={(e) => {            if (e.key === "ArrowDown") {              e.preventDefault();              setActive(pickable[0]?.value ?? null);              setOpen(true);            }          }}          className={`flex h-9 w-full items-center gap-2 rounded-full bg-background pl-4 pr-3 text-left text-[13.5px] cursor-pointer shadow-[inset_0_0_0_1px_var(--border)] transition-[box-shadow,background-color] duration-200 hover:bg-accent/40 disabled:cursor-default disabled:opacity-50 ${            error ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)]" : open ? "shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]" : ""          } ${INTERACTION_FOCUS}`}        >          <span className={`min-w-0 flex-1 truncate ${names.length ? "text-foreground" : "text-muted-foreground/70"}`}>            <TextMorph>{names.length ? summary : placeholder}</TextMorph>          </span>          <span            className="grid shrink-0"            style={{ gridTemplateColumns: extra ? "1fr" : "0fr", opacity: extra ? 1 : 0, transition: reduced ? "none" : `grid-template-columns 320ms ${EASE_OUT}, opacity 200ms ${EASE_OUT}` }}          >            <span className="min-w-0 overflow-hidden">              <span className="block rounded-full bg-muted px-1.5 text-[11.5px] font-medium tabular-nums text-muted-foreground">                +<NumberRoll value={Math.max(1, extra)} />              </span>            </span>          </span>          <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" style={{ transform: open ? "rotate(180deg)" : "none", transition: reduced ? "none" : `transform 380ms ${EASE_OUT}` }}>            <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />          </svg>        </button>      </div>      {mounted &&        createPortal(          <div            ref={surface}            onKeyDown={onKeyDown}            className="fixed left-0 top-0 z-[55] flex max-h-[min(360px,70dvh)] flex-col overflow-hidden rounded-[16px] bg-popover text-popover-foreground shadow-[0_0_0_1px_var(--border)]"            style={{ ...vars, width, left: place.x, top: place.y, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }}          >            {find && (              <div className="border-b border-border p-1.5">                <input                  value={query}                  onChange={(e) => {                    setQuery(e.target.value);                    setActive(null);                  }}                  placeholder="Filter"                  aria-label={`Filter ${label}`}                  aria-controls={listId}                  className="h-8 w-full rounded-full bg-muted/60 px-3 text-[16px] outline-none sm:text-[13px] placeholder:text-muted-foreground/70"                />              </div>            )}            <div              ref={list}              id={listId}              role="listbox"              aria-multiselectable="true"              aria-label={label}              aria-activedescendant={active ? `${listId}-${active}` : undefined}              tabIndex={find ? -1 : 0}              onPointerLeave={() => setActive(null)}              className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain p-1 outline-none"            >              <span                aria-hidden="true"                className="pointer-events-none absolute inset-x-1 top-0 rounded-[10px] bg-accent"                style={{ transform: `translateY(${mark?.y ?? 0}px)`, height: mark?.h ?? 0, opacity: mark ? 1 : 0, transition: reduced ? "opacity 120ms" : `transform 220ms ${EASE_OUT}, height 220ms ${EASE_OUT}, opacity 160ms ${EASE_OUT}` }}              />              {visible.map((o) => {                const on = chosen.includes(o.value);                return (                  <div                    key={o.value}                    id={`${listId}-${o.value}`}                    data-option={o.value}                    role="option"                    aria-selected={on}                    aria-disabled={o.disabled || undefined}                    onPointerMove={() => !o.disabled && active !== o.value && setActive(o.value)}                    onClick={() => !o.disabled && flip(o.value)}                    className={`relative flex min-h-8 select-none items-center gap-2.5 rounded-[10px] px-2.5 py-1.5 text-[13px] ${o.disabled ? "cursor-default opacity-40" : "cursor-pointer"}`}                  >                    <Tick on={on} reduced={reduced} />                    <span className="flex min-w-0 flex-1 flex-col">                      <span className="truncate text-foreground">{o.label}</span>                      {o.description && <span className="truncate text-[12px] text-muted-foreground">{o.description}</span>}                    </span>                  </div>                );              })}              {!visible.length && <p className="px-2.5 py-3 text-[13px] text-muted-foreground">Nothing matches “{query}”.</p>}            </div>            <div className="flex items-center justify-between gap-2 border-t border-border px-1.5 py-1.5">              <span className="pl-2 text-[12px] tabular-nums text-muted-foreground" aria-live="polite">                <NumberRoll value={chosen.length} /> selected              </span>              <span className="flex gap-1">                <button                  type="button"                  onClick={() => commit(allOn ? chosen.filter((v) => !pickable.some((o) => o.value === v)) : [...new Set([...chosen, ...pickable.map((o) => o.value)])])}                  className={`h-7 rounded-full px-2.5 text-[12.5px] font-medium text-foreground cursor-pointer hover:bg-accent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}                >                  <TextMorph>{allOn ? "Select none" : "Select all"}</TextMorph>                </button>                <button                  type="button"                  onClick={() => close(true)}                  className={`h-7 rounded-full bg-primary px-3 text-[12.5px] font-medium text-primary-foreground cursor-pointer hover:bg-primary/90 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}                >                  Done                </button>              </span>            </div>          </div>,          document.body,        )}    </Field>  );}export default MultiSelect;
components/field.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * *   label    above, with an optional aside on the right (a count) *   hint     under the control while all is well *   error    takes the hint's place: the hint folds away, the *            error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps {  id: string;  label?: React.ReactNode;  /** Right side of the label row, e.g. a character count. */  aside?: React.ReactNode;  hint?: React.ReactNode;  error?: React.ReactNode;  required?: boolean;  className?: string;  children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {  const ring = invalid    ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]"    : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]";  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${    disabled ? "pointer-events-none opacity-50" : ""  }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) {  const auto = React.useId();  const base = id ?? auto;  return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) {  const ref = React.useRef<T>(null);  const reduced = useInteractionReducedMotion();  React.useEffect(() => {    if (!on || reduced) return;    ref.current?.animate(      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],      { duration: 380, easing: "ease-out" },    );  }, [on, reduced]);  return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;  Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value);  el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {  const [kept, setKept] = React.useState(children);  if (show && children !== kept) setKept(children);  return (    <div      className="grid"      style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }}    >      <div className="min-h-0 overflow-hidden">        <p          id={id}          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}          style={{            opacity: show ? 1 : 0,            filter: show ? "blur(0px)" : "blur(4px)",            transform: show ? "none" : "translateY(-4px)",            transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,          }}        >          {show ? children : kept}        </p>      </div>    </div>  );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) {  const reduced = useInteractionReducedMotion();  const ids = { hint: `${id}-hint`, error: `${id}-error` };  return (    <div className={`flex w-full flex-col ${className}`}>      {(label || aside) && (        <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5">          {label && (            <label htmlFor={id} className="text-[13px] font-medium text-foreground">              {label}              {required && (                <span aria-hidden="true" className="ml-0.5 text-muted-foreground">                  *                </span>              )}            </label>          )}          {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>}        </div>      )}      {children}      <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}>        {hint}      </Line>      <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}>        {error}      </Line>    </div>  );}export default Field;
components/floating.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FLOATING: what every popover, menu and tooltip shares * *   place     beside an anchor on a side, lined up start, centre *             or end; flips to the other side when it doesn't fit *             and shifts to stay `padding` inside the viewport; *             follows scroll and resize *   presence  mounts on open, shows a frame later (so the entry *             transitions run), hides, then unmounts after `exit` *   dismiss   Escape and a press outside both close it *   theme     the portal carries the anchor's theme tokens, so a *             light specimen on a dark page stays light * ───────────────────────────────────────────────────────── */export type Side = "top" | "bottom" | "left" | "right";export type Align = "start" | "center" | "end";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring", "--card"];/** The anchor's theme tokens as inline styles for something portalled out of its tree. */export function themeVars(anchor: Element | null): React.CSSProperties {  if (!anchor) return {};  const style = getComputedStyle(anchor);  const vars: Record<string, string> = {};  for (const t of TOKENS) {    const v = style.getPropertyValue(t);    if (v) vars[t] = v;  }  return vars as React.CSSProperties;}const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };export interface Placement {  x: number;  y: number;  side: Side;  /** Where the surface grows from, for transform-origin. */  origin: string;  ready: boolean;}/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */export function useFloating({  open,  anchor,  point,  floating,  side = "bottom",  align = "center",  offset = 8,  padding = 8,}: {  open: boolean;  anchor?: React.RefObject<Element | null>;  point?: { x: number; y: number } | null;  floating: React.RefObject<HTMLElement | null>;  side?: Side;  align?: Align;  offset?: number;  padding?: number;}): Placement {  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: "center", ready: false });  React.useLayoutEffect(() => {    if (!open) return;    let frame = 0;    const measure = () => {      const el = floating.current;      if (!el) return;      const a = point        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }        : anchor?.current?.getBoundingClientRect();      if (!a) return;      const w = el.offsetWidth;      const h = el.offsetHeight;      const vw = window.innerWidth;      const vh = window.innerHeight;      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };      const need = side === "top" || side === "bottom" ? h + offset : w + offset;      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;      let x: number;      let y: number;      if (s === "top" || s === "bottom") {        y = s === "bottom" ? a.bottom + offset : a.top - offset - h;        x = align === "start" ? a.left : align === "end" ? a.right - w : a.left + a.width / 2 - w / 2;        if (point) x = a.left;      } else {        x = s === "right" ? a.right + offset : a.left - offset - w;        y = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + a.height / 2 - h / 2;      }      x = Math.max(padding, Math.min(vw - padding - w, x));      y = Math.max(padding, Math.min(vh - padding - h, y));      // Grow from the point on the anchor's edge nearest the surface.      const ox = s === "left" ? "100%" : s === "right" ? "0%" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;      const oy = s === "top" ? "100%" : s === "bottom" ? "0%" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));    };    const schedule = () => {      cancelAnimationFrame(frame);      frame = requestAnimationFrame(measure);    };    measure();    const ro = new ResizeObserver(schedule);    if (floating.current) ro.observe(floating.current);    window.addEventListener("scroll", schedule, { capture: true, passive: true });    window.addEventListener("resize", schedule);    return () => {      cancelAnimationFrame(frame);      ro.disconnect();      window.removeEventListener("scroll", schedule, { capture: true });      window.removeEventListener("resize", schedule);    };  }, [open, anchor, point, floating, side, align, offset, padding]);  return open ? place : { ...place, ready: false };}/** Mount → show next frame → hide → unmount after `exit` ms. */export function usePresence(open: boolean, exit = 160) {  const [mounted, setMounted] = React.useState(open);  const [shown, setShown] = React.useState(false);  if (open && !mounted) setMounted(true);  React.useEffect(() => {    if (open) {      let inner = 0;      const outer = requestAnimationFrame(() => {        inner = requestAnimationFrame(() => setShown(true));      });      return () => {        cancelAnimationFrame(outer);        cancelAnimationFrame(inner);      };    }    const timer = window.setTimeout(() => {      setMounted(false);      setShown(false);    }, exit);    return () => window.clearTimeout(timer);  }, [open, exit]);  return { mounted: mounted || open, shown: open && shown };}/** Escape and presses outside `refs` call onDismiss while `open`. */export function useDismiss(open: boolean, onDismiss: (reason: "escape" | "outside") => void, refs: React.RefObject<Element | null>[]) {  const latest = React.useRef({ onDismiss, refs });  React.useEffect(() => {    latest.current = { onDismiss, refs };  });  React.useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key !== "Escape") return;      e.stopPropagation();      latest.current.onDismiss("escape");    };    const onDown = (e: PointerEvent) => {      const target = e.target as Node;      if (latest.current.refs.some((r) => r.current?.contains(target))) return;      latest.current.onDismiss("outside");    };    document.addEventListener("keydown", onKey);    document.addEventListener("pointerdown", onDown, true);    return () => {      document.removeEventListener("keydown", onKey);      document.removeEventListener("pointerdown", onDown, true);    };  }, [open]);}/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */export function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = "cubic-bezier(0.16,1,0.3,1)"): React.CSSProperties {  const d = 6;  const shift = side === "top" ? `0 ${d}px` : side === "bottom" ? `0 -${d}px` : side === "left" ? `${d}px 0` : `-${d}px 0`;  return {    opacity: shown ? 1 : 0,    translate: shown || reduced ? "0 0" : shift,    scale: shown || reduced ? "1" : "0.96",    filter: shown || reduced ? "blur(0px)" : "blur(4px)",    transition: reduced      ? "opacity 120ms ease-out"      : shown        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,  };}

Registry item: /r/multi-select.json