Yuki UIYuki UI
Components

Form

Building your forms with Yuki UI and Standard Schema.

Features

The <Form> component is a flexible and accessible form builder that allows you to create forms with minimal boilerplate. It is built on top of the shadcn/ui component system and is fully customizable with CSS variables and Tailwind CSS.

  • Composable form components for building complex forms.
  • A <FormField> component for building controlled form fields.
  • Form validation using the Standard Schema library so you can validate your form data with Arktype, Zod and Valibot schemas.
  • Handles accessibility and error messages out of the box.
  • Use React.useId() to generate unique IDs for form fields.
  • Applies the correct aria attributes to form fields based on states.
  • You have full control over the form layout and styling.

Anatomy of a Form

A form is a collection of form fields that are grouped together. Each form field is a controlled component that can be used to collect user input. The <Form> component is a wrapper that contains all the form fields and handles form submission.

<form>
  <form.Field
    name="..."
    render={({ field, state, meta }) => (
      <div>
        <form.Label />
        <form.Control />
        <form.Description />
        <form.Message />
      </div>
    )}
  />
</form>

Example

Here is an example of a simple form that collects a user's name and email address.

const Example: React.FC = () => {
  const form = useForm(/* options */)

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="name"
        render={({ field, meta }) => (
          <div id={meta.id} className="grid gap-1">
            <form.Label>Name</form.Label>
            <form.Control {...field}>
              <Input placeholder="Yuki" />
            </form.Control>
            <form.Message />
          </div>
        )}
      />

      <Button disabled={form.state.isPending}>Submit</Button>
    </form>
  )
}

Installation

Command

npx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
npx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
pnpm dlx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
bunx --bun shadcn@latest add https://yuki-ui.vercel.app/r/form.json

Install your favorite schema library (optional)

npm install arktype #or zod or valibot
pnpm add arktype #or zod or valibot
yarn add arktype #or zod or valibot
bun add arktype #or zod or valibot

Install the following dependencies:

npm install @radix-ui/react-slot
pnpm add @radix-ui/react-slot
yarn add @radix-ui/react-slot
bun add @radix-ui/react-slot

Copy and paste the following code into your project.

import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'

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

interface FormError<TValue extends Record<string, unknown>> {
  message?: string
  issues?: Record<keyof TValue, string>
}

type ChangeEvent =
  | React.ChangeEvent<HTMLInputElement>
  | string
  | number
  | boolean

interface FormFieldContextValue<
  TValue extends Record<string, unknown>,
  TName extends keyof TValue = keyof TValue,
> {
  field: {
    name: TName
    value: TValue[TName]
    onChange: (event: ChangeEvent) => void
    onBlur: (event: React.FocusEvent<HTMLInputElement>) => Promise<void> | void
  }
  state: {
    isPending: boolean
    hasError: boolean
    error?: string
  }
  meta: {
    id: string
    formItemId: string
    formDescriptionId: string
    formMessageId: string
  }
}

const FormFieldContext = React.createContext<FormFieldContextValue<
  Record<string, unknown>
> | null>(null)

function useFormField<
  TForm extends ReturnType<typeof useForm>,
  TName extends keyof TForm['state']['value'] = keyof TForm['state']['value'],
>() {
  const formField = React.use(
    FormFieldContext,
  ) as unknown as FormFieldContextValue<TForm['state']['value'], TName> | null
  if (!formField)
    throw new Error('useFormField must be used within a FormField')
  return formField
}

function useForm<
  TValue extends Record<string, unknown>,
  TSchema extends StandardSchemaV1 | ((value: TValue) => TValue),
  TData,
  TError extends FormError<TValue>,
