# Combobox

A text input with a filterable list of options, for choosing one or more values by typing.

Combobox is an open-source component from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/combobox

**React**

**Example**

```tsx
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "Remix", "Astro", "SvelteKit", "Nuxt", "Gatsby"]

export default function ComboboxDemo() {
  return (
    <div className="w-72">
      <Combobox items={frameworks}>
        <ComboboxInput placeholder="Select a framework" disabled={false} />
        <ComboboxContent>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}
```

Combobox is built on the Base UI Combobox. The `Combobox` root takes the `items` to choose from and holds the value. `ComboboxInput` renders the text field inside an `InputGroup`, with an optional button that opens the list and an optional clear button. `ComboboxContent` renders the popup in a portal; inside it, `ComboboxList` renders each item through a render function, `ComboboxEmpty` appears when nothing matches, and `ComboboxItem` shows a check mark when selected. Group related items with `ComboboxGroup`, `ComboboxLabel`, `ComboboxCollection` and `ComboboxSeparator`.

For multiple selection, replace `ComboboxInput` with `ComboboxChips`, which shows each selected value as a `ComboboxChip` next to a `ComboboxChipsInput`. Use a combobox when the list is long enough that typing to filter helps. For a short, fixed list without filtering, use a select; for a list of actions rather than values, use a command menu.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/combobox
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add combobox
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react @phosphor-icons/react
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Input Group](https://ui.suiss.app/docs/components/input-group).

3. Copy the source into `components/ui/combobox.tsx`:

```tsx title="components/ui/combobox.tsx"
"use client"

import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { CaretDownIcon, CheckIcon, XIcon } from "@phosphor-icons/react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { cn } from "@/lib/utils"

const Combobox = ComboboxPrimitive.Root

function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
  return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}

function ComboboxTrigger({
  className,
  children,
  ...props
}: ComboboxPrimitive.Trigger.Props) {
  return (
    <ComboboxPrimitive.Trigger
      data-slot="combobox-trigger"
      className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
      {...props}
    >
      {children}
      <CaretDownIcon
        className="pointer-events-none size-4 text-muted-foreground"
      />
    </ComboboxPrimitive.Trigger>
  )
}

function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
  return (
    <ComboboxPrimitive.Clear
      data-slot="combobox-clear"
      aria-label="Clear"
      render={<InputGroupButton variant="ghost" size="icon-xs" />}
      className={cn(className)}
      {...props}
    >
      <XIcon className="pointer-events-none" />
    </ComboboxPrimitive.Clear>
  )
}

function ComboboxInput({
  className,
  children,
  disabled = false,
  showTrigger = true,
  showClear = false,
  ...props
}: ComboboxPrimitive.Input.Props & {
  showTrigger?: boolean
  showClear?: boolean
}) {
  return (
    <InputGroup className={cn("w-auto", className)}>
      <ComboboxPrimitive.Input
        render={<InputGroupInput disabled={disabled} />}
        {...props}
      />
      <InputGroupAddon align="inline-end">
        {showTrigger && (
          <InputGroupButton
            size="icon-xs"
            variant="ghost"
            render={<ComboboxTrigger />}
            aria-label="Show options"
            data-slot="input-group-button"
            className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
            disabled={disabled}
          />
        )}
        {showClear && <ComboboxClear disabled={disabled} />}
      </InputGroupAddon>
      {children}
    </InputGroup>
  )
}

function ComboboxContent({
  className,
  side = "bottom",
  sideOffset = 6,
  align = "start",
  alignOffset = 0,
  anchor,
  ...props
}: ComboboxPrimitive.Popup.Props &
  Pick<
    ComboboxPrimitive.Positioner.Props,
    "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
  >) {
  return (
    <ComboboxPrimitive.Portal>
      <ComboboxPrimitive.Positioner
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        anchor={anchor}
        className="isolate z-50"
      >
        <ComboboxPrimitive.Popup
          data-slot="combobox-content"
          data-chips={!!anchor}
          className={cn(
            "cn-menu-target cn-menu-translucent group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+(--spacing(7)))] origin-(--transform-origin) overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-lg ring-1 ring-foreground/5 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-start-2 data-[side=inline-start]:slide-in-from-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1.5 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-border/30 *:data-[slot=input-group]:bg-secondary *:data-[slot=input-group]:shadow-none dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
            className
          )}
          {...props}
        />
      </ComboboxPrimitive.Positioner>
    </ComboboxPrimitive.Portal>
  )
}

function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
  return (
    <ComboboxPrimitive.List
      data-slot="combobox-list"
      className={cn(
        "no-scrollbar max-h-[min(calc(--spacing(72)-(--spacing(9))),calc(var(--available-height)-(--spacing(9))))] scroll-py-1.5 overflow-y-auto overscroll-contain p-1.5 data-empty:p-0",
        className
      )}
      {...props}
    />
  )
}

function ComboboxItem({
  className,
  children,
  ...props
}: ComboboxPrimitive.Item.Props) {
  return (
    <ComboboxPrimitive.Item
      data-slot="combobox-item"
      className={cn(
        "relative flex w-full cursor-default items-center gap-2.5 rounded-md py-2 pe-8 ps-3 text-base outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:opacity-50",
        className
      )}
      {...props}
    >
      {children}
      <ComboboxPrimitive.ItemIndicator
        render={
          <span className="pointer-events-none absolute inset-e-2 flex size-4 items-center justify-center" />
        }
      >
        <CheckIcon
          className="pointer-events-none text-primary! **:text-primary!"
        />
      </ComboboxPrimitive.ItemIndicator>
    </ComboboxPrimitive.Item>
  )
}

function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
  return (
    <ComboboxPrimitive.Group
      data-slot="combobox-group"
      className={cn(className)}
      {...props}
    />
  )
}

function ComboboxLabel({
  className,
  ...props
}: ComboboxPrimitive.GroupLabel.Props) {
  return (
    <ComboboxPrimitive.GroupLabel
      data-slot="combobox-label"
      className={cn(
        "px-3 py-2.5 text-xs font-semibold text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
  return (
    <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
  )
}

function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
  return (
    <ComboboxPrimitive.Empty
      data-slot="combobox-empty"
      className={cn(
        "hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
        className
      )}
      {...props}
    />
  )
}

function ComboboxSeparator({
  className,
  ...props
}: ComboboxPrimitive.Separator.Props) {
  return (
    <ComboboxPrimitive.Separator
      data-slot="combobox-separator"
      className={cn("-mx-1.5 my-1.5 h-px bg-border", className)}
      {...props}
    />
  )
}

function ComboboxChips({
  className,
  ...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
  ComboboxPrimitive.Chips.Props) {
  return (
    <ComboboxPrimitive.Chips
      data-slot="combobox-chips"
      className={cn(
        "flex min-h-11 flex-wrap items-center gap-1.5 rounded-lg border border-transparent bg-secondary bg-clip-padding px-3 py-1.5 text-base transition-[color,box-shadow,background-color] focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:bg-destructive/5 dark:has-aria-invalid:bg-destructive/10 has-data-[slot=combobox-chip]:px-1.5",
        className
      )}
      {...props}
    />
  )
}

function ComboboxChip({
  className,
  children,
  showRemove = true,
  removeLabel,
  ...props
}: ComboboxPrimitive.Chip.Props & {
  showRemove?: boolean
  removeLabel?: string
}) {
  return (
    <ComboboxPrimitive.Chip
      data-slot="combobox-chip"
      className={cn(
        "flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-full bg-primary/10 dark:bg-primary/20 px-2.5 text-xs whitespace-nowrap text-primary has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-data-[slot=combobox-chip-remove]:pe-0.5 has-disabled:opacity-50",
        className
      )}
      {...props}
    >
      {children}
      {showRemove && (
        <ComboboxPrimitive.ChipRemove
          aria-label={
            removeLabel ??
            (typeof children === "string" ? `Remove ${children}` : "Remove")
          }
          render={<Button variant="ghost" size="icon-xs" />}
          className="-ms-1 opacity-50 hover:opacity-100"
          data-slot="combobox-chip-remove"
        >
          <XIcon className="pointer-events-none" />
        </ComboboxPrimitive.ChipRemove>
      )}
    </ComboboxPrimitive.Chip>
  )
}

function ComboboxChipsInput({
  className,
  ...props
}: ComboboxPrimitive.Input.Props) {
  return (
    <ComboboxPrimitive.Input
      data-slot="combobox-chip-input"
      className={cn("min-w-16 flex-1 outline-none", className)}
      {...props}
    />
  )
}

function useComboboxAnchor() {
  return React.useRef<HTMLDivElement | null>(null)
}

export {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
  ComboboxTrigger,
  ComboboxValue,
  useComboboxAnchor,
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { ComboboxValue, ComboboxTrigger, ComboboxInput, ComboboxContent, ComboboxList, ComboboxItem, ComboboxGroup, ComboboxLabel, ComboboxCollection, ComboboxEmpty, ComboboxSeparator, ComboboxChips, ComboboxChip, ComboboxChipsInput } from "@/components/ui/combobox"
```

```tsx
<div className="w-72">
  <Combobox items={frameworks}>
    <ComboboxInput
      placeholder="Select a framework"
      disabled={false}
    />
    <ComboboxContent>
      <ComboboxEmpty>No framework found.</ComboboxEmpty>
      <ComboboxList>
        {(item: string) => (
          <ComboboxItem key={item} value={item}>
            {item}
          </ComboboxItem>
        )}
      </ComboboxList>
    </ComboboxContent>
  </Combobox>
</div>
```

Pass `items` to the root and a function as the child of `ComboboxList`, which receives each item and returns a `ComboboxItem` with a matching `value`. The value is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`. To read or control the typed text, use `inputValue` and `onInputValueChange`.

For grouped data, pass groups as `items`, render a `ComboboxGroup` with its own `items` for each one, and render its items with `ComboboxCollection`. For multiple selection, set `multiple` on the root, create a ref with `useComboboxAnchor`, attach it to `ComboboxChips`, and pass it as `anchor` to `ComboboxContent` so the popup aligns to the chips field. Use `ComboboxValue` with a render function to map the selected values to chips.

## Examples

### With clear button

A combobox with a starting value and a clear button, enabled with `showClear`.

**Example**

```tsx
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "Remix", "Astro", "SvelteKit", "Nuxt", "Gatsby"]

export default function ComboboxWithClearButton() {
  return (
    <div className="w-72">
      <Combobox items={frameworks} defaultValue="Astro">
        <ComboboxInput placeholder="Select a framework" showClear />
        <ComboboxContent>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}
```

### Disabled

A disabled combobox whose input and toggle button cannot be focused or used.

**Example**

```tsx
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "Remix", "Astro", "SvelteKit", "Nuxt", "Gatsby"]

