Skip to the content

ComponentsPanel

Fieldset

A bordered box with a legend on its edge that groups related content, and can fold it away.

Import

import { Fieldset, FieldsetLegend, FieldsetContent } from "@booleanpress/ui/fieldset"

Usage

A fieldset is a native <fieldset> whose <legend> sits on the box's top edge. Inside a form, it groups related fields under one name, which screen readers announce on entering the group. For a group of fields without a box, use FieldSet from field.

import { Fieldset, FieldsetContent, FieldsetLegend } from "@booleanpress/ui/fieldset"

export function Sender() {
  return (
    <Fieldset toggleable>
      <FieldsetLegend>Sender</FieldsetLegend>
      <FieldsetContent>From Acme Support, support@example.com.</FieldsetContent>
    </Fieldset>
  )
}

With toggleable, the legend becomes a button that shows and hides the content, with a minus while open and a plus while closed; indicator replaces them. It is uncontrolled with defaultOpen (true by default), or controlled with open and onOpenChange. A fieldset takes the native attributes: disabled disables every control inside it except those in the legend, so the toggle keeps working, and name and form work as on any fieldset. Folded content stays in the page, hidden, so its fields keep what was typed and are still submitted with the form. The content's height animates from Radix's measured --radix-collapsible-content-height.

Examples

Basic

A legend on the edge of a box holding an invoice's lines.

import { Fieldset, FieldsetContent, FieldsetLegend } from "@booleanpress/ui/fieldset"
import { Separator } from "@booleanpress/ui/separator"

export default function FieldsetBasic() {
  return (
    <Fieldset className="w-full max-w-xs">
      <FieldsetLegend>Invoice #1024</FieldsetLegend>
      <FieldsetContent>
        <div className="flex flex-col p-2">
          <div className="flex justify-between">
            <span>Growth plan</span>
            <span className="text-muted-foreground">$29.00</span>
          </div>
          <div className="mt-3 flex justify-between">
            <span>Dedicated IP</span>
            <span className="text-muted-foreground">$5.99</span>
          </div>
          <Separator className="my-3.5" />
          <div className="flex justify-between font-medium">
            <span>Total</span>
            <span>$34.99</span>
          </div>
        </div>
      </FieldsetContent>
    </Fieldset>
  )
}

Toggleable

toggleable makes the legend a button: the minus folds the content away, the plus brings it back.

import { Fieldset, FieldsetContent, FieldsetLegend } from "@booleanpress/ui/fieldset"
import { Separator } from "@booleanpress/ui/separator"

export default function FieldsetToggleable() {
  return (
    <Fieldset toggleable className="w-full max-w-xs">
      <FieldsetLegend>Invoice #1024</FieldsetLegend>
      <FieldsetContent>
        <div className="flex flex-col p-2">
          <div className="flex justify-between">
            <span>Growth plan</span>
            <span className="text-muted-foreground">$29.00</span>
          </div>
          <div className="mt-3 flex justify-between">
            <span>Dedicated IP</span>
            <span className="text-muted-foreground">$5.99</span>
          </div>
          <Separator className="my-3.5" />
          <div className="flex justify-between font-medium">
            <span>Total</span>
            <span>$34.99</span>
          </div>
        </div>
      </FieldsetContent>
    </Fieldset>
  )
}

Controlled

open and onOpenChange keep the state outside, so other buttons can open and close it.

import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { Fieldset, FieldsetContent, FieldsetLegend } from "@booleanpress/ui/fieldset"

export default function FieldsetControlled() {
  const [open, setOpen] = useState(true)

  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <div className="flex justify-center gap-2">
        <Button onClick={() => setOpen(true)}>Open</Button>
        <Button variant="secondary" onClick={() => setOpen(false)}>
          Close
        </Button>
      </div>
      <Fieldset toggleable open={open} onOpenChange={setOpen}>
        <FieldsetLegend>Bounce handling</FieldsetLegend>
        <FieldsetContent>
          <p className="p-2">
            Hard bounces add the address to the suppression list at once. Soft bounces are retried for 24 hours before the
            address is suppressed.
          </p>
        </FieldsetContent>
      </Fieldset>
    </div>
  )
}

Custom indicator

indicator replaces the minus and plus with a chevron that turns.

import { ChevronDownIcon } from "lucide-react"
import { Fieldset, FieldsetContent, FieldsetLegend } from "@booleanpress/ui/fieldset"

// The legend's button is the Tailwind group `fieldset-trigger`: the chevron turns when the content opens.
const indicator = (
  <ChevronDownIcon className="size-3.5 transition-transform duration-(--bui-duration-control) group-data-[state=open]/fieldset-trigger:rotate-180" />
)

export default function FieldsetCustomIndicator() {
  return (
    <Fieldset toggleable className="w-full max-w-xs">
      <FieldsetLegend indicator={indicator}>Tracking</FieldsetLegend>
      <FieldsetContent>
        <p className="p-2">
          Open and click tracking add a pixel and rewrite links. Both are off for transactional emails such as password
          resets and receipts.
        </p>
      </FieldsetContent>
    </Fieldset>
  )
}

