Skip to content
  1. Selection
  2. Checkbox

Checkbox

A box people tick to opt into one or several independent options.

Invoice emails

Choose which billing events reach finance@northwind.io.

A PDF copy is attached as soon as the invoice gets its number.

Sent when a card is declined on a renewal.

'use client'

import { useState } from 'react'
import { Checkbox, CheckboxField, CheckboxGroup } from '@/components/checkbox'
import { Description, Fieldset, Label, Legend } from '@/components/fieldset'
import { Text } from '@/components/text'

export default function Example() {
  let [finalized, setFinalized] = useState(true)
  let [failed, setFailed] = useState(false)

  return (
    <Fieldset>
      <Legend>Invoice emails</Legend>
      <Text>Choose which billing events reach finance@northwind.io.</Text>
      <CheckboxGroup>
        <CheckboxField>
          <Checkbox name="emails" value="invoice_finalized" checked={finalized} onChange={setFinalized} />
          <Label>Invoice finalized</Label>
          <Description>A PDF copy is attached as soon as the invoice gets its number.</Description>
        </CheckboxField>
        <CheckboxField>
          <Checkbox name="emails" value="payment_failed" checked={failed} onChange={setFailed} />
          <Label>Payment failed</Label>
          <Description>Sent when a card is declined on a renewal.</Description>
        </CheckboxField>
      </CheckboxGroup>
    </Fieldset>
  )
}
<script setup lang="ts">
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import CheckboxGroup from '@/components/checkbox-group.vue'
import Description from '@/components/description.vue'
import Fieldset from '@/components/fieldset.vue'
import Label from '@/components/label.vue'
import Legend from '@/components/legend.vue'
import Text from '@/components/text.vue'
import { ref } from 'vue'

const finalized = ref(true)
const failed = ref(false)
</script>

<template>
  <Fieldset>
    <Legend>Invoice emails</Legend>
    <Text>Choose which billing events reach finance@northwind.io.</Text>
    <CheckboxGroup>
      <CheckboxField>
        <Checkbox name="emails" value="invoice_finalized" v-model="finalized" />
        <Label>Invoice finalized</Label>
        <Description>A PDF copy is attached as soon as the invoice gets its number.</Description>
      </CheckboxField>
      <CheckboxField>
        <Checkbox name="emails" value="payment_failed" v-model="failed" />
        <Label>Payment failed</Label>
        <Description>Sent when a card is declined on a renewal.</Description>
      </CheckboxField>
    </CheckboxGroup>
  </Fieldset>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import type React from 'react'
import { CheckIcon, MinusIcon } from '@heroicons/react/16/solid'

export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
  return (
    <div
      data-slot="control"
      {...props}
      className={clsx(
        className,
        'space-y-3 has-data-[slot=description]:space-y-6',
        'has-data-[slot=description]:**:data-[slot=label]:font-medium'
      )}
    />
  )
}

const fieldGrid = 'grid gap-x-4 gap-y-1 grid-cols-[1.125rem_1fr] sm:grid-cols-[1rem_1fr]'

const fieldColumns = '*:data-[slot=control]:col-start-1 *:data-[slot=label]:col-start-2 *:data-[slot=description]:col-start-2'

const fieldRows = '*:data-[slot=control]:row-start-1 *:data-[slot=label]:row-start-1 *:data-[slot=description]:row-start-2'

const controlOffset = '*:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1'

