Skip to main content

Field

Label, control, helper text and errors composed into one accessible form field. Vertical, horizontal or responsive.

Shown in the sidebar and in deploy notifications.

Optional. Markdown is supported.

1'use client';
2
3import { Button } from '@/components/ui/button';
4import { Field } from '@/components/ui/field';
5import { Input } from '@/components/ui/input';
6import { Textarea } from '@/components/ui/textarea';
7
8export function Default() {
9 return (
10 <form className="border-border bg-card w-full max-w-sm rounded-xl border p-6">
11 <Field.Group>
12 <Field>
13 <Field.Label>Project name</Field.Label>
14 <Field.Control>
15 <Input placeholder="Checkout redesign" />
16 </Field.Control>
17 <Field.Description>Shown in the sidebar and in deploy notifications.</Field.Description>
18 </Field>
19 <Field>
20 <Field.Label>Description</Field.Label>
21 <Field.Control>
22 <Textarea placeholder="What is this project for?" rows={3} />
23 </Field.Control>
24 <Field.Description>Optional. Markdown is supported.</Field.Description>
25 </Field>
26 <Field orientation="horizontal">
27 <Button type="button" variant="outline" size="sm">
28 Cancel
29 </Button>
30 <Button type="button" size="sm">
31 Create project
32 </Button>
33 </Field>
34 </Field.Group>
35 </form>
36 );
37}

Installation

pnpm dlx nachui add field

Anatomy

1import { Field } from '@/components/ui/field';
1<Field invalid={hasError}>
2 <Field.Label>Email</Field.Label>
3 <Field.Control>
4 <Input type="email" />
5 </Field.Control>
6 <Field.Description>We only use it for receipts.</Field.Description>
7 <Field.Error errors={errors} />
8</Field>
Field generates an id and shares it with its parts. Label points at it, Description and Error register their ids, and Control clones the element inside it with id, aria-describedby, aria-invalid and disabled. Anything that renders a native control works: Input, Textarea, Select, Switch, Checkbox, Radio or a plain element. For a component that cannot take a child, call useField() and spread the result.

Composition

Field.Set
├── Field.Legend
├── Field.Description
└── Field.Group
├── Field
│ ├── Field.Label
│ ├── Field.Control
│ ├── Field.Description
│ └── Field.Error
├── Field.Separator
└── Field
├── Field.Content
│ ├── Field.Label
│ └── Field.Description
└── Field.Control
Group spaces fields in a form. Set and Legend wrap related controls in a real fieldset. Content keeps a label and its description together when the control sits next to them.

Variants

Default

A vertical form: label, control, description.

Shown in the sidebar and in deploy notifications.

Optional. Markdown is supported.

1'use client';
2
3import { Button } from '@/components/ui/button';
4import { Field } from '@/components/ui/field';
5import { Input } from '@/components/ui/input';
6import { Textarea } from '@/components/ui/textarea';
7
8export function Default() {
9 return (
10 <form className="border-border bg-card w-full max-w-sm rounded-xl border p-6">
11 <Field.Group>
12 <Field>
13 <Field.Label>Project name</Field.Label>
14 <Field.Control>
15 <Input placeholder="Checkout redesign" />
16 </Field.Control>
17 <Field.Description>Shown in the sidebar and in deploy notifications.</Field.Description>
18 </Field>
19 <Field>
20 <Field.Label>Description</Field.Label>
21 <Field.Control>
22 <Textarea placeholder="What is this project for?" rows={3} />
23 </Field.Control>
24 <Field.Description>Optional. Markdown is supported.</Field.Description>
25 </Field>
26 <Field orientation="horizontal">
27 <Button type="button" variant="outline" size="sm">
28 Cancel
29 </Button>
30 <Button type="button" size="sm">
31 Create project
32 </Button>
33 </Field>
34 </Field.Group>
35 </form>
36 );
37}

Horizontal

orientation="horizontal" lines the parts up on one row. Put the label and description in Content so they stack and the control stays at the end.

Post to the team channel when a production deploy finishes.

Page the on-call engineer when an alert fires.

A summary of usage and spend every Monday.

1'use client';
2
3import { Field } from '@/components/ui/field';
4import { Switch } from '@/components/ui/switch';
5
6const settings = [
7 {
8 id: 'notify-deploys',
9 title: 'Deploy notifications',
10 description: 'Post to the team channel when a production deploy finishes.',
11 defaultChecked: true,
12 },
13 {
14 id: 'notify-incidents',
15 title: 'Incident pages',
16 description: 'Page the on-call engineer when an alert fires.',
17 defaultChecked: true,
18 },
19 {
20 id: 'notify-digest',
21 title: 'Weekly digest',
22 description: 'A summary of usage and spend every Monday.',
23 defaultChecked: false,
24 },
25];
26
27export function Horizontal() {
28 return (
29 <Field.Group className="w-full max-w-sm gap-5">
30 {settings.map((setting) => (
31 <Field key={setting.id} orientation="horizontal" id={setting.id}>
32 <Field.Content>
33 <Field.Label>{setting.title}</Field.Label>
34 <Field.Description>{setting.description}</Field.Description>
35 </Field.Content>
36 <Field.Control>
37 <Switch defaultChecked={setting.defaultChecked} />
38 </Field.Control>
39 </Field>
40 ))}
41 </Field.Group>
42 );
43}

