Skip to content

Checkbox

A checkbox with a third, indeterminate state, for a choice a form submits.

View source View as Markdown

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.

App.vue
<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 Label inside a Field or an aria-label on 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.readOnly never reaches the hidden input, so a read-only checkbox still submits its value. Use options.disabled to leave it out of the submission.

Anatomy

Assemble the root and its indicator.

NameRequiredDescription
Checkbox.Root true Renders a button with role="checkbox", plus a visually hidden checkbox input.
Checkbox.IndicatorRenders 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

PropTypeDefault
id
stringgenerated
modelValue
boolean | undefinedundefined
defaultValue
booleanfalse
indeterminate
boolean | undefinedundefined
options
CheckboxOptionssee below

Options

OptionTypeDefault
name
stringinherited from Field
form
stringundefined
value
string'on'
uncheckedValue
stringundefined
parent
booleanfalse
forceMount
booleanfalse
transition
string'mirror-checkbox-indicator'
disabled
booleaninherited from Field, else false
readOnly
booleaninherited from Field, else false
required
booleaninherited from Field, else false

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

EmitPayload
update:modelValue
boolean
update:indeterminate
boolean

Slot props

The field state set, plus the two below.

PropType
checked
boolean
indeterminate
boolean

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.

KeyType
checked
ComputedRef<boolean>
indeterminate
ComputedRef<boolean>
disabled
ComputedRef<boolean>
readOnly
ComputedRef<boolean>
setChecked
(next: boolean) => void
setIndeterminate
(next: boolean) => void
toggle
() => void

Data attributes

Both parts write the field state set, plus the ones below.

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
data-state
checked | unchecked | indeterminate
data-checked
true
data-indeterminate
true

CSS variables

PartVariableDefault
Checkbox.Root
--mirror-checkbox-cursor
pointer
Checkbox.Root
--mirror-checkbox-disabled-cursor
not-allowed
Checkbox.Indicator
--mirror-checkbox-indicator-pointer-events
none

Errors

Code
missing_checkbox_context

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.

KeyAction
SpaceToggles, on keyup.
EnterNothing. The keydown default is prevented, so Enter neither toggles the checkbox nor submits the surrounding form.