Checkbox
A checkbox with a third, indeterminate state, for a choice a form submits.
Checkbox is a tri-state checkbox that takes part in forms. Useful wherever a
choice should only count once the form is submitted; for a setting that applies
immediately, use Switch.
<template>
<div class="flex flex-col gap-3">
<Field.Root
v-for="option in options"
:key="option.name"
class="flex items-start gap-3"
:options="{ disabled: option.disabled, name: option.name }"
>
<Checkbox.Root
v-model="option.checked"
v-model:indeterminate="option.indeterminate"
:class="box"
>
<Checkbox.Indicator class="block size-[0.9375rem]">
<svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
<path v-if="option.indeterminate" :d="indeterminate" />
<path v-else :d="check" />
</svg>
</Checkbox.Indicator>
</Checkbox.Root>
<Label :class="label">{{ option.label }}</Label>
</Field.Root>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Checkbox, Field, 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 options = ref([
{
name: 'terms',
label: 'Accept the terms',
checked: true,
indeterminate: false,
disabled: false,
},
{
name: 'digest',
label: 'Weekly digest',
checked: false,
indeterminate: true,
disabled: false,
},
{
name: 'beta',
label: 'Beta features',
checked: false,
indeterminate: false,
disabled: true,
},
])
</script>
Usage guidelines
- Make sure the checkbox has an accessible name, either through a
Labelinside aFieldor anaria-labelon the root. - Bind the mixed state with
v-model:indeterminate. Passed as a plain prop it is controlled, so activating the checkbox emits but leaves the mixed state on screen. options.readOnlynever reaches the hidden input, so a read-only checkbox still submits its value. Useoptions.disabledto leave it out of the submission.
Anatomy
Assemble the root and its indicator.
| Name | Required | Description |
|---|---|---|
| Checkbox.Root | true | Renders a button with role="checkbox", plus a visually hidden checkbox input. |
| Checkbox.Indicator | Renders a span. Unmounted while unchecked. |
<script setup lang="ts">
import { Checkbox, Field, Label } from '@maas/mirror/vue'
</script>
<template>
<Field.Root :options="{ name: 'terms' }">
<Checkbox.Root v-model="accepted" v-model:indeterminate="partial">
<Checkbox.Indicator>
<IconDash v-if="partial" />
<IconCheck v-else />
</Checkbox.Indicator>
</Checkbox.Root>
<Label>I accept the terms</Label>
</Field.Root>
</template>
Examples
Controlled and uncontrolled
Leave modelValue out and the checkbox keeps its own state, seeded by
defaultValue. Bind v-model and you own it instead, which is
decided once at mount.
<template>
<Checkbox.Root default-value>
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Root>
<Checkbox.Root v-model="accepted">
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Root>
</template>
<script setup lang="ts">
const accepted = ref(false)
</script>
Indeterminate
indeterminate is independent of the checked state, so the two are reported
separately in data-state, data-checked and data-indeterminate.
<template>
<Checkbox.Root v-model="checked" v-model:indeterminate="partial">
<Checkbox.Indicator>
{{ partial ? '–' : '✓' }}
</Checkbox.Indicator>
</Checkbox.Root>
</template>
<script setup lang="ts">
const checked = ref(false)
const partial = ref(true)
</script>
Activating an indeterminate checkbox sets it checked and clears indeterminate, the way a native tri-state input behaves.
Driving a list of children
Work both flags out from the children, and write back when either changes.
<template>
<Checkbox.Root
:indeterminate="some && !all"
:model-value="all"
@update:model-value="setAll"
>
<Checkbox.Indicator>{{ all ? '✓' : '–' }}</Checkbox.Indicator>
</Checkbox.Root>
</template>
<script setup lang="ts">
const items = ref([
{ id: 'a', checked: false },
{ id: 'b', checked: true },
])
const all = computed(() => items.value.every((item) => item.checked))
const some = computed(() => items.value.some((item) => item.checked))
function setAll(next: boolean) {
items.value.forEach((item) => (item.checked = next))
}
</script>
Inside a form
The hidden input takes name, value, required and disabled, so
the form sees it, and a surrounding Field supplies each of them.
<template>
<form>
<Field.Root :options="{ name: 'terms', required: true }">
<Checkbox.Root>
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Root>
<Label>I accept the terms</Label>
</Field.Root>
</form>
</template>
An unchecked checkbox submits nothing, which is the native behaviour. If you
need a value either way, set uncheckedValue and a second hidden input holds it
while the checkbox is unchecked.
<template>
<Checkbox.Root
:options="{ name: 'terms', uncheckedValue: 'no', value: 'yes' }"
>
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Root>
</template>
A checkbox rendered outside the <form> it belongs to, in a dialog or a
sticky footer, points at it by id through form. Both hidden inputs take the
attribute.
<template>
<form id="signup">…</form>
<Checkbox.Root :options="{ form: 'signup', name: 'terms' }">
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Root>
</template>
Reaching the hidden input
The element the form actually submits is exposed as input on the component
instance, for the rare case that wants to read validity or call
setCustomValidity() on it.
<template>
<Checkbox.Root ref="terms" :options="{ name: 'terms', required: true }" />
</template>
<script setup lang="ts">
const terms = useTemplateRef('terms')
function report() {
terms.value?.input?.reportValidity()
}
</script>
Styling from state
Paint the root from data-state, and read the same values as slot props where
the markup itself has to branch.
<template>
<Checkbox.Root class="checkbox">
<Checkbox.Indicator v-slot="{ indeterminate }">
{{ indeterminate ? '–' : '✓' }}
</Checkbox.Indicator>
</Checkbox.Root>
</template>
<style>
.checkbox[data-state='checked'],
.checkbox[data-state='indeterminate'] {
background: var(--app-color-primary-bg-solid);
}
.checkbox[data-disabled='true'] {
background: var(--app-color-disabled-bg-subtle);
}
</style>
The indicator is unmounted while the checkbox is unchecked. If you would rather
animate it yourself, options.forceMount keeps it in the DOM in every state.
<template>
<Checkbox.Root :options="{ forceMount: true }">
<Checkbox.Indicator>✓</Checkbox.Indicator>
</Checkbox.Root>
</template>
API reference
Module. A bundled options object, and useMirrorCheckbox(id)
as the programmatic API.
Checkbox.Root
Renders a <button role="checkbox"> plus a visually hidden
<input type="checkbox">, as a sibling of the button rather than a child.
Props
Options
A disabled or read-only Checkbox.Group settles both flags for every checkbox
inside it, whatever their own options say.
Inside a Checkbox.Group the group owns
submission, so name, form, required and uncheckedValue stop meaning
anything on the member and warn ignored_member_options in development. Set
them on the group instead.
Emits
Slot props
The field state set, plus the two below.
Checkbox.Root also exposes input, the hidden <input type="checkbox">
element, on its instance.
Checkbox.Indicator
Renders a <span>, mounted while the checkbox is checked or indeterminate.
A root has one indicator, so forceMount and transition are answered by the
root’s options and the part takes no props beyond id.
Slot props
The same as the root.
Composable
useMirrorCheckbox(id) reaches a checkbox from anywhere in the app.
Data attributes
Both parts write the field state set, plus the ones below.
CSS variables
Errors
Accessibility
The rendered element takes role="checkbox" with aria-checked as true,
false or mixed, aria-required follows required, and a non-native
checkbox reports aria-disabled rather than leaving the tab order.