Styled autocomplete input with filtering, built on the @foldkit/ui Combobox submodel.

Preview
Style
Single
Multi
src/demo/views/combobox.ts236 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import { childAttributes, type Html, type HtmlBuilder } from 'foldkit/html'

import { Combobox as FoldkitCombobox } from '@foldkit/ui'

import * as combobox from '../../generated/registry/ui/combobox'

import { Framework, FrameworkCombobox, FrameworkMultiCombobox } from '../bundles'
import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'
import { icon } from '@/lib/icons'
import { X } from 'lucide'

const Message = defineMessageUnion({
  GotComboboxMessage: { message: combobox.Message },
  GotMultiComboboxMessage: { message: combobox.Message },
})

const FRAMEWORKS: ReadonlyArray<Framework> = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro']

const filteredFrameworks = (inputValue: string): ReadonlyArray<Framework> => {
  const query = inputValue.trim().toLowerCase()
  return query === '' ? FRAMEWORKS : FRAMEWORKS.filter((item) => item.toLowerCase().includes(query))
}

export const comboboxView = (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')], ['Single']),
          h.div(
            [h.Class('w-full max-w-xs')],
            [
              h.submodel({
                slotId: model.combobox.id,
                model: model.combobox,
                view: FrameworkCombobox.view,
                viewInputs: combobox.viewInputs<Framework>({
                  items:
                    model.combobox.isOpen &&
                    Option.exists(
                      model.maybeComboboxValue,
                      (value) => value === model.combobox.inputValue,
                    )
                      ? FRAMEWORKS
                      : filteredFrameworks(model.combobox.inputValue),
                  restingInputValue: Option.getOrElse(model.maybeComboboxValue, () => ''),
                  maybeSelectedValue: model.maybeComboboxValue,
                  itemToValue: (item) => item,
                  itemToDisplayText: (item) => item,
                  inputPlaceholder: 'Select framework...',
                  buttonContent: combobox.comboboxChevron(h),
                  inputAttributes: childAttributes([
                    h.OnInput(() =>
                      Message.GotComboboxMessage({
                        message: combobox.Message.Opened({ maybeActiveItemIndex: Option.none() }),
                      }),
                    ),
                  ]),
                  itemToConfig: (item, { isSelected, isActive }) => ({
                    className: isActive ? 'font-medium' : '',
                    content: h.span(
                      [h.Class('flex w-full items-center justify-between gap-2')],
                      [h.span([], [item]), ...(isSelected ? [h.span([], ['✓'])] : [])],
                    ),
                  }),
                }),
                toParentMessage: (message) => Message.GotComboboxMessage({ message }),
              }),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Multi']),
          h.div(
            [
              h.Id('combobox-multi-demo-input-wrapper'),
              h.Class('flex w-full max-w-xs flex-wrap items-center gap-1 rounded-lg border p-1'),
            ],
            [
              h.div(
                [h.Class('contents')],
                model.multiComboboxValues.map((value) =>
                  h.span(
                    [
                      h.Class(
                        'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs',
                      ),
                    ],
                    [
                      h.span([], [value]),
                      h.button(
                        [
                          h.Type('button'),
                          h.AriaLabel(`Remove ${value}`),
                          h.Class('rounded-sm text-muted-foreground hover:text-foreground'),
                          h.OnClick(
                            Message.GotMultiComboboxMessage({
                              message: combobox.Message.SelectedItem({
                                item: value,
                                displayText: value,
                                wasSelected: true,
                              }),
                            }),
                          ),
                        ],
                        [icon(h, X, 'size-3')],
                      ),
                    ],
                  ),
                ),
              ),
              h.submodel({
                slotId: model.multiCombobox.id,
                model: model.multiCombobox,
                view: FrameworkMultiCombobox.view,
                viewInputs: combobox.multiViewInputs<Framework>({
                  items: filteredFrameworks(model.multiCombobox.inputValue),
                  selectedValues: model.multiComboboxValues,
                  restingInputValue: '',
                  itemToValue: (item) => item,
                  itemToDisplayText: (item) => item,
                  inputPlaceholder:
                    model.multiComboboxValues.length === 0 ? 'Add framework...' : undefined,
                  buttonContent: combobox.comboboxChevron(h),
                  inputClass: 'border-0 shadow-none focus-visible:ring-0',
                  // Share the first row with chips until the input reaches
                  // its usable minimum, then wrap onto a new row.
                  // Match Base UI's chip input: keep a small usable input
                  // width, then wrap only after the row has no room left.
                  wrapperClass: 'min-w-16 flex-1',
                  inputWrapperClass: 'w-full min-w-0',
                  inputWrapperAttributes: [h.Id('combobox-multi-input-inner')],
                  inputAttributes: childAttributes([
                    h.OnInput(() =>
                      Message.GotMultiComboboxMessage({
                        message: combobox.Message.Opened({ maybeActiveItemIndex: Option.none() }),
                      }),
                    ),
                  ]),
                  itemToConfig: (item, { isSelected, isActive }) => ({
                    className: isActive ? 'font-medium' : '',
                    content: h.span(
                      [h.Class('flex w-full items-center justify-between gap-2')],
                      [h.span([], [item]), ...(isSelected ? [combobox.comboboxCheck(h)] : [])],
                    ),
                  }),
                }),
                toParentMessage: (message) => Message.GotMultiComboboxMessage({ message }),
              }),
            ],
          ),
        ],
      ),
    ],
  )

