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