Skip to content

Select

A styled native select with optional groups — the right choice whenever the option list is short and known.

Formsstarterformdropdownoptionsoptgroupnative

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Field } from '@/components/ui/field'
import { Select } from '@/components/ui/select'
import { DemoColumn, DemoStage } from './_kit'

const regions = [
  { value: 'us-east', label: 'US East (Virginia)' },
  { value: 'us-west', label: 'US West (Oregon)' },
  { value: 'eu-west', label: 'EU West (Ireland)' },
  { value: 'ap-south', label: 'Asia Pacific (Singapore)' },
  { value: 'sa-east', label: 'South America (São Paulo)', disabled: true },
]

export default function SelectDemo() {
  return (
    <DemoStage>
      <DemoColumn>
        <Field
          name="region"
          label="Deployment region"
          hint="Data never leaves the selected region."
          required
        >
          {(field) => <Select {...field} options={regions} defaultValue="eu-west" />}
        </Field>

        <Field name="plan" label="Plan" hint="Grouped options keep long lists scannable.">
          {(field) => (
            <Select
              {...field}
              options={[]}
              placeholder="Choose a plan"
              groups={[
                {
                  label: 'Self-serve',
                  options: [
                    { value: 'starter', label: 'Starter — $0' },
                    { value: 'team', label: 'Team — $18 / seat' },
                  ],
                },
                {
                  label: 'Sales-assisted',
                  options: [
                    { value: 'business', label: 'Business — from $2,400' },
                    { value: 'enterprise', label: 'Enterprise — custom' },
                  ],
                },
              ]}
            />
          )}
        </Field>

        <Field
          name="failover"
          label="Failover region"
          error="Failover must differ from the primary region."
        >
          {(field) => <Select {...field} options={regions} defaultValue="eu-west" />}
        </Field>

        <Field name="tier" label="Support tier">
          {(field) => (
            <Select
              {...field}
              disabled
              options={[{ value: 'standard', label: 'Standard (fixed on this plan)' }]}
            />
          )}
        </Field>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/select.tsx

import { Field } from '@/components/ui/field'
import { Select } from '@/components/ui/select'
import { DemoColumn, DemoStage } from './_kit'

const regions = [
  { value: 'us-east', label: 'US East (Virginia)' },
  { value: 'us-west', label: 'US West (Oregon)' },
  { value: 'eu-west', label: 'EU West (Ireland)' },
  { value: 'ap-south', label: 'Asia Pacific (Singapore)' },
  { value: 'sa-east', label: 'South America (São Paulo)', disabled: true },
]

export default function SelectDemo() {
  return (
    <DemoStage>
      <DemoColumn>
        <Field
          name="region"
          label="Deployment region"
          hint="Data never leaves the selected region."
          required
        >
          {(field) => <Select {...field} options={regions} defaultValue="eu-west" />}
        </Field>

        <Field name="plan" label="Plan" hint="Grouped options keep long lists scannable.">
          {(field) => (
            <Select
              {...field}
              options={[]}
              placeholder="Choose a plan"
              groups={[
                {
                  label: 'Self-serve',
                  options: [
                    { value: 'starter', label: 'Starter — $0' },
                    { value: 'team', label: 'Team — $18 / seat' },
                  ],
                },
                {
                  label: 'Sales-assisted',
                  options: [
                    { value: 'business', label: 'Business — from $2,400' },
                    { value: 'enterprise', label: 'Enterprise — custom' },
                  ],
                },
              ]}
            />
          )}
        </Field>

        <Field
          name="failover"
          label="Failover region"
          error="Failover must differ from the primary region."
        >
          {(field) => <Select {...field} options={regions} defaultValue="eu-west" />}
        </Field>

        <Field name="tier" label="Support tier">
          {(field) => (
            <Select
              {...field}
              disabled
              options={[{ value: 'standard', label: 'Standard (fixed on this plan)' }]}
            />
          )}
        </Field>
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/select.tsx

import type { SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/cn'
import { controlSurface } from './input'

/**
 * Select
 *
 * A styled native `<select>`, on purpose. The native control brings free
 * keyboard support, free mobile pickers and free assistive-tech semantics; the
 * only thing Foundry adds is the chevron and the shared control chrome.
 *
 * When the interaction genuinely needs filtering or multi-select, reach for the
 * Combobox instead — see `/components/combobox`.
 */
export interface SelectOption {
  value: string
  label: string
  disabled?: boolean
}

export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
  options: SelectOption[]
  /** Rendered as a disabled, selected-by-default first option. */
  placeholder?: string
  selectSize?: 'sm' | 'md' | 'lg'
  /** Groups options under `<optgroup>` labels. */
  groups?: Array<{ label: string; options: SelectOption[] }>
}

const heights = {
  sm: 'h-control-sm text-xs',
  md: 'h-control text-sm',
  lg: 'h-control-lg text-base',
} as const

export function Select({
  options,
  groups,
  placeholder,
  selectSize = 'md',
  className,
  ...props
}: SelectProps) {
  return (
    <div className="relative flex items-center">
      <select
        className={cn(
          controlSurface,
          heights[selectSize],
          'cursor-pointer appearance-none py-0 pr-9 pl-3',
          className,
        )}
        defaultValue={props.value === undefined && placeholder ? '' : undefined}
        {...props}
      >
        {placeholder ? (
          <option value="" disabled>
            {placeholder}
          </option>
        ) : null}
        {groups
          ? groups.map((group) => (
              <optgroup key={group.label} label={group.label}>
                {group.options.map((option) => (
                  <option key={option.value} value={option.value} disabled={option.disabled}>
                    {option.label}
                  </option>
                ))}
              </optgroup>
            ))
          : options.map((option) => (
              <option key={option.value} value={option.value} disabled={option.disabled}>
                {option.label}
              </option>
            ))}
      </select>
      <ChevronDown
        className="pointer-events-none absolute right-3 size-4 text-ink-subtle"
        aria-hidden="true"
      />
    </div>
  )
}

components/ui/input.tsx

import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'

/**
 * Input / Textarea
 *
 * Shared control chrome lives in `controlSurface` so every text-entry control
 * in the library — including the combobox and OTP field — sits on exactly the
 * same border, radius, height and invalid treatment.
 */
export const controlSurface = cn(
  'w-full min-w-0 bg-surface text-ink placeholder:text-ink-subtle',
  'border border-line rounded-md',
  'transition-[border-color,background-color] duration-150 ease-standard',
  'hover:border-line-strong',
  'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-subtle disabled:hover:border-line',
  'aria-[invalid=true]:border-danger aria-[invalid=true]:bg-danger-soft',
  'read-only:bg-surface-sunken',
)

export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  inputSize?: 'sm' | 'md' | 'lg'
  /** Icon or text rendered inside the control on the leading edge. */
  leading?: ReactNode
  trailing?: ReactNode
}

const heights = {
  sm: 'h-control-sm text-xs',
  md: 'h-control text-sm',
  lg: 'h-control-lg text-base',
} as const

export function Input({ inputSize = 'md', leading, trailing, className, ...props }: InputProps) {
  const paddingX = inputSize === 'lg' ? 'px-3.5' : 'px-3'

  if (!leading && !trailing) {
    return (
      <input className={cn(controlSurface, heights[inputSize], paddingX, className)} {...props} />
    )
  }

  return (
    <div
      className={cn(
        'relative flex items-center',
        // The wrapper carries no border; the input keeps it so focus-visible
        // still lands on the real control.
      )}
    >
      {leading ? (
        <span
          className="pointer-events-none absolute left-3 flex items-center text-ink-subtle"
          aria-hidden="true"
        >
          {leading}
        </span>
      ) : null}
      <input
        className={cn(
          controlSurface,
          heights[inputSize],
          paddingX,
          leading && 'pl-9',
          trailing && 'pr-9',
          className,
        )}
        {...props}
      />
      {trailing ? (
        <span className="absolute right-2.5 flex items-center text-ink-subtle">{trailing}</span>
      ) : null}
    </div>
  )
}

export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
  /** Grows with content up to this many rows before scrolling. */
  rows?: number
}

export function Textarea({ rows = 4, className, ...props }: TextareaProps) {
  return (
    <textarea
      rows={rows}
      className={cn(controlSurface, 'resize-y px-3 py-2 text-sm leading-normal', className)}
      {...props}
    />
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Deliberately native. The platform control brings keyboard support, the mobile picker and assistive-tech semantics for free; Foundry only supplies the chevron and the shared surface. Switch to Combobox when the list needs filtering.

  • Above roughly fifteen options, filtering matters more than styling — use Combobox.
  • A placeholder option must be `disabled` so it cannot be submitted as a value.
  • Group related options rather than sorting a long flat list alphabetically.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Flat option list
  • Grouped with optgroup
  • Placeholder
  • Disabled options
  • Invalid
  • Three sizes

Accessibility

Native semantics
Because the element is a real `<select>`, screen readers announce position ("2 of 5") without any ARIA.
Icon
The chevron is `pointer-events-none` and `aria-hidden`, so it never intercepts a click or is announced.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.