Skip to content

Checkbox Group

One value for a set of checkboxes, with a parent checkbox that ticks or clears them all.

View source View as Markdown

Checkbox Group collects a set of checkboxes into a single value, an array of the ones that are ticked, and takes part in forms as one control. Useful when the answer is “any of these” rather than “one of these”, which is where Radio belongs instead.

App.vue
<template>
  <Checkbox.Group
    v-model="selected"
    :all-values="allValues"
    aria-label="Notifications"
    class="flex flex-col gap-3"
    :options="{ name: 'notifications' }"
  >
    <div class="flex items-start gap-3">
      <Checkbox.Root
        id="notifications-all"
        :class="box"
        :options="{ parent: true }"
      >
        <Checkbox.Indicator
          v-slot="{ indeterminate: mixed }"
          class="block size-[0.9375rem]"
        >
          <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
            <path v-if="mixed" :d="indeterminate" />
            <path v-else :d="check" />
          </svg>
        </Checkbox.Indicator>
      </Checkbox.Root>

      <Label :class="label" for="notifications-all">
        All notifications
      </Label>
    </div>

    <div
      v-for="channel in channels"
      :key="channel.value"
      class="flex items-start gap-3 pl-8"
    >
      <Checkbox.Root
        :id="`notifications-${channel.value}`"
        :class="box"
        :options="{ value: channel.value }"
      >
        <Checkbox.Indicator class="block size-[0.9375rem]">
          <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
            <path :d="check" />
          </svg>
        </Checkbox.Indicator>
      </Checkbox.Root>

      <Label :class="label" :for="`notifications-${channel.value}`">
        {{ channel.label }}
      </Label>
    </div>
  </Checkbox.Group>
</template>

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

const box = [
  'flex size-5 items-center justify-center border-2 border-surface',
  'rounded-component-compact-md text-accent-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'active:border-primary-subtle',
  'data-[state=checked]:bg-accent-solid data-[state=indeterminate]:bg-accent-solid',
  'data-[state=checked]:border-[transparent] data-[state=indeterminate]:border-[transparent]',
  'data-[state=checked]:active:bg-accent-solid-active',
  'data-[state=indeterminate]:active:bg-accent-solid-active',
  'data-[disabled=true]:border-disabled-subtle',
  'data-[disabled=true]:data-[state=checked]:bg-disabled-solid',
  'data-[disabled=true]:text-disabled-on-solid',
].join(' ')

const label = [
  'type-component-xs text-surface translate-y-[0.1rem] [--mirror-label-cursor:pointer]',
  'data-[disabled=true]:text-disabled-solid',
].join(' ')

const check =
  'M13.6875 5.1875C13.875 5 14.1042 4.90625 14.375 4.90625C14.6458 4.90625 14.875 5 15.0625 5.1875C15.25 5.375 15.3438 5.60417 15.3438 5.875C15.3438 6.14583 15.25 6.375 15.0625 6.5625L7.96875 13.6562C7.63542 13.9896 7.22917 14.1562 6.75 14.1562C6.27083 14.1562 5.86458 13.9896 5.53125 13.6562L2.9375 11.0625C2.75 10.875 2.65625 10.6458 2.65625 10.375C2.65625 10.1042 2.75 9.875 2.9375 9.6875C3.125 9.5 3.35417 9.40625 3.625 9.40625C3.89583 9.40625 4.125 9.5 4.3125 9.6875L6.90625 12.2812C6.86458 12.2396 6.8125 12.2188 6.75 12.2188C6.6875 12.2188 6.63542 12.2396 6.59375 12.2812L13.6875 5.1875Z'

const indeterminate =
  'M14.0313 8.03125C14.3021 8.03125 14.5312 8.125 14.7187 8.3125C14.9062 8.5 15 8.72917 15 9C15 9.27083 14.9062 9.5 14.7187 9.6875C14.5312 9.875 14.3021 9.96875 14.0313 9.96875H3.96875C3.69792 9.96875 3.46875 9.875 3.28125 9.6875C3.09375 9.5 3 9.27083 3 9C3 8.72917 3.09375 8.5 3.28125 8.3125C3.46875 8.125 3.69792 8.03125 3.96875 8.03125H14.0313Z'

const channels = [
  { value: 'email', label: 'Email' },
  { value: 'sms', label: 'Text message' },
  { value: 'push', label: 'Push' },
]

