Skip to content

Fieldset

Groups related controls under one legend, and turns all of them off at once.

View source View as Markdown

Fieldset puts a heading on a group of controls and lets you disable the whole group in one go. Reach for it when several fields belong together, like an address or a set of payment details, and the person filling the form should see that they do.

Shipping address
Billing address
App.vue
<template>
  <div class="flex w-[19rem] flex-col gap-8">
    <Fieldset.Root :class="fieldset">
      <Fieldset.Legend :class="legend">Shipping address</Fieldset.Legend>

      <div class="flex flex-col gap-3">
        <Field.Root
          class="group flex flex-col gap-1.5"
          :options="{ name: 'recipient' }"
        >
          <div :class="box">
            <div :class="stack">
              <span :class="line">
                <Input
                  v-model="recipient"
                  :class="control"
                  placeholder=" "
                  autocomplete="name"
                />
              </span>
              <Label :class="label">Recipient</Label>
            </div>
          </div>
        </Field.Root>

        <Field.Root
          class="group flex flex-col gap-1.5"
          :options="{ name: 'street' }"
        >
          <div :class="box">
            <div :class="stack">
              <span :class="line">
                <Input
                  v-model="street"
                  :class="control"
                  placeholder=" "
                  autocomplete="street-address"
                />
              </span>
              <Label :class="label">Street and number</Label>
            </div>
          </div>
        </Field.Root>
      </div>
    </Fieldset.Root>

    <Fieldset.Root :class="fieldset" :options="{ disabled: true }">
      <Fieldset.Legend :class="legend">Billing address</Fieldset.Legend>

      <div class="flex flex-col gap-3">
        <Field.Root
          class="group flex flex-col gap-1.5"
          :options="{ name: 'billing' }"
        >
          <div :class="box">
            <div :class="stack">
              <span :class="line">
                <Input :class="control" placeholder=" " />
              </span>
              <Label :class="label">Same as shipping</Label>
            </div>
          </div>
        </Field.Root>
      </div>
    </Fieldset.Root>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Field, Fieldset, Input, Label } from '@maas/mirror/vue'

const recipient = ref('')
const street = ref('')

const fieldset = 'group/fieldset border-0 p-0'

const legend =
  'type-component-md text-surface mb-3 group-data-[disabled=true]/fieldset:text-disabled-muted'

const box = [
  'relative isolate flex h-12 w-full cursor-text items-center justify-between gap-1.5',
  'rounded-component-lg border-surface border-2 px-[0.875rem]',
  'transition-all duration-100 ease-linear [&_*]:transition-all [&_*]:duration-100 [&_*]:ease-linear',
  'outline-4 outline-transparent focus-within:focus-ring',
  'group-[[data-invalid=true]:not([data-focused=true])]:border-danger-subtle',
  'group-[[data-invalid=true]:not([data-focused=true])]:bg-danger-subtle',
  'group-data-[disabled=true]/fieldset:border-disabled-subtle group-data-[disabled=true]/fieldset:cursor-not-allowed',
].join(' ')

const stack = [
  'group/stack flex h-full max-h-full w-full flex-col-reverse',
  'items-center justify-center gap-0 px-1',
  'focus-within:gap-1 has-[input:not(:placeholder-shown)]:gap-1',
].join(' ')

const line = [
  'relative flex h-0 w-full items-end',
  'group-focus-within/stack:h-[0.9375rem]',
  'has-[input:not(:placeholder-shown)]:h-[0.9375rem]',
].join(' ')

const control = [
  'type-component-lg leading-[normal]! text-surface',
  'block box-content h-[1lh] w-full py-1.25 -my-1.25 appearance-none bg-transparent outline-none',
  'placeholder:text-transparent',
  'group-data-[disabled=true]/fieldset:text-disabled-solid',
].join(' ')

const label = [
  'type-component-lg leading-[normal]! text-surface-muted',
  'flex h-full w-full items-center [--mirror-label-cursor:text]',
  "before:absolute before:-inset-0.5 before:-z-10 before:content-['']",
  'group-focus-within/stack:h-auto group-focus-within/stack:text-[0.6875rem] group-focus-within/stack:[--mirror-label-cursor:auto]',
  'group-has-[input:not(:placeholder-shown)]/stack:h-auto group-has-[input:not(:placeholder-shown)]/stack:text-[0.6875rem] group-has-[input:not(:placeholder-shown)]/stack:[--mirror-label-cursor:auto]',
  'group-data-[disabled=true]/fieldset:text-disabled-muted',
].join(' ')
</script>

The second group is disabled, so the browser will not let anyone type in the control inside it.

Usage guidelines

  • Give every fieldset a legend. Without one a screen reader announces the group with no name at all.
  • Browsers give a fieldset min-width: min-content, which stops it shrinking inside a flex or grid parent. Fieldset.Root sets it back to 0, and --mirror-fieldset-min-inline-size is there if you want the old behaviour.
  • A native <legend> has to be the first child of the fieldset, and a fieldset that lays its children out with flex or grid renders it in ways that differ per browser. If you need the layout, turn nativeLegend off and keep the legend in normal flow.
  • disabled reaches the controls through the browser, not through Mirror, so the controls inside do not write data-disabled themselves. Style them from the fieldset instead, the way the demo does.

