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>
              <Field.Label :class="label">Recipient</Field.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>
              <Field.Label :class="label">Street and number</Field.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>
              <Field.Label :class="label">Same as shipping</Field.Label>
            </div>
          </div>
        </Field.Root>
      </div>
    </Fieldset.Root>
  </div>
</template>

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

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.must
  • 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 behavior.may
  • 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.should
  • 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.should