Styled text input with label and description, built on the @foldkit/ui Input helper.

Preview
Style
Basic
Invalid
With Label
With Description

Choose a unique username for your account.

Disabled
Input Types
With Select
With Button
With Native Select
Form

We'll never share your email with anyone.

Controlled
Your API key is encrypted and stored securely.
src/demo/views/input.ts500 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 { input, inputClass } from '../../generated/registry/ui/input'
import {
  field,
  fieldDescription,
  fieldGroup,
  fieldLabel,
} from '../../generated/registry/ui/fieldset'
import { button } from '../../generated/registry/ui/button'
import {
  nativeSelectClass,
  nativeSelectWrapperClass,
  nativeSelectIconClass,
} from '../../generated/registry/ui/native-select'
import { icon } from '../../generated/registry/lib/icons'
import { ChevronDown } from 'lucide'
import * as select from '../../generated/registry/ui/select'
import { LanguageSelect } from '../bundles'
import { Option } from 'effect'

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

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

const CURRENCY_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
  { value: 'usd', label: 'USD' },
  { value: 'eur', label: 'EUR' },
  { value: 'gbp', label: 'GBP' },
]

export const inputView = (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-sm')],
            [h.input([h.Type('email'), h.Placeholder('Email'), h.Class(inputClass)])],
          ),
        ],
      ),
      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-sm')],
            [
              h.input([
                h.Type('text'),
                h.Placeholder('Error'),
                h.AriaInvalid(true),
                h.Class(inputClass),
              ]),
            ],
          ),
        ],
      ),
      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 Label']),
          h.div(
            [h.Class('w-full max-w-sm')],
            [
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-email' }, ['Email'], h),
                  h.input([
                    h.Id('input-demo-email'),
                    h.Type('email'),
                    h.Placeholder('name@example.com'),
                    h.Class(inputClass),
                  ]),
                ],
                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 Description']),
          h.div(
            [h.Class('w-full max-w-sm')],
            [
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-username' }, ['Username'], h),
                  h.input([
                    h.Id('input-demo-username'),
                    h.Type('text'),
                    h.Placeholder('Enter your username'),
                    h.Class(inputClass),
                  ]),
                  fieldDescription<AppMessage>(
                    {},
                    ['Choose a unique username for your account.'],
                    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-sm')],
            [
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-disabled' }, ['Email'], h),
                  h.input([
                    h.Id('input-demo-disabled'),
                    h.Type('email'),
                    h.Placeholder('Email'),
                    h.Disabled(true),
                    h.Class(inputClass),
                  ]),
                ],
                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')], ['Input Types']),
          h.div(
            [h.Class('flex w-full max-w-sm flex-col gap-6')],
            [
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-password' }, ['Password'], h),
                  h.input([
                    h.Id('input-demo-password'),
                    h.Type('password'),
                    h.Placeholder('Password'),
                    h.Class(inputClass),
                  ]),
                ],
                h,
              ),
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-tel' }, ['Phone'], h),
                  h.input([
                    h.Id('input-demo-tel'),
                    h.Type('tel'),
                    h.Placeholder('+1 (555) 123-4567'),
                    h.Class(inputClass),
                  ]),
                ],
                h,
              ),
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-url' }, ['URL'], h),
                  h.input([
                    h.Id('input-demo-url'),
                    h.Type('url'),
                    h.Placeholder('https://example.com'),
                    h.Class(inputClass),
                  ]),
                ],
                h,
              ),
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-search' }, ['Search'], h),
                  h.input([
                    h.Id('input-demo-search'),
                    h.Type('search'),
                    h.Placeholder('Search'),
                    h.Class(inputClass),
                  ]),
                ],
                h,
              ),
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-number' }, ['Number'], h),
                  h.input([
                    h.Id('input-demo-number'),
                    h.Type('number'),
                    h.Placeholder('123'),
                    h.Class(inputClass),
                  ]),
                ],
                h,
              ),
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-date' }, ['Date'], h),
                  h.input([h.Id('input-demo-date'), h.Type('date'), h.Class(inputClass)]),
                ],
                h,
              ),
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-time' }, ['Time'], h),
                  h.input([h.Id('input-demo-time'), h.Type('time'), h.Class(inputClass)]),
                ],
                h,
              ),
              field<AppMessage>(
                {},
                [
                  fieldLabel<AppMessage>({ for: 'input-demo-file' }, ['File'], h),
                  h.input([h.Id('input-demo-file'), h.Type('file'), h.Class(inputClass)]),
                ],
                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 Select']),
          h.div(
            [h.Class('flex w-full max-w-sm gap-2')],
            [
              h.input([
                h.Type('text'),
                h.Placeholder('Enter amount'),
                h.Class(`${inputClass} flex-1`),
              ]),
              h.div(
                [h.Class('w-32')],
                [
                  h.submodel({
                    slotId: model.select.id,
                    model: model.select,
                    view: LanguageSelect.view,
                    viewInputs: select.styledViewInputs<
                      AppMessage,
                      { value: string; label: string },
                      string
                    >(
                      {
                        options: CURRENCY_OPTIONS,
                        maybeSelectedValue: Option.some('usd'),
                        itemToValue: (item) => item.value,
                        itemToLabel: (item) => item.label,
                        label: 'Currency',
                        triggerClass: 'w-32',
                      },
                      h,
                    ),
                    toParentMessage: (_message) => Message.UpdatedInputValue({ value: '' }),
                  }),
                ],
              ),
            ],
          ),
        ],
      ),
      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 Button']),
          h.div(
            [h.Class('flex w-full max-w-sm gap-2')],
            [
              h.input([
                h.Type('search'),
                h.Placeholder('Search...'),
                h.Class(`${inputClass} flex-1`),
              ]),
              button<AppMessage>({}, 'Search', 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 Native Select'],
          ),
          h.div(
            [h.Class('flex w-full max-w-sm gap-2')],
            [
              h.input([
                h.Type('tel'),
                h.Placeholder('(555) 123-4567'),
                h.Class(`${inputClass} flex-1`),
              ]),
              h.div(
                [h.Class(`${nativeSelectWrapperClass} w-24`)],
                [
                  h.select(
                    [h.Class(nativeSelectClass)],
                    [
                      h.option([h.Value('+1')], ['+1']),
                      h.option([h.Value('+44')], ['+44']),
                      h.option([h.Value('+46')], ['+46']),
                    ],
                  ),
                  h.span(
                    [h.Class(nativeSelectIconClass), h.AriaHidden(true)],
                    [icon(h, ChevronDown, 'size-4')],
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Form']),
          h.div(
            [h.Class('w-full max-w-md')],
            [
              h.form(
                [h.Class('w-full')],
                [
                  fieldGroup<AppMessage>(
                    {},
                    [
                      field<AppMessage>(
                        {},
                        [
                          fieldLabel<AppMessage>({ for: 'form-name' }, ['Name'], h),
                          h.input([
                            h.Id('form-name'),
                            h.Type('text'),
                            h.Placeholder('John Doe'),
                            h.Class(inputClass),
                          ]),
                        ],
                        h,
                      ),
                      field<AppMessage>(
                        {},
                        [
                          fieldLabel<AppMessage>({ for: 'form-email' }, ['Email'], h),
                          h.input([
                            h.Id('form-email'),
                            h.Type('email'),
                            h.Placeholder('john@example.com'),
                            h.Class(inputClass),
                          ]),
                          fieldDescription<AppMessage>(
                            {},
                            ["We'll never share your email with anyone."],
                            h,
                          ),
                        ],
                        h,
                      ),
                      h.div(
                        [h.Class('grid grid-cols-2 gap-4')],
                        [
                          field<AppMessage>(
                            {},
                            [
                              fieldLabel<AppMessage>({ for: 'form-phone' }, ['Phone'], h),
                              h.input([
                                h.Id('form-phone'),
                                h.Type('tel'),
                                h.Placeholder('+1 (555) 123-4567'),
                                h.Class(inputClass),
                              ]),
                            ],
                            h,
                          ),
                          field<AppMessage>(
                            {},
                            [
                              fieldLabel<AppMessage>({ for: 'form-country' }, ['Country'], h),
                              h.div(
                                [h.Class(nativeSelectWrapperClass)],
                                [
                                  h.select(
                                    [h.Id('form-country'), h.Class(nativeSelectClass)],
                                    [
                                      h.option([h.Value('us')], ['United States']),
                                      h.option([h.Value('uk')], ['United Kingdom']),
                                      h.option([h.Value('ca')], ['Canada']),
                                    ],
                                  ),
                                  h.span(
                                    [h.Class(nativeSelectIconClass), h.AriaHidden(true)],
                                    [icon(h, ChevronDown, 'size-4')],
                                  ),
                                ],
                              ),
                            ],
                            h,
                          ),
                        ],
                      ),
                      field<AppMessage>(
                        {},
                        [
                          fieldLabel<AppMessage>({ for: 'form-address' }, ['Address'], h),
                          h.input([
                            h.Id('form-address'),
                            h.Type('text'),
                            h.Placeholder('123 Main St'),
                            h.Class(inputClass),
                          ]),
                        ],
                        h,
                      ),
                      field<AppMessage>(
                        { orientation: 'horizontal' },
                        [
                          button<AppMessage>({ variant: 'outline', type: 'button' }, 'Cancel', h),
                          button<AppMessage>({ type: 'submit' }, 'Submit', h),
                        ],
                        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')], ['Controlled']),
          h.div(
            [h.Class('w-full max-w-sm')],
            [
              input<AppMessage>(
                {
                  id: 'input-demo-api-key',
                  label: 'API Key',
                  type: 'password',
                  value: model.inputValue,
                  onInput: (value) => Message.UpdatedInputValue({ value }),
                  placeholder: 'sk-...',
                  description: 'Your API key is encrypted and stored securely.',
                },
                h,
              ),
            ],
          ),
        ],
      ),
    ],
  )

const fields = { inputValue: S.String }

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

export const slice = defineSlice({
  fields,
  init: { inputValue: '' },
  messages: [Message.UpdatedInputValue],
  handlers: (model: State) => ({
    UpdatedInputValue: ({ value }: typeof Message.UpdatedInputValue.Type): UpdateReturn => ({
      model: evo(model, { inputValue: () => value }),
    }),
  }),
  samples: [Message.UpdatedInputValue({ value: 'sk-1234' })],
})

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

Source

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

registry/default/ui/input.ts89 lines
import { Input as FoldkitInput } from '@foldkit/ui'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { cn } from '@/lib/utils'

export const inputClass =
  '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 rounded-lg 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 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50'

/** Same string as the `label` item's component classes (upstream label.tsx). */
/** Upstream string re-keyed for foldkit: the label precedes the control, so
 *  upstream's native peer-disabled sibling variant can never match; disabled
 *  state flows from the wrapper (group/field + data-disabled, mirroring
 *  switch.ts). */
export const inputLabelClass =
  'gap-2 text-sm leading-none font-medium group-data-[disabled]:opacity-50 flex items-center select-none group-data-[disabled]/field:pointer-events-none group-data-[disabled]/field:cursor-not-allowed group-data-[disabled]/field:opacity-50'

export const inputDescriptionClass = 'text-sm text-muted-foreground'

export const inputWrapperClass = 'group/field flex flex-col gap-1.5 w-full'

export type InputConfig<M> = Readonly<{
  id: string
  label: string
  description?: string
  onInput?: (value: string) => M
  value?: string
  isDisabled?: boolean
  isReadOnly?: boolean
  isInvalid?: boolean
  isAutofocus?: boolean
  name?: string
  type?: string
  placeholder?: string
  className?: string
  labelClass?: string
  descriptionClass?: string
  wrapperClass?: string
}>

/** Styled text input with label and optional description, built on the
 *  @foldkit/ui Input helper. */
export const input = <M>(config: InputConfig<M>, h: HtmlBuilder<M>): Html =>
  FoldkitInput.view<M>(
    {
      id: config.id,
      onInput: config.onInput,
      value: config.value,
      isDisabled: config.isDisabled,
      isReadOnly: config.isReadOnly,
      isInvalid: config.isInvalid,
      isAutofocus: config.isAutofocus,
      name: config.name,
      type: config.type,
      placeholder: config.placeholder,
      toView: (attributes) =>
        h.div(
          [
            h.Class(cn(inputWrapperClass, config.wrapperClass)),
            ...(config.isDisabled ? [h.DataAttribute('disabled', '')] : []),
          ],
          [
            h.label(
              [
                ...attributes.label,
                h.DataAttribute('slot', 'label'),
                h.Class(cn(inputLabelClass, config.labelClass)),
              ],
              [config.label],
            ),
            h.input([
              ...attributes.input,
              h.DataAttribute('slot', 'input'),
              h.Class(cn(inputClass, config.className)),
            ]),
            config.description === undefined
              ? h.empty
              : h.span(
                  [
                    ...attributes.description,
                    h.Class(cn(inputDescriptionClass, config.descriptionClass)),
                  ],
                  [config.description],
                ),
          ],
        ),
    },
    h,
  )