const foldComboboxOutMessage = M.type<FoldkitCombobox.OutMessage<Framework>>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({ model: evo(model, { maybeComboboxValue: () => Option.some(value) }) }),
    ClearedSelection: () => (model) => ({ model }),
  }),
)

const foldMultiComboboxOutMessage = M.type<FoldkitCombobox.OutMessage<Framework>>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({
        model: evo(model, {
          multiComboboxValues: (values) =>
            values.includes(value) ? values.filter((item) => item !== value) : [...values, value],
        }),
      }),
    ClearedSelection: () => (model) => ({ model }),
  }),
)

const foldCombobox = Update.foldChild({
  update: FrameworkCombobox.update,
  read: (model: State) => Option.some(model.combobox),
  write: (model, next) => evo(model, { combobox: () => next }),
  toParentMessage: (message) => Message.GotComboboxMessage({ message }),
  foldOutMessage: foldComboboxOutMessage,
})

const foldMultiCombobox = Update.foldChild({
  update: FrameworkMultiCombobox.update,
  read: (model: State) => Option.some(model.multiCombobox),
  write: (model, next) => evo(model, { multiCombobox: () => next }),
  toParentMessage: (message) => Message.GotMultiComboboxMessage({ message }),
  foldOutMessage: foldMultiComboboxOutMessage,
})

const fields = {
  combobox: combobox.Model,
  maybeComboboxValue: S.Option(Framework),
  multiCombobox: combobox.Multi.Model,
  multiComboboxValues: S.Array(Framework),
}

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

export const slice = defineSlice({
  fields,
  init: {
    combobox: combobox.init({ id: 'combobox-demo' }),
    maybeComboboxValue: Option.none(),
    multiCombobox: combobox.Multi.init({ id: 'combobox-multi-demo' }),
    multiComboboxValues: [],
  },
  messages: [Message.GotComboboxMessage, Message.GotMultiComboboxMessage],
  handlers: (model: State) => ({
    GotComboboxMessage: (payload: typeof Message.GotComboboxMessage.Type): UpdateReturn =>
      foldCombobox(model, payload.message),
    GotMultiComboboxMessage: (payload: typeof Message.GotMultiComboboxMessage.Type): UpdateReturn =>
      foldMultiCombobox(model, payload.message),
  }),
  samples: [],
})

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

Source

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

