Skip to main content

Select

Choose one item from a non-editable collection.

Use Select for one choice from a non-editable collection. Use Combobox when users need to type a query, or BaseSelect when you need to compose the listbox yourself.

Basic usage#

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>
  )
}

Playground#

Props
Slots

Anatomy#

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 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.

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.

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.

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#

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.

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.

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.

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
data-closedSlot: select-control, select-content, select-triggerDescription: Present when disclosure or transition content is closed.
data-disabledSlot: select-control, select-item, select-triggerDescription: Present when the component, slot, or item is disabled.
data-expandedSlot: select-control, select-content, select-triggerDescription: Present when the panel, accordion, or menu is expanded.
data-invalidSlot: select-control, select-triggerDescription: Present when the field or form has a validation error.
data-readonlySlot: select-controlDescription: Present when the field is in read-only mode.
data-requiredSlot: select-controlDescription: Present when the field input is required.
data-sideSlot: select-contentDescription: Stores the resolved floating or drawer content side.
data-highlightedSlot: select-item, select-item-descriptionDescription: Present when the item is highlighted by pointer or keyboard navigation.
data-selectedSlot: select-itemDescription: Present when the item or tab is selected.
data-loadingSlot: select-trailingDescription: Present when the component or async operation is loading.
data-placeholderSlot: select-valueDescription: Present while placeholder content is displayed.

Props#

Renders a <div> element by default.

Prop