const allValues = channels.map((channel) => channel.value)

const selected = ref(['email'])
</script>

Usage guidelines

  • Every checkbox in the group needs options.value, because that string is what goes into the array and what the form submits.
  • The parent checkbox needs the group’s allValues, since nothing else tells it what “everything” means. Members that have not mounted yet still count.
  • The group is one control as far as a Field is concerned, so validation runs on the array and the members read the result rather than registering themselves.
  • Give every per-member Label its own id, or inside a Field they share the field’s label ID and all register as its label.

Anatomy

Assemble the group, one Checkbox.Root per value, plus a parent if you want one.

NameRequiredDescription
Checkbox.Group true Renders a div with role="group", plus one hidden input per selected value.
Checkbox.Root true Renders a button with role="checkbox". One per value, plus an optional parent.
Checkbox.IndicatorRenders a span. Only while the checkbox is ticked or mixed.
<script setup lang="ts">
import { Checkbox, Label } from '@maas/mirror/vue'
</script>

<template>
  <Checkbox.Group
    v-model="channels"
    :all-values="allValues"
    :options="{ name: 'channels' }"
  >
    <Checkbox.Root id="channels-email" :options="{ value: 'email' }">
      <Checkbox.Indicator />
    </Checkbox.Root>
    <Label id="channels-email-label" for="channels-email">Email</Label>
  </Checkbox.Group>
</template>

Examples

Controlled and uncontrolled

Leave modelValue out and the group keeps its own array, seeded by defaultValue. Bind v-model and you own it instead, which is decided once at mount.

<template>
  <Checkbox.Group :default-value="['email']">
    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>

  <Checkbox.Group v-model="channels">
    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>
</template>

<script setup lang="ts">
const channels = ref(['email'])
</script>

The parent checkbox

A checkbox with options.parent set stands for the whole group rather than for a value of its own. It is ticked while every value in allValues is selected, mixed while only some are, and activating it either selects everything or clears the lot.

<template>
  <Checkbox.Group v-model="channels" :all-values="['email', 'sms']">
    <Checkbox.Root :options="{ parent: true }">
      <Checkbox.Indicator v-slot="{ indeterminate }">
        {{ indeterminate ? '–' : '✓' }}
      </Checkbox.Indicator>
    </Checkbox.Root>

    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>
</template>

The parent has no value and never appears in the array or in the submission. allValues may hold values no Checkbox.Root renders, so a collapsed or paginated list still resolves the parent against the whole set.

Nesting

A group inside a group takes over for its own subtree, so the members between them belong to the inner one. Point both at the same array and each parent covers whatever its own allValues names, the outer one everything and the inner one its section.

<template>
  <Checkbox.Group v-model="all" :all-values="['email', 'sms', 'ping']">
    <Checkbox.Root :options="{ parent: true }" />

    <Checkbox.Group v-model="all" :all-values="['email', 'sms']">
      <Checkbox.Root :options="{ parent: true }" />
      <Checkbox.Root :options="{ value: 'email' }" />
      <Checkbox.Root :options="{ value: 'sms' }" />
    </Checkbox.Group>

    <Checkbox.Root :options="{ value: 'ping' }" />
  </Checkbox.Group>
</template>

<script setup lang="ts">
const all = ref(['email'])
</script>

The outer parent is mixed here, because ping is out. Ticking the inner parent adds sms and leaves the outer one mixed until ping follows.

Disabling

A group that is disabled disables every member and every hidden input, whatever the member says, and blocks changes from the keyboard, the pointer and the composable alike. readOnly blocks the same changes while keeping every member focusable.

<template>
  <Checkbox.Group v-model="channels" :options="{ disabled: true }">
    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>
</template>

Inside a form

The group renders one visually hidden input per selected value, all sharing its name, so the form sees it. The members render no hidden inputs of their own while they are in a group.

<template>
  <form @submit.prevent="submit">
    <Checkbox.Group v-model="channels" :options="{ name: 'channels' }">
      <Checkbox.Root :options="{ value: 'email' }" />
      <Checkbox.Root :options="{ value: 'sms' }" />
    </Checkbox.Group>
  </form>
</template>

Without a name, from either the group or the field, nothing is submitted at all. A group rendered outside the <form> it belongs to points at it by id through form, which reaches every hidden input.