registry/default/ui/combobox.ts220 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Combobox from '@/components/ui/combobox'`
 */
import { Combobox as FoldkitCombobox } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/combobox'
import type { Option } from 'effect/Option'
import {
  childAttributes,
  inertHtml,
  type Attribute,
  type ChildAttribute,
  type Html,
  type HtmlBuilder,
} from 'foldkit/html'

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

// Re-export the @foldkit/ui Combobox surface. Create a bundle once per
// item type:
//
//   export const CityCombobox = Combobox.create<City>()
//   export const CityMultiCombobox = Combobox.Multi.create<City>()

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

export type Bundle<Item extends string = string> = FoldkitCombobox.Bundle<Item>
export type InitConfig = FoldkitCombobox.InitConfig
export type ViewInputs<Item extends string = string> = FoldkitCombobox.ViewInputs<Item>
export type ItemConfig = FoldkitCombobox.ItemConfig
export type GroupHeading = FoldkitCombobox.GroupHeading

// foldkit deltas: items highlight via data-active (upstream
// data-highlighted:) per the derivation mapping. Gaps vs upstream: no chips
// UI for multi-select, no clear button, no Empty row; filtering is
// parent-owned. Content now uses cn-combobox-content family (was bare); item
// stripped of data-selected:font-medium etc — selection indicator is via
// check icon. Chips/clear/empty data-slots not supported — documented gap.

/** foldcn renders a bare input (upstream wraps one in an InputGroup inside
 *  the popup for chips mode); keep the input token string. */
export const comboboxInputClass =
  'dark:bg-input/30 border-input 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 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 aria-disabled:bg-input/50 data-disabled:bg-input/50 dark:aria-disabled:bg-input/80 dark:data-disabled:bg-input/80 w-full min-w-0 rounded-md pr-9 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50'

export const comboboxButtonClass =
  'absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4'

export const comboboxItemsClass =
  '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 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-lg shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none 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)] overflow-hidden! 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 group/combobox-content relative isolate z-50 max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)'

export const comboboxItemsAnimatedClass = comboboxItemsClass

export const comboboxItemClass =
  'data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*=\'size-\'])]:size-4 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 comboboxGroupHeadingClass = 'text-muted-foreground px-2 py-1.5 text-xs'

export const comboboxSeparatorClass = 'bg-border -mx-1 my-1 h-px'

export const comboboxItemsScrollClass = 'no-scrollbar max-h-72 scroll-py-1 overflow-y-auto p-1'

export const comboboxBackdropClass = 'fixed inset-0 z-0'

export const comboboxWrapperClass = 'relative w-full'

export const comboboxInputWrapperClass = 'relative'

export const COMBOBOX_ANCHOR: AnchorConfig = {
  placement: 'bottom-start',
  gap: 8,
  padding: 8,
}

export const comboboxChevron = <M>(h: HtmlBuilder<M>): Html =>
  h.span([h.Class('shrink-0 text-muted-foreground')], [icon(h, ChevronDown, 'size-4')])

export const comboboxCheck = <M>(h: HtmlBuilder<M>): Html =>
  h.span([h.Class('absolute right-2 flex size-4 items-center justify-center')], [icon(h, Check)])

type CommonConfig<Item extends string> = Readonly<{
  items: ReadonlyArray<Item>
  restingInputValue: string
  itemToConfig: (
    item: Item,
    context: Readonly<{
      isActive: boolean
      isDisabled: boolean
      isReadOnly: boolean
      isSelected: boolean
    }>,
  ) => ItemConfig
  itemToValue: (item: Item, index: number) => Item
  itemToDisplayText: (item: Item, index: number) => string
  anchor?: AnchorConfig
  isItemDisabled?: (item: Item, index: number) => boolean
  inputPlaceholder?: string
  buttonContent?: Html
  isAnimated?: boolean
  isDisabled?: boolean
  isReadOnly?: boolean
  isInvalid?: boolean
  openOnFocus?: boolean
  ariaLabel?: string
  ariaLabelledBy?: string
  inputAttributes?: ReadonlyArray<ChildAttribute>
  itemsAttributes?: ReadonlyArray<Attribute<unknown>>
  itemGroupKey?: (item: Item, index: number) => string
  groupToHeading?: (groupKey: string) => GroupHeading | undefined
  formName?: string
  inputClass?: string
  itemsClass?: string
  itemsScrollClass?: string
  itemClass?: string
  groupClass?: string
  separatorClass?: string
  backdropClass?: string
  wrapperClass?: string
  inputWrapperClass?: string
  inputWrapperAttributes?: ReadonlyArray<Attribute<unknown>>
}>

const common = <Item extends string>(config: CommonConfig<Item>) => ({
  items: config.items,
  restingInputValue: config.restingInputValue,
  anchor: config.anchor ?? COMBOBOX_ANCHOR,
  isItemDisabled: config.isItemDisabled,
  itemToValue: config.itemToValue,
  itemToDisplayText: config.itemToDisplayText,
  inputPlaceholder: config.inputPlaceholder,
  buttonContent: config.buttonContent,
  formName: config.formName,
  isDisabled: config.isDisabled,
  isReadOnly: config.isReadOnly,
  isInvalid: config.isInvalid,
  // Base UI opens the popup when the input receives focus. Keep the option
  // overridable for callers that need explicit keyboard-only opening.
  openOnFocus: config.openOnFocus ?? true,
  ariaLabel: config.ariaLabel,
  ariaLabelledBy: config.ariaLabelledBy,
  inputAttributes: config.inputAttributes,
  itemGroupKey: config.itemGroupKey,
  groupToHeading: config.groupToHeading,
  inputClassName: cn(comboboxInputClass, config.inputClass),
  itemsClassName: cn(
    config.isAnimated !== false ? comboboxItemsAnimatedClass : comboboxItemsClass,
    config.itemsClass,
  ),
  itemsAttributes: childAttributes([
    inertHtml.DataAttribute('slot', 'combobox-content'),
    ...(config.itemsAttributes ?? []),
  ]),
  itemsScrollClassName: config.itemsScrollClass ?? comboboxItemsScrollClass,
  itemToConfig: (item: Item, context: Parameters<CommonConfig<Item>['itemToConfig']>[1]) => {
    const { className, content } = config.itemToConfig(item, context)
    return { className: cn(comboboxItemClass, config.itemClass, className), content }
  },
  groupClassName: cn(comboboxGroupHeadingClass, config.groupClass),
  groupAttributes: childAttributes([inertHtml.DataAttribute('slot', 'combobox-group')]),
  separatorClassName: cn(comboboxSeparatorClass, config.separatorClass),
  separatorAttributes: childAttributes([inertHtml.DataAttribute('slot', 'combobox-separator')]),
  buttonClassName: comboboxButtonClass,
  buttonAttributes: childAttributes([inertHtml.DataAttribute('slot', 'combobox-trigger')]),
  inputWrapperClassName: cn(comboboxInputWrapperClass, config.inputWrapperClass),
  inputWrapperAttributes: childAttributes([
    inertHtml.DataAttribute('slot', 'combobox-input-wrapper'),
    ...(config.inputWrapperAttributes ?? []),
  ]),
  backdropClassName: cn(comboboxBackdropClass, config.backdropClass),
  className: cn(comboboxWrapperClass, config.wrapperClass),
  attributes: childAttributes([inertHtml.DataAttribute('slot', 'combobox')]),
})

export type SingleViewInputsConfig<Item extends string> = CommonConfig<Item> &
  Readonly<{
    maybeSelectedValue: Option<Item>
  }>

/** Build styled single-select `Combobox.ViewInputs`.
 *  Mirrors the shadcn v4 `combobox.tsx` trigger/content/item behavior:
 *  chevron trigger, check indicator on the selected item, scrollable list
 *  with max-height, grouping + separator support, and disabled/invalid/
 *  read-only + aria-label states. Filtering is parent-owned: pass the
 *  already-filtered `items` each render. The panel is hidden when `items`
 *  is empty (no empty-state row — show it outside the combobox if needed). */
export const viewInputs = <Item extends string>(
  config: SingleViewInputsConfig<Item>,
): ViewInputs<Item> => ({
  ...common(config),
  maybeSelectedValue: config.maybeSelectedValue,
})

export type MultiViewInputsConfig<Item extends string> = CommonConfig<Item> &
  Readonly<{
    selectedValues: ReadonlyArray<Item>
  }>

/** Build styled multi-select `Combobox.Multi` view inputs.
 *  Mirrors the same trigger/list/item styling as the single-select
 *  variant; the input rests empty after each commit and the parent
 *  toggles membership on `Selected` out-messages. Supports grouping and
 *  the same disabled/invalid/read-only states. */
export const multiViewInputs = <Item extends string>(
  config: MultiViewInputsConfig<Item>,
): FoldkitCombobox.Multi.ViewInputs<Item> => ({
  ...common(config),
  selectedValues: config.selectedValues,
})