rapidUI

Field

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

Installs to @/components/ui/field.

Manual installation

Install the dependencies:

pnpm add class-variance-authority@^0.7.1

Add the components it builds on: label, separator.

Copy the source into your project:

components/ui/field.tsx
'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