Styled custom dropdown select (single/multi) backed by the @foldkit/ui Listbox submodel — a real submodel you embed via h.submodel. For a plain native <select> helper, use native-select.

Preview
Style
src/demo/views/select.ts96 lines
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { Listbox as FoldkitListbox } from '@foldkit/ui'
import { Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as select from '../../generated/registry/ui/select'
import { LanguageSelect } from '../bundles'

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

const Message = defineMessageUnion({
  GotSelectMessage: { message: select.Message },
})

type FruitItem = { value: string; label: string }

const FRUITS: ReadonlyArray<FruitItem> = [
  { value: 'apple', label: 'Apple' },
  { value: 'banana', label: 'Banana' },
  { value: 'blueberry', label: 'Blueberry' },
  { value: 'grapes', label: 'Grapes' },
  { value: 'pineapple', label: 'Pineapple' },
]

export const selectView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('w-full max-w-48')],
    [
      h.submodel({
        slotId: model.select.id,
        model: model.select,
        view: LanguageSelect.view,
        viewInputs: select.styledViewInputs<AppMessage, FruitItem, string>(
          {
            options: FRUITS,
            maybeSelectedValue: model.maybeSelectValue,
            itemToValue: (item) => item.value,
            itemToLabel: (item) => item.label,
            label: 'Fruits',
            placeholder: 'Select a fruit',
            triggerClass: 'w-full max-w-48',
          },
          h,
        ),
        toParentMessage: (message) => Message.GotSelectMessage({ message }),
      }),
    ],
  )

const foldSelectOutMessage = M.type<FoldkitListbox.OutMessage<string>>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({ model: evo(model, { maybeSelectValue: () => Option.some(value) }) }),
  }),
)

const foldSelect = Update.foldChild({
  update: LanguageSelect.update,
  read: (model: State) => Option.some(model.select),
  write: (model, next) => evo(model, { select: () => next }),
  toParentMessage: (message) => Message.GotSelectMessage({ message }),
  foldOutMessage: foldSelectOutMessage,
})

const fields = {
  select: select.Model,
  maybeSelectValue: S.Option(S.String),
}

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

export const slice = defineSlice({
  fields,
  init: {
    select: select.init({ id: 'select-demo' }),
    maybeSelectValue: Option.none(),
  },
  messages: [Message.GotSelectMessage],
  handlers: (model: State) => ({
    GotSelectMessage: (payload: typeof Message.GotSelectMessage.Type): UpdateReturn =>
      foldSelect(model, payload.message),
  }),
  samples: [
    Message.GotSelectMessage({
      message: FoldkitListbox.Message.Opened({ maybeActiveItemIndex: Option.none() }),
    }),
  ],
})

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/select

Source

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

