Styled native <select> with label, chevron and description, built on the @foldkit/ui Select helper. Stateless; for a searchable dropdown submodel use select.

Preview
Style
Basic
Rendered by the native select element.
With Groups
Sizes
With Field

Select your country of residence.

Disabled
Invalid
src/demo/views/native-select.ts179 lines
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import {
  nativeSelect,
  nativeSelectClass,
  nativeSelectWrapperClass,
  nativeSelectIconClass,
} from '../../generated/registry/ui/native-select'
import { field, fieldDescription, fieldLabel } from '../../generated/registry/ui/fieldset'
import { icon } from '../../generated/registry/lib/icons'
import { ChevronDown } from 'lucide'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  ChangedFruit: { value: S.String },
})

const FRUITS = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple'] as const

const staticNative = (
  h: HtmlBuilder<AppMessage>,
  placeholder: string,
  opts?: { disabled?: boolean; invalid?: boolean; size?: 'sm' | 'default' },
): Html =>
  h.div(
    [
      h.Class(nativeSelectWrapperClass),
      h.DataAttribute('slot', 'native-select-wrapper'),
      h.DataAttribute('size', opts?.size ?? 'default'),
    ],
    [
      h.select(
        [
          h.Class(nativeSelectClass),
          h.DataAttribute('slot', 'native-select'),
          h.DataAttribute('size', opts?.size ?? 'default'),
          ...(opts?.disabled === true ? [h.Disabled(true)] : []),
          ...(opts?.invalid === true ? [h.AriaInvalid(true)] : []),
        ],
        [h.option([], [placeholder])],
      ),
      h.span(
        [
          h.DataAttribute('slot', 'native-select-icon'),
          h.Class(nativeSelectIconClass),
          h.AriaHidden(true),
        ],
        [icon(h, ChevronDown, 'size-4')],
      ),
    ],
  )

export const nativeSelectView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.div(
            [h.Class('w-full max-w-48')],
            [
              nativeSelect<AppMessage>(
                {
                  id: 'native-select-demo',
                  label: 'Favorite fruit',
                  value: model.fruit,
                  onChange: (value) => Message.ChangedFruit({ value }),
                  description: 'Rendered by the native select element.',
                  options: FRUITS.map((fruit) => h.option([h.Value(fruit)], [fruit])),
                },
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Groups']),
          h.div([h.Class('w-full max-w-48')], [staticNative(h, 'Select a food')]),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Sizes']),
          h.div(
            [h.Class('flex flex-col gap-4 w-full max-w-48')],
            [
              staticNative(h, 'Select a fruit', { size: 'sm' }),
              staticNative(h, 'Select a fruit', { size: 'default' }),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Field']),
          h.div(
            [h.Class('w-full max-w-48')],
            [
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'native-select-country' }, ['Country'], h),
                  h.div(
                    [h.Class(nativeSelectWrapperClass)],
                    [
                      h.select(
                        [
                          h.Id('native-select-country'),
                          h.Class(nativeSelectClass),
                          h.DataAttribute('slot', 'native-select'),
                        ],
                        [
                          h.option([h.Value('')], ['Select a country']),
                          h.option([h.Value('us')], ['United States']),
                          h.option([h.Value('uk')], ['United Kingdom']),
                          h.option([h.Value('ca')], ['Canada']),
                          h.option([h.Value('au')], ['Australia']),
                        ],
                      ),
                      h.span(
                        [h.Class(nativeSelectIconClass), h.AriaHidden(true)],
                        [icon(h, ChevronDown, 'size-4')],
                      ),
                    ],
                  ),
                  fieldDescription<AppMessage>({}, ['Select your country of residence.'], h),
                ],
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Disabled']),
          h.div([h.Class('w-full max-w-48')], [staticNative(h, 'Disabled', { disabled: true })]),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Invalid']),
          h.div([h.Class('w-full max-w-48')], [staticNative(h, 'Error state', { invalid: true })]),
        ],
      ),
    ],
  )

const fields = { fruit: S.String }

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: { fruit: 'Apple' },
  messages: [Message.ChangedFruit],
  handlers: (model: State) => ({
    ChangedFruit: ({ value }: typeof Message.ChangedFruit.Type): UpdateReturn => ({
      model: evo(model, { fruit: () => value }),
    }),
  }),
  samples: [Message.ChangedFruit({ value: 'Blueberry' })],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/native-select

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/native-select.ts162 lines
import { Select as FoldkitSelect } from '@foldkit/ui'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { icon } from '@/lib/icons'
import { ChevronDown } from 'lucide'
import { cn } from '@/lib/utils'

/** Stateless styled native `<select>` — use when every option is always
 *  visible and plain. For a searchable/filterable dropdown submodel, use
 *  `select` (Listbox-backed) instead. */

export const nativeSelectSizeKeys = ['default', 'sm'] as const
export type NativeSelectSize = (typeof nativeSelectSizeKeys)[number]

/** Upstream NativeSelect select string. */
export const nativeSelectClass =
  'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-8 w-full min-w-0 appearance-none rounded-lg border bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed data-disabled:pointer-events-none data-disabled:cursor-not-allowed outline-none disabled:pointer-events-none disabled:cursor-not-allowed'