With a form inside

Fields grouped under one legend, which screen readers read as the group's name.

import { Field, FieldGroup, FieldLabel } from "@booleanpress/ui/field"
import { Fieldset, FieldsetContent, FieldsetLegend } from "@booleanpress/ui/fieldset"
import { Input } from "@booleanpress/ui/input"
import { Switch } from "@booleanpress/ui/switch"

export default function FieldsetWithForm() {
  return (
    <Fieldset toggleable className="w-full max-w-sm">
      <FieldsetLegend>Sender</FieldsetLegend>
      <FieldsetContent>
        <FieldGroup className="gap-4 pt-1">
          <Field>
            <FieldLabel htmlFor="sender-name">From name</FieldLabel>
            <Input id="sender-name" defaultValue="Acme Support" />
          </Field>
          <Field>
            <FieldLabel htmlFor="sender-email">From email</FieldLabel>
            <Input id="sender-email" type="email" defaultValue="support@example.com" />
          </Field>
          <Field orientation="horizontal">
            <Switch id="sender-force" defaultChecked />
            <FieldLabel htmlFor="sender-force">Use this sender for every email</FieldLabel>
          </Field>
        </FieldGroup>
      </FieldsetContent>
    </Fieldset>
  )
}

Disabled

The native disabled attribute greys every field inside the fieldset and takes them out of the tab order.

import { Field, FieldGroup, FieldLabel } from "@booleanpress/ui/field"
import { Fieldset, FieldsetContent, FieldsetLegend } from "@booleanpress/ui/fieldset"
import { Input } from "@booleanpress/ui/input"
import { Switch } from "@booleanpress/ui/switch"

export default function FieldsetDisabled() {
  return (
    <Fieldset disabled className="w-full max-w-sm">
      <FieldsetLegend>Sender</FieldsetLegend>
      <FieldsetContent>
        <FieldGroup className="gap-4 pt-1">
          <Field>
            <FieldLabel htmlFor="sender-locked-name">From name</FieldLabel>
            <Input id="sender-locked-name" defaultValue="Acme Support" />
          </Field>
          <Field>
            <FieldLabel htmlFor="sender-locked-email">From email</FieldLabel>
            <Input id="sender-locked-email" type="email" defaultValue="support@example.com" />
          </Field>
          <Field orientation="horizontal">
            <Switch id="sender-locked-force" defaultChecked />
            <FieldLabel htmlFor="sender-locked-force">Use this sender for every email</FieldLabel>
          </Field>
        </FieldGroup>
      </FieldsetContent>
    </Fieldset>
  )
}

Accessibility

Semantics
A native fieldset (role group) named by its legend. When toggleable, the legend holds a button with aria-expanded and aria-controls pointing at the content; folded, the content is hidden: out of the focus order and the accessibility tree, but still in the page.
Labels
The legend's text names the group, and, when toggleable, the button. Keep it short: screen readers repeat it on entering the group.
Focus
The legend's button is a tab stop before the content's controls. Focus is a 1px --ring outline 2px outside the legend.
Known limits
  • Folded fields are still submitted with the form, and still checked by its validation: when the browser finds one invalid on submit, its section opens so the browser can move to the field and show its message. Validation of your own (noValidate forms) should open the section itself, through open.
  • The indicator is decoration (aria-hidden); the state is in aria-expanded.

Keyboard

Keyboard
KeyBehaviour
EnterorSpaceOn a toggleable legend: shows or hides the content.

API

Fieldset

Renders a fieldset and passes it every other prop.

Fieldset props
PropTypeDefaultDescription
defaultOpenbooleantrueWhether the content shows at the start, when the fieldset controls itself. true by default.
onOpenChange((open: boolean) => void)Called with the new state when the content is shown or hidden.
openbooleanWhether the content shows, when you control it. Pair it with onOpenChange. Needs toggleable.
toggleablebooleanfalseTurns the legend into a button that shows and hides the content. false by default.

FieldsetLegend

Renders a legend and passes it every other prop.

FieldsetLegend props
PropTypeDefaultDescription
indicatorReactNodeReplaces the minus (open) and plus (closed) of a toggleable legend. The button is the Tailwind group fieldset-trigger, so an icon can follow the state with group-data-[state=open]/fieldset-trigger:.

FieldsetContent

Renders Radix Collapsible.Content and passes it every other prop.

FieldsetContent props
PropTypeDefaultDescription
asChildboolean
forceMounttrueUsed to force mounting when more control is needed. Useful when controlling animation with React animation libraries.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="fieldset" (Fieldset), data-slot="fieldset-legend" (FieldsetLegend), data-slot="fieldset-content" (FieldsetContent).

Theming

The box is --card with a 1px --border edge, a 6px radius and 16px padding; the legend is 14px semibold on --card. A toggleable legend takes --accent under the pointer, its icon --muted-foreground (--secondary-foreground under the pointer); focus is --ring.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground
--accent-foregroundtext
--cardbackground
--card-foregroundtext
--muted-foregroundtext
--ringoutline
--secondary-foregroundtext