Row of single-character slots backed by one combined value.

Preview
Style
Simple
1
2
3
4
5
6
With Separator
4 Digits

Common pattern for PIN codes.

Disabled
1
2
3
4
5
6
Invalid

Example showing the invalid error state.

0
0
0
0
0
0
Controlled
Enter your one-time password.
Form
Verify your login
Enter the verification code we sent to m@example.com.
1
2
3
4
5
6

I no longer have access to this email address.

Having trouble signing in? Contact support
src/demo/views/input-otp.ts311 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 {
  inputOtp,
  inputOtpGroup,
  inputOtpSlot,
  inputOtpSeparator,
} from '../../generated/registry/ui/input-otp'
import {
  field,
  fieldDescription,
  fieldError,
  fieldLabel,
} from '../../generated/registry/ui/fieldset'
import { button } from '../../generated/registry/ui/button'
import { icon } from '../../generated/registry/lib/icons'
import { RefreshCw } from 'lucide'

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

const Message = defineMessageUnion({
  UpdatedOtp: { value: S.String },
  UpdatedSeparatedOtp: { value: S.String },
  UpdatedFourOtp: { value: S.String },
  UpdatedControlledOtp: { value: S.String },
  UpdatedInvalidOtp: { value: S.String },
})

const separatedGroups = (
  h: HtmlBuilder<AppMessage>,
  value: string,
  isInvalid = false,
): ReadonlyArray<Html> => [
  inputOtpGroup<AppMessage>(
    {},
    [0, 1].map((index) => inputOtpSlot({ index, value, length: 6, isInvalid }, h)),
    h,
  ),
  inputOtpSeparator({}, h),
  inputOtpGroup<AppMessage>(
    {},
    [2, 3].map((index) => inputOtpSlot({ index, value, length: 6, isInvalid }, h)),
    h,
  ),
  inputOtpSeparator({}, h),
  inputOtpGroup<AppMessage>(
    {},
    [4, 5].map((index) => inputOtpSlot({ index, value, length: 6, isInvalid }, h)),
    h,
  ),
]

