Fieldset
Groups related form fields under one legend and spaces them evenly.
import { Description, Field, FieldGroup, Fieldset, Label, Legend } from '@/components/fieldset'
import { Input } from '@/components/input'
import { Select } from '@/components/select'
import { Text } from '@/components/text'
export default function Example() {
return (
<Fieldset>
<Legend>Billing address</Legend>
<Text>Printed on every invoice sent from northwind.</Text>
<FieldGroup>
<Field>
<Label>Company name</Label>
<Input name="company" />
</Field>
<Field>
<Label>Street address</Label>
<Input name="street" />
</Field>
<Field>
<Label>Country</Label>
<Select name="country" defaultValue="CM">
<option value="CM">Cameroon</option>
<option value="CA">Canada</option>
<option value="FR">France</option>
</Select>
</Field>
<Field>
<Label>Tax ID</Label>
<Input name="tax_id" />
<Description>VAT or TIN. Leave it empty if the company is not registered.</Description>
</Field>
</FieldGroup>
</Fieldset>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import FieldGroup from '@/components/field-group.vue'
import Fieldset from '@/components/fieldset.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
import Legend from '@/components/legend.vue'
import Select from '@/components/select.vue'
import Text from '@/components/text.vue'
const country = ref('CM')
</script>
<template>
<Fieldset>
<Legend>Billing address</Legend>
<Text>Printed on every invoice sent from northwind.</Text>
<FieldGroup>
<Field>
<Label>Company name</Label>
<Input name="company" />
</Field>
<Field>
<Label>Street address</Label>
<Input name="street" />
</Field>
<Field>
<Label>Country</Label>
<Select name="country" v-model="country">
<option value="CM">Cameroon</option>
<option value="CA">Canada</option>
<option value="FR">France</option>
</Select>
</Field>
<Field>
<Label>Tax ID</Label>
<Input name="tax_id" />
<Description>VAT or TIN. Leave it empty if the company is not registered.</Description>
</Field>
</FieldGroup>
</Fieldset>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
type HeadlessProps<T> = { className?: string } & Omit<T, 'as' | 'className'>
const base = 'text-base/6 sm:text-sm/6 data-disabled:opacity-50'
const afterLabel = '[&>[data-slot=label]+[data-slot=description]]:mt-1 [&>[data-slot=label]+[data-slot=control]]:mt-3'
const afterDescription = '[&>[data-slot=description]+[data-slot=control]]:mt-3'
const afterControl = '[&>[data-slot=control]+[data-slot=error]]:mt-3 [&>[data-slot=control]+[data-slot=description]]:mt-3'
const fieldsetSpacing = '[&>*+[data-slot=control]]:mt-6 *:data-[slot=text]:mt-1'
export function Fieldset({ className, ...props }: HeadlessProps<Headless.FieldsetProps>) {
return <Headless.Fieldset {...props} className={clsx(className, fieldsetSpacing)} />
}
export function Legend({ className, ...props }: HeadlessProps<Headless.LegendProps>) {
return (
<Headless.Legend
data-slot="legend"
{...props}
className={clsx(className, base, 'font-semibold text-zinc-950 dark:text-white')}
/>
)
}
export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
}
export function Field({ className, ...props }: HeadlessProps<Headless.FieldProps>) {
return <Headless.Field {...props} className={clsx(className, afterLabel, afterDescription, afterControl, '*:data-[slot=label]:font-medium')} />
}
export function Label({
className,
required = false,
children,
...props
}: { required?: boolean; children?: React.ReactNode } & HeadlessProps<Omit<Headless.LabelProps, 'children'>>) {
return (
<Headless.Label data-slot="label" {...props} className={clsx(className, base, 'text-zinc-950 select-none dark:text-white')}>
{children}
{required && (
<span aria-hidden="true" className="text-red-500">
{' '}
*
</span>
)}
</Headless.Label>
)
}
export function Description({ className, ...props }: HeadlessProps<Headless.DescriptionProps>) {
return (
<Headless.Description
data-slot="description"
{...props}
className={clsx(className, base, 'text-zinc-500 dark:text-zinc-400')}
/>
)
}
export function ErrorMessage({ className, ...props }: HeadlessProps<Headless.DescriptionProps>) {
return (
<Headless.Description
data-slot="error"
{...props}
className={clsx(className, base, 'text-red-600 dark:text-red-500')}
/>
)
}<script setup lang="ts">
import { provideDisabled } from './fields'
const props = defineProps<{ disabled?: boolean }>()
const disabled = provideDisabled(() => props.disabled)
const fieldsetSpacing = '[&>*+[data-slot=control]]:mt-6 *:data-[slot=text]:mt-1'
</script>
<template>
<fieldset
:disabled="disabled"
:data-disabled="disabled ? '' : undefined"
:class="fieldsetSpacing"
>
<slot />
</fieldset>
</template>Component API
| Prop | Default | Description |
|---|---|---|
Fieldsetbuilt on the Headless UI <Headless.Fieldset> component | ||
disabled | false | Turns off every control in the group at once. |
Legendbuilt on the Headless UI <Headless.Legend> component | ||
| This component does not expose any component-specific props. | ||
FieldGrouprenders a <div> element | ||
| This component does not expose any component-specific props. | ||
Fieldbuilt on the Headless UI <Headless.Field> component | ||
disabled | false | Turns off this one field and dims its label. |
Labelbuilt on the Headless UI <Headless.Label> component | ||
required | false | Adds a red asterisk, hidden from screen readers. |
| Prop | Default | Description |
|---|---|---|
Fieldsetrenders a <fieldset> element | ||
disabled | false | Turns off every control in the group at once. |
Legendrenders a <legend> element | ||
| This component does not expose any component-specific props. | ||
FieldGrouprenders a <div> element | ||
| This component does not expose any component-specific props. | ||
Fieldrenders a <div> element | ||
disabled | false | Turns off this one field and dims its label. |
Labelrenders a <label> element | ||
required | false | Adds a red asterisk, hidden from screen readers. |
Usage
Group fields that only make sense together, like the parts of an address or the details of a bank account. A settings page with one fieldset per topic is easier to scan than one long column of inputs. A single field does not need a fieldset.
Group an address
Fieldset renders a native <fieldset> and names it with its Legend. A Text under the legend explains the whole group, and FieldGroup spaces the fields evenly:
import { Description, Field, FieldGroup, Fieldset, Label, Legend } from '@/components/fieldset'
import { Input } from '@/components/input'
import { Select } from '@/components/select'
import { Text } from '@/components/text'
export default function Example() {
return (
<Fieldset>
<Legend>Billing address</Legend>
<Text>Printed on every invoice sent from northwind.</Text>
<FieldGroup>
<Field>
<Label>Company name</Label>
<Input name="company" />
</Field>
<Field>
<Label>Street address</Label>
<Input name="street" />
</Field>
<Field>
<Label>Country</Label>
<Select name="country" defaultValue="CM">
<option value="CM">Cameroon</option>
<option value="CA">Canada</option>
<option value="FR">France</option>
</Select>
</Field>
<Field>
<Label>Tax ID</Label>
<Input name="tax_id" />
<Description>VAT or TIN. Leave it empty if the company is not registered.</Description>
</Field>
</FieldGroup>
</Fieldset>
)
}<script setup lang="ts">
import { ref } from 'vue'
import Description from '@/components/description.vue'
import Field from '@/components/field.vue'
import FieldGroup from '@/components/field-group.vue'
import Fieldset from '@/components/fieldset.vue'
import Input from '@/components/input.vue'
import Label from '@/components/label.vue'
import Legend from '@/components/legend.vue'
import Select from '@/components/select.vue'
import Text from '@/components/text.vue'
const country = ref('CM')
</script>
<template>
<Fieldset>
<Legend>Billing address</Legend>
<Text>Printed on every invoice sent from northwind.</Text>
<FieldGroup>
<Field>
<Label>Company name</Label>
<Input name="company" />
</Field>
<Field>
<Label>Street address</Label>
<Input name="street" />
</Field>
<Field>
<Label>Country</Label>
<Select name="country" v-model="country">
<option value="CM">Cameroon</option>
<option value="CA">Canada</option>
<option value="FR">France</option>
</Select>
</Field>
<Field>
<Label>Tax ID</Label>
<Input name="tax_id" />
<Description>VAT or TIN. Leave it empty if the company is not registered.</Description>
</Field>
</FieldGroup>
</Fieldset>
</template>Layout
FieldGroup stacks fields in one column. For anything else, put your own grid inside it and keep one column on small screens.
Short fields side by side
Pair fields that people fill one after the other, like city and postal code. Wrap them in a grid div inside the FieldGroup, and span the wider field over two columns when the widths differ:
<Fieldset>
<Legend>Billing address</Legend>
<Text>Printed on every invoice sent from northwind.</Text>
<FieldGroup>
<Field>
<Label>Street address</Label>
<Input name="street" />
</Field>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 sm:gap-4">
<Field>
<Label>City</Label>
<Input name="city" />
</Field>
<Field>
<Label>Postal code</Label>
<Input name="postal_code" />
</Field>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-3 sm:gap-4">
<Field className="sm:col-span-2">
<Label>Country</Label>
<Select name="country" defaultValue="CA">
<option value="CM">Cameroon</option>
<option value="CA">Canada</option>
<option value="FR">France</option>
</Select>
</Field>
<Field>
<Label>Region</Label>
<Input name="region" />
</Field>
</div>
</FieldGroup>
</Fieldset><Fieldset>
<Legend>Billing address</Legend>
<Text>Printed on every invoice sent from northwind.</Text>
<FieldGroup>
<Field>
<Label>Street address</Label>
<Input name="street" />
</Field>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 sm:gap-4">
<Field>
<Label>City</Label>
<Input name="city" />
</Field>
<Field>
<Label>Postal code</Label>
<Input name="postal_code" />
</Field>
</div>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-3 sm:gap-4">
<Field class="sm:col-span-2">
<Label>Country</Label>
<Select name="country" v-model="country">
<option value="CM">Cameroon</option>
<option value="CA">Canada</option>
<option value="FR">France</option>
</Select>
</Field>
<Field>
<Label>Region</Label>
<Input name="region" />
</Field>
</div>
</FieldGroup>
</Fieldset>Labels in their own column
Replace the FieldGroup with a grid and make each Field a subgrid. From the sm breakpoint, labels line up on the left and controls on the right:
<Fieldset>
<Legend>Payout account</Legend>
<Text>Where northwind sends your available balance.</Text>
<div data-slot="control" className="grid grid-cols-1 items-center gap-x-4 gap-y-6 sm:grid-cols-3">
<Field className="grid grid-cols-subgrid sm:col-span-3">
<Label>Account holder</Label>
<Input className="mt-3 sm:col-span-2 sm:mt-0" name="account_holder" />
</Field>
<Field className="grid grid-cols-subgrid sm:col-span-3">
<Label>IBAN</Label>
<Input className="mt-3 sm:col-span-2 sm:mt-0" name="iban" />
</Field>
<Field className="grid grid-cols-subgrid sm:col-span-3">
<Label>Currency</Label>
<Select className="mt-3 sm:col-span-2 sm:mt-0" name="currency" defaultValue="eur">
<option value="eur">EUR</option>
<option value="usd">USD</option>
<option value="xaf">XAF</option>
</Select>
</Field>
</div>
</Fieldset><Fieldset>
<Legend>Payout account</Legend>
<Text>Where northwind sends your available balance.</Text>
<div data-slot="control" class="grid grid-cols-1 items-center gap-x-4 gap-y-6 sm:grid-cols-3">
<Field class="grid grid-cols-subgrid sm:col-span-3">
<Label>Account holder</Label>
<Input class="mt-3 sm:col-span-2 sm:mt-0" name="account_holder" />
</Field>
<Field class="grid grid-cols-subgrid sm:col-span-3">
<Label>IBAN</Label>
<Input class="mt-3 sm:col-span-2 sm:mt-0" name="iban" />
</Field>
<Field class="grid grid-cols-subgrid sm:col-span-3">
<Label>Currency</Label>
<Select class="mt-3 sm:col-span-2 sm:mt-0" name="currency" v-model="currency">
<option value="eur">EUR</option>
<option value="usd">USD</option>
<option value="xaf">XAF</option>
</Select>
</Field>
</div>
</Fieldset>The data-slot="control" attribute on the grid gives it the same gap under the legend as a FieldGroup.
Composition
Group named by the page heading
When a heading right above already says what the fields are for, a visible legend repeats it. Drop the Legend and give the Fieldset an aria-label, so screen readers still announce the group:
<Fieldset aria-label="Invoice delivery">
<FieldGroup>
<Field>
<Label>Send invoices to</Label>
<Input type="email" name="invoice_email" placeholder="billing@example.com" />
</Field>
<Field>
<Label>Copy to</Label>
<Input type="email" name="invoice_cc" />
<Description>Your accountant, for example.</Description>
</Field>
</FieldGroup>
</Fieldset><Fieldset aria-label="Invoice delivery">
<FieldGroup>
<Field>
<Label>Send invoices to</Label>
<Input type="email" name="invoice_email" placeholder="billing@example.com" />
</Field>
<Field>
<Label>Copy to</Label>
<Input type="email" name="invoice_cc" />
<Description>Your accountant, for example.</Description>
</Field>
</FieldGroup>
</Fieldset>Spacing without a group
FieldGroup is a plain div that spaces its fields. Used alone it adds no grouping for assistive technology, which is right for a short form where the fields are not one unit:
<FieldGroup>
<Field>
<Label>Key name</Label>
<Input name="key_name" placeholder="Production server" />
</Field>
<Field>
<Label>Permissions</Label>
<Select name="permissions" defaultValue="read">
<option value="read">Read only</option>
<option value="write">Read and write</option>
</Select>
</Field>
</FieldGroup><FieldGroup>
<Field>
<Label>Key name</Label>
<Input name="key_name" placeholder="Production server" />
</Field>
<Field>
<Label>Permissions</Label>
<Select name="permissions" v-model="permissions">
<option value="read">Read only</option>
<option value="write">Read and write</option>
</Select>
</Field>
</FieldGroup>States
Lock a section managed elsewhere
disabled on the Fieldset turns off every control inside it in one go. Use the Text to say where the values can be changed:
<Fieldset disabled>
<Legend>Billing address</Legend>
<Text>Synced from your accounting software. Edit it there.</Text>
<FieldGroup>{/* ... */}</FieldGroup>
</Fieldset><Fieldset disabled>
<Legend>Billing address</Legend>
<Text>Synced from your accounting software. Edit it there.</Text>
<FieldGroup><!-- ... --></FieldGroup>
</Fieldset>