export function CheckboxField({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
  return (
    <Headless.Field
      data-slot="field"
      {...props}
      className={clsx(className, fieldGrid, fieldColumns, fieldRows, controlOffset, 'has-data-[slot=description]:**:data-[slot=label]:font-medium')}
    />
  )
}

type CheckboxColors = {
  mark: string
  background: string
  border: string
  dark?: Partial<Omit<CheckboxColors, 'dark'>>
}

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

const palette = (hue: string) => (shade: number) => `var(--color-${hue}-${shade})`

const accent = (shade: number) => `var(--accent-${shade},var(--color-blue-${shade}))`

const zinc = palette('zinc')

const white = 'var(--color-white)'

function whiteMark(color: (shade: number) => string, background: number, border: number, opacity = 90): CheckboxColors {
  return { mark: white, background: color(background), border: withOpacity(color(border), opacity) }
}

function darkMark(color: (shade: number) => string, background: number, border: number): CheckboxColors {
  return { mark: color(950), background: color(background), border: withOpacity(color(border), 80) }
}

const darkCheckbox = whiteMark(zinc, 900, 950)

const whiteCheckbox = { mark: zinc(900), background: white, border: withOpacity(zinc(950), 15) }

const colors = {
  'dark/zinc': { ...darkCheckbox, dark: { background: zinc(600) } },
  'dark/white': { ...darkCheckbox, dark: whiteCheckbox },
  white: whiteCheckbox,
  dark: darkCheckbox,
  zinc: whiteMark(zinc, 600, 700),
  red: whiteMark(palette('red'), 600, 700),
  orange: whiteMark(palette('orange'), 500, 600),
  amber: darkMark(palette('amber'), 400, 500),
  yellow: darkMark(palette('yellow'), 300, 400),
  lime: darkMark(palette('lime'), 300, 400),
  green: whiteMark(palette('green'), 600, 700),
  emerald: whiteMark(palette('emerald'), 600, 700),
  teal: whiteMark(palette('teal'), 600, 700),
  cyan: darkMark(palette('cyan'), 300, 400),
  sky: whiteMark(palette('sky'), 500, 600, 80),
  blue: whiteMark(palette('blue'), 600, 700),
  accent: whiteMark(accent, 600, 700),
  indigo: whiteMark(palette('indigo'), 500, 600),
  violet: whiteMark(palette('violet'), 500, 600),
  purple: whiteMark(palette('purple'), 500, 600),
  fuchsia: whiteMark(palette('fuchsia'), 500, 600),
  pink: whiteMark(palette('pink'), 500, 600),
  rose: whiteMark(palette('rose'), 500, 600),
} satisfies Record<string, CheckboxColors>

type Color = keyof typeof colors

function cssVariables(colors: CheckboxColors) {
  return {
    '--checkbox-mark': colors.mark,
    '--checkbox-fill': colors.background,
    '--checkbox-border': colors.border,
    '--checkbox-dark-mark': colors.dark?.mark,
    '--checkbox-dark-fill': colors.dark?.background,
    '--checkbox-dark-border': colors.dark?.border,
  } as React.CSSProperties
}

const themeColors = [
  '[--mark-color:var(--checkbox-mark)] [--checked-background:var(--checkbox-fill)] [--checked-border:var(--checkbox-border)]',
  'dark:[--mark-color:var(--checkbox-dark-mark,var(--checkbox-mark))]',
  'dark:[--checked-background:var(--checkbox-dark-fill,var(--checkbox-fill))]',
  'dark:[--checked-border:var(--checkbox-dark-border,var(--checkbox-border))]',
]

const layout = 'relative isolate flex items-center justify-center size-4.5 sm:size-4 rounded-[0.3125rem]'

const background = [
  'before:absolute before:-z-10 before:inset-0 before:rounded-[calc(0.3125rem-1px)]',
  'before:shadow-sm before:bg-white',
]

const highlight = [
  'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)]',
  'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
]

const border = 'border border-zinc-950/15 group-data-hover:border-zinc-950/30'

const checked = [
  'group-data-checked:bg-(--checked-border) group-data-checked:before:bg-(--checked-background)',
  'group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent',
]

const dark = [
  'dark:bg-white/5 dark:border-white/15 dark:group-data-hover:border-white/30',
  'dark:before:hidden dark:after:hidden dark:after:-inset-px dark:after:rounded-[0.3125rem]',
]

const darkChecked = [
  'dark:group-data-checked:bg-(--checked-background) dark:group-data-checked:after:block',
  'dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5',
]

const focus = 'group-data-focus:outline-accent-500 group-data-focus:outline-2 group-data-focus:outline-offset-2'

const disabled = [
  'group-data-disabled:opacity-50 group-data-disabled:bg-zinc-950/5 group-data-disabled:border-zinc-950/25',
  'group-data-disabled:before:bg-transparent group-data-disabled:[--mark-color:var(--color-zinc-950)]/50',
  'dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:border-white/20',
  'dark:group-data-disabled:[--mark-color:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
]

const forcedColors = [
  'forced-colors:[--checked-background:Highlight] dark:forced-colors:[--checked-background:Highlight]',
  'forced-colors:[--mark-color:HighlightText] dark:forced-colors:[--mark-color:HighlightText]',
  'forced-colors:group-data-disabled:[--mark-color:Highlight] dark:forced-colors:group-data-disabled:[--mark-color:Highlight]',
]

const box = [layout, background, highlight, border, checked, dark, darkChecked, focus, disabled, forcedColors, themeColors]

type CheckboxProps = {
  color?: Color
  className?: string
} & Omit<Headless.CheckboxProps, 'as' | 'className'>

export function Checkbox({ color = 'accent', className, ...props }: CheckboxProps) {
  return (
    <Headless.Checkbox data-slot="control" {...props} className={clsx(className, 'group inline-flex focus:outline-hidden')}>
      <span style={cssVariables(colors[color])} className={clsx(box)}>
        <CheckIcon className="size-4 sm:size-3.5 text-(--mark-color) opacity-0 group-data-checked:opacity-100 group-data-indeterminate:hidden" />
        <MinusIcon className="hidden size-4 sm:size-3.5 text-(--mark-color) opacity-0 group-data-checked:opacity-100 group-data-indeterminate:block" />
      </span>
    </Headless.Checkbox>
  )
}
<script setup lang="ts">
import { CheckIcon, MinusIcon } from '@heroicons/vue/16/solid'
import { CheckboxRoot } from 'reka-ui'
import { computed, useAttrs, type CSSProperties } from 'vue'
import { useDisabled, useFieldControl } from './fields'
import { vInteractions } from './interactions'

defineOptions({ inheritAttrs: false })

type CheckboxColors = {
  mark: string
  background: string
  border: string
  dark?: Partial<Omit<CheckboxColors, 'dark'>>
}

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

const palette = (hue: string) => (shade: number) => `var(--color-${hue}-${shade})`

const accent = (shade: number) => `var(--accent-${shade},var(--color-blue-${shade}))`

const zinc = palette('zinc')

const white = 'var(--color-white)'

function whiteMark(color: (shade: number) => string, background: number, border: number, opacity = 90): CheckboxColors {
  return { mark: white, background: color(background), border: withOpacity(color(border), opacity) }
}

function darkMark(color: (shade: number) => string, background: number, border: number): CheckboxColors {
  return { mark: color(950), background: color(background), border: withOpacity(color(border), 80) }
}

const darkCheckbox = whiteMark(zinc, 900, 950)

const whiteCheckbox = { mark: zinc(900), background: white, border: withOpacity(zinc(950), 15) }

const colors = {
  'dark/zinc': { ...darkCheckbox, dark: { background: zinc(600) } },
  'dark/white': { ...darkCheckbox, dark: whiteCheckbox },
  white: whiteCheckbox,
  dark: darkCheckbox,
  zinc: whiteMark(zinc, 600, 700),
  red: whiteMark(palette('red'), 600, 700),
  orange: whiteMark(palette('orange'), 500, 600),
  amber: darkMark(palette('amber'), 400, 500),
  yellow: darkMark(palette('yellow'), 300, 400),
  lime: darkMark(palette('lime'), 300, 400),
  green: whiteMark(palette('green'), 600, 700),
  emerald: whiteMark(palette('emerald'), 600, 700),
  teal: whiteMark(palette('teal'), 600, 700),
  cyan: darkMark(palette('cyan'), 300, 400),
  sky: whiteMark(palette('sky'), 500, 600, 80),
  blue: whiteMark(palette('blue'), 600, 700),
  accent: whiteMark(accent, 600, 700),
  indigo: whiteMark(palette('indigo'), 500, 600),
  violet: whiteMark(palette('violet'), 500, 600),
  purple: whiteMark(palette('purple'), 500, 600),
  fuchsia: whiteMark(palette('fuchsia'), 500, 600),
  pink: whiteMark(palette('pink'), 500, 600),
  rose: whiteMark(palette('rose'), 500, 600),
} satisfies Record<string, CheckboxColors>

type Color = keyof typeof colors

function cssVariables(colors: CheckboxColors) {
  return {
    '--checkbox-mark': colors.mark,
    '--checkbox-fill': colors.background,
    '--checkbox-border': colors.border,
    '--checkbox-dark-mark': colors.dark?.mark,
    '--checkbox-dark-fill': colors.dark?.background,
    '--checkbox-dark-border': colors.dark?.border,
  } as CSSProperties
}

const themeColors = [
  '[--mark-color:var(--checkbox-mark)] [--checked-background:var(--checkbox-fill)] [--checked-border:var(--checkbox-border)]',
  'dark:[--mark-color:var(--checkbox-dark-mark,var(--checkbox-mark))]',
  'dark:[--checked-background:var(--checkbox-dark-fill,var(--checkbox-fill))]',
  'dark:[--checked-border:var(--checkbox-dark-border,var(--checkbox-border))]',
]

const layout = 'relative isolate flex items-center justify-center size-4.5 sm:size-4 rounded-[0.3125rem]'

const background = [
  'before:absolute before:-z-10 before:inset-0 before:rounded-[calc(0.3125rem-1px)]',
  'before:shadow-sm before:bg-white',
]

const highlight = [
  'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)]',
  'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
]

const border = 'border border-zinc-950/15 group-data-hover:border-zinc-950/30'

const checked = [
  'group-data-checked:bg-(--checked-border) group-data-checked:before:bg-(--checked-background)',
  'group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent',
]

const dark = [
  'dark:bg-white/5 dark:border-white/15 dark:group-data-hover:border-white/30',
  'dark:before:hidden dark:after:hidden dark:after:-inset-px dark:after:rounded-[0.3125rem]',
]

const darkChecked = [
  'dark:group-data-checked:bg-(--checked-background) dark:group-data-checked:after:block',
  'dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5',
]

const focus = 'group-data-focus:outline-accent-500 group-data-focus:outline-2 group-data-focus:outline-offset-2'

const disabled = [
  'group-data-disabled:opacity-50 group-data-disabled:bg-zinc-950/5 group-data-disabled:border-zinc-950/25',
  'group-data-disabled:before:bg-transparent group-data-disabled:[--mark-color:var(--color-zinc-950)]/50',
  'dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:border-white/20',
  'dark:group-data-disabled:[--mark-color:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
]

const forcedColors = [
  'forced-colors:[--checked-background:Highlight] dark:forced-colors:[--checked-background:Highlight]',
  'forced-colors:[--mark-color:HighlightText] dark:forced-colors:[--mark-color:HighlightText]',
  'forced-colors:group-data-disabled:[--mark-color:Highlight] dark:forced-colors:group-data-disabled:[--mark-color:Highlight]',
]

const box = [layout, background, highlight, border, checked, dark, darkChecked, focus, disabled, forcedColors, themeColors]

const props = withDefaults(
  defineProps<{
    color?: Color
    disabled?: boolean
    indeterminate?: boolean
  }>(),
  { color: 'accent' }
)

const model = defineModel<boolean>({ default: false })
const attrs = useAttrs()
const fieldControl = useFieldControl(attrs)

const isDisabled = useDisabled(() => props.disabled)

const innerAttrs = computed(() => {
  const { class: _class, ...rest } = attrs
  return rest
})
</script>

<template>
  <CheckboxRoot
    v-bind="innerAttrs"
    v-interactions
    data-slot="control"
    :model-value="indeterminate ? 'indeterminate' : model"
    :disabled="isDisabled"
    :id="fieldControl.id.value"
    :aria-describedby="fieldControl.describedBy.value"
    :data-checked="model ? '' : undefined"
    :data-indeterminate="indeterminate ? '' : undefined"
    :data-disabled="isDisabled ? '' : undefined"
    :class="[attrs.class, 'group inline-flex focus:outline-hidden']"
    @update:model-value="model = !model"
  >
    <span :style="cssVariables(colors[color])" :class="box">
      <CheckIcon class="size-4 sm:size-3.5 text-(--mark-color) opacity-0 group-data-checked:opacity-100 group-data-indeterminate:hidden" />
      <MinusIcon class="hidden size-4 sm:size-3.5 text-(--mark-color) opacity-0 group-data-checked:opacity-100 group-data-indeterminate:block" />
    </span>
  </CheckboxRoot>
</template>

Component API

PropDefaultDescription
Checkboxbuilt on the Headless UI <Headless.Checkbox> component
checkedfalseTicked state when you control it yourself.
onChange-Receives the new boolean each time the box is toggled.
coloraccentFill color of the ticked box.
indeterminatefalseDraws a dash instead of a tick for a partial selection.
disabledfalseBlocks interaction and dims the box.
CheckboxFieldbuilt on the Headless UI <Headless.Field> component
disabledfalseDisables the box and dims its label and description.
CheckboxGrouprenders a <div> element
This component does not expose any component-specific props.
PropDefaultDescription
Checkboxbuilt on the Reka UI <CheckboxRoot> component
v-modelfalseTwo-way binding of the ticked state.
coloraccentFill color of the ticked box.
indeterminatefalseDraws a dash instead of a tick for a partial selection.
disabledfalseBlocks interaction and dims the box.
CheckboxFieldrenders a <div> element
disabledfalseDisables the box and dims its label and description.
CheckboxGrouprenders a <div> element
This component does not expose any component-specific props.

Usage

A checkbox records a choice that the user confirms later, usually with a save or submit button. Each box stands on its own, so any number of them can be ticked. When the change should apply the moment it is flipped, a switch reads better; when only one answer is allowed, use radio buttons.

Label a single option

CheckboxField places the box in front of its Label and links the two, so a click on the text toggles the box too. Write the label as the thing that happens when the box is ticked:

import { Checkbox, CheckboxField } from '@/components/checkbox'
import { Label } from '@/components/fieldset'

export default function Example() {
  return (
    <CheckboxField>
      <Checkbox name="payout_receipts" />
      <Label>Email me a receipt for each payout</Label>
    </CheckboxField>
  )
}
<script setup lang="ts">
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import Label from '@/components/label.vue'
</script>

<template>
  <CheckboxField>
    <Checkbox name="payout_receipts" />
    <Label>Email me a receipt for each payout</Label>
  </CheckboxField>
</template>

Explain the effect of an option

A Description goes under the label when the name alone does not say what changes. The label turns medium weight so the two lines stay easy to tell apart:

Adds the customer VAT or GST number under the billing address.

<CheckboxField>
  <Checkbox name="tax_ids" />
  <Label>Show tax IDs on invoices</Label>
  <Description>Adds the customer VAT or GST number under the billing address.</Description>
</CheckboxField>
<CheckboxField>
  <Checkbox name="tax_ids" />
  <Label>Show tax IDs on invoices</Label>
  <Description>Adds the customer VAT or GST number under the billing address.</Description>
</CheckboxField>

Wrap the fields in a CheckboxGroup for consistent spacing, and in a Fieldset with a Legend when the list needs a heading. Giving every box the same name sends the ticked values together:

Webhook events

Pick the events sent to https://api.northwind.io/hooks.

import { Checkbox, CheckboxField, CheckboxGroup } from '@/components/checkbox'
import { Fieldset, Label, Legend } from '@/components/fieldset'
import { Text } from '@/components/text'

export default function Example() {
  return (
    <Fieldset>
      <Legend>Webhook events</Legend>
      <Text>Pick the events sent to https://api.northwind.io/hooks.</Text>
      <CheckboxGroup>
        <CheckboxField>
          <Checkbox name="events" value="invoice.paid" defaultChecked />
          <Label>invoice.paid</Label>
        </CheckboxField>
        <CheckboxField>
          <Checkbox name="events" value="invoice.payment_failed" defaultChecked />
          <Label>invoice.payment_failed</Label>
        </CheckboxField>
        <CheckboxField>
          <Checkbox name="events" value="customer.created" />
          <Label>customer.created</Label>
        </CheckboxField>
      </CheckboxGroup>
    </Fieldset>
  )
}
<script setup lang="ts">
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import CheckboxGroup from '@/components/checkbox-group.vue'
import Fieldset from '@/components/fieldset.vue'
import Label from '@/components/label.vue'
import Legend from '@/components/legend.vue'
import Text from '@/components/text.vue'
import { ref } from 'vue'

const paid = ref(true)
const failed = ref(true)
const created = ref(false)
</script>

<template>
  <Fieldset>
    <Legend>Webhook events</Legend>
    <Text>Pick the events sent to https://api.northwind.io/hooks.</Text>
    <CheckboxGroup>
      <CheckboxField>
        <Checkbox name="events" value="invoice.paid" v-model="paid" />
        <Label>invoice.paid</Label>
      </CheckboxField>
      <CheckboxField>
        <Checkbox name="events" value="invoice.payment_failed" v-model="failed" />
        <Label>invoice.payment_failed</Label>
      </CheckboxField>
      <CheckboxField>
        <Checkbox name="events" value="customer.created" v-model="created" />
        <Label>customer.created</Label>
      </CheckboxField>
    </CheckboxGroup>
  </Fieldset>
</template>

States

Start with the box ticked

Set defaultChecked when the recommended choice is on and you do not need to track the value in state:

Start the v-model ref at true to render the box ticked on first paint:

<CheckboxField>
  <Checkbox name="auto_advance" defaultChecked />
  <Label>Finalize drafts one hour after creation</Label>
</CheckboxField>
<script setup lang="ts">
import { ref } from 'vue'

const autoAdvance = ref(true)
</script>

<template>
  <CheckboxField>
    <Checkbox name="auto_advance" v-model="autoAdvance" />
    <Label>Finalize drafts one hour after creation</Label>
  </CheckboxField>
</template>

Reflect a partial selection

A parent box that drives several children should show a dash while only some of them are ticked. Compute indeterminate from the children and let the parent tick or clear them all:

'use client'

import { useState } from 'react'
import { Checkbox, CheckboxField, CheckboxGroup } from '@/components/checkbox'
import { Label } from '@/components/fieldset'

let columns = ['Invoice number', 'Customer', 'Amount']

export default function Example() {
  let [selected, setSelected] = useState(['Invoice number'])

  return (
    <CheckboxGroup role="group" aria-label="Export columns">
      <CheckboxField>
        <Checkbox
          checked={selected.length > 0}
          indeterminate={selected.length > 0 && selected.length !== columns.length}
          onChange={(checked) => setSelected(checked ? [...columns] : [])}
        />
        <Label>All columns</Label>
      </CheckboxField>

      {columns.map((column) => (
        <CheckboxField key={column}>
          <Checkbox
            name="columns"
            value={column}
            checked={selected.includes(column)}
            onChange={(checked) =>
              setSelected((current) =>
                checked ? [...current, column] : current.filter((item) => item !== column)
              )
            }
          />
          <Label>{column}</Label>
        </CheckboxField>
      ))}
    </CheckboxGroup>
  )
}
<script setup lang="ts">
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import CheckboxGroup from '@/components/checkbox-group.vue'
import Label from '@/components/label.vue'
import { computed, ref } from 'vue'

const columns = ['Invoice number', 'Customer', 'Amount']
const selected = ref(['Invoice number'])

const all = computed({
  get: () => selected.value.length > 0,
  set: (checked: boolean) => {
    selected.value = checked ? [...columns] : []
  },
})
const partial = computed(() => selected.value.length > 0 && selected.value.length !== columns.length)

function toggle(column: string, checked: boolean) {
  selected.value = checked ? [...selected.value, column] : selected.value.filter((item) => item !== column)
}
</script>

<template>
  <CheckboxGroup role="group" aria-label="Export columns">
    <CheckboxField>
      <Checkbox v-model="all" :indeterminate="partial" />
      <Label>All columns</Label>
    </CheckboxField>

    <CheckboxField v-for="column in columns" :key="column">
      <Checkbox
        name="columns"
        :value="column"
        :model-value="selected.includes(column)"
        @update:model-value="toggle(column, $event)"
      />
      <Label>{{ column }}</Label>
    </CheckboxField>
  </CheckboxGroup>
</template>

Show an option the plan does not include

Keeping an unavailable option on screen tells people it exists. disabled on the CheckboxField blocks the box and dims its label and description, and the description is a good place to say how to unlock it:

Download invoices and payouts as a spreadsheet.

Available on the Scale plan.

<CheckboxGroup>
  <CheckboxField>
    <Checkbox name="exports" value="csv" defaultChecked />
    <Label>CSV export</Label>
    <Description>Download invoices and payouts as a spreadsheet.</Description>
  </CheckboxField>
  <CheckboxField disabled>
    <Checkbox name="exports" value="audit_log" />
    <Label>Audit log export</Label>
    <Description>Available on the Scale plan.</Description>
  </CheckboxField>
</CheckboxGroup>
<CheckboxGroup>
  <CheckboxField>
    <Checkbox name="exports" value="csv" v-model="csv" />
    <Label>CSV export</Label>
    <Description>Download invoices and payouts as a spreadsheet.</Description>
  </CheckboxField>
  <CheckboxField disabled>
    <Checkbox name="exports" value="audit_log" />
    <Label>Audit log export</Label>
    <Description>Available on the Scale plan.</Description>
  </CheckboxField>
</CheckboxGroup>

To disable a lone box outside a field, put disabled on the Checkbox itself.

Forms

Keep the value in your own state

Pass checked and onChange when other parts of the screen depend on the box, such as a summary or a save button:

Bind a ref with v-model when other parts of the screen depend on the box, such as a summary or a save button:

Receipts: on

'use client'

import { useState } from 'react'
import { Checkbox, CheckboxField } from '@/components/checkbox'
import { Label } from '@/components/fieldset'

export default function Example() {
  let [receipts, setReceipts] = useState(true)

  return (
    <>
      <CheckboxField>
        <Checkbox checked={receipts} onChange={setReceipts} />
        <Label>Email me a receipt for each payout</Label>
      </CheckboxField>
      <p>Receipts: {receipts ? 'on' : 'off'}</p>
    </>
  )
}
<script setup lang="ts">
import Checkbox from '@/components/checkbox.vue'
import CheckboxField from '@/components/checkbox-field.vue'
import Label from '@/components/label.vue'
import { ref } from 'vue'

const receipts = ref(true)
</script>

<template>
  <CheckboxField>
    <Checkbox v-model="receipts" />
    <Label>Email me a receipt for each payout</Label>
  </CheckboxField>
  <p>Receipts: {{ receipts ? 'on' : 'off' }}</p>
</template>

Choose what the form submits

With a name, the box takes part in native form submission. value sets what is sent for it, which defaults to on:

<CheckboxField>
  <Checkbox name="events" value="payout.paid" />
  <Label>payout.paid</Label>
</CheckboxField>
<CheckboxField>
  <Checkbox name="events" value="payout.paid" />
  <Label>payout.paid</Label>
</CheckboxField>

Customization

Change the fill color

The ticked box follows your theme accent, and falls back to blue when none is set. Pass color when a box should carry a meaning of its own, like green for a settled payment:

<CheckboxField>
  <Checkbox color="emerald" checked={checked} onChange={setChecked} />
  <Label>Mark INV-2041 as paid</Label>
</CheckboxField>
<CheckboxField>
  <Checkbox color="emerald" v-model="checked" />
  <Label>Mark INV-2041 as paid</Label>
</CheckboxField>

Available colors

dark/zinc and dark/white swap shades between light and dark mode. The other 20 names keep the same fill in both:

<Checkbox color="dark/zinc" checked={checked} onChange={setChecked} />
<Checkbox color="dark/white" checked={checked} onChange={setChecked} />
<Checkbox color="teal" checked={checked} onChange={setChecked} />
<Checkbox color="dark/zinc" v-model="checked" />
<Checkbox color="dark/white" v-model="checked" />
<Checkbox color="teal" v-model="checked" />

Place the box after its label

When the field layout does not fit, for example a filter row with the label first, render the Checkbox alone and point your own <label> at it with id and htmlFor:

When the field layout does not fit, for example a filter row with the label first, render the Checkbox alone and point your own <label> at it with id and for:

<div className="flex items-center justify-between gap-4">
  <label htmlFor="overdue" className="text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">
    Only overdue invoices
  </label>
  <Checkbox id="overdue" name="overdue" />
</div>
<div class="flex items-center justify-between gap-4">
  <label for="overdue" class="text-base/6 text-zinc-950 select-none sm:text-sm/6 dark:text-white">
    Only overdue invoices
  </label>
  <Checkbox id="overdue" name="overdue" />
</div>

Accessibility

Inside a CheckboxField, the Label names the box and the Description is announced with it. Outside a field, nothing names the box for you.

Name a box that has no visible label

Row selection in a table is the usual case. Add an aria-label that says which record the box belongs to:

<Checkbox name="selected" value="INV-2041" aria-label="Select INV-2041" />