export default function ComboboxDisabled() {
  return (
    <div className="w-72">
      <Combobox disabled items={frameworks}>
        <ComboboxInput placeholder="Select a framework" disabled={true} />
        <ComboboxContent>
          <ComboboxEmpty>No framework found.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}
```

### Grouped

Options grouped by region with labels and separators between groups.

**Example**

```tsx
import {
  Combobox,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
} from "@/components/ui/combobox"

const timezones = [
  {
    value: "Americas",
    items: ["New York", "Chicago", "Los Angeles", "São Paulo"],
  },
  {
    value: "Europe",
    items: ["London", "Berlin", "Istanbul", "Madrid"],
  },
  {
    value: "Asia",
    items: ["Tokyo", "Singapore", "Dubai"],
  },
]

export default function ComboboxGrouped() {
  return (
    <div className="w-72">
      <Combobox items={timezones}>
        <ComboboxInput placeholder="Select a city" />
        <ComboboxContent>
          <ComboboxEmpty>No city found.</ComboboxEmpty>
          <ComboboxList>
            {(group: (typeof timezones)[number], index: number) => (
              <ComboboxGroup key={group.value} items={group.items}>
                {index > 0 && <ComboboxSeparator />}
                <ComboboxLabel>{group.value}</ComboboxLabel>
                <ComboboxCollection>
                  {(item: string) => (
                    <ComboboxItem key={item} value={item}>
                      {item}
                    </ComboboxItem>
                  )}
                </ComboboxCollection>
              </ComboboxGroup>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </div>
  )
}
```

### Highlight match

A controlled `inputValue` used to emphasize the matching part of each option.

**Example**

```tsx
import * as React from "react"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "Remix", "Astro", "SvelteKit", "Nuxt", "Gatsby"]

function HighlightedMatch({ text, query }: { text: string; query: string }) {
  const start = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1
  if (start < 0) return text
  return (
    <span>
      {text.slice(0, start)}
      <span className="font-semibold">
        {text.slice(start, start + query.length)}
      </span>
      {text.slice(start + query.length)}
    </span>
  )
}

function HighlightMatchCombobox() {
  const [query, setQuery] = React.useState("")
  return (
    <Combobox
      items={frameworks}
      inputValue={query}
      onInputValueChange={setQuery}
    >
      <ComboboxInput placeholder="Type to filter" />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              <HighlightedMatch text={item} query={query} />
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export default function ComboboxHighlightMatch() {
  return (
    <div className="w-72">
      <HighlightMatchCombobox />
    </div>
  )
}
```

### Multiple

Multiple selection shown as removable chips, with Backspace removing the last chip.

**Example**

```tsx
import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
  useComboboxAnchor,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "Remix", "Astro", "SvelteKit", "Nuxt", "Gatsby"]

function MultipleCombobox({ disabled }: { disabled?: boolean }) {
  const anchor = useComboboxAnchor()

  return (
    <Combobox
      disabled={disabled}
      multiple
      items={frameworks}
      defaultValue={["Next.js", "Astro"]}
    >
      <ComboboxChips ref={anchor}>
        <ComboboxValue>
          {(values: string[]) => (
            <>
              {values.map((value) => (
                <ComboboxChip key={value}>{value}</ComboboxChip>
              ))}
              <ComboboxChipsInput placeholder="Add framework" />
            </>
          )}
        </ComboboxValue>
      </ComboboxChips>
      <ComboboxContent anchor={anchor}>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export default function ComboboxMultiple() {
  return (
    <div className="w-72">
      <MultipleCombobox disabled={false} />
    </div>
  )
}
```

## API Reference

- `Combobox`
- `ComboboxInput`
- `ComboboxContent`
  - `ComboboxEmpty`
  - `ComboboxList`
    - `ComboboxItem`
    - `ComboboxGroup`
      - `ComboboxSeparator`
      - `ComboboxLabel`
      - `ComboboxCollection`
        - `ComboboxItem`

### Combobox

The root that holds the items, the selected value and the open state.

Also accepts the props of `ComboboxPrimitive.Root`.

### ComboboxValue

Renders the current value, or passes it to a render function, for example to draw chips.

Also accepts the props of `ComboboxPrimitive.Value.Props`.

### ComboboxTrigger

A button that opens the list, with a caret icon.

Also accepts the props of `ComboboxPrimitive.Trigger.Props`.

### ComboboxInput

The text field, rendered inside an `InputGroup` with optional open and clear buttons.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showTrigger` | `boolean` | `true` | Shows the button at the end of the field that opens the list. |
| `showClear` | `boolean` | `false` | Shows a clear button at the end of the field. When shown, it hides the open button. |

Also accepts the props of `ComboboxPrimitive.Input.Props`.

### ComboboxContent

The popup, rendered in a portal and positioned below the input.

Also accepts the props of `ComboboxPrimitive.Popup.Props`, `ComboboxPrimitive.Positioner.Props`.

### ComboboxList

The scrollable list that renders items through a render function.

Also accepts the props of `ComboboxPrimitive.List.Props`.

### ComboboxItem

A selectable option that shows a check mark when selected.

Also accepts the props of `ComboboxPrimitive.Item.Props`.

### ComboboxGroup

Groups related options and takes its own `items`.

Also accepts the props of `ComboboxPrimitive.Group.Props`.

### ComboboxLabel

The visible label of a group.

Also accepts the props of `ComboboxPrimitive.GroupLabel.Props`.

### ComboboxCollection

Renders the items of a group through a render function.

Also accepts the props of `ComboboxPrimitive.Collection.Props`.

### ComboboxEmpty

Content shown when no options match the typed text.

Also accepts the props of `ComboboxPrimitive.Empty.Props`.

### ComboboxSeparator

A divider between groups.

Also accepts the props of `ComboboxPrimitive.Separator.Props`.

### ComboboxChips

The field that holds selected values as chips in multiple selection.

Also accepts the props of `React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips>`, `ComboboxPrimitive.Chips.Props`.

### ComboboxChip

A selected value shown as a chip, with an optional remove button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showRemove` | `boolean` | `true` | Shows a button inside the chip that removes the value. |
| `removeLabel` | `string` | `Remove {value}` | Accessible name of the remove button. Defaults to `Remove` followed by the chip text. |

Also accepts the props of `ComboboxPrimitive.Chip.Props`.

### ComboboxChipsInput

The text field placed after the chips in multiple selection.

Also accepts the props of `ComboboxPrimitive.Input.Props`.

## Accessibility

- The input has the `combobox` role and sets `aria-expanded` to reflect whether the list is open.
- The popup has the `listbox` role, options have the `option` role and selected options set `aria-selected`; groups have the `group` role and take their name from `ComboboxLabel`.
- Typing filters the options. Arrow keys move the highlight, Enter selects the highlighted option, and Escape closes the list.
- In multiple selection, Backspace in the empty input removes the last chip.
- The open button is labelled `Show options` and the clear button is labelled `Clear`.
- When disabled, the input and the open button are disabled and removed from the Tab order.
- Give the input an accessible name with a visible label or `aria-label`.

## Notes

- Styling hooks: `data-slot` values such as `combobox-content`, `combobox-list`, `combobox-item`, `combobox-label`, `combobox-empty`, `combobox-chips`, `combobox-chip`, `combobox-chip-remove` and `combobox-chip-input`.
- Highlighted items get `data-highlighted` and disabled items get `data-disabled`.
- The popup matches the width of its anchor through `--anchor-width` and limits its height to the available space.
- Uses the `popover`, `accent`, `primary`, `secondary` and `border` colors.

**React Native, SwiftUI, Jetpack Compose**

**React Native, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-combobox--open-by-default.light.webp)

```tsx
import * as React from "react"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function ComboboxDemo({
  defaultValue = null,
  defaultOpen,
  disabled,
  showClear,
  labelledBy,
  invalid,
}: {
  defaultValue?: string | null
  defaultOpen?: boolean
  disabled?: boolean
  showClear?: boolean
  labelledBy?: string
  invalid?: boolean
}) {
  const [value, setValue] = React.useState<string | null>(defaultValue)
  return (
    <Combobox
      items={frameworks}
      value={value}
      onValueChange={setValue}
      defaultOpen={defaultOpen}
      disabled={disabled}
    >
      <ComboboxInput
        className="w-64"
        placeholder="Select a framework"
        showClear={showClear}
        disabled={disabled}
        invalid={invalid}
        accessibilityLabelledBy={labelledBy}
      />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxOpenByDefaultExample() {
  return (
    <ComboboxDemo defaultValue="Expo" defaultOpen />
  )
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox

@Composable
fun ComboboxOpenByDefaultExample() {
    ComboboxDemo(defaultValue = "Expo")
}

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxDemo(
    defaultValue: String? = null,
    disabled: Boolean = false,
    showClear: Boolean = false,
    label: String? = null,
    invalid: Boolean? = null,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    Column {
        Combobox(
            items = ComboboxFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier
                .width(256.dp)
                .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier),
            placeholder = "Select a framework",
            emptyText = "No framework found.",
            showClear = showClear,
            invalid = invalid,
            enabled = !disabled,
        )
    }
}
```

`Combobox` pairs a text input with a floating list that filters its `items` as the user types. `ComboboxInput` draws the field with a caret trigger and an optional clear button, and `ComboboxContent` renders the list through a portal (inside a full-window overlay on iOS), positioned below the input and as wide as it.

For multiple selection, set `multiple` and use `ComboboxChips` with a `ComboboxChip` per selected value and a `ComboboxChipsInput` for typing. Each chip has a remove button.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/combobox
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add combobox
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add combobox --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add combobox --platform kotlin
```

The CLI also adds what it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/combobox.tsx`, `components/primitives/field.tsx`, `components/primitives/positioner.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/combobox.tsx`:

```tsx title="uniwind/components/ui/combobox.tsx"
import { cn } from "cn"
import { CaretDownIcon, CheckIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  type StyleProp,
  type View,
  type ViewStyle,
} from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as ComboboxPrimitive from "@/components/primitives/combobox"
import { useFieldRootContext } from "@/components/primitives/field"
import { useOptionalPositionerContext } from "@/components/primitives/positioner"
import {
  resolveClassName,
  resolveStyle,
} from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

const Combobox = ComboboxPrimitive.Root

const ComboboxValue = ComboboxPrimitive.Value

const ComboboxCollection = ComboboxPrimitive.Collection

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

function wrapText(children: React.ReactNode, className: string) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text className={className}>{children}</Text>
  }
  return children
}

function ComboboxTrigger({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Trigger> & {
  children?: React.ReactNode
}) {
  return (
    <ComboboxPrimitive.Trigger
      accessibilityLabel="Show options"
      className={(state) =>
        cn(
          "size-6 items-center justify-center rounded-full active:opacity-80",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {children}
      <Icon
        as={CaretDownIcon}
        className={cn(
          "size-4 text-muted-foreground",
          props.disabled && "opacity-50"
        )}
      />
    </ComboboxPrimitive.Trigger>
  )
}

function ComboboxClear({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Clear>) {
  return (
    <ComboboxPrimitive.Clear
      accessibilityLabel="Clear"
      className={(state) =>
        cn(
          "size-6 items-center justify-center rounded-full active:opacity-80",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      <Icon
        as={XIcon}
        className={cn("size-4 text-foreground", props.disabled && "opacity-50")}
      />
    </ComboboxPrimitive.Clear>
  )
}

function ComboboxInput({
  className,
  children,
  disabled = false,
  showTrigger = true,
  showClear = false,
  invalid,
  onFocus,
  onBlur,
  ...props
}: Omit<React.ComponentProps<typeof ComboboxPrimitive.Input>, "className"> & {
  className?: string
  children?: React.ReactNode
  showTrigger?: boolean
  showClear?: boolean
  invalid?: boolean
}) {
  const combobox = ComboboxPrimitive.useComboboxRootContext("ComboboxInput")
  const field = useFieldRootContext()
  const [focused, setFocused] = React.useState(false)
  const isDisabled = disabled || combobox.disabled
  const isInvalid = invalid ?? field.state.invalid
  const inPopup = useOptionalPositionerContext() !== null
  const clearVisible =
    showClear && (combobox.hasValue || combobox.inputValue !== "")
  return (
    <ComboboxPrimitive.InputGroup
      className={cn(
        "h-11 w-full min-w-0 flex-row items-center gap-1 rounded-lg border border-transparent bg-secondary pr-2",
        isInvalid &&
          "border-destructive bg-destructive/5 dark:bg-destructive/10",
        focused && !inPopup && "border-ring ring-3 ring-ring/50",
        inPopup && "m-1.5 mb-0 h-8 w-auto border-border/30",
        isDisabled && "opacity-50",
        className
      )}
    >
      <ComboboxPrimitive.Input
        disabled={disabled}
        className={cn(
          "h-full min-w-0 flex-1 py-1 pr-1.5 pl-3 text-[16px] text-foreground"
        )}
        placeholderTextColorClassName="accent-muted-foreground"
        cursorColorClassName="accent-primary"
        selectionColorClassName="accent-primary"
        onFocus={(event) => {
          setFocused(true)
          onFocus?.(event)
        }}
        onBlur={(event) => {
          setFocused(false)
          onBlur?.(event)
        }}
        {...props}
      />
      {showTrigger && !clearVisible ? (
        <ComboboxTrigger disabled={disabled} />
      ) : null}
      {showClear ? <ComboboxClear disabled={disabled} /> : null}
      {children}
    </ComboboxPrimitive.InputGroup>
  )
}

type PositionerProps = React.ComponentProps<typeof ComboboxPrimitive.Positioner>

function ContentPopup({
  className,
  style,
  chips,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Popup> & { chips: boolean }) {
  const positioner = useOptionalPositionerContext()
  const anchorWidth = positioner?.anchorWidth ?? 0
  const sizing = {
    width: anchorWidth || undefined,
    minWidth: chips ? anchorWidth : anchorWidth + 28,
    maxWidth: positioner?.availableWidth,
    maxHeight: positioner?.availableHeight,
  }
  return (
    <ComboboxPrimitive.Popup
      className={(state) =>
        cn(
          "relative overflow-hidden rounded-xl border border-foreground/5 bg-popover shadow-lg dark:border-foreground/10",
          resolveClassName(className, state)
        )
      }
      style={(state) => [
        sizing,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

function ComboboxContent({
  className,
  side = "bottom",
  sideOffset = 6,
  align = "start",
  alignOffset = 0,
  anchor,
  portalHost,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Popup> &
  Pick<
    PositionerProps,
    "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
  > & {
    portalHost?: string
  }) {
  return (
    <ComboboxPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <ComboboxPrimitive.Positioner
          side={side}
          sideOffset={sideOffset}
          align={align}
          alignOffset={alignOffset}
          anchor={anchor}
        >
          <AnimatedView
            entering={FadeIn.reduceMotion(ReduceMotion.System)}
            exiting={FadeOut.reduceMotion(ReduceMotion.System)}
          >
            <TextClassContext.Provider value="text-base text-foreground">
              <ContentPopup className={className} chips={!!anchor} {...props} />
            </TextClassContext.Provider>
          </AnimatedView>
        </ComboboxPrimitive.Positioner>
      </FullWindowOverlay>
    </ComboboxPrimitive.Portal>
  )
}

function ComboboxList({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.List>) {
  return (
    <ComboboxPrimitive.List
      className={(state) => cn("max-h-72", resolveClassName(className, state))}
      contentContainerClassName="p-1.5"
      showsVerticalScrollIndicator={false}
      {...props}
    />
  )
}

function ComboboxItem({
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof ComboboxPrimitive.Item>, "children"> & {
  children?: React.ReactNode
}) {
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <ComboboxPrimitive.Item
        className={(state) =>
          cn(
            "relative w-full flex-row items-center gap-2.5 rounded-md py-2 pr-8 pl-3",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        {wrapText(children, "flex-1")}
        <ComboboxPrimitive.ItemIndicator className="absolute right-2 size-4 items-center justify-center">
          <Icon as={CheckIcon} className="size-4 shrink-0 text-primary" />
        </ComboboxPrimitive.ItemIndicator>
      </ComboboxPrimitive.Item>
    </TextClassContext.Provider>
  )
}

function ComboboxGroup({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Group>) {
  return <ComboboxPrimitive.Group className={className} {...props} />
}

function ComboboxLabel({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.GroupLabel>) {
  return (
    <ComboboxPrimitive.GroupLabel
      className={(state) =>
        cn(
          "px-3 py-2.5 text-xs font-semibold text-muted-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function ComboboxEmpty({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Empty>) {
  return (
    <ComboboxPrimitive.Empty
      className={(state) =>
        cn(
          "w-full items-center justify-center py-2",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {wrapText(children, "text-center text-sm text-muted-foreground")}
    </ComboboxPrimitive.Empty>
  )
}

function ComboboxSeparator({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Separator>) {
  return (
    <ComboboxPrimitive.Separator
      className={(state) =>
        cn("-mx-1.5 my-1.5 h-px bg-border", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function ComboboxChips({
  className,
  invalid,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Chips> & {
  invalid?: boolean
}) {
  const { hasValue, disabled } =
    ComboboxPrimitive.useComboboxRootContext("ComboboxChips")
  const field = useFieldRootContext()
  const isInvalid = invalid ?? field.state.invalid
  return (
    <ComboboxPrimitive.Chips
      className={(state) =>
        cn(
          "min-h-11 w-full flex-row flex-wrap items-center gap-1.5 rounded-lg border border-transparent bg-secondary px-3 py-1.5",
          hasValue && "px-1.5",
          isInvalid &&
            "border-destructive bg-destructive/5 dark:bg-destructive/10",
          disabled && "opacity-50",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function ComboboxChip({
  className,
  children,
  showRemove = true,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Chip> & {
  showRemove?: boolean
}) {
  return (
    <ComboboxPrimitive.Chip
      className={(state) =>
        cn(
          "h-5.5 flex-row items-center justify-center gap-1 rounded-full bg-primary/10 dark:bg-primary/20 px-2.5",
          showRemove && "pr-0.5",
          state.disabled && "opacity-50",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {wrapText(children, "text-xs text-primary")}
      {showRemove ? (
        <ComboboxPrimitive.ChipRemove
          accessibilityLabel="Remove"
          render={
            <Button
              variant="ghost"
              size="icon-xs"
              className="-ml-1 opacity-50 active:opacity-100"
            />
          }
        >
          <Icon as={XIcon} className="size-3 text-primary" />
        </ComboboxPrimitive.ChipRemove>
      ) : null}
    </ComboboxPrimitive.Chip>
  )
}

function ComboboxChipsInput({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Input>) {
  return (
    <ComboboxPrimitive.Input
      className={(state) =>
        cn(
          "h-6 min-w-16 flex-1 px-1 py-0 text-[16px] text-foreground",
          resolveClassName(className, state)
        )
      }
      placeholderTextColorClassName="accent-muted-foreground"
      cursorColorClassName="accent-primary"
      selectionColorClassName="accent-primary"
      {...props}
    />
  )
}

function useComboboxAnchor() {
  return React.useRef<React.ComponentRef<typeof View> | null>(null)
}

export {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
  ComboboxTrigger,
  ComboboxValue,
  useComboboxAnchor,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn nativewind phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/combobox.tsx`, `components/primitives/field.tsx`, `components/primitives/positioner.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/combobox.tsx`:

```tsx title="nativewind/components/ui/combobox.tsx"
import { cn } from "cn"
import { cssInterop } from "nativewind"
import { CaretDownIcon, CheckIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  type StyleProp,
  TextInput,
  type View,
  type ViewStyle,
} from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as ComboboxPrimitive from "@/components/primitives/combobox"
import { useFieldRootContext } from "@/components/primitives/field"
import { useOptionalPositionerContext } from "@/components/primitives/positioner"
import {
  resolveClassName,
  resolveStyle,
} from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

declare module "react-native" {
  interface TextInputProps {
    cursorColorClassName?: string
    selectionColorClassName?: string
  }
}

cssInterop(TextInput, {
  className: { target: "style", nativeStyleToProp: { textAlign: true } },
  placeholderClassName: {
    target: false,
    nativeStyleToProp: { color: "placeholderTextColor" },
  },
  cursorColorClassName: {
    target: false,
    nativeStyleToProp: { color: "cursorColor" },
  },
  selectionColorClassName: {
    target: false,
    nativeStyleToProp: { color: "selectionColor" },
  },
})

const Combobox = ComboboxPrimitive.Root

const ComboboxValue = ComboboxPrimitive.Value

const ComboboxCollection = ComboboxPrimitive.Collection

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

function wrapText(children: React.ReactNode, className: string) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text className={className}>{children}</Text>
  }
  return children
}

function ComboboxTrigger({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Trigger> & {
  children?: React.ReactNode
}) {
  return (
    <ComboboxPrimitive.Trigger
      accessibilityLabel="Show options"
      className={(state) =>
        cn(
          "size-6 items-center justify-center rounded-full active:opacity-80",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {children}
      <Icon
        as={CaretDownIcon}
        className={cn(
          "size-4 text-muted-foreground",
          props.disabled && "opacity-50"
        )}
      />
    </ComboboxPrimitive.Trigger>
  )
}

function ComboboxClear({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Clear>) {
  return (
    <ComboboxPrimitive.Clear
      accessibilityLabel="Clear"
      className={(state) =>
        cn(
          "size-6 items-center justify-center rounded-full active:opacity-80",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      <Icon
        as={XIcon}
        className={cn("size-4 text-foreground", props.disabled && "opacity-50")}
      />
    </ComboboxPrimitive.Clear>
  )
}

function ComboboxInput({
  className,
  children,
  disabled = false,
  showTrigger = true,
  showClear = false,
  invalid,
  onFocus,
  onBlur,
  ...props
}: Omit<React.ComponentProps<typeof ComboboxPrimitive.Input>, "className"> & {
  className?: string
  children?: React.ReactNode
  showTrigger?: boolean
  showClear?: boolean
  invalid?: boolean
}) {
  const combobox = ComboboxPrimitive.useComboboxRootContext("ComboboxInput")
  const field = useFieldRootContext()
  const [focused, setFocused] = React.useState(false)
  const isDisabled = disabled || combobox.disabled
  const isInvalid = invalid ?? field.state.invalid
  const inPopup = useOptionalPositionerContext() !== null
  const clearVisible =
    showClear && (combobox.hasValue || combobox.inputValue !== "")
  return (
    <ComboboxPrimitive.InputGroup
      className={cn(
        "h-11 w-full min-w-0 flex-row items-center gap-1 rounded-lg border border-transparent bg-secondary pr-2",
        isInvalid &&
          "border-destructive bg-destructive-5 dark:bg-destructive-10",
        focused && !inPopup && "border-ring ring ring-ring-50",
        inPopup && "m-1.5 mb-0 h-8 w-auto border-border-30",
        isDisabled && "opacity-50",
        className
      )}
    >
      <ComboboxPrimitive.Input
        disabled={disabled}
        className={cn(
          "h-full min-w-0 flex-1 py-1 pr-1.5 pl-3 text-[16px] text-foreground"
        )}
        placeholderClassName="text-muted-foreground"
        cursorColorClassName="text-primary"
        selectionColorClassName="text-primary"
        onFocus={(event) => {
          setFocused(true)
          onFocus?.(event)
        }}
        onBlur={(event) => {
          setFocused(false)
          onBlur?.(event)
        }}
        {...props}
      />
      {showTrigger && !clearVisible ? (
        <ComboboxTrigger disabled={disabled} />
      ) : null}
      {showClear ? <ComboboxClear disabled={disabled} /> : null}
      {children}
    </ComboboxPrimitive.InputGroup>
  )
}

type PositionerProps = React.ComponentProps<typeof ComboboxPrimitive.Positioner>

function ContentPopup({
  className,
  style,
  chips,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Popup> & { chips: boolean }) {
  const positioner = useOptionalPositionerContext()
  const anchorWidth = positioner?.anchorWidth ?? 0
  const sizing = {
    width: anchorWidth || undefined,
    minWidth: chips ? anchorWidth : anchorWidth + 28,
    maxWidth: positioner?.availableWidth,
    maxHeight: positioner?.availableHeight,
  }
  return (
    <ComboboxPrimitive.Popup
      className={(state) =>
        cn(
          "relative overflow-hidden rounded-xl border border-foreground-5 bg-popover shadow-lg dark:border-foreground-10",
          resolveClassName(className, state)
        )
      }
      style={(state) => [
        sizing,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

function ComboboxContent({
  className,
  side = "bottom",
  sideOffset = 6,
  align = "start",
  alignOffset = 0,
  anchor,
  portalHost,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Popup> &
  Pick<
    PositionerProps,
    "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
  > & {
    portalHost?: string
  }) {
  return (
    <ComboboxPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <ComboboxPrimitive.Positioner
          side={side}
          sideOffset={sideOffset}
          align={align}
          alignOffset={alignOffset}
          anchor={anchor}
        >
          <AnimatedView
            entering={FadeIn.reduceMotion(ReduceMotion.System)}
            exiting={FadeOut.reduceMotion(ReduceMotion.System)}
          >
            <TextClassContext.Provider value="text-base text-foreground">
              <ContentPopup className={className} chips={!!anchor} {...props} />
            </TextClassContext.Provider>
          </AnimatedView>
        </ComboboxPrimitive.Positioner>
      </FullWindowOverlay>
    </ComboboxPrimitive.Portal>
  )
}

function ComboboxList({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.List>) {
  return (
    <ComboboxPrimitive.List
      className={(state) => cn("max-h-72", resolveClassName(className, state))}
      contentContainerClassName="p-1.5"
      showsVerticalScrollIndicator={false}
      {...props}
    />
  )
}

function ComboboxItem({
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof ComboboxPrimitive.Item>, "children"> & {
  children?: React.ReactNode
}) {
  return (
    <TextClassContext.Provider
      value={cn("select-none text-base text-foreground")}
    >
      <ComboboxPrimitive.Item
        className={(state) =>
          cn(
            "relative w-full flex-row items-center gap-2.5 rounded-md py-2 pr-8 pl-3",
            props.disabled && "opacity-50",
            state.highlighted && "opacity-80",
            resolveClassName(className, state)
          )
        }
        {...props}
      >
        {wrapText(children, "flex-1")}
        <ComboboxPrimitive.ItemIndicator className="absolute right-2 size-4 items-center justify-center">
          <Icon as={CheckIcon} className="size-4 shrink-0 text-primary" />
        </ComboboxPrimitive.ItemIndicator>
      </ComboboxPrimitive.Item>
    </TextClassContext.Provider>
  )
}

function ComboboxGroup({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Group>) {
  return <ComboboxPrimitive.Group className={className} {...props} />
}

function ComboboxLabel({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.GroupLabel>) {
  return (
    <ComboboxPrimitive.GroupLabel
      className={(state) =>
        cn(
          "px-3 py-2.5 text-xs font-semibold text-muted-foreground",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function ComboboxEmpty({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Empty>) {
  return (
    <ComboboxPrimitive.Empty
      className={(state) =>
        cn(
          "w-full items-center justify-center py-2",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {wrapText(children, "text-center text-sm text-muted-foreground")}
    </ComboboxPrimitive.Empty>
  )
}

function ComboboxSeparator({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Separator>) {
  return (
    <ComboboxPrimitive.Separator
      className={(state) =>
        cn("-mx-1.5 my-1.5 h-px bg-border", resolveClassName(className, state))
      }
      {...props}
    />
  )
}

function ComboboxChips({
  className,
  invalid,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Chips> & {
  invalid?: boolean
}) {
  const { hasValue, disabled } =
    ComboboxPrimitive.useComboboxRootContext("ComboboxChips")
  const field = useFieldRootContext()
  const isInvalid = invalid ?? field.state.invalid
  return (
    <ComboboxPrimitive.Chips
      className={(state) =>
        cn(
          "min-h-11 w-full flex-row flex-wrap items-center gap-1.5 rounded-lg border border-transparent bg-secondary px-3 py-1.5",
          hasValue && "px-1.5",
          isInvalid &&
            "border-destructive bg-destructive-5 dark:bg-destructive-10",
          disabled && "opacity-50",
          resolveClassName(className, state)
        )
      }
      {...props}
    />
  )
}

function ComboboxChip({
  className,
  children,
  showRemove = true,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Chip> & {
  showRemove?: boolean
}) {
  return (
    <ComboboxPrimitive.Chip
      className={(state) =>
        cn(
          "h-[1.375rem] flex-row items-center justify-center gap-1 rounded-full bg-primary-10 dark:bg-primary-20 px-2.5",
          showRemove && "pr-0.5",
          state.disabled && "opacity-50",
          resolveClassName(className, state)
        )
      }
      {...props}
    >
      {wrapText(children, "text-xs text-primary")}
      {showRemove ? (
        <ComboboxPrimitive.ChipRemove
          accessibilityLabel="Remove"
          render={
            <Button
              variant="ghost"
              size="icon-xs"
              className="-ml-1 opacity-50 active:opacity-100"
            />
          }
        >
          <Icon as={XIcon} className="size-3 text-primary" />
        </ComboboxPrimitive.ChipRemove>
      ) : null}
    </ComboboxPrimitive.Chip>
  )
}

function ComboboxChipsInput({
  className,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Input>) {
  return (
    <ComboboxPrimitive.Input
      className={(state) =>
        cn(
          "h-6 min-w-16 flex-1 px-1 py-0 text-[16px] text-foreground",
          resolveClassName(className, state)
        )
      }
      placeholderClassName="text-muted-foreground"
      cursorColorClassName="text-primary"
      selectionColorClassName="text-primary"
      {...props}
    />
  )
}

function useComboboxAnchor() {
  return React.useRef<React.ComponentRef<typeof View> | null>(null)
}

export {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
  ComboboxTrigger,
  ComboboxValue,
  useComboboxAnchor,
}
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/combobox.tsx`, `components/primitives/field.tsx`, `components/primitives/positioner.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/combobox.tsx`:

```tsx title="stylesheet/components/ui/combobox.tsx"
import { CaretDownIcon, CheckIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  type View,
  type ViewStyle,
} from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as ComboboxPrimitive from "@/components/primitives/combobox"
import { useFieldRootContext } from "@/components/primitives/field"
import { useOptionalPositionerContext } from "@/components/primitives/positioner"
import { resolveStyle } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text, TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

function withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

const Combobox = ComboboxPrimitive.Root

const ComboboxValue = ComboboxPrimitive.Value

const ComboboxCollection = ComboboxPrimitive.Collection

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const styles = StyleSheet.create({
  iconButton: {
    width: 24,
    height: 24,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  inputGroup: {
    height: 44,
    width: "100%",
    minWidth: 0,
    flexDirection: "row",
    alignItems: "center",
    gap: 4,
    borderRadius: radius.lg,
    borderWidth: 1,
    borderColor: "transparent",
    paddingRight: 8,
  },
  inputGroupInPopup: {
    width: "auto",
    height: 32,
    margin: 6,
    marginBottom: 0,
  },
  input: {
    height: "100%",
    minWidth: 0,
    flex: 1,
    paddingLeft: 12,
    paddingRight: 6,
    paddingVertical: 4,
    fontSize: fontSize.base.fontSize,
  },
  content: {
    position: "relative",
    overflow: "hidden",
    borderRadius: radius.xl,
    borderWidth: 1,
    ...shadow.lg,
  },
  list: { maxHeight: 288 },
  listContent: { padding: 6 },
  item: {
    position: "relative",
    width: "100%",
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderRadius: radius.md,
    paddingVertical: 8,
    paddingRight: 32,
    paddingLeft: 12,
  },
  itemText: { flex: 1, ...fontSize.base, userSelect: "none" },
  indicator: {
    position: "absolute",
    right: 8,
    width: 16,
    height: 16,
    alignItems: "center",
    justifyContent: "center",
  },
  label: {
    paddingHorizontal: 12,
    paddingVertical: 10,
    ...fontSize.xs,
    fontWeight: "600",
  },
  empty: {
    width: "100%",
    alignItems: "center",
    justifyContent: "center",
    paddingVertical: 8,
  },
  emptyText: { ...fontSize.sm, textAlign: "center" },
  separator: { marginHorizontal: -6, marginVertical: 6, height: 1 },
  chips: {
    minHeight: 44,
    width: "100%",
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "center",
    gap: 6,
    borderRadius: radius.lg,
    borderWidth: 1,
    borderColor: "transparent",
    paddingHorizontal: 12,
    paddingVertical: 6,
  },
  chipsFilled: { paddingHorizontal: 6 },
  chip: {
    height: 22,
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    gap: 4,
    borderRadius: radius.full,
    paddingHorizontal: 10,
  },
  chipRemovable: { paddingRight: 2 },
  chipText: { ...fontSize.xs },
  chipRemove: { marginLeft: -4, opacity: 0.5 },
  chipsInput: {
    height: 24,
    minWidth: 64,
    flex: 1,
    paddingHorizontal: 4,
    paddingVertical: 0,
    fontSize: fontSize.base.fontSize,
  },
})

function wrapText(children: React.ReactNode, style: StyleProp<TextStyle>) {
  if (typeof children === "string" || typeof children === "number") {
    return <Text style={style}>{children}</Text>
  }
  return children
}

function ComboboxTrigger({
  style,
  children,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Trigger> & {
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.Trigger
      accessibilityLabel="Show options"
      style={(state) => [
        styles.iconButton,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    >
      {children}
      <Icon as={CaretDownIcon} size={16} color={colors.mutedForeground} />
    </ComboboxPrimitive.Trigger>
  )
}

function ComboboxClear({
  style,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Clear>) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.Clear
      accessibilityLabel="Clear"
      style={(state) => [
        styles.iconButton,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    >
      <Icon as={XIcon} size={16} color={colors.foreground} />
    </ComboboxPrimitive.Clear>
  )
}

function ComboboxInput({
  style,
  children,
  disabled = false,
  showTrigger = true,
  showClear = false,
  invalid,
  onFocus,
  onBlur,
  ...props
}: Omit<React.ComponentProps<typeof ComboboxPrimitive.Input>, "style"> & {
  style?: StyleProp<ViewStyle>
  children?: React.ReactNode
  showTrigger?: boolean
  showClear?: boolean
  invalid?: boolean
}) {
  const { colors, isDark } = useTheme()
  const combobox = ComboboxPrimitive.useComboboxRootContext("ComboboxInput")
  const field = useFieldRootContext()
  const [focused, setFocused] = React.useState(false)
  const isDisabled = disabled || combobox.disabled
  const isInvalid = invalid ?? field.state.invalid
  const inPopup = useOptionalPositionerContext() !== null
  const clearVisible =
    showClear && (combobox.hasValue || combobox.inputValue !== "")
  return (
    <ComboboxPrimitive.InputGroup
      style={[
        styles.inputGroup,
        { backgroundColor: colors.secondary },
        isInvalid && {
          borderColor: colors.destructive,
          backgroundColor: `${colors.destructive}${isDark ? "1a" : "0d"}`,
        },
        focused && !inPopup && { borderColor: colors.ring },
        inPopup && [
          styles.inputGroupInPopup,
          { borderColor: `${colors.border}4d` },
        ],
        isDisabled && { opacity: 0.5 },
        style,
      ]}
    >
      <ComboboxPrimitive.Input
        disabled={disabled}
        style={[styles.input, { color: colors.foreground }]}
        placeholderTextColor={colors.mutedForeground}
        cursorColor={colors.primary}
        selectionColor={colors.primary}
        onFocus={(event) => {
          setFocused(true)
          onFocus?.(event)
        }}
        onBlur={(event) => {
          setFocused(false)
          onBlur?.(event)
        }}
        {...props}
      />
      {showTrigger && !clearVisible ? (
        <ComboboxTrigger disabled={disabled} />
      ) : null}
      {showClear ? <ComboboxClear disabled={disabled} /> : null}
      {children}
    </ComboboxPrimitive.InputGroup>
  )
}

type PositionerProps = React.ComponentProps<typeof ComboboxPrimitive.Positioner>

function ContentPopup({
  style,
  chips,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Popup> & { chips: boolean }) {
  const { colors, isDark } = useTheme()
  const positioner = useOptionalPositionerContext()
  const anchorWidth = positioner?.anchorWidth ?? 0
  const sizing: ViewStyle = {
    width: anchorWidth || undefined,
    minWidth: chips ? anchorWidth : anchorWidth + 28,
    maxWidth: positioner?.availableWidth,
    maxHeight: positioner?.availableHeight,
  }
  return (
    <ComboboxPrimitive.Popup
      style={(state) => [
        styles.content,
        {
          borderColor: `${colors.foreground}${isDark ? "1a" : "0d"}`,
          backgroundColor: colors.popover,
        },
        sizing,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

function ComboboxContent({
  style,
  side = "bottom",
  sideOffset = 6,
  align = "start",
  alignOffset = 0,
  anchor,
  portalHost,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Popup> &
  Pick<
    PositionerProps,
    "side" | "align" | "sideOffset" | "alignOffset" | "anchor"
  > & {
    portalHost?: string
  }) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.Portal hostName={portalHost}>
      <FullWindowOverlay>
        <ComboboxPrimitive.Positioner
          side={side}
          sideOffset={sideOffset}
          align={align}
          alignOffset={alignOffset}
          anchor={anchor}
        >
          <AnimatedView
            entering={FadeIn.reduceMotion(ReduceMotion.System)}
            exiting={FadeOut.reduceMotion(ReduceMotion.System)}
          >
            <TextStyleContext.Provider
              value={{ ...fontSize.base, color: colors.foreground }}
            >
              <ContentPopup style={style} chips={!!anchor} {...props} />
            </TextStyleContext.Provider>
          </AnimatedView>
        </ComboboxPrimitive.Positioner>
      </FullWindowOverlay>
    </ComboboxPrimitive.Portal>
  )
}

function ComboboxList({
  style,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.List>) {
  return (
    <ComboboxPrimitive.List
      style={(state) => [
        styles.list,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      contentContainerStyle={styles.listContent}
      showsVerticalScrollIndicator={false}
      {...props}
    />
  )
}

function ComboboxItem({
  style,
  children,
  ...props
}: Omit<React.ComponentProps<typeof ComboboxPrimitive.Item>, "children"> & {
  children?: React.ReactNode
}) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.Item
      style={(state) => [
        styles.item,
        state.highlighted && { opacity: 0.8 },
        props.disabled && { opacity: 0.5 },
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    >
      {wrapText(children, [styles.itemText, { color: colors.foreground }])}
      <ComboboxPrimitive.ItemIndicator style={styles.indicator}>
        <Icon as={CheckIcon} size={16} color={colors.primary} />
      </ComboboxPrimitive.ItemIndicator>
    </ComboboxPrimitive.Item>
  )
}

function ComboboxGroup(
  props: React.ComponentProps<typeof ComboboxPrimitive.Group>
) {
  return <ComboboxPrimitive.Group {...props} />
}

function ComboboxLabel({
  style,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.GroupLabel>) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.GroupLabel
      style={(state) => [
        styles.label,
        { color: colors.mutedForeground },
        resolveStyle(style, state) as StyleProp<TextStyle>,
      ]}
      {...props}
    />
  )
}

function ComboboxEmpty({
  style,
  children,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Empty>) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.Empty
      style={(state) => [
        styles.empty,
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    >
      {wrapText(children, [
        styles.emptyText,
        { color: colors.mutedForeground },
      ])}
    </ComboboxPrimitive.Empty>
  )
}

function ComboboxSeparator({
  style,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Separator>) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.Separator
      style={(state) => [
        styles.separator,
        { backgroundColor: colors.border },
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

function ComboboxChips({
  style,
  invalid,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Chips> & {
  invalid?: boolean
}) {
  const { colors, isDark } = useTheme()
  const { hasValue, disabled } =
    ComboboxPrimitive.useComboboxRootContext("ComboboxChips")
  const field = useFieldRootContext()
  const isInvalid = invalid ?? field.state.invalid
  return (
    <ComboboxPrimitive.Chips
      style={(state) => [
        styles.chips,
        { backgroundColor: colors.secondary },
        hasValue && styles.chipsFilled,
        isInvalid && {
          borderColor: colors.destructive,
          backgroundColor: `${colors.destructive}${isDark ? "1a" : "0d"}`,
        },
        disabled && { opacity: 0.5 },
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    />
  )
}

function ComboboxChip({
  style,
  children,
  showRemove = true,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Chip> & {
  showRemove?: boolean
}) {
  const { colors, isDark } = useTheme()
  const color = colors.primary
  return (
    <ComboboxPrimitive.Chip
      style={(state) => [
        styles.chip,
        { backgroundColor: withAlpha(colors.primary, isDark ? 0.2 : 0.1) },
        showRemove && styles.chipRemovable,
        state.disabled && { opacity: 0.5 },
        resolveStyle(style, state) as StyleProp<ViewStyle>,
      ]}
      {...props}
    >
      {wrapText(children, [styles.chipText, { color }])}
      {showRemove ? (
        <ComboboxPrimitive.ChipRemove
          accessibilityLabel="Remove"
          render={
            <Button variant="ghost" size="icon-xs" style={styles.chipRemove} />
          }
        >
          <Icon as={XIcon} size={12} color={color} />
        </ComboboxPrimitive.ChipRemove>
      ) : null}
    </ComboboxPrimitive.Chip>
  )
}

function ComboboxChipsInput({
  style,
  ...props
}: React.ComponentProps<typeof ComboboxPrimitive.Input>) {
  const { colors } = useTheme()
  return (
    <ComboboxPrimitive.Input
      style={(state) => [
        styles.chipsInput,
        { color: colors.foreground },
        resolveStyle(style, state) as StyleProp<TextStyle>,
      ]}
      placeholderTextColor={colors.mutedForeground}
      cursorColor={colors.primary}
      selectionColor={colors.primary}
      {...props}
    />
  )
}

function useComboboxAnchor() {
  return React.useRef<React.ComponentRef<typeof View> | null>(null)
}

export {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
  ComboboxTrigger,
  ComboboxValue,
  useComboboxAnchor,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/Combobox.swift"
import PhosphorSwift
import SwiftUI

public struct Combobox<Item: Hashable & Sendable>: View {
  let items: [Item]
  let single: Binding<Item?>?
  let multiple: Binding<[Item]>?
  let placeholder: LocalizedStringKey
  let searchPlaceholder: LocalizedStringKey
  let emptyText: LocalizedStringKey
  let title: (Item) -> String

  @State private var open = false
  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.isInvalid) private var invalid
  @Environment(\.colorScheme) private var colorScheme

  public init(
    _ items: [Item],
    selection: Binding<Item?>,
    placeholder: LocalizedStringKey = "Select",
    searchPlaceholder: LocalizedStringKey = "Search",
    emptyText: LocalizedStringKey = "No items found.",
    title: @escaping (Item) -> String
  ) {
    self.items = items
    self.single = selection
    self.multiple = nil
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.title = title
  }

  public init(
    _ items: [Item],
    selection: Binding<[Item]>,
    placeholder: LocalizedStringKey = "Select",
    searchPlaceholder: LocalizedStringKey = "Search",
    emptyText: LocalizedStringKey = "No items found.",
    title: @escaping (Item) -> String
  ) {
    self.items = items
    self.single = nil
    self.multiple = selection
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.title = title
  }

  public var body: some View {
    trigger
      .anchoredPopup(
        isPresented: $open,
        side: .bottom,
        align: .start,
        matchWidth: true,
        minWidth: 144,
        maxHeight: 340
      ) {
        ComboboxPanel(
          items: items,
          single: single,
          multiple: multiple,
          searchPlaceholder: searchPlaceholder,
          emptyText: emptyText,
          title: title
        )
      }
  }

  private var shape: RoundedRectangle {
    RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
  }

  private var background: Color {
    if invalid { return EsuissColor.destructive.opacity(colorScheme == .dark ? 0.1 : 0.05) }
    return EsuissColor.secondary
  }

  @ViewBuilder private var trigger: some View {
    if let multiple {
      chips(multiple)
    } else if let single {
      input(single)
    }
  }

  private func input(_ selection: Binding<Item?>) -> some View {
    HStack(spacing: 4) {
      Button {
        open = true
      } label: {
        Group {
          if let value = selection.wrappedValue {
            Text(verbatim: title(value))
              .foregroundStyle(EsuissColor.foreground)
          } else {
            Text(placeholder)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
        }
        .scaledFont(TextStyle.base)
        .lineLimit(1)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
        .padding(.leading, 12)
        .contentShape(Rectangle())
      }
      .buttonStyle(.plain)
      .accessibilityHint(Text("Shows options"))
      if selection.wrappedValue != nil {
        Button {
          selection.wrappedValue = nil
        } label: {
          Ph.x.regular
            .frame(width: 16, height: 16)
            .foregroundStyle(EsuissColor.foreground)
        }
        .buttonStyle(ComboboxIconButtonStyle())
        .accessibilityLabel(Text("Clear"))
      }
      Button {
        open = true
      } label: {
        Ph.caretDown.regular
          .frame(width: 16, height: 16)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .buttonStyle(ComboboxIconButtonStyle())
      .accessibilityHidden(true)
    }
    .padding(.trailing, 8)
    .frame(height: 44)
    .background(background, in: shape)
    .overlay {
      if invalid { shape.strokeBorder(EsuissColor.destructive) }
    }
    .opacity(isEnabled ? 1 : 0.5)
  }

  private func chips(_ selection: Binding<[Item]>) -> some View {
    let values = selection.wrappedValue
    return ComboboxFlowLayout(spacing: 6) {
      ForEach(values, id: \.self) { value in
        ComboboxChip(text: title(value)) {
          selection.wrappedValue.removeAll { $0 == value }
        }
      }
      if values.isEmpty {
        Text(placeholder)
          .scaledFont(TextStyle.base)
          .foregroundStyle(EsuissColor.mutedForeground)
          .lineLimit(1)
      }
    }
    .padding(.horizontal, values.isEmpty ? 12 : 6)
    .padding(.vertical, 6)
    .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
    .background(background, in: shape)
    .overlay {
      if invalid { shape.strokeBorder(EsuissColor.destructive) }
    }
    .opacity(isEnabled ? 1 : 0.5)
    .contentShape(shape)
    .onTapGesture {
      if isEnabled { open = true }
    }
    .accessibilityElement(children: .contain)
    .accessibilityAction(named: Text("Show options")) { open = true }
  }
}

extension Combobox where Item == String {
  public init(
    _ items: [String],
    selection: Binding<String?>,
    placeholder: LocalizedStringKey = "Select",
    searchPlaceholder: LocalizedStringKey = "Search",
    emptyText: LocalizedStringKey = "No items found."
  ) {
    self.init(
      items,
      selection: selection,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText
    ) { $0 }
  }

  public init(
    _ items: [String],
    selection: Binding<[String]>,
    placeholder: LocalizedStringKey = "Select",
    searchPlaceholder: LocalizedStringKey = "Search",
    emptyText: LocalizedStringKey = "No items found."
  ) {
    self.init(
      items,
      selection: selection,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText
    ) { $0 }
  }
}

private struct ComboboxIconButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .frame(width: 24, height: 24)
      .opacity(configuration.isPressed ? 0.8 : 1)
      .contentShape(Circle().inset(by: -6))
  }
}

private struct ComboboxChip: View {
  let text: String
  let remove: () -> Void

  @Environment(\.isEnabled) private var isEnabled

  var body: some View {
    HStack(spacing: 2) {
      Text(verbatim: text)
        .scaledFont(TextStyle.xs)
        .lineLimit(1)
      Button(action: remove) {
        Ph.x.regular
          .frame(width: 12, height: 12)
          .frame(width: 20, height: 20)
          .contentShape(Rectangle())
      }
      .buttonStyle(ChipRemoveStyle())
      .accessibilityLabel(Text("Remove \(text)"))
    }
    .foregroundStyle(EsuissColor.primary)
    .padding(.leading, 10)
    .padding(.trailing, 2)
    .frame(height: 22)
    .background(Color.adaptive(light: EsuissColor.primary.opacity(0.1), dark: EsuissColor.primary.opacity(0.2)), in: Capsule())
  }
}

private struct ChipRemoveStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label.opacity(configuration.isPressed ? 1 : 0.5)
  }
}

struct ComboboxFlowLayout: Layout {
  var spacing: CGFloat

  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
    let width = rows.map(\.width).max() ?? 0
    let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0))
    return CGSize(width: proposal.width ?? width, height: height)
  }

  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    var y = bounds.minY
    for row in arrange(width: bounds.width, subviews: subviews) {
      var x = bounds.minX
      for index in row.indices {
        let size = subviews[index].sizeThatFits(.unspecified)
        subviews[index].place(
          at: CGPoint(x: x, y: y + (row.height - size.height) / 2),
          anchor: .topLeading,
          proposal: ProposedViewSize(width: min(size.width, bounds.width), height: size.height)
        )
        x += size.width + spacing
      }
      y += row.height + spacing
    }
  }

  private struct Row {
    var indices: [Int] = []
    var width: CGFloat = 0
    var height: CGFloat = 0
  }

  private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
    var rows: [Row] = []
    var row = Row()
    for index in subviews.indices {
      let size = subviews[index].sizeThatFits(.unspecified)
      let next = row.indices.isEmpty ? size.width : row.width + spacing + size.width
      if !row.indices.isEmpty && next > width {
        rows.append(row)
        row = Row()
      }
      row.width = row.indices.isEmpty ? size.width : row.width + spacing + size.width
      row.height = max(row.height, size.height)
      row.indices.append(index)
    }
    if !row.indices.isEmpty { rows.append(row) }
    return rows
  }
}

struct ComboboxPanel<Item: Hashable & Sendable>: View {
  let items: [Item]
  let single: Binding<Item?>?
  let multiple: Binding<[Item]>?
  let searchPlaceholder: LocalizedStringKey
  let emptyText: LocalizedStringKey
  let title: (Item) -> String
  var initialQuery = ""
  var autofocus = true

  @State private var query = ""
  @FocusState private var focused: Bool
  @Environment(\.overlayDismiss) private var dismiss

  var body: some View {
    let filtered = items.filter { query.isEmpty || title($0).localizedCaseInsensitiveContains(query) }
    let list = VStack(alignment: .leading, spacing: 0) {
      if filtered.isEmpty {
        Text(emptyText)
          .scaledFont(TextStyle.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .multilineTextAlignment(.center)
          .frame(maxWidth: .infinity)
          .padding(.vertical, 8)
      }
      ForEach(filtered, id: \.self) { item in
        row(item)
      }
    }
    .padding(6)
    VStack(spacing: 0) {
      TextField(searchPlaceholder, text: $query)
        .focused($focused)
        .textInputAutocapitalization(.never)
        .autocorrectionDisabled()
        .scaledFont(TextStyle.base)
        .foregroundStyle(EsuissColor.foreground)
        .tint(EsuissColor.primary)
        .padding(.horizontal, 12)
        .frame(height: 32)
        .background(EsuissColor.secondary, in: RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
        .overlay {
          RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
            .strokeBorder(EsuissColor.border.opacity(0.3))
        }
        .padding([.horizontal, .top], 6)
      ViewThatFits(in: .vertical) {
        list
        ScrollView { list }
      }
    }
    .scaledFont(TextStyle.base)
    .foregroundStyle(EsuissColor.foreground)
    .popupSurface(radius: Radius.xl)
    .accessibilityElement(children: .contain)
    .onAppear {
      query = initialQuery
      if autofocus { focused = true }
    }
  }

  private func isSelected(_ item: Item) -> Bool {
    if let multiple { return multiple.wrappedValue.contains(item) }
    return single?.wrappedValue == item
  }

  private func row(_ item: Item) -> some View {
    let selected = isSelected(item)
    return Button {
      if let multiple {
        if selected {
          multiple.wrappedValue.removeAll { $0 == item }
        } else {
          multiple.wrappedValue.append(item)
        }
      } else {
        single?.wrappedValue = item
        dismiss?()
      }
    } label: {
      Text(verbatim: title(item))
    }
    .buttonStyle(MenuRowStyle(trailing: 32))
    .overlay(alignment: .trailing) {
      if selected {
        Ph.check.regular
          .frame(width: 16, height: 16)
          .foregroundStyle(EsuissColor.primary)
          .padding(.trailing, 8)
          .accessibilityHidden(true)
      }
    }
    .accessibilityAddTraits(selected ? .isSelected : [])
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/Combobox.kt"
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CaretDown
import com.adamglin.phosphoricons.regular.Check
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

private fun comboboxSelectionColors(colors: Colors) = TextSelectionColors(
    handleColor = colors.primary,
    backgroundColor = colors.primary.copy(alpha = 0.3f),
)

@Composable
fun <T> Combobox(
    items: List<T>,
    value: T?,
    onValueChange: (T?) -> Unit,
    modifier: Modifier = Modifier,
    itemText: (T) -> String = { it.toString() },
    placeholder: String = "",
    emptyText: String = "No items found.",
    groupBy: ((T) -> String)? = null,
    showTrigger: Boolean = true,
    showClear: Boolean = false,
    invalid: Boolean? = null,
    enabled: Boolean = true,
    filter: (T, String) -> Boolean = { item, query -> itemText(item).contains(query, ignoreCase = true) },
) {
    val colors = EsuissTheme.colors
    val field = LocalFieldState.current
    val isInvalid = invalid ?: field.invalid
    val isEnabled = enabled && field.enabled
    val selectedText = value?.let(itemText).orEmpty()
    var query by remember { mutableStateOf(selectedText) }
    var filtering by remember { mutableStateOf(false) }
    var expanded by remember { mutableStateOf(false) }
    var focused by remember { mutableStateOf(false) }
    val currentSelectedText by rememberUpdatedState(selectedText)
    LaunchedEffect(selectedText) {
        if (!filtering) query = selectedText
    }
    val interaction = remember { MutableInteractionSource() }
    LaunchedEffect(interaction) {
        interaction.interactions.collect { if (it is PressInteraction.Release) expanded = true }
    }
    val visible = if (filtering && query.isNotEmpty()) items.filter { filter(it, query) } else items
    val clearVisible = showClear && (value != null || query.isNotEmpty())
    val shape = RoundedCornerShape(Radius.lg)
    val foreground = colors.foreground

    Box(modifier = modifier) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(44.dp)
                .alpha(if (isEnabled) 1f else 0.5f)
                .clip(shape)
                .background(controlBackground(colors, isInvalid), shape)
                .border(
                    1.dp,
                    when {
                        isInvalid -> colors.destructive
                        focused -> colors.ring
                        else -> Color.Transparent
                    },
                    shape,
                )
                .padding(end = 8.dp),
            horizontalArrangement = Arrangement.spacedBy(4.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            CompositionLocalProvider(LocalTextSelectionColors provides comboboxSelectionColors(colors)) {
                BasicTextField(
                    value = query,
                    onValueChange = {
                        query = it
                        filtering = true
                        expanded = true
                    },
                    modifier = Modifier
                        .weight(1f)
                        .padding(start = 12.dp, end = 6.dp)
                        .onFocusChanged { state ->
                            focused = state.isFocused
                            if (!state.isFocused) {
                                expanded = false
                                filtering = false
                                query = currentSelectedText
                            }
                        },
                    enabled = isEnabled,
                    singleLine = true,
                    textStyle = Typography.base.copy(color = foreground),
                    cursorBrush = SolidColor(colors.primary),
                    interactionSource = interaction,
                    decorationBox = { inner ->
                        Box(contentAlignment = Alignment.CenterStart) {
                            if (query.isEmpty()) {
                                BasicText(
                                    placeholder,
                                    style = Typography.base.copy(
                                        color = colors.mutedForeground,
                                    ),
                                    maxLines = 1,
                                )
                            }
                            inner()
                        }
                    },
                )
            }
            if (showTrigger && !clearVisible) {
                ComboboxIconButton(PhosphorIcons.Regular.CaretDown, "Show options", isEnabled, colors.mutedForeground) {
                    expanded = !expanded
                }
            }
            if (clearVisible) {
                ComboboxIconButton(PhosphorIcons.Regular.X, "Clear", isEnabled, colors.foreground) {
                    query = ""
                    filtering = false
                    onValueChange(null)
                }
            }
        }
        ComboboxPopup(
            expanded = expanded,
            onDismissRequest = { expanded = false },
            items = visible,
            isSelected = { it == value },
            itemText = itemText,
            groupBy = groupBy,
            emptyText = emptyText,
            onItemClick = { item ->
                onValueChange(item)
                query = itemText(item)
                filtering = false
                expanded = false
            },
        )
    }
}

@Composable
fun <T> MultiCombobox(
    items: List<T>,
    values: List<T>,
    onValuesChange: (List<T>) -> Unit,
    modifier: Modifier = Modifier,
    itemText: (T) -> String = { it.toString() },
    placeholder: String = "",
    emptyText: String = "No items found.",
    groupBy: ((T) -> String)? = null,
    invalid: Boolean? = null,
    enabled: Boolean = true,
    filter: (T, String) -> Boolean = { item, query -> itemText(item).contains(query, ignoreCase = true) },
) {
    val colors = EsuissTheme.colors
    val field = LocalFieldState.current
    val isInvalid = invalid ?: field.invalid
    val isEnabled = enabled && field.enabled
    var query by remember { mutableStateOf("") }
    var expanded by remember { mutableStateOf(false) }
    val interaction = remember { MutableInteractionSource() }
    LaunchedEffect(interaction) {
        interaction.interactions.collect { if (it is PressInteraction.Release) expanded = true }
    }
    val visible = if (query.isNotEmpty()) items.filter { filter(it, query) } else items
    val shape = RoundedCornerShape(Radius.lg)
    val hasValue = values.isNotEmpty()

    Box(modifier = modifier) {
        FlowRow(
            modifier = Modifier
                .fillMaxWidth()
                .heightIn(min = 44.dp)
                .alpha(if (isEnabled) 1f else 0.5f)
                .clip(shape)
                .background(controlBackground(colors, isInvalid), shape)
                .border(1.dp, if (isInvalid) colors.destructive else Color.Transparent, shape)
                .padding(horizontal = if (hasValue) 6.dp else 12.dp, vertical = 6.dp),
            horizontalArrangement = Arrangement.spacedBy(6.dp),
            verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
            itemVerticalAlignment = Alignment.CenterVertically,
        ) {
            values.forEach { item ->
                ComboboxChip(itemText(item), isEnabled) { onValuesChange(values - item) }
            }
            CompositionLocalProvider(LocalTextSelectionColors provides comboboxSelectionColors(colors)) {
                BasicTextField(
                    value = query,
                    onValueChange = {
                        query = it
                        expanded = true
                    },
                    modifier = Modifier
                        .weight(1f)
                        .widthIn(min = 64.dp)
                        .height(30.dp)
                        .padding(horizontal = 4.dp)
                        .onFocusChanged { if (!it.isFocused) expanded = false }
                        .onPreviewKeyEvent { event ->
                            if (event.type == KeyEventType.KeyDown && event.key == Key.Backspace && query.isEmpty() && hasValue) {
                                onValuesChange(values.dropLast(1))
                                true
                            } else {
                                false
                            }
                        },
                    enabled = isEnabled,
                    singleLine = true,
                    textStyle = Typography.base.copy(color = colors.foreground),
                    cursorBrush = SolidColor(colors.primary),
                    interactionSource = interaction,
                    decorationBox = { inner ->
                        Box(contentAlignment = Alignment.CenterStart) {
                            if (query.isEmpty() && !hasValue) {
                                BasicText(placeholder, style = Typography.base.copy(color = colors.mutedForeground), maxLines = 1)
                            }
                            inner()
                        }
                    },
                )
            }
        }
        ComboboxPopup(
            expanded = expanded,
            onDismissRequest = { expanded = false },
            items = visible,
            isSelected = { it in values },
            itemText = itemText,
            groupBy = groupBy,
            emptyText = emptyText,
            onItemClick = { item ->
                onValuesChange(if (item in values) values - item else values + item)
                query = ""
            },
        )
    }
}

@Composable
private fun ComboboxIconButton(
    icon: ImageVector,
    label: String,
    enabled: Boolean,
    tint: Color,
    onClick: () -> Unit,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Box(
        modifier = Modifier
            .size(32.dp)
            .clickable(interactionSource = interaction, indication = null, enabled = enabled, role = Role.Button, onClick = onClick)
            .semantics { contentDescription = label },
        contentAlignment = Alignment.Center,
    ) {
        Box(
            modifier = Modifier
                .size(24.dp)
                .alpha(if (pressed) 0.8f else 1f),
            contentAlignment = Alignment.Center,
        ) {
            Icon(icon, contentDescription = null, size = 16.dp, tint = tint)
        }
    }
}

@Composable
private fun ComboboxChip(text: String, enabled: Boolean, onRemove: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val foreground = colors.primary
    Row(
        modifier = Modifier
            .height(22.dp)
            .background(colors.primary.copy(alpha = if (colors.isDark) 0.2f else 0.1f), CircleShape)
            .padding(start = 10.dp, end = 2.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        BasicText(text, style = Typography.xs.copy(color = foreground), maxLines = 1)
        Box(
            modifier = Modifier
                .size(22.dp)
                .clip(CircleShape)
                .clickable(interactionSource = interaction, indication = null, enabled = enabled, role = Role.Button, onClick = onRemove)
                .semantics { contentDescription = "Remove $text" }
                .alpha(if (pressed) 1f else 0.5f),
            contentAlignment = Alignment.Center,
        ) {
            Icon(PhosphorIcons.Regular.X, contentDescription = null, size = 12.dp, tint = foreground)
        }
    }
}

@Composable
private fun <T> ComboboxPopup(
    expanded: Boolean,
    onDismissRequest: () -> Unit,
    items: List<T>,
    isSelected: (T) -> Boolean,
    itemText: (T) -> String,
    groupBy: ((T) -> String)?,
    emptyText: String,
    onItemClick: (T) -> Unit,
) {
    val colors = EsuissTheme.colors
    AnchoredPopup(
        expanded = expanded,
        onDismissRequest = onDismissRequest,
        side = PopupSide.Bottom,
        align = PopupAlign.Start,
        sideOffset = 6.dp,
        focusable = false,
    ) { placement ->
        val width = with(LocalDensity.current) { placement.anchorWidth.toDp() }
        Column(
            modifier = Modifier
                .width(width)
                .raisedSurface(colors, RoundedCornerShape(Radius.xl))
                .heightIn(max = 288.dp)
                .verticalScroll(rememberScrollState())
                .padding(6.dp),
        ) {
            if (items.isEmpty()) {
                BasicText(
                    emptyText,
                    modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
                    style = Typography.sm.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
            } else if (groupBy == null) {
                items.forEach { ComboboxOption(itemText(it), isSelected(it)) { onItemClick(it) } }
            } else {
                items.groupBy(groupBy).entries.forEachIndexed { index, (label, group) ->
                    if (index > 0) {
                        MenuSeparatorLine(Modifier, bleed = 6.dp, color = colors.border)
                    }
                    BasicText(
                        label,
                        modifier = Modifier
                            .fillMaxWidth()
                            .semantics { heading() }
                            .padding(horizontal = 12.dp, vertical = 10.dp),
                        style = Typography.xs.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold),
                    )
                    group.forEach { ComboboxOption(itemText(it), isSelected(it)) { onItemClick(it) } }
                }
            }
        }
    }
}

@Composable
private fun ComboboxOption(text: String, selected: Boolean, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val shape = RoundedCornerShape(Radius.md)
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(min = 44.dp)
            .clip(shape)
            .alpha(if (pressed) 0.8f else 1f)
            .toggleable(
                value = selected,
                interactionSource = interaction,
                indication = null,
                role = Role.Checkbox,
                onValueChange = { onClick() },
            )
            .padding(start = 12.dp, end = 8.dp),
        contentAlignment = Alignment.CenterStart,
    ) {
        BasicText(
            text,
            modifier = Modifier.padding(end = 24.dp, top = 8.dp, bottom = 8.dp),
            style = Typography.base.copy(color = colors.foreground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        if (selected) {
            Icon(
                PhosphorIcons.Regular.Check,
                contentDescription = null,
                modifier = Modifier.align(Alignment.CenterEnd),
                size = 16.dp,
                tint = colors.primary,
            )
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/DropdownMenu.kt`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Select.kt`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { ComboboxTrigger, ComboboxInput, ComboboxContent, ComboboxList, ComboboxItem, ComboboxGroup, ComboboxLabel, ComboboxEmpty, ComboboxSeparator, ComboboxChips, ComboboxChip, ComboboxChipsInput } from "@/components/ui/combobox"
```

```tsx
<ComboboxDemo defaultValue="Expo" defaultOpen />
```

Pass `items` to the root and render the filtered list with a function child of `ComboboxList`. For grouped items, render a `ComboboxGroup` per group with a `ComboboxLabel` and a `ComboboxCollection`. Add `ComboboxEmpty` for the no-results message.

Use `value`, `defaultValue` and `onValueChange` for the selection, and `inputValue` with `onInputValueChange` for the typed text. For object items, provide `itemToStringLabel`. `filter`, `limit` and `locale` tune matching.

With chips, create a ref with `useComboboxAnchor()`, attach it to `ComboboxChips`, and pass it to `ComboboxContent` as `anchor` so the list lines up with the chips field. On `ComboboxInput`, set `showClear` to add a clear button and `showTrigger={false}` to hide the caret.

## Examples

### Default

![Preview](https://ui.suiss.app/previews/components-combobox--default.light.webp)

```tsx
import * as React from "react"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function ComboboxDemo({
  defaultValue = null,
  defaultOpen,
  disabled,
  showClear,
  labelledBy,
  invalid,
}: {
  defaultValue?: string | null
  defaultOpen?: boolean
  disabled?: boolean
  showClear?: boolean
  labelledBy?: string
  invalid?: boolean
}) {
  const [value, setValue] = React.useState<string | null>(defaultValue)
  return (
    <Combobox
      items={frameworks}
      value={value}
      onValueChange={setValue}
      defaultOpen={defaultOpen}
      disabled={disabled}
    >
      <ComboboxInput
        className="w-64"
        placeholder="Select a framework"
        showClear={showClear}
        disabled={disabled}
        invalid={invalid}
        accessibilityLabelledBy={labelledBy}
      />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxExample() {
  return (
    <ComboboxDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

let comboboxFrameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

struct ComboboxDemo: View {
  @State private var value: String?

  init(initial: String? = nil) {
    _value = State(initialValue: initial)
  }

  var body: some View {
    Combobox(
      comboboxFrameworks,
      selection: $value,
      placeholder: "Select a framework",
      emptyText: "No framework found."
    )
    .frame(width: 256)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxDemo(
    defaultValue: String? = null,
    disabled: Boolean = false,
    showClear: Boolean = false,
    label: String? = null,
    invalid: Boolean? = null,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    Column {
        Combobox(
            items = ComboboxFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier
                .width(256.dp)
                .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier),
            placeholder = "Select a framework",
            emptyText = "No framework found.",
            showClear = showClear,
            invalid = invalid,
            enabled = !disabled,
        )
    }
}
```

### With value

Starts with a selected value.

![Preview](https://ui.suiss.app/previews/components-combobox--with-value.light.webp)

```tsx
import * as React from "react"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function ComboboxDemo({
  defaultValue = null,
  defaultOpen,
  disabled,
  showClear,
  labelledBy,
  invalid,
}: {
  defaultValue?: string | null
  defaultOpen?: boolean
  disabled?: boolean
  showClear?: boolean
  labelledBy?: string
  invalid?: boolean
}) {
  const [value, setValue] = React.useState<string | null>(defaultValue)
  return (
    <Combobox
      items={frameworks}
      value={value}
      onValueChange={setValue}
      defaultOpen={defaultOpen}
      disabled={disabled}
    >
      <ComboboxInput
        className="w-64"
        placeholder="Select a framework"
        showClear={showClear}
        disabled={disabled}
        invalid={invalid}
        accessibilityLabelledBy={labelledBy}
      />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxWithValueExample() {
  return (
    <ComboboxDemo defaultValue="Expo" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ComboboxWithValueExample: View {
  var body: some View {
    ComboboxDemo(initial: "Expo")
  }
}

let comboboxFrameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

struct ComboboxDemo: View {
  @State private var value: String?

  init(initial: String? = nil) {
    _value = State(initialValue: initial)
  }

  var body: some View {
    Combobox(
      comboboxFrameworks,
      selection: $value,
      placeholder: "Select a framework",
      emptyText: "No framework found."
    )
    .frame(width: 256)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox

@Composable
fun ComboboxWithValueExample() {
    ComboboxDemo(defaultValue = "Expo")
}

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxDemo(
    defaultValue: String? = null,
    disabled: Boolean = false,
    showClear: Boolean = false,
    label: String? = null,
    invalid: Boolean? = null,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    Column {
        Combobox(
            items = ComboboxFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier
                .width(256.dp)
                .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier),
            placeholder = "Select a framework",
            emptyText = "No framework found.",
            showClear = showClear,
            invalid = invalid,
            enabled = !disabled,
        )
    }
}
```

### With clear

A clear button through `showClear`.

![Preview](https://ui.suiss.app/previews/components-combobox--with-clear.light.webp)

```tsx
import * as React from "react"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function ComboboxDemo({
  defaultValue = null,
  defaultOpen,
  disabled,
  showClear,
  labelledBy,
  invalid,
}: {
  defaultValue?: string | null
  defaultOpen?: boolean
  disabled?: boolean
  showClear?: boolean
  labelledBy?: string
  invalid?: boolean
}) {
  const [value, setValue] = React.useState<string | null>(defaultValue)
  return (
    <Combobox
      items={frameworks}
      value={value}
      onValueChange={setValue}
      defaultOpen={defaultOpen}
      disabled={disabled}
    >
      <ComboboxInput
        className="w-64"
        placeholder="Select a framework"
        showClear={showClear}
        disabled={disabled}
        invalid={invalid}
        accessibilityLabelledBy={labelledBy}
      />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxWithClearExample() {
  return (
    <ComboboxDemo defaultValue="Remix" showClear />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ComboboxWithClearExample: View {
  var body: some View {
    ComboboxDemo(initial: "Remix")
  }
}

let comboboxFrameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

struct ComboboxDemo: View {
  @State private var value: String?

  init(initial: String? = nil) {
    _value = State(initialValue: initial)
  }

  var body: some View {
    Combobox(
      comboboxFrameworks,
      selection: $value,
      placeholder: "Select a framework",
      emptyText: "No framework found."
    )
    .frame(width: 256)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox

@Composable
fun ComboboxWithClearExample() {
    ComboboxDemo(defaultValue = "Remix", showClear = true)
}

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxDemo(
    defaultValue: String? = null,
    disabled: Boolean = false,
    showClear: Boolean = false,
    label: String? = null,
    invalid: Boolean? = null,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    Column {
        Combobox(
            items = ComboboxFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier
                .width(256.dp)
                .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier),
            placeholder = "Select a framework",
            emptyText = "No framework found.",
            showClear = showClear,
            invalid = invalid,
            enabled = !disabled,
        )
    }
}
```

**React Native, Jetpack Compose**

### Grouped

Items in labelled groups with separators.

![Preview](https://ui.suiss.app/previews/components-combobox--grouped.light.webp)

```tsx
import * as React from "react"
import { Combobox, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxLabel, ComboboxList, ComboboxSeparator } from "@/components/ui/combobox"

interface Produce {
  value: string
  label: string
}

const produce: { value: string; items: Produce[] }[] = [
  {
    value: "Fruits",
    items: [
      { value: "mango", label: "Mango" },
      { value: "banana", label: "Banana" },
      { value: "blueberry", label: "Blueberry" },
    ],
  },
  {
    value: "Vegetables",
    items: [
      { value: "carrot", label: "Carrot" },
      { value: "broccoli", label: "Broccoli" },
      { value: "spinach", label: "Spinach" },
    ],
  },
]

function GroupedDemo() {
  return (
    <Combobox items={produce} itemToStringLabel={(item: Produce) => item.label}>
      <ComboboxInput className="w-64" placeholder="Select produce" />
      <ComboboxContent>
        <ComboboxEmpty>No produce found.</ComboboxEmpty>
        <ComboboxList>
          {(group: (typeof produce)[number], index: number) => (
            <React.Fragment key={group.value}>
              {index > 0 ? <ComboboxSeparator /> : null}
              <ComboboxGroup items={group.items}>
                <ComboboxLabel>{group.value}</ComboboxLabel>
                <ComboboxCollection>
                  {(item: Produce) => (
                    <ComboboxItem key={item.value} value={item}>
                      {item.label}
                    </ComboboxItem>
                  )}
                </ComboboxCollection>
              </ComboboxGroup>
            </React.Fragment>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxGroupedExample() {
  return (
    <GroupedDemo />
  )
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox

data class ComboboxProduce(val value: String, val label: String, val group: String)

val ComboboxProduceItems = listOf(
    ComboboxProduce("mango", "Mango", "Fruits"),
    ComboboxProduce("banana", "Banana", "Fruits"),
    ComboboxProduce("blueberry", "Blueberry", "Fruits"),
    ComboboxProduce("carrot", "Carrot", "Vegetables"),
    ComboboxProduce("broccoli", "Broccoli", "Vegetables"),
    ComboboxProduce("spinach", "Spinach", "Vegetables"),
)

@Composable
fun ComboboxGrouped() {
    var value by rememberSaveable { mutableStateOf<String?>(null) }
    Column {
        Combobox(
            items = ComboboxProduceItems,
            value = ComboboxProduceItems.firstOrNull { it.value == value },
            onValueChange = { value = it?.value },
            modifier = Modifier.width(256.dp),
            itemText = { it.label },
            placeholder = "Select produce",
            emptyText = "No produce found.",
            groupBy = { it.group },
        )
    }
}
```

### Multiple

Multiple selection shown as removable chips.

![Preview](https://ui.suiss.app/previews/components-combobox--multiple.light.webp)

```tsx
import * as React from "react"
import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, useComboboxAnchor } from "@/components/ui/combobox"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function MultipleDemo() {
  const anchor = useComboboxAnchor()
  const [value, setValue] = React.useState<string[]>(["Expo", "Astro"])
  return (
    <Combobox
      multiple
      items={frameworks}
      value={value}
      onValueChange={setValue}
    >
      <ComboboxChips ref={anchor} className="w-72">
        {value.map((item) => (
          <ComboboxChip key={item} value={item}>
            {item}
          </ComboboxChip>
        ))}
        <ComboboxChipsInput placeholder="Add framework" />
      </ComboboxChips>
      <ComboboxContent anchor={anchor}>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxMultipleExample() {
  return (
    <MultipleDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

let comboboxFrameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

struct ComboboxMultipleDemo: View {
  @State private var value = ["Expo", "Astro"]

  var body: some View {
    Combobox(
      comboboxFrameworks,
      selection: $value,
      placeholder: "Add framework",
      emptyText: "No framework found."
    )
    .frame(width: 288)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.MultiCombobox

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxMultiple() {
    var values by rememberSaveable { mutableStateOf(listOf("Expo", "Astro")) }
    Column {
        MultiCombobox(
            items = ComboboxFrameworks,
            values = values,
            onValuesChange = { values = it },
            modifier = Modifier.width(288.dp),
            placeholder = "Add framework",
            emptyText = "No framework found.",
        )
    }
}
```

### With label

Linked to a `Label` with helper text.

![Preview](https://ui.suiss.app/previews/components-combobox--with-label.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"
import { Label } from "@/components/ui/label"
import { Text } from "@/components/ui/text"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function ComboboxDemo({
  defaultValue = null,
  defaultOpen,
  disabled,
  showClear,
  labelledBy,
  invalid,
}: {
  defaultValue?: string | null
  defaultOpen?: boolean
  disabled?: boolean
  showClear?: boolean
  labelledBy?: string
  invalid?: boolean
}) {
  const [value, setValue] = React.useState<string | null>(defaultValue)
  return (
    <Combobox
      items={frameworks}
      value={value}
      onValueChange={setValue}
      defaultOpen={defaultOpen}
      disabled={disabled}
    >
      <ComboboxInput
        className="w-64"
        placeholder="Select a framework"
        showClear={showClear}
        disabled={disabled}
        invalid={invalid}
        accessibilityLabelledBy={labelledBy}
      />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxWithLabelExample() {
  return (
    <View className="gap-2">
      <Label nativeID="combobox-framework">Framework</Label>
      <ComboboxDemo labelledBy="combobox-framework" />
      <Text className="text-sm text-muted-foreground">
        Search and pick the framework you use.
      </Text>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ComboboxWithLabelExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      FieldLabel("Framework")
        .accessibilityHidden(true)
      ComboboxDemo()
        .accessibilityLabel(Text("Framework"))
      Text("Search and pick the framework you use.")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
    }
  }
}

let comboboxFrameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

struct ComboboxDemo: View {
  @State private var value: String?

  init(initial: String? = nil) {
    _value = State(initialValue: initial)
  }

  var body: some View {
    Combobox(
      comboboxFrameworks,
      selection: $value,
      placeholder: "Select a framework",
      emptyText: "No framework found."
    )
    .frame(width: 256)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox
import com.esuiss.ui.components.Label
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxDemo(
    defaultValue: String? = null,
    disabled: Boolean = false,
    showClear: Boolean = false,
    label: String? = null,
    invalid: Boolean? = null,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    Column {
        Combobox(
            items = ComboboxFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier
                .width(256.dp)
                .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier),
            placeholder = "Select a framework",
            emptyText = "No framework found.",
            showClear = showClear,
            invalid = invalid,
            enabled = !disabled,
        )
    }
}

@Composable
fun ComboboxWithLabel() {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Label("Framework")
        ComboboxDemo(label = "Framework")
        Text(
            "Search and pick the framework you use.",
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
    }
}
```

### Disabled

A disabled combobox.

![Preview](https://ui.suiss.app/previews/components-combobox--disabled.light.webp)

```tsx
import * as React from "react"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function ComboboxDemo({
  defaultValue = null,
  defaultOpen,
  disabled,
  showClear,
  labelledBy,
  invalid,
}: {
  defaultValue?: string | null
  defaultOpen?: boolean
  disabled?: boolean
  showClear?: boolean
  labelledBy?: string
  invalid?: boolean
}) {
  const [value, setValue] = React.useState<string | null>(defaultValue)
  return (
    <Combobox
      items={frameworks}
      value={value}
      onValueChange={setValue}
      defaultOpen={defaultOpen}
      disabled={disabled}
    >
      <ComboboxInput
        className="w-64"
        placeholder="Select a framework"
        showClear={showClear}
        disabled={disabled}
        invalid={invalid}
        accessibilityLabelledBy={labelledBy}
      />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxDisabledExample() {
  return (
    <ComboboxDemo defaultValue="Astro" disabled />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ComboboxDisabledExample: View {
  var body: some View {
    ComboboxDemo(initial: "Astro")
      .disabled(true)
  }
}

let comboboxFrameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

struct ComboboxDemo: View {
  @State private var value: String?

  init(initial: String? = nil) {
    _value = State(initialValue: initial)
  }

  var body: some View {
    Combobox(
      comboboxFrameworks,
      selection: $value,
      placeholder: "Select a framework",
      emptyText: "No framework found."
    )
    .frame(width: 256)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox

@Composable
fun ComboboxDisabledExample() {
    ComboboxDemo(defaultValue = "Astro", disabled = true)
}

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxDemo(
    defaultValue: String? = null,
    disabled: Boolean = false,
    showClear: Boolean = false,
    label: String? = null,
    invalid: Boolean? = null,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    Column {
        Combobox(
            items = ComboboxFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier
                .width(256.dp)
                .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier),
            placeholder = "Select a framework",
            emptyText = "No framework found.",
            showClear = showClear,
            invalid = invalid,
            enabled = !disabled,
        )
    }
}
```

### Invalid

The `invalid` style with an error message.

![Preview](https://ui.suiss.app/previews/components-combobox--invalid.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"
import { Text } from "@/components/ui/text"

const frameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

function ComboboxDemo({
  defaultValue = null,
  defaultOpen,
  disabled,
  showClear,
  labelledBy,
  invalid,
}: {
  defaultValue?: string | null
  defaultOpen?: boolean
  disabled?: boolean
  showClear?: boolean
  labelledBy?: string
  invalid?: boolean
}) {
  const [value, setValue] = React.useState<string | null>(defaultValue)
  return (
    <Combobox
      items={frameworks}
      value={value}
      onValueChange={setValue}
      defaultOpen={defaultOpen}
      disabled={disabled}
    >
      <ComboboxInput
        className="w-64"
        placeholder="Select a framework"
        showClear={showClear}
        disabled={disabled}
        invalid={invalid}
        accessibilityLabelledBy={labelledBy}
      />
      <ComboboxContent>
        <ComboboxEmpty>No framework found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export function ComboboxInvalidExample() {
  return (
    <View className="gap-2">
      <ComboboxDemo invalid />
      <Text className="text-sm text-destructive">
        Please select a framework.
      </Text>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ComboboxInvalidExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      ComboboxDemo()
        .invalid()
      Text("Please select a framework.")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.destructive)
    }
  }
}

let comboboxFrameworks = [
  "Next.js",
  "SvelteKit",
  "Nuxt.js",
  "Remix",
  "Astro",
  "Gatsby",
  "Expo",
]

struct ComboboxDemo: View {
  @State private var value: String?

  init(initial: String? = nil) {
    _value = State(initialValue: initial)
  }

  var body: some View {
    Combobox(
      comboboxFrameworks,
      selection: $value,
      placeholder: "Select a framework",
      emptyText: "No framework found."
    )
    .frame(width: 256)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Combobox
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

val ComboboxFrameworks = listOf("Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro", "Gatsby", "Expo")

@Composable
fun ComboboxDemo(
    defaultValue: String? = null,
    disabled: Boolean = false,
    showClear: Boolean = false,
    label: String? = null,
    invalid: Boolean? = null,
) {
    var value by rememberSaveable { mutableStateOf(defaultValue) }
    Column {
        Combobox(
            items = ComboboxFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier
                .width(256.dp)
                .then(if (label != null) Modifier.semantics { contentDescription = label } else Modifier),
            placeholder = "Select a framework",
            emptyText = "No framework found.",
            showClear = showClear,
            invalid = invalid,
            enabled = !disabled,
        )
    }
}

@Composable
fun ComboboxInvalid() {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        ComboboxDemo(invalid = true)
        Text("Please select a framework.", style = Typography.sm.copy(color = EsuissTheme.colors.destructive))
    }
}
```

## API Reference

### Combobox

The root that holds the items, the selected value, the typed text and the open state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `Item[] \| ComboboxItemGroup<Item>[]` | - | The items to filter, flat or grouped. |
| `value` | `Value \| Value[] \| null` | - | The selected value, for controlled use. |
| `defaultValue` | `Value \| Value[] \| null` | - | The initial value when uncontrolled. |
| `onValueChange` | `(value, details) => void` | - | Called when the selection changes. |
| `multiple` | `boolean` | `false` | Allows several values to be selected. |
| `inputValue` | `string` | - | The typed text, for controlled use. |
| `onInputValueChange` | `(inputValue: string, details) => void` | - | Called when the typed text changes. |
| `filter` | `ComboboxFilter<Item> \| null` | - | Custom filter function; `null` turns filtering off. |
| `limit` | `number` | - | The maximum number of items shown. |
| `itemToStringLabel` | `(itemValue: Value) => string` | - | Returns the label shown for an item value. |
| `openOnInputClick` | `boolean` | `true` | Opens the list when the input is pressed. |
| `open` | `boolean` | - | Whether the list is open, for controlled use. |
| `defaultOpen` | `boolean` | `false` | Whether the list starts open when uncontrolled. |
| `onOpenChange` | `(open: boolean, details) => void` | - | Called when the list opens or closes. |
| `disabled` | `boolean` | `false` | Disables the combobox. |
| `readOnly` | `boolean` | `false` | Shows the value without allowing changes. |

### ComboboxTrigger

Round button that opens the list.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Trigger>`.

### ComboboxInput

Text field with caret trigger and optional clear button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | - |  |
| `children` | `React.ReactNode` | - |  |
| `showTrigger` | `boolean` | `true` | Shows the caret button. Defaults to `true`. |
| `showClear` | `boolean` | `false` | Shows a clear button when there is text or a value. |
| `invalid` | `boolean` | - | Shows the destructive border; also read from a surrounding Field. |

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Input>`.

### ComboboxContent

Floating popup for the list.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `portalHost` | `string` | - | Name of the portal host to render into. |

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Popup>`, `PositionerProps`.

### ComboboxList

Scrollable list of items.

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.List>`.

### ComboboxItem

Selectable option with a check mark when selected.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Item>`.

### ComboboxGroup

Group of items.

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Group>`.

### ComboboxLabel

Heading for a group.

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.GroupLabel>`.

### ComboboxEmpty

Message shown when nothing matches.

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Empty>`.

### ComboboxSeparator

Line between groups.

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Separator>`.

### ComboboxChips

Field that holds chips for multiple selection.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `invalid` | `boolean` | - | Shows the destructive border. |

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Chips>`.

### ComboboxChip

Selected value with a remove button.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showRemove` | `boolean` | `true` | Shows the remove button. Defaults to `true`. |
| `removeLabel` | `string` | `Remove {value}` | Accessible name of the remove button. Defaults to `Remove` followed by the chip text. |

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Chip>`.

### ComboboxChipsInput

Text input inside the chips field.

Also accepts the props of `React.ComponentProps<typeof ComboboxPrimitive.Input>`.

## Accessibility

- The input has the `combobox` role with `expanded` and `disabled` state.
- Items have the `option` role with `selected` and `disabled` state; the list has the `list` role.
- The empty message is a polite live region.
- The trigger, clear and chip remove buttons are labelled "Show options", "Clear" and "Remove".

## Notes

- Uses `bg-secondary`, `bg-popover`, `border-ring` and `border-destructive` tokens; light and dark follow the system appearance.
- In Uniwind and NativeWind, a focused field also draws the `ring-3 ring-ring/50` focus ring.
- Fade animations respect the system Reduce Motion setting.

