{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "title": "Combobox",
  "description": "An animated combobox for React and shadcn, with filtering, a clear button and multi-select chips.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-overlay-core.json",
    "https://eased.dev/r/eased-floating.json",
    "https://eased.dev/r/eased-field.json",
    "https://eased.dev/r/spinner.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/combobox/combobox.tsx",
      "content": "\"use client\";\n\nimport { Combobox as BaseCombobox } from \"@base-ui/react/combobox\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Fragment, useRef, useState, type ReactNode } from \"react\";\nimport { InputGroup, controlClass, type ControlSize } from \"../_primitives/field/field\";\nimport { FloatingLayer } from \"../_primitives/floating/floating\";\nimport {\n  groupClass,\n  groupLabelClass,\n  itemClass,\n  ItemCheck,\n  ItemParts,\n  menuSurface,\n  OVERLAY_ROLES,\n  separatorClass,\n} from \"../_primitives/overlay-core/overlay-core\";\nimport { exitFast, fade, reducedFade, snappy, useSafeReducedMotion } from \"../lib/motion\";\nimport { pressIcon } from \"../lib/press\";\nimport { Spinner } from \"../spinner/spinner\";\n\n/**\n * A filterable select on Base UI's Combobox, in the Eased Input's shell. Type to narrow the\n * list; the clear button pops in once there is something to clear. With `multiple`, choices\n * become chips inside the field that pop in on the snappy spring and leave fast; Backspace\n * removes the last one. The list fills rows in place (no glide while you type, rule 84).\n */\n\nexport type ComboboxOption = {\n  /** A stable id: compared for selection and submitted with the form. */\n  value: string;\n  /** The row's text, what filtering matches and what the input shows once chosen. */\n  label: string;\n  /** An avatar, colour dot or 16px glyph before the label (and on its chip). */\n  icon?: ReactNode;\n  /** Muted text in the trailing column: a team, a count, \"Private\". */\n  detail?: ReactNode;\n  disabled?: boolean;\n};\n\nexport type ComboboxOptionGroup<T extends ComboboxOption = ComboboxOption> = {\n  /** The group's heading. */\n  label: string;\n  items: T[];\n};\n\ntype RootProps<T, M extends boolean | undefined> = Omit<\n  BaseCombobox.Root.Props<T, M>,\n  \"items\" | \"children\" | \"itemToStringLabel\" | \"itemToStringValue\" | \"isItemEqualToValue\"\n>;\n\nexport type ComboboxProps<T extends ComboboxOption, M extends boolean | undefined = false> = RootProps<T, M> & {\n  /** The options, flat or in groups. Base UI filters them as you type. */\n  items: T[] | ComboboxOptionGroup<T>[];\n  /** Muted text in the empty input. In `multiple` mode it shows only while nothing is chosen. */\n  placeholder?: string;\n  /** sm 32px, md 36px (default), lg 40px. */\n  size?: ControlSize;\n  /**\n   * Mark the combobox invalid outside a `Field` (inside one, the field's validity drives it):\n   * red border and one shake when it turns on.\n   */\n  invalid?: boolean;\n  /** What the list says when nothing matches. Default: No matches for “query”. */\n  emptyText?: ReactNode | ((query: string) => ReactNode);\n  /** Show a spinner row instead of the empty state, e.g. while results load. */\n  loading?: boolean;\n  /** The loading row's text. Default \"Searching…\". */\n  loadingText?: string;\n  /** How an option reads in the input and to filtering. Default: its `label`. */\n  itemToStringLabel?: (item: T) => string;\n  /** Keep the list open after a pick in `multiple` mode, so several can be chosen. Default true. */\n  keepOpenOnSelect?: boolean;\n  /** The input's id, for a `<label htmlFor>` outside a `Field`. */\n  id?: string;\n  /** The input's accessible name when no label names it. */\n  \"aria-label\"?: string;\n  /** Accessible name for the clear button. Default \"Clear\". */\n  clearLabel?: string;\n  /** Portal target. Default `document.body`. */\n  container?: HTMLElement | null;\n  /** Stacking order of the popup. Default 50; raise it inside a higher layer (a dialog). */\n  zIndex?: number;\n  /** Classes for the bordered shell (set a width here; it fills its container by default). */\n  className?: string;\n  /** Classes for the popup. */\n  popupClassName?: string;\n};\n\n// Multiple mode: the chip area's min height is the shell's inner height, so one line of chips\n// centres like text; the trailing buttons sit in a box of the same height, on the first line.\nconst LINE: Record<ControlSize, string> = { sm: \"min-h-7.5 py-0.5\", md: \"min-h-8.5 py-1\", lg: \"min-h-9.5 py-1\" };\nconst TRAIL: Record<ControlSize, string> = { sm: \"h-7.5\", md: \"h-8.5\", lg: \"h-9.5\" };\n\nconst CLEAR = `inline-flex size-5 cursor-pointer items-center justify-center rounded-pill bg-surface-hover text-text-secondary hover-fine:bg-surface-active hover-fine:text-text ${pressIcon} data-starting-style:scale-60 data-starting-style:opacity-0 data-ending-style:scale-60 data-ending-style:opacity-0 data-ending-style:[transition-duration:var(--eased-duration-instant)] reduced:data-starting-style:scale-100 reduced:data-ending-style:scale-100`;\n\nconst chipIn = { opacity: 0, scale: 0.9 } as const;\nconst chipShown = { opacity: 1, scale: 1 } as const;\n\nconst isGroup = <T extends ComboboxOption>(entry: T | ComboboxOptionGroup<T>): entry is ComboboxOptionGroup<T> =>\n  Array.isArray((entry as ComboboxOptionGroup<T>).items);\n\n/**\n * The combobox. Values are option objects (compared by `value`):\n *\n *   <Combobox items={countries} placeholder=\"Search countries\" aria-label=\"Country\" />\n *   <Combobox multiple items={people} defaultValue={[people[0]]} placeholder=\"Add reviewers\" />\n */\nexport function Combobox<T extends ComboboxOption, M extends boolean | undefined = false>({\n  items,\n  multiple,\n  placeholder,\n  size = \"md\",\n  invalid,\n  emptyText,\n  loading = false,\n  loadingText = \"Searching…\",\n  itemToStringLabel,\n  keepOpenOnSelect = true,\n  id,\n  \"aria-label\": ariaLabel,\n  clearLabel = \"Clear\",\n  container,\n  zIndex,\n  className = \"\",\n  popupClassName = \"\",\n  onOpenChange,\n  onInputValueChange,\n  onValueChange,\n  ...props\n}: ComboboxProps<T, M>) {\n  const reduced = useSafeReducedMotion();\n  const anchor = useRef<HTMLDivElement>(null);\n  const [query, setQuery] = useState(\"\");\n\n  const input = (extra: string, placeholderText?: string) => (\n    <BaseCombobox.Input\n      id={id}\n      aria-label={ariaLabel}\n      placeholder={placeholderText}\n      {...(invalid !== undefined ? { \"aria-invalid\": invalid } : null)}\n      className={`${controlClass} ${extra}`}\n    />\n  );\n\n  return (\n    <BaseCombobox.Root<T, M>\n      // Typing highlights the first match, so Enter picks it. Pass autoHighlight={false} to opt out.\n      autoHighlight\n      {...props}\n      multiple={multiple}\n      items={items}\n      itemToStringLabel={itemToStringLabel ?? ((item: T) => item.label)}\n      itemToStringValue={(item: T) => item.value}\n      isItemEqualToValue={(a: T, b: T) => a.value === b.value}\n      // In multiple mode the query is controlled, so a pick clears it for the next search.\n      inputValue={multiple && props.inputValue === undefined ? query : props.inputValue}\n      onInputValueChange={(value, details) => {\n        setQuery(value);\n        onInputValueChange?.(value, details);\n      }}\n      onValueChange={(value, details) => {\n        if (multiple) setQuery(\"\");\n        onValueChange?.(value, details);\n      }}\n      onOpenChange={(open, details) => {\n        if (multiple && keepOpenOnSelect && !open && details.reason === \"item-press\") details.cancel();\n        onOpenChange?.(open, details);\n      }}\n    >\n      <div ref={anchor} className={`flex w-full min-w-0 ${className}`}>\n        <InputGroup size={size} invalid={invalid}>\n          {multiple ? (\n            <BaseCombobox.Value>\n              {(value: T[]) => (\n                <BaseCombobox.Chips\n                  aria-label={value.length > 0 ? \"Selected\" : undefined}\n                  className={`relative flex min-w-0 flex-1 flex-wrap content-center items-center gap-1 ${LINE[size]}`}\n                >\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {value.map((item) => (\n                      <BaseCombobox.Chip\n                        key={item.value}\n                        data-chip=\"\"\n                        aria-label={item.label}\n                        render={\n                          <motion.div\n                            initial={reduced ? { opacity: 0 } : chipIn}\n                            animate={reduced ? { opacity: 1 } : chipShown}\n                            exit={reduced ? { opacity: 0, transition: reducedFade } : { ...chipIn, transition: exitFast }}\n                            transition={reduced ? reducedFade : { ...snappy, opacity: fade }}\n                          />\n                        }\n                        className={`inline-flex h-6 max-w-full min-w-0 cursor-default items-center gap-1.5 rounded-control-xs bg-surface-hover pe-0.5 ${item.icon !== undefined ? \"ps-1\" : \"ps-1.5\"} text-ui-sm text-text outline-none select-none focus:bg-surface-active [&_svg]:shrink-0`}\n                      >\n                        {item.icon !== undefined && (\n                          <span aria-hidden className=\"inline-flex shrink-0\">\n                            {item.icon}\n                          </span>\n                        )}\n                        <span className=\"truncate\">{item.label}</span>\n                        <BaseCombobox.ChipRemove\n                          aria-label={`Remove ${item.label}`}\n                          className={`inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-pill text-text-muted hover-fine:bg-surface-active hover-fine:text-text ${pressIcon}`}\n                        >\n                          <XIcon className=\"size-3\" />\n                        </BaseCombobox.ChipRemove>\n                      </BaseCombobox.Chip>\n                    ))}\n                  </AnimatePresence>\n                  {input(\"h-6 min-w-16\", value.length > 0 ? undefined : placeholder)}\n                </BaseCombobox.Chips>\n              )}\n            </BaseCombobox.Value>\n          ) : (\n            input(\"\", placeholder)\n          )}\n\n          <span className={`-me-1 flex shrink-0 items-center gap-0.5 ${multiple ? `self-start ${TRAIL[size]}` : \"\"}`}>\n            {/* The clear button's slot is always held, so the text never shifts when it pops in (R108). */}\n            <span className=\"inline-flex size-5 items-center justify-center\">\n              <BaseCombobox.Clear aria-label={clearLabel} className={CLEAR}>\n                <XIcon className=\"size-3\" />\n              </BaseCombobox.Clear>\n            </span>\n            <BaseCombobox.Trigger\n              aria-label=\"Show options\"\n              className=\"inline-flex size-5 cursor-pointer items-center justify-center rounded-control-xs text-text-muted outline-none [transition:color_150ms_var(--eased-ease-standard)] hover-fine:text-text data-popup-open:text-text disabled:cursor-not-allowed disabled:text-text-disabled\"\n            >\n              <ChevronDownIcon />\n            </BaseCombobox.Trigger>\n          </span>\n        </InputGroup>\n      </div>\n\n      <FloatingLayer\n        parts={BaseCombobox}\n        motion={OVERLAY_ROLES.listbox}\n        side={OVERLAY_ROLES.listbox.side}\n        sideOffset={OVERLAY_ROLES.listbox.sideOffset}\n        align=\"start\"\n        container={container}\n        zIndex={zIndex}\n        positionerProps={{ anchor }}\n        className={`${menuSurface} w-(--anchor-width) max-h-[min(20rem,var(--available-height))]! ${popupClassName}`}\n      >\n        {/* Status and Empty stay mounted so screen readers hear their changes; only their content toggles. */}\n        <BaseCombobox.Status className=\"col-span-full\">\n          {loading && (\n            <div className=\"flex h-8 items-center gap-2 px-2 text-ui-sm text-text-muted\">\n              <Spinner size=\"sm\" label={false} className=\"text-current\" />\n              {loadingText}\n            </div>\n          )}\n        </BaseCombobox.Status>\n        <BaseCombobox.Empty className=\"col-span-full\">\n          {!loading && (\n            <div className=\"flex min-h-8 items-center px-2 py-1.5 text-ui-sm text-pretty text-text-muted\">\n              {typeof emptyText === \"function\" ? emptyText(query) : (emptyText ?? (query ? `No matches for “${query}”` : \"No options\"))}\n            </div>\n          )}\n        </BaseCombobox.Empty>\n        <BaseCombobox.List className=\"col-span-full grid grid-cols-subgrid outline-none\">\n          {(entry: T | ComboboxOptionGroup<T>, index: number) =>\n            isGroup(entry) ? (\n              <Fragment key={`group:${entry.label}`}>\n                {index > 0 && <BaseCombobox.Separator className={separatorClass} />}\n                <BaseCombobox.Group items={entry.items} className={groupClass}>\n                  <BaseCombobox.GroupLabel className={groupLabelClass}>{entry.label}</BaseCombobox.GroupLabel>\n                  <BaseCombobox.Collection>{(item: T) => <OptionRow key={item.value} item={item} />}</BaseCombobox.Collection>\n                </BaseCombobox.Group>\n              </Fragment>\n            ) : (\n              <OptionRow key={entry.value} item={entry} />\n            )\n          }\n        </BaseCombobox.List>\n      </FloatingLayer>\n    </BaseCombobox.Root>\n  );\n}\n\nfunction OptionRow<T extends ComboboxOption>({ item }: { item: T }) {\n  return (\n    <BaseCombobox.Item value={item} disabled={item.disabled} className={itemClass()}>\n      <ItemParts\n        icon={\n          <BaseCombobox.ItemIndicator keepMounted render={<span />} className=\"inline-flex\">\n            <ItemCheck />\n          </BaseCombobox.ItemIndicator>\n        }\n        trailing={item.detail !== undefined ? <span className=\"text-ui-xs text-text-muted\">{item.detail}</span> : undefined}\n      >\n        <span className=\"flex min-w-0 items-center gap-2\">\n          {item.icon !== undefined && (\n            <span aria-hidden className=\"inline-flex shrink-0 [&_svg]:size-4\">\n              {item.icon}\n            </span>\n          )}\n          <span className=\"truncate\">{item.label}</span>\n        </span>\n      </ItemParts>\n    </BaseCombobox.Item>\n  );\n}\n\nfunction XIcon({ className = \"\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className={className}>\n      <path d=\"m4.5 4.5 7 7m0-7-7 7\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction ChevronDownIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"size-4\">\n      <path d=\"m4.5 6.5 3.5 3.5 3.5-3.5\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/combobox/combobox.tsx"
    }
  ],
  "meta": {
    "details": "A filterable select whose list drops 4px from the field, whose clear button pops in, and whose chips pop in from 0.9 on the snappy spring and leave fast.",
    "industries": [
      "saas",
      "devtools",
      "hr",
      "ecommerce"
    ],
    "releaseAt": "2026-10-21"
  },
  "docs": "Import Combobox from components/eased/combobox/combobox; pass items as { value, label } objects (flat or grouped), add multiple for chips, and wrap it in Field for a label. The Eased tokens were merged into your global CSS; nothing to import.",
  "type": "registry:ui"
}