Multi-selectNew
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
$ npxnpx shadcn@latest add https://hairlineui.com/r/multi-select.jsonTemplates
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.
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.
- 01
One-line summary
First names, then a rolling +N.
- 02
Stays open
Pick several; Done or Escape closes it.
- 03
Filter for long lists
Appears past seven options.
- 04
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
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
| Prop | Type | Description |
|---|---|---|
| options | MultiOption[] | value, label, description, disabled. |
| value / defaultValue | string[] | Chosen values, kept in the options' order. |
| onValueChange | (value: string[]) => void | Called on every tick. |
| label | string | Shown above; accessible name. |
| searchable | booleandefault More than 7 options | Show the filter. |
| maxShown | numberdefault 2 | Labels named before +N. |
| hint / error / placeholder | ReactNode | As 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
"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;"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;"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


