A labelled form control with a description and validation errors, plus a form that checks them all.

As it should appear on your profile.

Role
'use client'

import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
import { Field, FieldControl, FieldDescription, FieldError, FieldForm, FieldGroup, FieldLabel } from '@/components/ui/field'

const roles = [
  { value: 'designer', label: 'Designer' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'product', label: 'Product manager' },
]

/** Client-side validation with no form library: Field checks each control, FieldForm checks them all on submit. */
export default function FieldFormExample() {
  const [saved, setSaved] = React.useState<string | null>(null)

  return (
    <FieldForm
      className="max-w-sm"
      onFormSubmit={values => setSaved(`Welcome aboard, ${String(values.name).trim()}!`)}
    >
      <FieldGroup>
        <Field
          name="name"
          validate={value => (String(value ?? '').trim().length < 2 ? 'Enter at least 2 characters.' : null)}
        >
          <FieldLabel>Full name</FieldLabel>
          <FieldControl required placeholder="Ada Lovelace" />
          <FieldDescription>As it should appear on your profile.</FieldDescription>
          <FieldError />
        </Field>

        <Field name="email">
          <FieldLabel>Email</FieldLabel>
          <FieldControl type="email" required placeholder="ada@example.com" />
          <FieldError match="valueMissing">Email is required.</FieldError>
          <FieldError match="typeMismatch">That doesn&apos;t look like an email address.</FieldError>
        </Field>

        <Field name="role">
          <FieldLabel nativeLabel={false} render={<div />}>
            Role
          </FieldLabel>
          <Select name="role" required>
            <SelectTrigger className="w-full">
              <SelectValue placeholder="Choose a role" />
            </SelectTrigger>
            <SelectContent>
              {roles.map(role => (
                <SelectItem key={role.value} value={role.value}>
                  {role.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          <FieldError match="valueMissing">Pick the role closest to yours.</FieldError>
        </Field>

        <Field name="terms">
          <FieldLabel className="font-normal">
            <Checkbox required />
            I agree to the terms of service
          </FieldLabel>
          <FieldError match="valueMissing">You need to accept the terms to continue.</FieldError>
        </Field>
      </FieldGroup>

      <div className="flex items-center gap-3">
        <Button type="submit">Create account</Button>
        {saved && (
          <p role="status" className="text-sm text-muted-foreground">
            {saved}
          </p>
        )}
      </div>
    </FieldForm>
  )
}

Built on Base UI

Installation

pnpm dlx shadcn@latest add https://kagadmodyaa.vercel.app/r/field.json

Needs a React + Tailwind project set up with shadcn. Copies the component source straight into your project, so you can edit it.

Field imports cn() from lib/utils.ts — add it too if you don't have it yet:

import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Usage

import { Field, FieldLabel, FieldControl, FieldDescription, FieldError, FieldForm } from '@/components/ui/field'
import { Button } from '@/components/ui/button'

<FieldForm onFormSubmit={values => console.log(values)}>
  <Field name="email">
    <FieldLabel>Email</FieldLabel>
    <FieldControl type="email" required />
    <FieldDescription>We only use it to send receipts.</FieldDescription>
    <FieldError match="valueMissing">Email is required.</FieldError>
    <FieldError match="typeMismatch">That doesn't look like an email.</FieldError>
  </Field>
  <Button type="submit">Subscribe</Button>
</FieldForm>

// Checkbox and Select work inside a Field too — wrap them in a FieldLabel:
<Field name="terms">
  <FieldLabel><Checkbox required /> I agree</FieldLabel>
  <FieldError match="valueMissing">Please accept the terms.</FieldError>
</Field>

Examples

States and fieldset

Pick something you can live with — it shows in your profile URL.

Enter a full email address, like ada@example.com.

Only an owner can rename the workspace.

Notify me about
import { Checkbox } from '@/components/ui/checkbox'
import {
  Field,
  FieldControl,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from '@/components/ui/field'

/** The states a Field can be in, and a FieldSet grouping related options under a legend. */
export default function FieldStates() {
  return (
    <FieldGroup className="max-w-sm">
      <Field>
        <FieldLabel>Username</FieldLabel>
        <FieldControl defaultValue="kagad" />
        <FieldDescription>Pick something you can live with — it shows in your profile URL.</FieldDescription>
      </Field>

      <Field invalid>
        <FieldLabel>Email</FieldLabel>
        <FieldControl type="email" defaultValue="ada@" />
        <FieldError match>Enter a full email address, like ada@example.com.</FieldError>
      </Field>

      <Field disabled>
        <FieldLabel>Workspace</FieldLabel>
        <FieldControl defaultValue="Kagadmodyaa Studio" />
        <FieldDescription>Only an owner can rename the workspace.</FieldDescription>
      </Field>

      <FieldSet>
        <FieldLegend>Notify me about</FieldLegend>
        <Field name="comments">
          <FieldLabel className="font-normal">
            <Checkbox defaultChecked />
            Comments on my work
          </FieldLabel>
        </Field>
        <Field name="releases">
          <FieldLabel className="font-normal">
            <Checkbox />
            New releases
          </FieldLabel>
        </Field>
      </FieldSet>
    </FieldGroup>
  )
}

API Reference

NameTypeDefaultDescription
Field namestring—Identifies the field in the submitted values and in server-provided errors.
Field validate(value, formValues) => string | string[] | null—Custom check. Return a message (or several) when the value is invalid, or null when it is fine.
Field validationMode'onSubmit' | 'onBlur' | 'onChange''onSubmit'When to validate first. After a failed submit the field re-checks as the value changes, so errors clear when fixed.
Field invalid / disabledboolean—Force the invalid or disabled state, e.g. for an error that came from the server.
FieldError matchboolean | keyof ValidityState—Show this message for one kind of failure ('valueMissing', 'typeMismatch', …), or always with true. Omit it to show the browser's or validate's own message.
FieldControlinput props—Our Input, wired to the label, description and error for accessibility. Accepts every native input attribute.
FieldForm errorsRecord<string, string | string[]>—Server errors by field name; shown on the matching Field until it changes.
FieldForm onFormSubmit(values, details) => void—Called with the form values once every field is valid.
FieldSet / FieldLegendcomponent—A fieldset with a legend, for groups of related fields.