registry/default/ui/select.ts214 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Select from '@/components/ui/select'`
 */
import { Listbox as FoldkitListbox } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/listbox'
import { Option } from 'effect'
import { childAttributes, type Html, type HtmlBuilder } from 'foldkit/html'

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

/** Themed custom dropdown select backed by the @foldkit/ui Listbox submodel.
 *
 *  Usage: `import * as Select from '@/components/ui/select'`
 *
 *  1. Create a typed bundle once (module scope):
 *       export const LanguageSelect = Select.create<{ value: string; label: string }, string>()
 *  2. Embed its Model in your app Model (`languageSelect: Select.Model`),
 *     init it (`Select.init({ id: 'language' })`), route its Message through
 *     your update, and render via `h.submodel({ ..., view: LanguageSelect.view,
 *     viewInputs: Select.styledViewInputs({ ... }, h) })`.
 *
 *  For a plain native `<select>` helper, see `@/components/ui/native-select`.
 */

export const create = FoldkitListbox.create
export const init = (config: InitConfig): Model =>
  FoldkitListbox.init({ isAnimated: true, ...config })
export const buttonId = FoldkitListbox.buttonId
export const Model = FoldkitListbox.Model
export type Model = typeof Model.Type
export const Message = FoldkitListbox.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitListbox.OutMessage
export type OutMessage = typeof OutMessage.Type

export type Bundle<Item = string> = FoldkitListbox.Bundle<Item>
export type InitConfig = FoldkitListbox.InitConfig
export type ViewInputs<Item, Value extends string = string> = FoldkitListbox.ViewInputs<Item, Value>
export type ItemConfig = FoldkitListbox.ItemConfig

export const selectSizeKeys = ['default', 'sm'] as const
export type SelectSize = (typeof selectSizeKeys)[number]

/** Upstream SelectTrigger string. */
export const selectTriggerClass =
  'border-input data-placeholder:text-muted-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 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*=\'size-\'])]:size-4 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:opacity-50 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0'

export const selectTriggerIconClass = 'text-muted-foreground size-4 pointer-events-none'

export const selectValueClass = 'flex flex-1 text-left'

/** Upstream SelectContent popup string. */
export const selectContentClass =
  'text-popover-foreground data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 data-leave:zoom-out-95 data-enter:zoom-in-95 data-[side=bottom]:slide-in-from-top-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 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 not-data-[side=bottom]:data-[placement=bottom]:slide-in-from-top-2 not-data-[side=left]:data-[placement=left]:slide-in-from-right-2 not-data-[side=right]:data-[placement=right]:slide-in-from-left-2 not-data-[side=top]:data-[placement=top]:slide-in-from-bottom-2 [--anchor-width:var(--button-width)] data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 animate-none! before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! bg-popover relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none'

/** Backwards compat alias: prefer `selectContentClass`. */
export const selectItemsClass = selectContentClass

export const selectGroupClass = 'scroll-my-1 p-1'

export const selectLabelClass = 'text-muted-foreground px-1.5 py-1 text-xs'

export const selectItemClass =
  'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-active:bg-accent data-active:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'

export const selectItemTextClass = 'flex flex-1 gap-2 shrink-0 whitespace-nowrap'

export const selectItemIndicatorClass = 'pointer-events-none absolute right-2 flex size-4 items-center justify-center'

export const selectItemIndicatorIconClass = 'pointer-events-none'

export const selectSeparatorClass = 'bg-border -mx-1 my-1 h-px pointer-events-none'

export const selectScrollUpClass = 'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=\'size-\'])]:size-4 top-0 w-full'

export const selectScrollDownClass = 'bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*=\'size-\'])]:size-4 bottom-0 w-full'

export const selectDescriptionClass = 'text-sm text-muted-foreground'
export const selectWrapperClass = 'flex w-full flex-col gap-1.5'
export const selectBackdropClass = 'fixed inset-0 z-0'
export const SELECT_ANCHOR: AnchorConfig = { placement: 'bottom-start', gap: 4, padding: 8 }

export type SelectOption = Readonly<{ value: string; label: string }>

export type SelectViewInputsConfig<Item, Value extends string = string> = Readonly<{
  options: ReadonlyArray<Item>
  maybeSelectedValue: Option.Option<Value>
  itemToValue: (item: Item) => Value
  itemToLabel: (item: Item) => string
  label: string
  placeholder?: string
  description?: string
  anchor?: AnchorConfig
  size?: SelectSize
  isDisabled?: boolean
  isReadOnly?: boolean
  isInvalid?: boolean
  name?: string
  form?: string
  ariaLabel?: string
  triggerClass?: string
  itemsClass?: string
  itemClass?: string
  wrapperClass?: string
}>

/** Build a themed custom select view from the @foldkit/ui Listbox submodel.
 *  Mirrors the shadcn v4 `select.tsx` trigger/content/item behavior: chevron
 *  trigger with muted placeholder, check indicator on the selected item, and
 *  disabled/invalid/read-only states. */
export const styledViewInputs = <M, Item, Value extends string = string>(
  config: SelectViewInputsConfig<Item, Value>,
  h: HtmlBuilder<M>,
): ViewInputs<Item, Value> => {
  const itemToValue = config.itemToValue
  const maybeFound = Option.flatMap(config.maybeSelectedValue, (value) => {
    const found = config.options.find((item) => itemToValue(item) === value)
    return found === undefined ? Option.none<Item>() : Option.some(found)
  })
  const label = config.label
  const hasLabel = label.trim().length > 0
  return {
    items: config.options,
    maybeSelectedValue: config.maybeSelectedValue,
    itemToValue,
    itemToSearchText: (item) => config.itemToLabel(item),
    itemGroupKey: hasLabel ? () => label : undefined,
    groupToHeading: hasLabel
      ? () => ({
          content: h.span([], [label]),
          className: selectLabelClass,
        })
      : undefined,
    buttonContent: h.span(
      [h.Class('flex w-full items-center justify-between gap-2')],
      [
        h.span(
          [
            h.DataAttribute('slot', 'select-value'),
            h.Class(cn(selectValueClass, Option.isNone(maybeFound) && 'text-muted-foreground')),
          ],
          [
            Option.match(maybeFound, {
              onNone: () => config.placeholder ?? 'Select an option',
              onSome: (item) => config.itemToLabel(item),
            }),
          ],
        ),
        selectChevron(h),
      ],
    ),
    buttonAttributes: childAttributes([
      h.DataAttribute('slot', 'select-trigger'),
      h.DataAttribute('size', config.size ?? 'default'),
    ]),
    buttonClassName: cn(selectTriggerClass, config.triggerClass),
    itemsAttributes: childAttributes([
      h.DataAttribute('slot', 'select-content'),
      h.DataAttribute('align-trigger', 'true'),
    ]),
    itemsClassName: cn(selectContentClass, config.itemsClass),
    groupAttributes: childAttributes([h.DataAttribute('slot', 'select-group')]),
    groupClassName: selectGroupClass,
    separatorAttributes: childAttributes([h.DataAttribute('slot', 'select-separator')]),
    separatorClassName: selectSeparatorClass,
    itemToConfig: (item, context) => ({
      className: cn(selectItemClass, config.itemClass),
      content: h.span(
        [h.Class('flex w-full items-center')],
        [
          h.span(
            [h.DataAttribute('slot', 'select-item-text'), h.Class(selectItemTextClass)],
            [config.itemToLabel(item)],
          ),
          context.isSelected
            ? h.span(
                [
                  h.DataAttribute('slot', 'select-item-indicator'),
                  h.Class(selectItemIndicatorClass),
                ],
                [icon(h, Check, selectItemIndicatorIconClass)],
              )
            : h.empty,
        ],
      ),
    }),
    backdropClassName: cn(selectBackdropClass),
    className: cn(selectWrapperClass, config.wrapperClass),
    attributes: childAttributes([h.DataAttribute('slot', 'select')]),
    anchor: config.anchor ?? SELECT_ANCHOR,
    isButtonDisabled: config.isDisabled,
    isReadOnly: config.isReadOnly,
    isInvalid: config.isInvalid,
    name: config.name,
    form: config.form,
    ariaLabel: config.ariaLabel,
  }
}

export const selectLabel = <M>(label: string, h: HtmlBuilder<M>, className?: string): Html =>
  h.label([h.Class(cn(selectLabelClass, className))], [label])

export const selectDescription = <M>(
  description: string,
  h: HtmlBuilder<M>,
  className?: string,
): Html => h.span([h.Class(cn(selectDescriptionClass, className))], [description])

export const selectChevron = <M>(h: HtmlBuilder<M>): Html =>
  icon(h, ChevronDown, selectTriggerIconClass)