---
title: Select
description: Choose one item from a non-editable collection.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# Select

> Choose one item from a non-editable collection.

Use Select for one choice from a non-editable collection. Use [Combobox](https://moraine.subf.dev/components/combobox.md) when users need to type a query, or [BaseSelect](https://moraine.subf.dev/components/base-select.md) when you need to compose the listbox yourself.

## Basic usage

```tsx
import { Field, Select } from 'moraine'

export function Example() {
  return (
    <Field label="Size">
      <Select
        items={[
          { label: 'Small', value: 'sm' },
          { label: 'Large', value: 'lg' },
        ]}
        placeholder="Choose a size"
      />
    </Field>
  )
}
```

## Anatomy

```text
Select [component; no DOM]
├── control [slot]
│   └── trigger [slot]
│       ├── leading [slot]
│       ├── value [slot]
│       ├── clear [slot]
│       └── trailing [slot]
└── positioner [internal]
    └── content [slot]
        └── listbox [slot]
            ├── group [slot]
            │   └── groupLabel [slot]
            ├── separator [slot]
            └── item [slot]
                ├── itemLeading [slot]
                ├── itemWrapper [slot]
                │   ├── itemLabel [slot]
                │   └── itemDescription [slot]
                └── itemIndicator [slot]
```

Select renders the portaled popup collection internally; its slots are styling targets. Use [BaseSelect](https://moraine.subf.dev/components/base-select.md) to compose the listbox yourself.

## Usage

### String items

Pass strings when the label and value are identical. Strings and objects can be mixed at the
root or inside a group. Whitespace is preserved, and an empty string is a valid value.

```tsx
import { Select } from 'moraine'
import type { SelectT } from 'moraine'

const MIXED_ITEMS: SelectT.Entry[] = [
  'Apple',
  { value: 'pear', label: 'Pear', description: 'Fresh fruit' },
  {
    type: 'group',
    label: 'More fruit',
    items: ['Banana', { value: 'cherry', label: 'Cherry', disabled: true }],
  },
]

export function StringItems() {
  return (
    <div class="flex flex-col gap-3 max-w-xs w-full">
      <Select
        aria-label="String fruit"
        items={['Apple', 'Banana']}
        defaultValue="Apple"
        allowClear
      />
      <Select
        aria-label="Mixed fruit"
        items={MIXED_ITEMS}
        placeholder="Choose a fruit..."
        allowClear
      />
    </div>
  )
}
```

String items are converted to the same item shape used by callbacks: `'Apple'` becomes
`{ value: 'Apple', label: 'Apple' }`. Object items retain their identity and custom fields.
`SelectT.NormalizedItem<TItem>` names the callback item type. Duplicate values use the first
occurrence, including collisions between strings and objects.

### Value and collection

The value slot shows the selected label or `String(value)` for an unresolved value. Clear is a pointer-only action inside the trigger that clears selection without toggling the popup.

Pair `value` with `onValueChange` for controlled selection, or use `defaultValue` for an initial
uncontrolled choice. `null` means no selection; an empty string is a valid item value. Use unique
item values so each selection identifies one option.

Printable keys perform rapid typeahead navigation across available options without opening an editable input.

```tsx
import { Button, Select } from 'moraine'
import type { SelectT } from 'moraine'
import { createSignal } from 'solid-js'

const COUNTRIES: SelectT.Item[] = [
  { label: 'United States', value: 'us' },
  { label: 'Germany', value: 'de' },
  { label: 'Japan', value: 'jp' },
  { label: 'United Kingdom', value: 'uk' },
  { label: 'Canada', value: 'ca' },
]

export function UsageValue() {
  const [selected, setSelected] = createSignal<string | null>('de')

  return (
    <div class="max-w-xs w-full space-y-3">
      <Select
        placeholder="Select a country..."
        leadingIcon="i-lucide:globe"
        items={COUNTRIES}
        value={selected()}
        onValueChange={setSelected}
        allowClear
      />
      <div class="text-xs flex items-center justify-between">
        <span class="text-muted-foreground">
          Selected code:{' '}
          <span class="text-foreground font-medium font-mono">{selected() ?? 'null'}</span>
        </span>
        <Button
          variant="ghost"
          size="sm"
          disabled={selected() === null}
          onClick={() => setSelected(null)}
        >
          Reset
        </Button>
      </div>
    </div>
  )
}
```

### Keyboard interaction

| Key                 | Behavior                                      |
| ------------------- | --------------------------------------------- |
| Enter / Space       | Open the popup or commit the active option    |
| ArrowDown / ArrowUp | Open or move through selectable options       |
| Home / End          | Move to the first or last option while open   |
| Printable key       | Typeahead to the matching item label          |
| Escape              | Close the dropdown without changing the value |

## Examples

### Rich items

Each item supports an `icon` and secondary `description` without requiring custom item templates.

```tsx
import { Select } from 'moraine'
import type { SelectT } from 'moraine'

const PLANS: SelectT.Item[] = [
  {
    label: 'Starter',
    value: 'starter',
    icon: 'i-lucide:sparkles',
    description: 'Up to 3 members · Free forever',
  },
  {
    label: 'Professional',
    value: 'pro',
    icon: 'i-lucide:zap',
    description: '$19/mo · Ideal for growing teams',
  },
  {
    label: 'Team',
    value: 'team',
    icon: 'i-lucide:rocket',
    description: '$49/mo · Advanced collaboration tools',
  },
  {
    label: 'Enterprise',
    value: 'enterprise',
    icon: 'i-lucide:shield-check',
    description: 'Custom pricing · Dedicated support & SLA',
  },
]

export function RichItems() {
  return (
    <div class="max-w-sm w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Subscription Plan</label>
      <Select items={PLANS} defaultValue="pro" placeholder="Choose your plan..." />
    </div>
  )
}
```

### Team roles

```tsx
import { Select } from 'moraine'
import type { SelectT } from 'moraine'

const ROLES: SelectT.Item[] = [
  {
    label: 'Owner',
    value: 'owner',
    icon: 'i-lucide:crown',
    description: 'Full access to all resources and billing',
  },
  {
    label: 'Admin',
    value: 'admin',
    icon: 'i-lucide:shield',
    description: 'Can manage team members and settings',
  },
  {
    label: 'Member',
    value: 'member',
    icon: 'i-lucide:user',
    description: 'Can create and edit team projects',
  },
  {
    label: 'Viewer',
    value: 'viewer',
    icon: 'i-lucide:eye',
    description: 'Read-only access to published workflows',
  },
]

export function UserAssignee() {
  return (
    <div class="max-w-sm w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Member Permission Role</label>
      <Select items={ROLES} defaultValue="member" placeholder="Assign a role..." />
    </div>
  )
}
```

### Grouped options

Organize options into labeled categories using `type: 'group'`. Insert `{ type: 'separator' }` between groups or items to show a divider. Labels and separators are excluded from selection and keyboard navigation.

```tsx
import { Select } from 'moraine'
import type { SelectT } from 'moraine'

const TIMEZONE_GROUPS: SelectT.Entry[] = [
  {
    label: 'Americas',
    type: 'group',
    items: [
      { label: 'America/New_York (UTC-5)', value: 'America/New_York' },
      { label: 'America/Chicago (UTC-6)', value: 'America/Chicago' },
      { label: 'America/Los_Angeles (UTC-8)', value: 'America/Los_Angeles' },
      { label: 'America/Sao_Paulo (UTC-3)', value: 'America/Sao_Paulo' },
    ],
  },
  {
    label: 'Europe',
    type: 'group',
    items: [
      { label: 'Europe/London (UTC+0)', value: 'Europe/London' },
      { label: 'Europe/Frankfurt (UTC+1)', value: 'Europe/Frankfurt' },
      { label: 'Europe/Paris (UTC+1)', value: 'Europe/Paris' },
    ],
  },
  {
    label: 'Asia Pacific',
    type: 'group',
    items: [
      { label: 'Asia/Tokyo (UTC+9)', value: 'Asia/Tokyo' },
      { label: 'Asia/Singapore (UTC+8)', value: 'Asia/Singapore' },
      { label: 'Australia/Sydney (UTC+11)', value: 'Australia/Sydney' },
    ],
  },
]

export function GroupedItems() {
  return (
    <div class="max-w-xs w-full space-y-2">
      <label class="text-xs text-muted-foreground font-medium block">Workspace Timezone</label>
      <Select
        items={TIMEZONE_GROUPS}
        defaultValue="Europe/London"
        placeholder="Select timezone..."
      />
    </div>
  )
}
```

### Incremental collections

Use `onScrollBottom` and `scrollBottomThreshold` to load additional pages of options as the user scrolls.

```tsx
import { Select } from 'moraine'
import type { SelectT } from 'moraine'
import { createSignal } from 'solid-js'

export function InfiniteScroll() {
  function makeOptions(count: number, offset = 0): SelectT.Item[] {
    return Array.from({ length: count }, (_, i) => ({
      label: `Option ${offset + i + 1}`,
      value: `opt-${offset + i + 1}`,
    }))
  }

  const [infiniteOptions, setInfiniteOptions] = createSignal<SelectT.Item[]>(makeOptions(20))

  const [loadingMore, setLoadingMore] = createSignal(false)

  return (
    <div class="w-80 space-y-2">
      <Select
        items={infiniteOptions()}
        classes={{
          listbox: 'max-h-100',
        }}
        onScrollBottom={() => {
          if (loadingMore()) {
            return
          }
          setLoadingMore(true)
          setTimeout(() => {
            const next = infiniteOptions().length
            setInfiniteOptions((prev) => [...prev, ...makeOptions(10, next)])
            setLoadingMore(false)
          }, 1000)
        }}
        scrollBottomThreshold={30}
        loading={loadingMore()}
        placeholder="Scroll to load more..."
      />
      <p class="text-xs text-muted-foreground">Total items: {infiniteOptions().length}</p>
    </div>
  )
}
```

### Form integration

For an empty `form.Field`-bound Select, initialize the field with `null` and accept `null` in the schema.

```tsx
import { Button, createForm, Select } from 'moraine'
import { createSignal } from 'solid-js'
import * as v from 'valibot'

const COUNTRIES = [
  { label: 'United States', value: 'us' },
  { label: 'Germany', value: 'de' },
  { label: 'Japan', value: 'jp' },
  { label: 'United Kingdom', value: 'uk' },
  { label: 'Canada', value: 'ca' },
]

export function FormIntegration() {
  const [submittedCountry, setSubmittedCountry] = createSignal<string | null>(null)
  const form = createForm({
    schema: v.object({
      country: v.pipe(
        v.nullable(v.string()),
        v.check(
          (value): value is string => value !== null,
          'Please select your country of residence.',
        ),
      ),
    }),
    initialInput: { country: null },
    validate: 'input',
  })

  return (
    <form.Form onSubmit={(output) => setSubmittedCountry(output.country)}>
      <div class="max-w-xl space-y-4">
        <form.Field
          name="country"
          label="Country / Region"
          description="Used for tax calculation and regional billing."
          required
        >
          <Select items={COUNTRIES} placeholder="Select a country..." />
        </form.Field>
        <div class="flex gap-3 items-center">
          <Button type="submit" variant="secondary" size="sm">
            Validate
          </Button>
          <p class="text-xs text-muted-foreground">
            Selected country: {submittedCountry() || 'none'}
          </p>
        </div>
      </div>
    </form.Form>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-closed` | `select-control`, `select-content`, `select-trigger` | Present when disclosure or transition content is closed. |
| `data-disabled` | `select-control`, `select-item`, `select-trigger` | Present when the component, slot, or item is disabled. |
| `data-expanded` | `select-control`, `select-content`, `select-trigger` | Present when the panel, accordion, or menu is expanded. |
| `data-invalid` | `select-control`, `select-trigger` | Present when the field or form has a validation error. |
| `data-readonly` | `select-control` | Present when the field is in read-only mode. |
| `data-required` | `select-control` | Present when the field input is required. |
| `data-side` | `select-content` | Stores the resolved floating or drawer content side. |
| `data-highlighted` | `select-item`, `select-item-description` | Present when the item is highlighted by pointer or keyboard navigation. |
| `data-selected` | `select-item` | Present when the item or tab is selected. |
| `data-loading` | `select-trailing` | Present when the component or async operation is loading. |
| `data-placeholder` | `select-value` | Present while placeholder content is displayed. |

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| allowClear | boolean \| undefined | — | Show a pointer clear affordance when a value is selected. |
| closeIcon | IconT.Name \| undefined | — | Icon used by the clear affordance. |
| closeOnSelect | boolean \| undefined | — | Close after selection. Defaults to true for single, false for multiple. |
| defaultOpen | boolean \| undefined | false | Initial popup state. |
| defaultValue | NormalizedItem<TItem>['value'] \| null \| undefined | — | The default value of the input (uncontrolled). |
| disabled | boolean \| undefined | false | Whether the input is disabled. |
| gutter | number \| undefined | 0 | Anchor gap in pixels. |
| id | string \| undefined | — | The ID of the input element. |
| isItemDisabled | ((item: NormalizedItem<TItem>, values: readonly NormalizedItem<TItem>['value'][]) => boolean) \| undefined | — | Additional disabled policy evaluated against the current selection. |
| itemProps | ((state: BaseSelectT.ItemRenderProps<NormalizedItem<TItem>>) => ElementProps<HTMLDivElement> \| undefined) \| undefined | — | Additional row attributes. |
| itemRender | ((props: BaseSelectT.ItemRenderProps<NormalizedItem<TItem>>) => JSX.Element) \| undefined | — | Renderer for each collection item. |
| items | (string \| {<br>  /** Unique selection and form value. */<br>  value: string \| number;<br>  /** Visual label. */<br>  label: JSX.Element;<br>  /** Whether this item cannot be selected. */<br>  disabled?: boolean;<br>  /** Leading item icon. */<br>  icon?: IconT.Name;<br>  /** Secondary item description. */<br>  description?: JSX.Element;<br>} \| {<br>  /** Structural group discriminator. */<br>  type: 'group';<br>  label: JSX.Element;<br>  items: (string \| {<br>    /** Unique selection and form value. */<br>    value: string \| number;<br>    /** Visual label. */<br>    label: JSX.Element;<br>    /** Whether this item cannot be selected. */<br>    disabled?: boolean;<br>    /** Leading item icon. */<br>    icon?: IconT.Name;<br>    /** Secondary item description. */<br>    description?: JSX.Element;<br>  })[];<br>} \| {<br>  /** Structural separator discriminator. */<br>  type: 'separator';<br>})[] \| undefined | — | String shorthand or object items, optionally grouped or separated. Item values must be unique within the collection. |
| itemToLabelString | ((item: NormalizedItem<TItem>) => string) \| undefined | — | Machine-readable text for matching; does not change visual labels. |
| leadingIcon | IconT.Name \| undefined | — | Icon shown before the input/value area. |
| listboxProps | ElementProps<HTMLDivElement> \| undefined | — | Additional listbox attributes. |
| loading | boolean \| undefined | — | Whether the select is in a loading state. |
| loadingIcon | IconT.Name \| undefined | 'icon-loading' | Icon shown during loading state. |
| name | string \| undefined | — | The name of the input element, used for form submission. |
| onClear | (() => void) \| undefined | — | Called when clear is triggered. |
| onOpenChange | ((open: boolean) => void) \| undefined | — | Called when popup state changes. |
| onReset | (() => void) \| undefined | — | Post-reset notification after an unprevented native form reset. |
| onScrollBottom | (() => void) \| undefined | — | Called once when scrolling reaches the bottom. |
| onValueChange | ((value: NoInfer<NormalizedItem<TItem>['value'] \| null>) => void) \| undefined | — | Called when the selection changes. |
| open | boolean \| undefined | — | Controlled popup state. |
| overflowPadding | number \| undefined | 4 | Collision padding in pixels. |
| placeholder | string \| undefined | "" | Placeholder text shown when no value is selected. |
| readOnly | boolean \| undefined | false | Whether the input is read-only. |
| required | boolean \| undefined | false | Whether the input is required. |
| scrollBottomThreshold | number \| undefined | 20 | Bottom threshold in pixels. |
| size | 'sm' \| 'md' \| 'lg' \| undefined | 'md' | Visual size of the component. |
| trailingIcon | IconT.Name \| undefined | 'icon-chevron-down' | Icon for the dropdown trigger. |
| value | NormalizedItem<TItem>['value'] \| null \| undefined | — | The current value of the input (controlled). |
| variant | 'outline' \| 'subtle' \| 'ghost' \| 'none' \| undefined | 'outline' | Visual treatment of the component. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | Classes \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | Styles \| undefined | — | Family slot style defaults for this instance. |