Fieldset

Radio or checkbox options grouped under a legend. Each option is its own Field so the label clicks the control.
Plan

You can change this at any time from billing.

Free, one project, community support

$20 per user, unlimited projects

$48 per user, SSO and audit log

1'use client';
2
3import { Field } from '@/components/ui/field';
4import { Radio } from '@/components/ui/radio';
5
6const plans = [
7 { value: 'hobby', title: 'Hobby', detail: 'Free, one project, community support' },
8 { value: 'pro', title: 'Pro', detail: '$20 per user, unlimited projects' },
9 { value: 'team', title: 'Team', detail: '$48 per user, SSO and audit log' },
10];
11
12export function Fieldset() {
13 return (
14 <Field.Set className="w-full max-w-sm">
15 <Field.Legend>Plan</Field.Legend>
16 <Field.Description className="-mt-4">
17 You can change this at any time from billing.
18 </Field.Description>
19 <Field.Group className="gap-3">
20 {plans.map((plan) => (
21 <Field
22 key={plan.value}
23 orientation="horizontal"
24 id={`plan-${plan.value}`}
25 className="border-border bg-card rounded-lg border p-3"
26 >
27 <Field.Control>
28 <Radio name="plan" value={plan.value} defaultChecked={plan.value === 'pro'} />
29 </Field.Control>
30 <Field.Content>
31 <Field.Label>{plan.title}</Field.Label>
32 <Field.Description>{plan.detail}</Field.Description>
33 </Field.Content>
34 </Field>
35 ))}
36 </Field.Group>
37 </Field.Set>
38 );
39}

With error

Pass invalid to the root and the errors to Error. Strings, objects with a message and empty values are all accepted, so a form library's error array works as is. One message renders as text, several as a list.

northwind.app/My Team!

1'use client';
2
3import * as React from 'react';
4import { Button } from '@/components/ui/button';
5import { Field } from '@/components/ui/field';
6import { Input } from '@/components/ui/input';
7
8function validate(value: string): string[] {
9 const errors: string[] = [];
10 if (value.length < 3) errors.push('Use at least 3 characters.');
11 if (/[^a-z0-9-]/.test(value)) errors.push('Only lowercase letters, numbers and dashes.');
12 if (value.startsWith('-') || value.endsWith('-')) errors.push('Cannot start or end with a dash.');
13 return errors;
14}
15
16export function WithError() {
17 const [slug, setSlug] = React.useState('My Team!');
18 const errors = validate(slug);
19
20 return (
21 <form
22 className="border-border bg-card w-full max-w-sm rounded-xl border p-6"
23 onSubmit={(event) => event.preventDefault()}
24 >
25 <Field.Group>
26 <Field invalid={errors.length > 0}>
27 <Field.Label>Workspace URL</Field.Label>
28 <Field.Control>
29 <Input value={slug} onChange={(event) => setSlug(event.target.value)} />
30 </Field.Control>
31 <Field.Description>northwind.app/{slug || 'your-workspace'}</Field.Description>
32 <Field.Error errors={errors} />
33 </Field>
34 <Field orientation="horizontal">
35 <Button type="submit" size="sm" disabled={errors.length > 0}>
36 Save
37 </Button>
38 </Field>
39 </Field.Group>
40 </form>
41 );
42}

Responsive

orientation="responsive" is vertical by default and becomes horizontal when the enclosing Field.Group is at least 28rem wide. It uses a container query, so it follows the form, not the viewport.

Hook

1import { useField } from '@/components/ui/field';
2
3function ColorPicker() {
4 const field = useField();
5 return <input type="color" {...field} />;
6}
useField returns id, aria-describedby, aria-invalid and disabled for the nearest Field.

API Reference

Field

PropTypeDefaultDescription
orientation"vertical" | "horizontal" | "responsive""vertical"Layout of the parts
invalidbooleanfalseMarks the field and its control invalid
disabledbooleanfalseDims the field and disables the control
idstringgeneratedId given to the control
namestring-Stored as data-name for styling and testing
classNamestring-Additional CSS classes

Field.Control

PropTypeDefaultDescription
childrenReactElement-The single element that receives the props

Field.Error

PropTypeDefaultDescription
errorsArray<string | { message?: string } | null>-Messages to show. Duplicates are dropped
childrenReactNode-Custom content instead of errors

Field.Legend

PropTypeDefaultDescription
variant"legend" | "label""legend"Larger heading or label sized

Field.Label, Field.Description, Field.Content, Field.Title, Field.Set, Field.Group, Field.Separator

Presentational parts. Each accepts className and the attributes of the element it renders. Separator takes optional children rendered in the middle of the line.
Found something to improve?

Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.

Create an Issue
Ctrl+I