>({
  defaultValues,
  validator,
  onSubmit,
  onSuccess,
  onError,
}: {
  defaultValues: TValue
  validator?: TSchema extends StandardSchemaV1
    ? Required<InferInput<TSchema>> extends TValue
      ? TSchema
      : Types<TValue>['input']
    : (value: TValue) => Result<TValue>
  onSubmit: (value: TValue) => TData | Promise<TData>
  onSuccess?: (data: TData) => void | Promise<void>
  onError?: (error: TError) => void | Promise<void>
}) {
  const formValueRef = React.useRef<TValue>(defaultValues)
  const formDataRef = React.useRef<TData | null>(null)
  const formErrorRef = React.useRef<TError>(null)

  const [isPending, startTransition] = React.useTransition()

  const validateField = React.useCallback(
    async <TKey extends keyof TValue>(
      fieldKey?: TKey,
      fieldValue?: TValue[TKey],
    ): Promise<
      | { isValid: true; data: TValue }
      | { isValid: false; errors: Record<keyof TValue, string> }
    > => {
      const valueToValidate = fieldKey
        ? { ...formValueRef.current, [fieldKey]: fieldValue }
        : formValueRef.current

      if (!validator) return { isValid: true, data: valueToValidate }

      let validationResult: Result<TValue> | null = null
      if (typeof validator === 'function') {
        validationResult = validator(valueToValidate)
      } else {
        validationResult = await (validator as StandardSchemaV1<TValue>)[
          '~standard'
        ].validate(valueToValidate)
      }

      if (validationResult.issues) {
        const errors = validationResult.issues.reduce(
          (errorMap, issue) => {
            errorMap[issue.path as unknown as keyof TValue] = issue.message
            return errorMap
          },
          {} as Record<keyof TValue, string>,
        )
        return { isValid: false, errors }
      }
      return { isValid: true, data: validationResult.value }
    },
    [validator],
  )

  const handleSubmit = React.useCallback(() => {
    startTransition(async () => {
      formDataRef.current = null
      formErrorRef.current = null

      const validationResult = await validateField()
      if (!validationResult.isValid) {
        formErrorRef.current = { issues: validationResult.errors } as TError
        return
      }

      try {
        const result = await onSubmit(validationResult.data)
        formDataRef.current = result
        await onSuccess?.(result)
      } catch (error) {
        formDataRef.current = null
        const message = error instanceof Error ? error.message : 'Unknown error'
        formErrorRef.current = { message } as TError
        await onError?.({ message } as TError)
      }
    })
  }, [onError, onSubmit, onSuccess, validateField])

  const Field = React.useCallback(
    function FormField<TFieldName extends keyof TValue>({
      name,
      render,
    }: {
      name: TFieldName
      render: (
        props: FormFieldContextValue<TValue, TFieldName>,
      ) => React.ReactNode
    }) {
      const [value, setValue] = React.useState(formValueRef.current[name])
      const prevValueRef = React.useRef(value)

      const [error, setError] = React.useState(
        formErrorRef.current?.issues?.[name] ?? '',
      )

      const parseValue = React.useCallback((target: HTMLInputElement) => {
        switch (target.type) {
          case 'number':
            return target.valueAsNumber as TValue[TFieldName]
          case 'checkbox':
            return target.checked as TValue[TFieldName]
          case 'date':
            return target.valueAsDate as TValue[TFieldName]
          default:
            return target.value as TValue[TFieldName]
        }
      }, [])

      const handleChange = React.useCallback(
        (event: ChangeEvent) => {
          const newValue =
            typeof event === 'object' && 'target' in event
              ? parseValue(event.target)
              : (event as TValue[TFieldName])

          setValue(newValue)

          React.startTransition(() => {
            formValueRef.current[name] = newValue
          })
        },
        [name, parseValue],
      )

      const handleBlur = React.useCallback(async () => {
        if (prevValueRef.current === value) return
        prevValueRef.current = value

        const results = await validateField(name, value)
        if (!results.isValid && results.errors[name])
          setError(results.errors[name])
        else setError('')
      }, [name, value])

      const id = React.useId()

      const formFieldContextValue = React.useMemo(
        () =>
          ({
            field: { name, value, onChange: handleChange, onBlur: handleBlur },
            state: { isPending, hasError: !!error, error },
            meta: {
              id,
              formItemId: `${id}-form-item`,
              formDescriptionId: `${id}-form-item-description`,
              formMessageId: `${id}-form-item-message`,
            },
          }) satisfies FormFieldContextValue<TValue, TFieldName>,
        [error, handleBlur, handleChange, id, name, value],
      )

      return (
        <FormFieldContext value={formFieldContextValue as never}>
          {render(formFieldContextValue)}
        </FormFieldContext>
      )
    },
    [isPending, validateField],
  )

  const Label = React.useCallback(function FormLabel({
    className,
    ...props
  }: React.ComponentProps<'label'>) {
    const { state, meta } = useFormField<never>()

    return (
      <label
        data-slot="form-label"
        htmlFor={meta.formItemId}
        aria-disabled={state.isPending}
        aria-invalid={state.hasError}
        className={cn(
          'text-sm leading-none font-medium',
          'aria-disabled:cursor-not-allowed aria-disabled:opacity-70',
          'aria-invalid:text-destructive',
          className,
        )}
        {...props}
      />
    )
  }, [])

  const Control = React.useCallback(function FormControl({
    className,
    ...props
  }: React.ComponentProps<'input'>) {
    const { state, meta } = useFormField()

    return (
      <Slot
        data-slot="form-control"
        id={meta.formItemId}
        aria-describedby={
          !state.hasError
            ? meta.formDescriptionId
            : `${meta.formDescriptionId} ${meta.formMessageId}`
        }
        aria-invalid={state.hasError}
        aria-disabled={state.isPending}
        className={cn(
          'aria-disabled:cursor-not-allowed aria-disabled:opacity-70',
          className,
        )}
        {...props}
      />
    )
  }, [])

  const Description = React.useCallback(function FormDescription({
    children,
    className,
    ...props
  }: React.ComponentProps<'span'>) {
    const { meta } = useFormField()

    return (
      <span
        data-slot="form-description"
        id={meta.formDescriptionId}
        className={cn('text-muted-foreground text-sm', className)}
        {...props}
      >
        {children}
      </span>
    )
  }, [])

  const Message = React.useCallback(function FormMessage({
    children,
    className,
    ...props
  }: React.ComponentProps<'span'>) {
    const { state, meta } = useFormField()
    const body = state.hasError ? String(state.error) : children

    return (
      <span
        data-slot="form-message"
        id={meta.formMessageId}
        className={cn('text-destructive text-sm', className)}
        {...props}
      >
        {body}
      </span>
    )
  }, [])

  const reset = React.useCallback(() => {
    Object.assign(formValueRef.current, defaultValues)
    formErrorRef.current = null
    formDataRef.current = null
  }, [defaultValues])

  return React.useMemo(
    () => ({
      Field,
      Label,
      Control,
      Description,
      Message,
      handleSubmit,
      reset,

      state: {
        isPending,
        hasError: !!formErrorRef.current,
        value: formValueRef.current,
        data: formDataRef.current,
        error: formErrorRef.current,
      },
    }),
    [
      Control,
      Description,
      Field,
      Label,
      Message,
      handleSubmit,
      isPending,
      reset,
    ],
  )
}