/** Upstream NativeSelect wrapper string. */
export const nativeSelectWrapperClass =
  'has-[select[aria-disabled=true]]:opacity-50 has-[select[data-disabled]]:opacity-50 group/native-select relative w-fit has-[select:disabled]:opacity-50'

export const nativeSelectIconClass =
  'text-muted-foreground top-1/2 right-2.5 size-4 -translate-y-1/2 pointer-events-none absolute select-none'

export const nativeSelectOptionClass = 'bg-[Canvas] text-[CanvasText]'

export const nativeSelectOptGroupClass = 'bg-[Canvas] text-[CanvasText]'

export const nativeSelectLabelClass = 'px-1.5 py-1 text-xs text-muted-foreground'
export const nativeSelectDescriptionClass = 'text-sm text-muted-foreground'
export const nativeSelectFieldWrapperClass = 'flex w-full flex-col gap-1.5'

export type NativeSelectConfig<M> = Readonly<{
  id: string
  label: string
  description?: string
  onChange?: (value: string) => M
  value?: string
  size?: NativeSelectSize
  isDisabled?: boolean
  isInvalid?: boolean
  isAutofocus?: boolean
  name?: string
  /** `<option>` elements — pass prebuilt markup, e.g.
   *  `cities.map((city) => h.option([], [city]))`. */
  options: ReadonlyArray<Html | string>
  className?: string
  labelClass?: string
  descriptionClass?: string
  wrapperClass?: string
}>

/** Styled native select with label, chevron and optional description, built
 *  on the @foldkit/ui Select helper. Mirrors the shadcn v4 `native-select.tsx`:
 *  an `appearance-none` select whose chevron overlays the control at its right
 *  edge. Pass `<option>` markup via `options`.
 *
 *  ```ts
 *  import { nativeSelect } from '@/components/ui/native-select'
 *
 *  nativeSelect(
 *    {
 *      id: 'fruit',
 *      label: 'Fruit',
 *      onChange: (value) => Message.PickedFruit({ value }),
 *      options: fruits.map((fruit) => h.option([h.Value(fruit)], [fruit])),
 *    },
 *    h,
 *  )
 *  ``` */
export const nativeSelect = <M>(config: NativeSelectConfig<M>, h: HtmlBuilder<M>): Html =>
  FoldkitSelect.view<M>(
    {
      id: config.id,
      onChange: config.onChange,
      value: config.value,
      isDisabled: config.isDisabled,
      isInvalid: config.isInvalid,
      isAutofocus: config.isAutofocus,
      name: config.name,
      toView: (attributes) =>
        h.div(
          [h.Class(cn(nativeSelectFieldWrapperClass, config.wrapperClass))],
          [
            h.label(
              [...attributes.label, h.Class(cn(nativeSelectLabelClass, config.labelClass))],
              [config.label],
            ),
            h.div(
              [
                h.Class(cn(nativeSelectWrapperClass)),
                h.DataAttribute('slot', 'native-select-wrapper'),
                h.DataAttribute('size', config.size ?? 'default'),
              ],
              [
                h.select(
                  [
                    ...attributes.select,
                    h.DataAttribute('slot', 'native-select'),
                    h.DataAttribute('size', config.size ?? 'default'),
                    h.Class(cn(nativeSelectClass, config.className)),
                  ],
                  config.options,
                ),
                h.span(
                  [
                    h.DataAttribute('slot', 'native-select-icon'),
                    h.Class(nativeSelectIconClass),
                    h.AriaHidden(true),
                  ],
                  [nativeSelectChevron(h)],
                ),
              ],
            ),
            config.description === undefined
              ? h.empty
              : h.span(
                  [
                    ...attributes.description,
                    h.Class(cn(nativeSelectDescriptionClass, config.descriptionClass)),
                  ],
                  [config.description],
                ),
          ],
        ),
    },
    h,
  )

export const nativeSelectChevron = <M>(h: HtmlBuilder<M>): Html => icon(h, ChevronDown, 'size-4')

/** Helper to render an `<option>` with correct data-slot and Canvas colors. */
export const nativeSelectOption = <M>(
  config: Readonly<{ value: string; label: string; isDisabled?: boolean; className?: string }>,
  h: HtmlBuilder<M>,
): Html =>
  h.option(
    [
      h.Value(config.value),
      h.DataAttribute('slot', 'native-select-option'),
      h.Class(cn(nativeSelectOptionClass, config.className)),
      ...(config.isDisabled === true ? [h.Disabled(true)] : []),
    ],
    [config.label],
  )

/** Helper to render an `<optgroup>` with correct data-slot and Canvas colors. */
export const nativeSelectOptGroup = <M>(
  config: Readonly<{ label: string; className?: string }>,
  children: ReadonlyArray<Html | string>,
  h: HtmlBuilder<M>,
): Html =>
  h.optgroup(
    [
      h.DataAttribute('slot', 'native-select-optgroup'),
      h.Class(cn(nativeSelectOptGroupClass, config.className)),
      h.Attribute('label', config.label),
    ],
    [...children],
  )