Glrk UI

Field Wrapper

Opinionated form field wrappers combining Field layout, label, error display, and form controls. Available as library-agnostic, React Hook Form, and TanStack Form variants.

Generic

Gender
Interest

React Hook Form

Gender
Interest

TanStack Form

Role
Hobbies

Installation

npx shadcn@latest add @glrk-ui/field-wrapper

If you haven't set up the prerequisites yet, check out Prerequest section.

Add following shadcn components: select, popover, calendar, textarea, input, radio-group, form, button, label, checkbox, switch.

Update same components from our site.

Copy and paste the following code into your project.

ui/field-wrapper.tsx
'use client'

import { useState } from 'react'
import { CalendarIcon } from 'lucide-react'
import { format } from 'date-fns'

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

import { Field, FieldLabel, FieldSet, FieldLegend, FieldError } from './field'
import { Popover, PopoverContent, PopoverTrigger } from './popover'
import { AutocompleteWrapper as Autocomplete } from './autocomplete'
import { InputGroupWrapper as InputGroup } from './input-group'
import { InputOTPWrapper as InputOTP } from './input-otp'
import { ComboboxWrapper as Combobox } from './combobox'
import { CheckboxWrapper as Checkbox } from './checkbox'
import { SelectWrapper as Select } from './select'
import { RadioWrapper as Radio } from './radio'
import { NumberFieldWrapper } from './number-field'
import { Calendar } from './calendar'
import { Textarea } from './textarea'
import { Slider } from './slider'
import { Button } from './button'
import { Switch } from './switch'
import { Input } from './input'

type BaseProps = {
  name: string
  label?: React.ReactNode
  error?: { message?: string }
  invalid?: boolean
  className?: string
}

function labelString(label: React.ReactNode): string | undefined {
  return typeof label === 'string' ? label : undefined
}

type InputProps = BaseProps & React.InputHTMLAttributes<HTMLInputElement>
export function InputWrapper({
  name,
  label,
  error,
  invalid,
  className,
  type = 'text',
  placeholder,
  ...props
}: InputProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <Input
        id={name}
        name={name}
        type={type}
        placeholder={placeholder || (labelString(label) && `Enter ${labelString(label)}`)}
        aria-invalid={isInvalid}
        {...props}
      />
      <FieldError errors={[error]} />
    </Field>
  )
}

type TextareaProps = BaseProps & React.TextareaHTMLAttributes<HTMLTextAreaElement>
export function TextareaWrapper({
  name,
  label,
  error,
  invalid,
  className,
  placeholder,
  ...rest
}: TextareaProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <Textarea
        id={name}
        name={name}
        placeholder={placeholder || (labelString(label) && `Enter ${labelString(label)}`)}
        aria-invalid={isInvalid}
        {...rest}
      />
      <FieldError errors={[error]} />
    </Field>
  )
}

type RadioProps = BaseProps &
  Omit<React.ComponentProps<typeof Radio>, 'value' | 'onValueChange' | 'as'> & {
    value?: allowedPrimitiveT
    onValueChange?: (value: allowedPrimitiveT) => void
  }
export function RadioWrapper({
  name,
  label,
  error,
  invalid,
  className,
  value,
  onValueChange,
  ...props
}: RadioProps) {
  const isInvalid = invalid || !!error

  return (
    <FieldSet className={cn(className)}>
      {label && <FieldLegend variant="label">{label}</FieldLegend>}
      <Radio
        {...props}
        value={value != null ? String(value) : undefined}
        onValueChange={val => onValueChange?.(parseAllowedPrimitive(val))}
        aria-invalid={isInvalid}
      />
      <FieldError errors={[error]} />
    </FieldSet>
  )
}

type CheckboxProps = BaseProps &
  Omit<React.ComponentProps<typeof Checkbox>, 'value' | 'onValueChange' | 'as'> & {
    value?: allowedPrimitiveT[]
    onValueChange?: (value: allowedPrimitiveT[]) => void
  }