export { useForm }

/** The Standard Schema interface. */
interface StandardSchemaV1<Input = unknown, Output = Input> {
  /** The Standard Schema properties. */
  readonly '~standard': Props<Input, Output>
}

interface Props<Input = unknown, Output = Input> {
  /** The version number of the standard. */
  readonly version: 1
  /** The vendor name of the schema library. */
  readonly vendor: string
  /** Validates unknown input values. */
  readonly validate: (
    value: unknown,
  ) => Result<Output> | Promise<Result<Output>>
  /** Inferred types associated with the schema. */
  readonly types?: Types<Input, Output> | undefined
}

/** The result interface of the validate function. */
type Result<Output> = SuccessResult<Output> | FailureResult

/** The result interface if validation succeeds. */
interface SuccessResult<Output> {
  /** The typed output value. */
  readonly value: Output
  /** The non-existent issues. */
  readonly issues?: undefined
}

/** The result interface if validation fails. */
interface FailureResult {
  /** The issues of failed validation. */
  readonly issues: readonly Issue[]
}

/** The issue interface of the failure output. */
interface Issue {
  /** The error message of the issue. */
  readonly message: string
  /** The path of the issue, if any. */
  readonly path?: readonly (PropertyKey | PathSegment)[] | undefined
}

/** The path segment interface of the issue. */
interface PathSegment {
  /** The key representing a path segment. */
  readonly key: PropertyKey
}

/** The Standard Schema types interface. */
interface Types<Input = unknown, Output = Input> {
  /** The input type of the schema. */
  readonly input: Input
  /** The output type of the schema. */
  readonly output: Output
}

/** Infers the input type of a Standard Schema. */
type InferInput<Schema extends StandardSchemaV1> = NonNullable<
  Schema['~standard']['types']
>['input']

Usage

Create a form schema

Arktype

const loginSchema = type({
  email: type('string.email').configure({ message: 'Invalid email' }),
  password: type(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/,
  ).configure({
    message:
      'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character',
  }),
})

Zod

const loginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z
    .string()
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/,
      'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character',
    ),
})

Valibot

const loginSchema = valibot({
  email: valibot.email('Invalid email'),
  password: valibot.pipe(
    valibot.string(),
    valibot.regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/,
      'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character',
    ),
  ),
})

Function

const loginSchema = (value) => {
  const issues = []
  if (!value.email)
    issues.push({ path: ['email'], message: 'Email is required' })
  if (!value.password)
    issues.push({ path: ['password'], message: 'Password is required' })

  if (issues.length > 0) return { issues }
  return { value }
}

Define the form

import { toast } from 'sonner'

import { useForm } from '@/components/ui/form'

export const LoginForm: React.FC = () => {
  // 1. Define your form.
  const form = useForm({
    defaultValues: { email: '', password: '' },
    validator: loginSchema,
    // 2. Define a submit handler.
    onSubmit: (values) => {
      // Do something with the form values.
      // ✅ This will be type-safe and validated.
      toast.success('Logged in successfully', {
        description: <pre>{JSON.stringify(data, null, 2)}</pre>,
      })
    },
  })
}

Build your form

We can now use the <Form /> components to build our form.

import { Button } from '@/components/ui/button'
import { useForm } from '@/components/ui/form'
import { Input } from '@/components/ui/input'

export const LoginForm: React.FC = () => {
  // ...

  return (
    <form
      className="grid gap-4"
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="email"
        render={({ field, meta }) => (
          <div id={meta.id} className="grid gap-1">
            <form.Label>Email</form.Label>
            <form.Control {...field}>
              <Input type="email" placeholder="yuki@example.com" />
            </form.Control>
            <form.Message />
          </div>
        )}
      />

      <form.Field
        name="password"
        render={({ field, meta }) => (
          <div id={meta.id} className="grid gap-1">
            <form.Label>Password</form.Label>
            <form.Control {...field}>
              <Input type="password" placeholder="********" />
            </form.Control>
            <form.Message />
          </div>
        )}
      />

      <Button disabled={form.isPending}>Login</Button>
    </form>
  )
}

Tips

You can custom onChange function by using

<form.Control
  onChange={() => {
    field.onChange('your mom is fat')
  }}
/>

Done

That's it. You now have a fully accessible form that is type-safe with client-side validation.