Checkbox Group
One value for a set of checkboxes, with a parent checkbox that ticks or clears them all.
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.
<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
Fieldis concerned, so validation runs on the array and the members read the result rather than registering themselves. - Give every per-member
Labelits ownid, or inside aFieldthey 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.
| Name | Required | Description |
|---|---|---|
| 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.Indicator | Renders 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
allValues is the set the group covers rather than a piece of configuration,
so it stays a prop alongside the value itself.
Options
Emits
Slot props
The field state set, plus the three below.
Checkbox.Root inside a group
Two options on Checkbox.Root only mean anything
inside a group.
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.
Data attributes
The group writes the field state set
and nothing beyond it. Each member writes the same set plus the Checkbox
attributes.
CSS variables
None. The group renders no functional CSS, and its members keep the
Checkbox variables.
Errors
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.