Inside a Field

The group registers as the field’s single control, so validate receives the array and the four validity attributes reach the group and every member.

<template>
  <Field.Root :options="{ name: 'channels', validate }">
    <Label :native-label="false">Notifications</Label>
    <Checkbox.Group v-model="channels" :all-values="allValues">
      <Checkbox.Root id="channels-email" :options="{ value: 'email' }" />
      <Label id="channels-email-label" for="channels-email">Email</Label>
    </Checkbox.Group>
    <Field.Error />
  </Field.Root>
</template>

<script setup lang="ts">
function validate(value: unknown) {
  return (value as Array<string>).length > 0 ? null : 'Pick at least one'
}
</script>

Styling from state

The group publishes the field state set and nothing else, so paint it from validity and read the array as a slot prop where the markup has to branch.

<template>
  <Checkbox.Group v-slot="{ value, allChecked }" class="group">
    <p>{{ allChecked ? 'Everything' : `${value.length} selected` }}</p>
  </Checkbox.Group>
</template>

<style>
.group[data-invalid='true'] {
  color: var(--app-color-danger-fg-muted);
}
</style>

API reference

Module. A bundled options object, and useMirrorCheckboxGroup(id) as the programmatic API. Checkbox.Group is one part of the Checkbox namespace, and the checkboxes it collects are the same Checkbox.Root you render on its own.

Checkbox.Group

Renders a <div role="group">, plus one visually hidden <input type="hidden"> per selected value.

Props

PropTypeDefault
id
stringgenerated
modelValue
string[] | undefinedundefined
defaultValue
string[][]
allValues
string[]undefined
options
CheckboxGroupOptionssee below

allValues is the set the group covers rather than a piece of configuration, so it stays a prop alongside the value itself.

Options

OptionTypeDefault
name
stringinherited from Field
form
stringundefined
disabled
booleaninherited from Field, else false
readOnly
booleaninherited from Field, else false
required
booleaninherited from Field, else false

Emits

EmitPayload
update:modelValue
string[]

Slot props

The field state set, plus the three below.

PropType
value
string[]
allChecked
boolean
anyChecked
boolean

Checkbox.Root inside a group

Two options on Checkbox.Root only mean anything inside a group.

OptionTypeDefault
value
string'on'
parent
booleanfalse

Four options stop meaning anything: name, form, required and uncheckedValue. The group owns submission and a member renders no input of its own, so set them on the group instead. A member that sets any of them warns ignored_member_options in development.

<template>
  <Checkbox.Group v-model="channels" :options="{ name: 'channels' }">
    <Checkbox.Root :options="{ value: 'email' }" />
  </Checkbox.Group>
</template>

options.disabled and options.readOnly still count. Each is merged with the group’s, so either side can block a member and neither can unblock one the other has stopped.

Composable

useMirrorCheckboxGroup(id) reaches a group from anywhere in the app.

KeyType
value
ComputedRef<string[]>
allValues
ComputedRef<string[]>
allChecked
ComputedRef<boolean>
anyChecked
ComputedRef<boolean>
disabled
ComputedRef<boolean>
readOnly
ComputedRef<boolean>
setValue
(next: string[]) => void
setMember
(value: string, next: boolean) => void
toggleValue
(value: string) => void
setAll
(next: boolean) => void
clear
() => void

Data attributes

The group writes the field state set and nothing beyond it. Each member writes the same set plus the Checkbox attributes.

AttributeValue
data-disabled
true
data-readonly
true
data-required
true
data-valid
true
data-invalid
true
data-dirty
true
data-touched
true
data-filled
true
data-focused
true

CSS variables

None. The group renders no functional CSS, and its members keep the Checkbox variables.

Errors

Code
missing_checkbox_value
missing_all_values
ignored_member_options

Accessibility

The group renders role="group", takes its name from a Field label through aria-labelledby or from an aria-label of your own, and reports aria-required and aria-invalid from the field. Every member keeps role="checkbox", and the parent reports aria-checked="mixed" while the group is partly selected.

Focus is not managed: each member is its own tab stop, which is the checkbox pattern rather than the radio one.

KeyAction
TabMoves to the next member, and out of the group after the last one.
SpaceToggles the focused member, on keyup.
EnterNothing. The keydown default is prevented, leaving Enter to the surrounding form.