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.
Installation
Add this component to your project:
pnpm dlx shadcn@latest add @foldcn/selectSource
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)