Anatomy

A root and a legend, with the fields in between.

NameRequiredDescription
Fieldset.Root true Renders a <fieldset>. Owns the disabled state.
Fieldset.LegendRenders a <legend> that names the group.
<script setup lang="ts">
import { Field, Fieldset, Input, Label } from '@maas/mirror/vue'
</script>

<template>
  <Fieldset.Root>
    <Fieldset.Legend>Shipping address</Fieldset.Legend>

    <Field.Root :options="{ name: 'recipient' }">
      <Label>Recipient</Label>
      <Input v-model="recipient" />
    </Field.Root>
  </Fieldset.Root>
</template>

Examples

Turning a group off

disabled sets the native attribute, and the browser takes every control inside out of the tab order and stops it responding. Nothing has to be passed down.

<template>
  <Fieldset.Root :options="{ disabled: sameAsShipping }">
    <Fieldset.Legend>Billing address</Fieldset.Legend>
    <Field.Root :options="{ name: 'billing' }">
      <Label>Street and number</Label>
      <Input v-model="billing" />
    </Field.Root>
  </Fieldset.Root>
</template>

Both parts write data-disabled, so the look of the group hangs off the root rather than off each control. Put a named group on the root and read it from the controls inside.

<template>
  <Fieldset.Root class="group/fieldset" :options="{ disabled: true }">
    <Fieldset.Legend
      class="group-data-[disabled=true]/fieldset:text-disabled-muted"
    >
      Billing address
    </Fieldset.Legend>
  </Fieldset.Root>
</template>

When the legend cannot be a legend

A <legend> only counts as one while it is the first child of a <fieldset>, and browsers place it themselves. Set nativeLegend to false and the legend renders a <div> with an ID instead, which the root picks up as aria-labelledby. The group is named either way.

<template>
  <Fieldset.Root :options="{ nativeLegend: false }" class="flex flex-col gap-3">
    <Fieldset.Legend>Shipping address</Fieldset.Legend>
    <Field.Root :options="{ name: 'recipient' }">
      <Label>Recipient</Label>
      <Input v-model="recipient" />
    </Field.Root>
  </Fieldset.Root>
</template>

The option lives on the root because the root has to know how it is being named, and there is one legend per fieldset for it to answer for.

Rendering something else

Fieldset.Root renders a <fieldset> and Fieldset.Legend a <legend>, and both take as and as-child like every other part. A root that is no longer a <fieldset> has no native disabled to set, so pair it with nativeLegend: false and take the group’s disabled state from the data attribute.

<template>
  <Fieldset.Root as="section" :options="{ nativeLegend: false }">
    <Fieldset.Legend as="h3">Shipping address</Fieldset.Legend>
  </Fieldset.Root>
</template>

Reaching a fieldset from elsewhere

Give the root an id and useMirrorFieldset reads the same state from anywhere, which is how a summary elsewhere on the page can tell whether a group is currently off.

<script setup lang="ts">
import { useMirrorFieldset } from '@maas/mirror/vue'

const { disabled } = useMirrorFieldset('billing')
</script>

<template>
  <p v-if="disabled">Billing address is the same as shipping.</p>
</template>

API reference

Module. A bundled options object, and useMirrorFieldset(id) as the programmatic API.

Fieldset.Root

Renders a <fieldset>.

Props

PropTypeDefault
id
stringgenerated
options
FieldsetOptions{}

Options

OptionTypeDefault
disabled
booleanfalse
nativeLegend
booleantrue

Slot props

PropType
disabled
boolean

Fieldset.Legend

Renders a <legend>, or a <div> while nativeLegend is false.

Props

PropTypeDefault
id
stringinjected

Slot props

PropType
disabled
boolean

Composable

useMirrorFieldset(id) reaches a fieldset from anywhere in the app.

KeyType
disabled
ComputedRef<boolean>
legendId
ComputedRef<string>
labelledBy
ComputedRef<string | undefined>

Data attributes

PartAttributeValue
all
data-disabled
true
all
data-scope
root | legend

CSS variables

PartVariableDefault
Fieldset.Root
--mirror-fieldset-min-inline-size
0

Errors

Code
missing_fieldset_context

Accessibility

A <fieldset> with a <legend> is already the accessible grouping the ARIA group role describes, so Mirror adds no roles of its own. Screen readers read the legend before each control inside, which is why the legend should be short.

While nativeLegend is false the legend is no longer part of that pair, so it renders an ID and the root points aria-labelledby at it. Nothing else changes, including where the legend sits in the reading order.

There is no keyboard behaviour. disabled takes the controls inside out of the tab order because the browser does that for a disabled fieldset, and focus moves on to whatever follows the group.