export function CheckboxWrapper({
  name,
  label,
  error,
  invalid,
  className,
  value = [],
  onValueChange,
  ...props
}: CheckboxProps) {
  const isInvalid = invalid || !!error

  return (
    <FieldSet className={cn(className)}>
      {label && <FieldLegend variant="label">{label}</FieldLegend>}
      <Checkbox
        orientation="horizontal"
        {...props}
        value={value.map(String)}
        onValueChange={vals => onValueChange?.(vals.map(parseAllowedPrimitive))}
        aria-invalid={isInvalid}
      />
      <FieldError errors={[error]} />
    </FieldSet>
  )
}

type SwitchProps = BaseProps & {
  checked?: boolean
  onCheckedChange?: (checked: boolean) => void
}
export function SwitchWrapper({
  name,
  label,
  error,
  invalid,
  className,
  checked,
  onCheckedChange,
}: SwitchProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      <div className="flex items-center justify-between gap-4">
        {label && (
          <FieldLabel htmlFor={name} className="font-normal">
            {label}
          </FieldLabel>
        )}
        <Switch
          id={name}
          checked={checked}
          onCheckedChange={onCheckedChange}
          aria-label={typeof label === 'string' ? label : name}
          aria-invalid={isInvalid}
        />
      </div>
      <FieldError errors={[error]} />
    </Field>
  )
}

type SelectProps = BaseProps &
  Omit<React.ComponentProps<typeof Select>, 'value' | 'onValueChange'> & {
    value?: allowedPrimitiveT
    onValueChange?: (value: allowedPrimitiveT) => void
  }
export function SelectWrapper({
  name,
  label,
  error,
  invalid,
  className,
  options,
  placeholder,
  value,
  onValueChange,
  ...props
}: SelectProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <Select
        {...props}
        id={name}
        options={options}
        value={value != null ? String(value) : undefined}
        placeholder={placeholder ?? (labelString(label) && `Select ${labelString(label)}`)}
        onValueChange={val => onValueChange?.(parseAllowedPrimitive(val as any))}
        aria-invalid={isInvalid}
      />
      <FieldError errors={[error]} />
    </Field>
  )
}

type DatePickerProps = BaseProps &
  Omit<React.ComponentProps<typeof Calendar>, 'selected' | 'onSelect'> & {
    value?: Date
    onSelect?: (date: Date | undefined) => void
  }
export function DatePickerWrapper({
  name,
  label,
  error,
  invalid,
  className,
  value,
  onSelect,
  ...calendarProps
}: DatePickerProps) {
  const [open, setOpen] = useState(false)
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger
          render={
            <Button
              id={name}
              variant={'outline'}
              className={cn('w-full pl-3 text-left font-normal shadow-xs', !value && 'text-muted-foreground')}
              aria-invalid={isInvalid}
            >
              {value ? format(value, 'dd/MM/yyyy') : <span>Pick a date</span>}
              <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
            </Button>
          }
        />

        <PopoverContent className="w-auto p-0" align="start">
          <Calendar
            autoFocus
            mode="single"
            captionLayout="dropdown"
            selected={value}
            onSelect={date => {
              onSelect?.(date)
              setOpen(false)
            }}
            defaultMonth={value}
            {...(calendarProps as any)}
          />
        </PopoverContent>
      </Popover>
      <FieldError errors={[error]} />
    </Field>
  )
}

type ComboboxProps = BaseProps & React.ComponentProps<typeof Combobox>
export function ComboboxWrapper({
  name,
  label,
  error,
  invalid,
  className,
  placeholder,
  value,
  onValueChange,
  ...rest
}: ComboboxProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <Combobox
        {...rest}
        id={name}
        value={value}
        placeholder={placeholder || (labelString(label) && `Select ${labelString(label)}`)}
        onValueChange={onValueChange}
        aria-invalid={isInvalid}
      />
      <FieldError errors={[error]} />
    </Field>
  )
}

type NumberProps = BaseProps &
  Omit<React.ComponentProps<typeof NumberFieldWrapper>, 'name' | 'id' | 'className'>
