Skip to content
  1. Inputs
  2. Fieldset

Fieldset

Groups related form fields under one legend and spaces them evenly.

Billing address

Printed on every invoice sent from northwind.

VAT or TIN. Leave it empty if the company is not registered.

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

PropDefaultDescription
Fieldsetbuilt on the Headless UI <Headless.Fieldset> component
disabledfalseTurns 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
disabledfalseTurns off this one field and dims its label.
Labelbuilt on the Headless UI <Headless.Label> component
requiredfalseAdds a red asterisk, hidden from screen readers.
PropDefaultDescription
Fieldsetrenders a <fieldset> element
disabledfalseTurns 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
disabledfalseTurns off this one field and dims its label.
Labelrenders a <label> element
requiredfalseAdds 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:

Billing address

Printed on every invoice sent from northwind.

VAT or TIN. Leave it empty if the company is not registered.

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:

Billing address

Printed on every invoice sent from northwind.

<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:

Payout account

Where northwind sends your available balance.

<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:

Your accountant, for example.

<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:

Billing address

Synced from your accounting software. Edit it there.

VAT or TIN. Leave it empty if the company is not registered.

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