export const inputOtpView = (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')], ['Simple']),
          field<AppMessage>(
            {},
            [
              fieldLabel<AppMessage>({ for: 'otp-simple' }, ['Simple'], h),
              inputOtp<AppMessage>(
                {
                  length: 6,
                  value: model.otp,
                  id: 'otp-simple',
                  onInput: (value) => Message.UpdatedOtp({ value }),
                },
                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')], ['With Separator']),
          field<AppMessage>(
            {},
            [
              fieldLabel<AppMessage>({ for: 'otp-separator' }, ['With Separator'], h),
              inputOtp<AppMessage>(
                {
                  length: 6,
                  value: model.otpSeparated,
                  id: 'otp-separator',
                  onInput: (value) => Message.UpdatedSeparatedOtp({ value }),
                  children: separatedGroups(h, model.otpSeparated),
                },
                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')], ['4 Digits']),
          field<AppMessage>(
            {},
            [
              fieldLabel<AppMessage>({ for: 'otp-four' }, ['4 Digits'], h),
              fieldDescription<AppMessage>({}, ['Common pattern for PIN codes.'], h),
              inputOtp<AppMessage>(
                {
                  length: 4,
                  value: model.otpFour,
                  id: 'otp-four',
                  onInput: (value) => Message.UpdatedFourOtp({ value }),
                },
                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']),
          field<AppMessage>(
            {},
            [
              fieldLabel<AppMessage>({ for: 'otp-disabled' }, ['Disabled'], h),
              inputOtp<AppMessage>(
                { length: 6, value: '123456', id: 'otp-disabled', isDisabled: true },
                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')], ['Invalid']),
          field<AppMessage>(
            {},
            [
              fieldLabel<AppMessage>({ for: 'otp-invalid' }, ['Invalid State'], h),
              fieldDescription<AppMessage>({}, ['Example showing the invalid error state.'], h),
              inputOtp<AppMessage>(
                {
                  length: 6,
                  value: model.otpInvalid,
                  id: 'otp-invalid',
                  isInvalid: true,
                  onInput: (value) => Message.UpdatedInvalidOtp({ value }),
                  children: separatedGroups(h, model.otpInvalid, true),
                },
                h,
              ),
              fieldError<AppMessage>(
                { errors: [{ message: 'Invalid code. Please try again.' }] },
                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']),
          inputOtp<AppMessage>(
            {
              length: 6,
              value: model.otpControlled,
              id: 'otp-controlled',
              onInput: (value) => Message.UpdatedControlledOtp({ value }),
            },
            h,
          ),
          h.div(
            [h.Class('text-center text-sm')],
            [
              model.otpControlled === ''
                ? 'Enter your one-time password.'
                : model.otpControlled.length >= 6
                  ? `You entered: ${model.otpControlled}`
                  : `${6 - model.otpControlled.length} digits remaining.`,
            ],
          ),
        ],
      ),
      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('mx-auto max-w-md rounded-xl border bg-card p-6')],
            [
              h.div(
                [h.Class('mb-4 flex flex-col gap-1')],
                [
                  h.div([h.Class('font-semibold')], ['Verify your login']),
                  h.div(
                    [h.Class('text-sm text-muted-foreground')],
                    ['Enter the verification code we sent to m@example.com.'],
                  ),
                ],
              ),
              field<AppMessage>(
                {},
                [
                  h.div(
                    [h.Class('flex items-center justify-between')],
                    [
                      fieldLabel<AppMessage>({ for: 'otp-verification' }, ['Verification code'], h),
                      button<AppMessage>(
                        { variant: 'outline', size: 'xs' },
                        h.span([], [icon(h, RefreshCw, 'size-3'), ' Resend Code']),
                        h,
                      ),
                    ],
                  ),
                  inputOtp<AppMessage>(
                    {
                      length: 6,
                      value: model.otp,
                      id: 'otp-verification',
                      onInput: (value) => Message.UpdatedOtp({ value }),
                    },
                    h,
                  ),
                  fieldDescription<AppMessage>(
                    {},
                    ['I no longer have access to this email address.'],
                    h,
                  ),
                ],
                h,
              ),
              h.div(
                [h.Class('mt-4 flex flex-col gap-2')],
                [
                  button<AppMessage>({ type: 'submit' }, 'Verify', h),
                  h.div(
                    [h.Class('text-sm text-muted-foreground text-center')],
                    ['Having trouble signing in? Contact support'],
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    ],
  )

const fields = {
  otp: S.String,
  otpSeparated: S.String,
  otpFour: S.String,
  otpControlled: S.String,
  otpInvalid: S.String,
}

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

export const slice = defineSlice({
  fields,
  init: { otp: '123456', otpSeparated: '', otpFour: '', otpControlled: '', otpInvalid: '000000' },
  messages: [
    Message.UpdatedOtp,
    Message.UpdatedSeparatedOtp,
    Message.UpdatedFourOtp,
    Message.UpdatedControlledOtp,
    Message.UpdatedInvalidOtp,
  ],
  handlers: (model: State) => ({
    UpdatedOtp: ({ value }: typeof Message.UpdatedOtp.Type): UpdateReturn => ({
      model: evo(model, { otp: () => value }),
    }),
    UpdatedSeparatedOtp: ({ value }: typeof Message.UpdatedSeparatedOtp.Type): UpdateReturn => ({
      model: evo(model, { otpSeparated: () => value }),
    }),
    UpdatedFourOtp: ({ value }: typeof Message.UpdatedFourOtp.Type): UpdateReturn => ({
      model: evo(model, { otpFour: () => value }),
    }),
    UpdatedControlledOtp: ({ value }: typeof Message.UpdatedControlledOtp.Type): UpdateReturn => ({
      model: evo(model, { otpControlled: () => value }),
    }),
    UpdatedInvalidOtp: ({ value }: typeof Message.UpdatedInvalidOtp.Type): UpdateReturn => ({
      model: evo(model, { otpInvalid: () => value }),
    }),
  }),
  samples: [
    Message.UpdatedOtp({ value: '1234' }),
    Message.UpdatedSeparatedOtp({ value: '12' }),
    Message.UpdatedFourOtp({ value: '34' }),
    Message.UpdatedControlledOtp({ value: '56' }),
    Message.UpdatedInvalidOtp({ value: '00' }),
  ],
})

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-otp

Source

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

registry/default/ui/input-otp.ts213 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

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

type Child = Html | string

// InputOtp renders one transparent <input> overlaid across a row of visual
// slots. Every keystroke lands in that single input, so the browser handles
// the hard parts natively — auto-advancing as digits are typed, stepping back
// on Backspace, arrow-key navigation, and pasting a full code into the slots —
// the way shadcn's `input-otp` base surface does through the `input-otp`
// library. The slots are purely presentational: each shows one character of
// `value`, and the next-to-fill slot shows a blinking caret.
//
// The dependency-free helpers below (`normalizeOtpValue`,
// `isCompleteOtpValue`, `otpSlotStates`) are the headless core: they own
// every domain rule with no foldkit import, so they can move verbatim into a
// future `@foldkit/ui` OTP primitive while this file keeps the styled parts.

/** Digits-only value capped at `length`. Mirrors the numeric-OTP demos: every
 *  keystroke, paste, or autofill is reduced to this before it reaches slots
 *  or callbacks. */
export const normalizeOtpValue = (raw: string, length: number): string =>
  raw.replace(/\D/g, '').slice(0, length)

/** A normalized value fills every slot. Owners derive completion from their
 *  stored value with this instead of a second callback. */
export const isCompleteOtpValue = (value: string, length: number): boolean =>
  normalizeOtpValue(value, length).length >= length

/** Per-slot render state, always `length` long. Exactly one slot is active
 *  (the insert position) until the code is complete, when none is — the same
 *  rule the `input-otp` library's `hasFakeCaret` follows. */
export type OtpSlotState = Readonly<{
  index: number
  char: string | null
  isActive: boolean
}>

export const otpSlotStates = (value: string, length: number): ReadonlyArray<OtpSlotState> => {
  const digits = normalizeOtpValue(value, length).split('')
  const activeIndex = digits.length >= length ? -1 : digits.length
  return Array.from({ length }, (_, index) => ({
    index,
    char: digits[index] ?? null,
    isActive: index === activeIndex,
  }))
}

export const inputOtpClass = 'gap-2 relative flex items-center has-disabled:opacity-50'

/** Wrapper grouping the joined slots. Upstream keys the group's invalid ring
 *  off descendant `aria-invalid` (`has-aria-invalid:`), which the default
 *  layout threads onto every slot via `isInvalid`; custom `children`
 *  layouts must pass `isInvalid` to their own slots for the ring to light. */
export const inputOtpGroupClass = 'has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive rounded-lg has-aria-invalid:ring-3 flex items-center'

export const inputOtpInputClass = 'absolute inset-0 z-10 h-full w-full bg-transparent text-transparent caret-transparent outline-none disabled:cursor-not-allowed'

export const inputOtpSlotClass =
  'dark:bg-input/30 border-input data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive size-8 border-y border-r text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg data-[active=true]:ring-3 relative flex items-center justify-center data-[active=true]:z-10'

export const inputOtpCaretClass =
  'pointer-events-none absolute inset-0 flex items-center justify-center'

export const inputOtpCaretLineClass = 'animate-caret-blink bg-foreground h-4 w-px duration-1000'

export const inputOtpSeparatorClass = '[&_svg:not([class*=\'size-\'])]:size-4 flex items-center'

export type InputOtpConfig<M> = Readonly<{
  length: number
  value: string
  onInput?: (value: string) => M
  onComplete?: (value: string) => M
  isDisabled?: boolean
  isInvalid?: boolean
  autoFocus?: boolean
  id?: string
  className?: string
  children?: ReadonlyArray<Child>
}>

/** Visual separator between OTP groups (e.g. between groups of 3). Upstream
 *  renders a `Minus` icon; foldcn uses a text fallback to avoid extra icon
 *  deps. Carries `data-slot="input-otp-separator"` and `role="separator"` to
 *  match upstream. */
export const inputOtpSeparator = <M>(
  config: Readonly<{ className?: string }>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.DataAttribute('slot', 'input-otp-separator'),
      h.Role('separator'),
      h.Class(cn(inputOtpSeparatorClass, config.className)),
    ],
    ['−'],
  )

/** One visual slot. The parent owns the code string and passes it down with
 *  the slot's `index` — the same information upstream's `OTPInputContext`
 *  carries, without framework context. */
export type InputOtpSlotConfig = Readonly<{
  index: number
  value: string
  length: number
  isInvalid?: boolean
  className?: string
}>

export const inputOtpSlot = <M>(config: InputOtpSlotConfig, h: HtmlBuilder<M>): Html => {
  const state = otpSlotStates(config.value, config.length)[config.index] ?? {
    index: config.index,
    char: null,
    isActive: false,
  }
  return h.div(
    [
      h.Class(cn(inputOtpSlotClass, config.className)),
      h.DataAttribute('slot', 'input-otp-slot'),
      h.DataAttribute('active', state.isActive ? 'true' : 'false'),
      ...(config.isInvalid === true ? [h.AriaInvalid(true)] : []),
    ],
    [
      state.char ?? '',
      state.isActive
        ? h.div([h.Class(inputOtpCaretClass)], [h.div([h.Class(inputOtpCaretLineClass)], [])])
        : null,
    ],
  )
}

/** A row of joined slots. Pass slots (and separators between groups) as
 *  children to compose multi-group layouts like upstream's separator demo. */
export const inputOtpGroup = <M>(
  config: Readonly<{ className?: string }>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.Class(cn(inputOtpGroupClass, config.className)), h.DataAttribute('slot', 'input-otp-group')],
    children,
  )

/** A row of single-character OTP slots backed by one combined string `value`.
 *  Digit-only filtering is intentional (mirrors upstream numeric OTP):
 *  non-digits are stripped on display and on input.
 *
 *  `onInput` is the update channel and fires on every change, including the
 *  completing one (mirrors upstream `onChange`). With no `onInput`,
 *  `onComplete` doubles as the update channel and fires on every change —
 *  check `isCompleteOtpValue(next, length)` before treating it as
 *  completion. Controlled owners observe completion from their stored value;
 *  foldkit maps one event to one message, so the two callbacks never fire
 *  for the same keystroke.
 *
 *  Pass `children` (groups interleaved with separators) for multi-group
 *  layouts; omit it for the default single group of `length` slots. */
export const inputOtp = <M>(config: InputOtpConfig<M>, h: HtmlBuilder<M>): Html => {
  const value = normalizeOtpValue(config.value, config.length)

  return h.div(
    [h.Class(cn(inputOtpClass, config.className)), h.DataAttribute('slot', 'input-otp')],
    [
      h.input([
        h.Type('text'),
        h.InputMode('numeric'),
        h.Attribute('autocomplete', 'one-time-code'),
        // No native maxlength: pasting a spaced/dashed code must reach the
        // handler whole so `normalizeOtpValue` strips separators before
        // capping at `length`. Over-length typing collapses back to the
        // normalized value on re-render.
        h.Spellcheck(false),
        ...(config.isDisabled === true ? [h.Disabled(true)] : []),
        ...(config.isInvalid === true ? [h.AriaInvalid(true)] : []),
        ...(config.autoFocus === true ? [h.Autofocus(true)] : []),
        ...(config.id === undefined ? [] : [h.Id(config.id)]),
        h.Value(value),
        h.Class(inputOtpInputClass),
        h.DataAttribute('slot', 'input-otp-input'),
        ...(config.onInput === undefined && config.onComplete === undefined
          ? []
          : [
              h.OnInput((raw) => {
                const next = normalizeOtpValue(raw, config.length)
                if (config.onInput !== undefined) {
                  return config.onInput(next)
                }
                if (config.onComplete !== undefined) {
                  return config.onComplete(next)
                }
                throw new Error('unreachable: OnInput is only attached with a callback')
              }),
            ]),
      ]),
      ...(config.children === undefined
        ? [
            inputOtpGroup<M>(
              {},
              Array.from({ length: config.length }, (_, index) =>
                inputOtpSlot(
                  { index, value, length: config.length, isInvalid: config.isInvalid },
                  h,
                ),
              ),
              h,
            ),
          ]
        : config.children),
    ],
  )
}