Accessible form field layout: label, control, description and error, with horizontal and vertical orientations.
Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/field.jsonInstalls to @/components/ui/field.
Manual installation
Install the dependencies:
pnpm add class-variance-authority@^0.7.1Add the components it builds on: label, separator.
Copy the source into your project:
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { useMemo } from 'react'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn('mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base', className)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
className,
)}
{...props}
/>
)
}
const fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', {
variants: {
orientation: {
vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
horizontal:
'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
responsive:
'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
},
},
defaultVariants: {
orientation: 'vertical',
},
})
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn('group/field-content flex flex-1 flex-col gap-0.5 leading-snug', className)}
{...props}
/>
)
}
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
className,
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5',
'last:mt-0 nth-last-2:-mt-1',
'[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className,
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn('relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2', className)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error) => error?.message && <li key={error.message}>{error.message}</li>)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-sm font-normal text-destructive', className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}Combines labels, controls and help text to compose accessible form fields and grouped inputs. Field supports vertical, horizontal and responsive layouts, while FieldSet and FieldLegend group related fields semantically. It works with any control, such as inputs, selects, checkboxes, radio groups, switches and sliders.
Features
- Vertical, horizontal and responsive (container query) orientation
- FieldSet and FieldLegend for semantic grouping
- FieldDescription for help text
- FieldError accepting children or an errors array
- FieldSeparator with optional label
- Choice cards by wrapping a Field in FieldLabel
- Invalid and disabled styling via data attributes
Usage
import { Input } from '@/components/ui/input'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet } from '@/components/ui/field'
export function ApplicantFields() {
return (
<FieldSet>
<FieldLegend>Applicant details</FieldLegend>
<FieldGroup>
<Field>
<FieldLabel htmlFor="case">Case number</FieldLabel>
<Input id="case" placeholder="2026-04812" />
<FieldDescription>Found on your decision letter.</FieldDescription>
</Field>
<Field data-invalid="true">
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" aria-invalid="true" />
<FieldError>Enter a valid email address.</FieldError>
</Field>
</FieldGroup>
</FieldSet>
)
}Examples
Default
A complete building permit application, with two field sets divided by a FieldSeparator.
Input
Inputs with descriptions above and below.
With Error
Invalid fields with a single error and a list of errors. Link the error to the control with aria-describedby.
Checkbox Group
A group of checkboxes with a legend.
Radio
A radio group with horizontal fields.
Choice Card
Selectable cards built by wrapping a Field in FieldLabel.
Switch
A switch with label and description.
Slider
A slider with a title, its current value and a description that follows the value.
Responsive
Fields that switch from stacked to horizontal as the container grows.
Disabled
Set data-disabled on the Field to dim its label, next to a disabled control. FieldSeparator can carry a label.
Accessibility
- FieldSet and FieldLegend give grouped controls an accessible group name
- Connect FieldLabel to its control with htmlFor and id
- FieldError renders with role="alert" so errors are announced
- Set aria-invalid on the control and data-invalid on the Field for invalid state
- Link descriptions with aria-describedby when extra context is important