export function NumberWrapper({
  name,
  label,
  error,
  invalid,
  className,
  ...props
}: NumberProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <NumberFieldWrapper id={name} name={name} aria-invalid={isInvalid} {...props} />
      <FieldError errors={[error]} />
    </Field>
  )
}

type SliderProps = BaseProps &
  Omit<React.ComponentProps<typeof Slider>, 'name'>
export function SliderWrapper({
  name,
  label,
  error,
  invalid,
  className,
  ...props
}: SliderProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel>{label}</FieldLabel>}
      <Slider name={name} aria-invalid={isInvalid} {...props} />
      <FieldError errors={[error]} />
    </Field>
  )
}

type AutocompleteProps = BaseProps &
  Omit<React.ComponentProps<typeof Autocomplete>, 'className'>
export function AutocompleteWrapper({
  name,
  label,
  error,
  invalid,
  className,
  ...props
}: AutocompleteProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <Autocomplete aria-invalid={isInvalid} {...props} />
      <FieldError errors={[error]} />
    </Field>
  )
}

type OTPProps = BaseProps & Omit<React.ComponentProps<typeof InputOTP>, 'className'>
export function OTPWrapper({
  name,
  label,
  error,
  invalid,
  className,
  ...props
}: OTPProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel>{label}</FieldLabel>}
      <InputOTP name={name} aria-invalid={isInvalid} {...props} />
      <FieldError errors={[error]} />
    </Field>
  )
}

type InputGroupFieldProps = BaseProps & Omit<React.ComponentProps<typeof InputGroup>, 'wrapperClassName'>
export function InputGroupWrapper({
  name,
  label,
  error,
  invalid,
  className,
  ...props
}: InputGroupFieldProps) {
  const isInvalid = invalid || !!error

  return (
    <Field className={className} invalid={isInvalid}>
      {label && <FieldLabel htmlFor={name}>{label}</FieldLabel>}
      <InputGroup
        id={name}
        name={name}
        aria-invalid={isInvalid}
        {...props}
      />
      <FieldError errors={[error]} />
    </Field>
  )
}

For React Hook Form integration:

ui/field-wrapper-rhf.tsx
'use client'

import { Controller, type Control, type FieldValues, type Path } from 'react-hook-form'

import {
  OTPWrapper as OTP,
  InputWrapper as Input,
  RadioWrapper as Radio,
  SwitchWrapper as Switch,
  SelectWrapper as Select,
  SliderWrapper as Slider,
  NumberWrapper as NumberInput,
  TextareaWrapper as Textarea,
  CheckboxWrapper as Checkbox,
  ComboboxWrapper as Combobox,
  DatePickerWrapper as DatePicker,
  InputGroupWrapper as InputGroup,
  AutocompleteWrapper as Autocomplete,
} from './field-wrapper'

type BaseProps<T extends FieldValues> = {
  name: Path<T>
  control: Control<T>
  className?: string
  label?: React.ReactNode
}

type InputProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name' | 'value' | 'onChange' | 'onBlur'>
export function InputWrapper<T extends FieldValues>({ name, control, ...props }: InputProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Input
          {...props}
          name={name}
          value={field.value ?? ''}
          onChange={field.onChange}
          onBlur={field.onBlur}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type TextareaProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'name' | 'value' | 'onChange' | 'onBlur'>
export function TextareaWrapper<T extends FieldValues>({
  name,
  control,
  ...props
}: TextareaProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Textarea
          {...props}
          name={name}
          value={field.value ?? ''}
          onChange={field.onChange}
          onBlur={field.onBlur}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type RadioProps<T extends FieldValues> = BaseProps<T> & {
  options: (allowedPrimitiveT | optionT)[]
}
export function RadioWrapper<T extends FieldValues>({ name, control, ...props }: RadioProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Radio
          {...props}
          name={name}
          value={field.value}
          onValueChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type CheckboxProps<T extends FieldValues> = BaseProps<T> & {
  options: (allowedPrimitiveT | optionT)[]
}
export function CheckboxWrapper<T extends FieldValues>({
  name,
  control,
  ...props
}: CheckboxProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Checkbox
          {...props}
          name={name}
          value={field.value ?? []}
          onValueChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type SwitchProps<T extends FieldValues> = BaseProps<T>
export function SwitchWrapper<T extends FieldValues>({ name, control, ...props }: SwitchProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Switch
          {...props}
          name={name}
          checked={field.value ?? false}
          onCheckedChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type SelectProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.ComponentProps<typeof Select>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
export function SelectWrapper<T extends FieldValues>({ name, control, ...props }: SelectProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Select
          {...props}
          name={name}
          value={field.value}
          onValueChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type DatePickerProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.ComponentProps<typeof DatePicker>, 'name' | 'value' | 'onSelect' | 'error' | 'invalid'>
export function DatePickerWrapper<T extends FieldValues>({
  name,
  control,
  ...props
}: DatePickerProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <DatePicker
          {...props}
          name={name}
          value={field.value}
          onSelect={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type ComboboxProps<T extends FieldValues> = BaseProps<T> & Omit<React.ComponentProps<typeof Combobox>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
export function ComboboxWrapper<T extends FieldValues>({
  name,
  control,
  ...props
}: ComboboxProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Combobox
          {...props}
          name={name}
          value={field.value}
          onValueChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type NumberProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.ComponentProps<typeof NumberInput>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
export function NumberWrapper<T extends FieldValues>({ name, control, ...props }: NumberProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <NumberInput
          {...props}
          name={name}
          value={field.value ?? null}
          onValueChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type SliderProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.ComponentProps<typeof Slider>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
export function SliderWrapper<T extends FieldValues>({ name, control, ...props }: SliderProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Slider
          {...props}
          name={name}
          value={field.value}
          onValueChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type AutocompleteProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.ComponentProps<typeof Autocomplete>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
export function AutocompleteWrapper<T extends FieldValues>({ name, control, ...props }: AutocompleteProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <Autocomplete
          {...props}
          name={name}
          value={field.value ?? ''}
          onValueChange={field.onChange}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type OTPProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.ComponentProps<typeof OTP>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
export function OTPWrapper<T extends FieldValues>({ name, control, ...props }: OTPProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <OTP
          {...props}
          name={name}
          value={field.value ?? ''}
          onValueChange={(value) => field.onChange(value)}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

type InputGroupProps<T extends FieldValues> = BaseProps<T> &
  Omit<React.ComponentProps<typeof InputGroup>, 'name' | 'value' | 'onChange' | 'onBlur' | 'error' | 'invalid'>
export function InputGroupWrapper<T extends FieldValues>({ name, control, ...props }: InputGroupProps<T>) {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <InputGroup
          {...props}
          name={name}
          value={field.value ?? ''}
          onChange={field.onChange}
          onBlur={field.onBlur}
          error={fieldState.error}
          invalid={fieldState.invalid}
        />
      )}
    />
  )
}

For TanStack Form integration:

ui/field-wrapper-tf.tsx
'use client'

import { createFormHookContexts, createFormHook } from '@tanstack/react-form'

import {
  OTPWrapper as OTP,
  InputWrapper as Input,
  RadioWrapper as Radio,
  SwitchWrapper as Switch,
  SelectWrapper as Select,
  SliderWrapper as Slider,
  NumberWrapper as NumberInput,
  TextareaWrapper as Textarea,
  CheckboxWrapper as Checkbox,
  ComboboxWrapper as Combobox,
  DatePickerWrapper as DatePicker,
  InputGroupWrapper as InputGroup,
  AutocompleteWrapper as Autocomplete,
} from './field-wrapper'

export const { fieldContext, useFieldContext, formContext, useFormContext } =
  createFormHookContexts()

type inputFieldProps = Omit<
  React.InputHTMLAttributes<HTMLInputElement>,
  'value' | 'onChange' | 'onBlur'
> & {
  label?: React.ReactNode
}
function InputField(props: inputFieldProps) {
  const field = useFieldContext<string>()

  return (
    <Input
      {...props}
      name={field.name}
      value={field.state.value ?? ''}
      onChange={e => field.handleChange(e.target.value)}
      onBlur={field.handleBlur}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type textareaFieldProps = Omit<
  React.TextareaHTMLAttributes<HTMLTextAreaElement>,
  'value' | 'onChange' | 'onBlur'
> & {
  label?: React.ReactNode
}
function TextareaField(props: textareaFieldProps) {
  const field = useFieldContext<string>()

  return (
    <Textarea
      {...props}
      name={field.name}
      value={field.state.value ?? ''}
      onChange={e => field.handleChange(e.target.value)}
      onBlur={field.handleBlur}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type radioFieldProps = {
  label?: React.ReactNode
  options: (allowedPrimitiveT | optionT)[]
  className?: string
}
function RadioField(props: radioFieldProps) {
  const field = useFieldContext<allowedPrimitiveT>()

  return (
    <Radio
      {...props}
      name={field.name}
      value={field.state.value}
      onValueChange={value => field.handleChange(value)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

function CheckboxField(props: radioFieldProps) {
  const field = useFieldContext<allowedPrimitiveT[]>()

  return (
    <Checkbox
      {...props}
      name={field.name}
      value={field.state.value ?? []}
      onValueChange={value => field.handleChange(value)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type switchFieldProps = {
  label?: React.ReactNode
  className?: string
}
function SwitchField(props: switchFieldProps) {
  const field = useFieldContext<boolean>()

  return (
    <Switch
      {...props}
      name={field.name}
      checked={field.state.value ?? false}
      onCheckedChange={checked => field.handleChange(checked)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type selectFieldProps = Omit<React.ComponentProps<typeof Select>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
function SelectField(props: selectFieldProps) {
  const field = useFieldContext<allowedPrimitiveT>()

  return (
    <Select
      {...props}
      name={field.name}
      value={field.state.value}
      onValueChange={value => field.handleChange(value)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type datePickerFieldProps = Omit<
  React.ComponentProps<typeof DatePicker>,
  'name' | 'value' | 'onSelect' | 'error' | 'invalid'
> & {
  label?: React.ReactNode
}
function DatePickerField(props: datePickerFieldProps) {
  const field = useFieldContext<Date | undefined>()

  return (
    <DatePicker
      {...props}
      name={field.name}
      value={field.state.value}
      onSelect={date => field.handleChange(date)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type comboboxFieldProps = Omit<React.ComponentProps<typeof Combobox>, 'name' | 'value' | 'onValueChange' | 'error' | 'invalid'>
function ComboboxField(props: comboboxFieldProps) {
  const field = useFieldContext<allowedPrimitiveT | allowedPrimitiveT[]>()

  return (
    <Combobox
      {...props}
      name={field.name}
      value={field.state.value}
      onValueChange={value => field.handleChange(value as allowedPrimitiveT | allowedPrimitiveT[])}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type numberFieldProps = Omit<
  React.ComponentProps<typeof NumberInput>,
  'name' | 'value' | 'onValueChange' | 'error' | 'invalid'
> & { label?: React.ReactNode }
function NumberField(props: numberFieldProps) {
  const field = useFieldContext<number | null>()

  return (
    <NumberInput
      {...props}
      name={field.name}
      value={field.state.value}
      onValueChange={val => field.handleChange(val)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type sliderFieldProps = Omit<
  React.ComponentProps<typeof Slider>,
  'name' | 'value' | 'onValueChange' | 'error' | 'invalid'
> & { label?: React.ReactNode }
function SliderField(props: sliderFieldProps) {
  const field = useFieldContext<number | number[]>()

  return (
    <Slider
      {...props}
      name={field.name}
      value={field.state.value}
      onValueChange={val => field.handleChange(val as number | number[])}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type autocompleteFieldProps = Omit<
  React.ComponentProps<typeof Autocomplete>,
  'name' | 'value' | 'onValueChange' | 'error' | 'invalid'
> & { label?: React.ReactNode }
function AutocompleteField(props: autocompleteFieldProps) {
  const field = useFieldContext<string>()

  return (
    <Autocomplete
      {...props}
      name={field.name}
      value={field.state.value ?? ''}
      onValueChange={val => field.handleChange(val)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type otpFieldProps = Omit<
  React.ComponentProps<typeof OTP>,
  'name' | 'value' | 'onValueChange' | 'error' | 'invalid'
> & { label?: React.ReactNode }
function OTPField(props: otpFieldProps) {
  const field = useFieldContext<string>()

  return (
    <OTP
      {...props}
      name={field.name}
      value={field.state.value ?? ''}
      onValueChange={(value) => field.handleChange(value)}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

type inputGroupFieldProps = Omit<
  React.ComponentProps<typeof InputGroup>,
  'name' | 'value' | 'onChange' | 'onBlur' | 'error' | 'invalid'
> & { label?: React.ReactNode }
function InputGroupField(props: inputGroupFieldProps) {
  const field = useFieldContext<string>()

  return (
    <InputGroup
      {...props}
      name={field.name}
      value={field.state.value ?? ''}
      onChange={e => field.handleChange(e.target.value)}
      onBlur={field.handleBlur}
      error={
        field.state.meta.errors.length > 0 ? { message: field.state.meta.errors[0] } : undefined
      }
      invalid={field.state.meta.errors.length > 0}
    />
  )
}

export const { useAppForm, withForm, withFieldGroup } = createFormHook({
  fieldContext,
  formContext,
  fieldComponents: {
    InputField,
    TextareaField,
    RadioField,
    CheckboxField,
    SwitchField,
    SelectField,
    DatePickerField,
    ComboboxField,
    NumberField,
    SliderField,
    AutocompleteField,
    OTPField,
    InputGroupField,
  },
  formComponents: {},
})

These wrappers automatically handle:

  • Label
  • Form control wiring
  • Error messages
  • Placeholder generation
  • Value parsing (e.g., converting "1" → number)

Available wrappers

All wrappers are available in three flavours:

WrapperGeneric importRHF importTanStack Form
InputWrapperfield-wrapperfield-wrapper-rhffield.InputField
TextareaWrapperfield-wrapperfield-wrapper-rhffield.TextareaField
RadioWrapperfield-wrapperfield-wrapper-rhffield.RadioField
CheckboxWrapperfield-wrapperfield-wrapper-rhffield.CheckboxField
SwitchWrapperfield-wrapperfield-wrapper-rhffield.SwitchField
SelectWrapperfield-wrapperfield-wrapper-rhffield.SelectField
DatePickerWrapperfield-wrapperfield-wrapper-rhffield.DatePickerField
ComboboxWrapperfield-wrapperfield-wrapper-rhffield.ComboboxField
AutocompleteWrapperfield-wrapperfield-wrapper-rhffield.AutocompleteField
NumberWrapperfield-wrapperfield-wrapper-rhffield.NumberField
SliderWrapperfield-wrapperfield-wrapper-rhffield.SliderField
OTPWrapperfield-wrapperfield-wrapper-rhffield.OTPField
InputGroupWrapperfield-wrapperfield-wrapper-rhffield.InputGroupField

Usage

Generic (library-agnostic)

import {
  InputWrapper,
  SelectWrapper,
  ComboboxWrapper,
  NumberWrapper,
  SliderWrapper,
} from "@/components/ui/field-wrapper"

<InputWrapper
  name="email"
  label="Email"
  type="email"
  value={value}
  onChange={e => setValue(e.target.value)}
/>

<SelectWrapper
  name="country"
  label="Country"
  options={[
    { value: "us", label: "United States" },
    { value: "uk", label: "United Kingdom" },
  ]}
  value={country}
  onValueChange={setCountry}
/>

<NumberWrapper
  name="qty"
  label="Quantity"
  value={qty}
  onValueChange={setQty}
  min={0}
  step={1}
/>

<SliderWrapper
  name="volume"
  label="Volume"
  value={[volume]}
  onValueChange={([v]) => setVolume(v)}
  min={0}
  max={100}
/>

Validation state

All wrappers accept invalid and error props:

<InputWrapper
  name="email"
  label="Email"
  invalid={!!errors.email}
  error={errors.email}
/>

React Hook Form

import {
  InputWrapper,
  SelectWrapper,
  NumberWrapper,
  SliderWrapper,
  AutocompleteWrapper,
} from "@/components/ui/field-wrapper-rhf"
import { FormProvider, useForm } from "react-hook-form"

const form = useForm({ defaultValues: { email: "", country: "" } })

<FormProvider {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    <InputWrapper name="email" label="Email" type="email" control={form.control} />
    <SelectWrapper name="country" label="Country" options={countryOptions} control={form.control} />
    <NumberWrapper name="qty" label="Quantity" control={form.control} min={0} />
    <SliderWrapper name="volume" label="Volume" control={form.control} min={0} max={100} />
    <AutocompleteWrapper name="city" label="City" items={cities} control={form.control} />
    <button type="submit">Submit</button>
  </form>
</FormProvider>

TanStack Form

import { useAppForm } from "@/components/ui/field-wrapper-tf"

const form = useAppForm({
  defaultValues: { email: "", qty: null, volume: [50] },
  onSubmit: async ({ value }) => console.log(value),
})

<form onSubmit={e => { e.preventDefault(); form.handleSubmit() }}>
  <form.AppField
    name="email"
    validators={{
      onChange: ({ value }) =>
        !value.includes("@") ? "Invalid email" : undefined,
    }}
  >
    {field => <field.InputField label="Email" type="email" />}
  </form.AppField>

  <form.AppField name="qty">
    {field => <field.NumberField label="Quantity" min={0} />}
  </form.AppField>

  <form.AppField name="volume">
    {field => <field.SliderField label="Volume" min={0} max={100} />}
  </form.AppField>

  <button type="submit">Submit</button>
</form>

Combobox multi-select

// Generic
<ComboboxWrapper
  multiple
  name="hobbies"
  label="Hobbies"
  items={["Music", "Sports", "Travel", "Gaming"]}
  value={hobbies}
  onValueChange={setHobbies}
/>

// RHF
<ComboboxWrapper multiple name="hobbies" label="Hobbies" items={items} control={form.control} />

// TanStack Form
<form.AppField name="hobbies">
  {field => <field.ComboboxField multiple label="Hobbies" items={items} />}
</form.AppField>

DatePicker

// Generic
<DatePickerWrapper
  name="dob"
  label="Date of Birth"
  value={date}
  onSelect={setDate}
/>

// RHF
<DatePickerWrapper name="dob" label="Date of Birth" control={form.control} />

// TanStack Form
<form.AppField name="dob">
  {field => <field.DatePickerField label="Date of Birth" />}
</form.AppField>

OTP

import { OTPWrapper } from "@/components/ui/field-wrapper"

// Generic
<OTPWrapper
  name="otp"
  label="Verification Code"
  length={6}
  separator
  value={value}
  onValueChange={setValue}
/>

// RHF
<OTPWrapper name="otp" label="Verification Code" control={form.control} length={6} separator />

// TanStack Form
<form.AppField name="otp">
  {field => <field.OTPField label="Verification Code" length={6} separator />}
</form.AppField>

Input Group

import { InputGroupWrapper } from "@/components/ui/field-wrapper"
import { InputGroupText } from "@/components/ui/input-group"
import { AtSignIcon } from "lucide-react"

// Generic
<InputGroupWrapper
  name="username"
  label="Username"
  placeholder="username"
  addonStart={<InputGroupText><AtSignIcon /></InputGroupText>}
  value={value}
  onChange={e => setValue(e.target.value)}
/>

// RHF
<InputGroupWrapper
  name="username"
  label="Username"
  control={form.control}
  placeholder="username"
  addonStart={<InputGroupText><AtSignIcon /></InputGroupText>}
/>

// TanStack Form
<form.AppField name="username">
  {field => (
    <field.InputGroupField
      label="Username"
      placeholder="username"
      addonStart={<InputGroupText><AtSignIcon /></InputGroupText>}
    />
  )}
</form.AppField>

Reference

BaseProps (shared by all wrappers)

Prop

Type

InputWrapper

type InputProps = BaseProps & 
  React.InputHTMLAttributes<HTMLInputElement>
<InputWrapper
  name="username"
  label="Username"
  value={value}
  onChange={e => onChange(e.target.value)}
/>

TextareaWrapper

type TextareaProps = BaseProps & 
  React.TextareaHTMLAttributes<HTMLTextAreaElement>
<TextareaWrapper
  name="bio"
  label="Bio"
  value={value}
  onChange={e => onChange(e.target.value)}
/>

RadioWrapper

type RadioProps = BaseProps & {
  options: (allowedPrimitiveT | optionT)[]
  value?: allowedPrimitiveT
  onValueChange?: (value: allowedPrimitiveT) => void
}
<RadioWrapper
  name="gender"
  label="Gender"
  options={["male", "female", "other"]}
  value={value}
  onValueChange={val => onChange(val)}
/>

CheckboxWrapper

type CheckboxProps = BaseProps & {
  options: (allowedPrimitiveT | optionT)[]
  value?: allowedPrimitiveT[]
  onValueChange?: (value: allowedPrimitiveT[]) => void
}
<CheckboxWrapper
  name="interest"
  label="Interest"
  options={["Book reading", "Music", "TV", "Movie"]}
  value={value}
  onValueChange={val => onChange(val)}
/>

SwitchWrapper

type SwitchProps = BaseProps & {
  checked?: boolean
  onCheckedChange?: (checked: boolean) => void
}
<SwitchWrapper
  name="isCompleted"
  label="Is completed"
  checked={value}
  onCheckedChange={val => onChange(val)}
/>

SelectWrapper

type SelectProps = BaseProps & Omit<selectProps, "value" | "onValueChange"> & {
  value?: allowedPrimitiveT
  onValueChange?: (value: allowedPrimitiveT) => void
}
<SelectWrapper
  name="country"
  label="Country"
  options={[
    { value: "in", label: "India" },
    { value: "us", label: "USA" }
  ]}
  value={value}
  onValueChange={val => onChange(val)}
/>

DatePickerWrapper

type DatePickerProps = BaseProps & Omit<React.ComponentProps<typeof Calendar>, "selected" | "onSelect"> & {
  value?: Date
  onSelect?: (date: Date | undefined) => void
}
<DatePickerWrapper
  name="dob"
  label="Date of Birth"
  value={value}
  onSelect={val => onChange(val)}
/>

ComboboxWrapper

type ComboboxProps = BaseProps & comboboxProps
<ComboboxWrapper
  name="fruit"
  label="Favorite Fruit"
  options={["Apple", "Banana", "Mango"]}
  value={value}
  onValueChange={val => onChange(val)}
/>

OTPWrapper

type OTPProps = BaseProps & {
  length?: number               // default 6
  separator?: boolean | number  // true = split in half, number = split after Nth slot
  slotClassName?: string
  value?: string
  onValueChange?: (value: string, eventDetails: object) => void
  mask?: boolean
  validationType?: 'numeric' | 'alphanumeric' | 'none'
  disabled?: boolean
  readOnly?: boolean
  required?: boolean
}
<OTPWrapper
  name="otp"
  label="Verification Code"
  length={6}
  separator
  value={value}
  onValueChange={setValue}
/>

InputGroupWrapper

type InputGroupFieldProps = BaseProps & {
  addonStart?: React.ReactNode   // inline-start addon (text, icon, button)
  addonEnd?: React.ReactNode     // inline-end addon (text, icon, button)
  placeholder?: string
  value?: string
  onChange?: React.ChangeEventHandler<HTMLInputElement>
  disabled?: boolean
  readOnly?: boolean
  type?: string
}
<InputGroupWrapper
  name="amount"
  label="Amount"
  placeholder="0.00"
  addonStart={<InputGroupText><DollarSignIcon /></InputGroupText>}
  addonEnd={<InputGroupText>USD</InputGroupText>}
  value={value}
  onChange={e => setValue(e.target.value)}
/>