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
andValibot
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.