Use it for
Reach for BbCheckbox when the answer is one yes/no standing on its own: accept
the terms, remember this device, include archived records.
Use something else when
BbCheckboxGroup: several boxes inside one set. Never hand-stackBbCheckboxBbRadioGroup: exactly one choice out of the setBbSelect: the set is long, searchable or fetchedBbSwitch: it is the state of a thing, like notifications or dark mode
Also see: Validated forms.
A checkbox is an answer, a switch is a state. A Save button below the field changes nothing about which of the two you have.
This page also covers the field states and the layout of
BbRadio and BbSwitch,
and the value contract of BbSwitch; those pages link back here.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
Default
You need a label and a bound v-model. Clicking the box or the label toggles
the value.
Model value: false
<template>
<div class="flex max-w-sm flex-col gap-2">
<BbCheckbox
id="basic-remember"
v-model="remember"
label="Remember this device for 30 days"
name="remember"
/>
<p class="text-sm opacity-70">
Model value: <code>{{ remember }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckbox } from 'bitboss-ui';
// A checkbox with no `true-value` / `false-value` stores a plain boolean.
const remember = ref(false);
</script>
label is required: it is the control's accessible name. id and name are
generated when you leave them off, but pass both in a form that posts.
States and messages
A checkbox carries the same chrome as every other field in the library, and the four message channels are not interchangeable.
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbCheckbox
id="states-digest"
v-model="digest"
description="Sent every Monday, and never more than once a week."
label="Weekly digest"
name="digest-state"
/>
<BbCheckbox
id="states-beta"
v-model="beta"
hint="You can leave the programme at any time."
label="Join the beta programme"
name="beta"
persistent-hint
/>
<BbCheckbox
id="states-terms"
v-model="terms"
:errors="terms ? undefined : 'Accept the terms to continue.'"
label="I accept the terms of service"
name="terms-state"
/>
<BbCheckbox
id="states-archived"
v-model="archived"
label="Include archived records in exports"
name="archived"
warnings="Exports will take noticeably longer."
/>
<!-- Readonly: focusable, announced, still submitted, never changed. -->
<BbCheckbox
id="states-sso"
checked
description="Enforced by your organisation."
label="Sign in with SSO"
name="sso"
readonly
/>
<!-- Disabled: out of the tab order, and its value never reaches the server. -->
<BbCheckbox
id="states-audit"
disabled
description="Available on the Scale plan."
label="Stream the audit log"
name="audit"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckbox } from 'bitboss-ui';
const digest = ref(true);
const beta = ref(false);
const terms = ref(false);
const archived = ref(true);
</script>
Put durable information in description and momentary guidance in hint.
hint shows only while the field has focus, unless persistent-hint pins it
open.
errors takes a string or an array, and a non-empty value puts the field into
the error state on its own. has-errors gives you the styling without a
message. warnings is the amber counterpart and does not set aria-invalid.
When both are set errors win, so the field is never red and amber at once.
All four are wired into aria-describedby in that order: errors, warnings,
description, hint. An aria-live="polite" region announces them when they
appear and re-reads them on focus, so keep them short.
readonly is not disabled
disabled sets the native attribute: the box leaves the tab order and its value
is never submitted. readonly keeps it focusable and submitted, and blocks only
the changes. Clicks and Space do nothing, and update:modelValue never fires.
Use readonly for a value that must reach the server without changing, and
disabled for a value that must not post. When you disable, say why in the
description: a box that is off for no visible reason reads as a bug.
What the model holds
With nothing else set the model is true or false. Point true-value and
false-value at your own values and it holds those.
<template>
<form
action="/preferences"
class="flex max-w-sm flex-col gap-2"
method="post"
@submit.prevent
>
<BbCheckbox
id="values-digest"
v-model="digest"
false-value="off"
label="Weekly digest"
name="digest-frequency"
submit-when-false
true-value="weekly"
/>
<p class="text-sm opacity-70">
Posted as <code>digest={{ digest }}</code> whichever way it is set.
</p>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckbox } from 'bitboss-ui';
// The model holds a domain value, never a boolean. Checked-ness is derived by
// comparing it with `true-value`, so it starts out on the false value.
const digest = ref<'weekly' | 'off'>('off');
</script>
Any serializable value works, matched structurally rather than by reference.
update:modelValue emits true-value on check and false-value on uncheck, so
never read the checked state off a @click or @change handler. Bind an array
instead and the checkbox adds or removes its true-value in it, as a native
checkbox does.
For a classic form post the native input carries name and a serialized value.
An unchecked box posts nothing, which is standard HTML and a recurring
surprise on the server. submit-when-false renders a hidden input carrying
false-value, so the field always arrives.
In an SPA or an Inertia app you bind into form state and drop both name and
submit-when-false.
<BbCheckbox v-model="form.newsletter" label="Subscribe to the newsletter" />
A select-all box
Not everything a box shows comes from its model. A parent box that summarises a group is checked when every child is on, and indeterminate when only some are.
<template>
<fieldset
class="m-0 flex max-w-xs flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<!--
The master is display-driven: `checked` and `indeterminate` are painted
from the children, and the toggle is handled as an event rather than
with v-model. Binding both would give the box two sources of truth.
-->
<BbCheckbox
id="scope-all"
:checked="allSelected"
:indeterminate="someSelected"
label="Notify every team"
name="notify-all"
@update:model-value="toggleAll"
/>
<hr class="m-0 border-[color:var(--bb-border)]">
<BbCheckboxGroup
id="scope-list"
v-model="notified"
class="pl-5"
hide-legend
:items="teams"
legend="Teams to notify"
list-layout="vertical"
name="notify-team"
/>
<p class="m-0 text-sm opacity-70">
{{ notified.length }} of {{ teams.length }} teams notified
</p>
</fieldset>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbCheckbox, BbCheckboxGroup } from 'bitboss-ui';
import { teams } from '~/demo-data';
const notified = ref<string[]>(['Engineering', 'Support']);
const allSelected = computed(() => notified.value.length === teams.length);
const someSelected = computed(
() => notified.value.length > 0 && !allSelected.value
);
const toggleAll = (checked: boolean) => {
notified.value = checked ? [...teams] : [];
};
</script>
indeterminate is a purely visual third state, a dash instead of a check. It
never enters the model, and the component re-applies it after every click. If
your server needs a real tri-state value, model it separately.
The master box is driven from outside, so it takes checked rather than a
v-model. Passing both gives it two sources of truth: pick one.
The children are a BbCheckboxGroup, not a hand-stacked row.
Coming from v2
checked used to accept the strings 'true' and 'false' too. In v3 it is a
plain boolean, so checked="false" no longer compiles. That string was truthy
and checked the box, so this is a loud failure replacing a silent one.
Consent that gates an action
The "I agree" box, with links inside its label, that unlocks the primary action.
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbCheckbox
id="consent-terms"
v-model="accepted"
label="I agree to the terms of service and the privacy policy"
name="terms"
required
>
<!-- The whole row is one <label>, so the anchors have to be real
anchors: a click on one navigates instead of toggling the box. -->
<template #label>
<span>
I agree to the
<a class="font-medium underline" href="#consent-terms">terms of service</a>
and the
<a class="font-medium underline" href="#consent-terms">privacy policy</a>
</span>
</template>
</BbCheckbox>
<BbButton class="self-start" :disabled="!accepted" variant="primary">
Create account
</BbButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbCheckbox } from 'bitboss-ui';
const accepted = ref(false);
</script>
The label slot decorates the text without touching the accessible name, and
receives { text, hasErrors, hasWarnings }. The whole control is one <label>,
so the links inside it have to be real anchors. A click on an <a> navigates, a
click on anything else toggles the box.
Bind the button's disabled to the model, rather than intercepting the click
and reading the box state by hand.
Inside a BbForm the box joins the form under its
name. Give it rules="required", a rule you register once,
and the form holds the submit until the box is ticked. The label is a sentence, so
name the field for the message with validation-label="Terms", or write the
message in the rule itself. The required prop on a box inside a form is a hint:
it marks the box and enforces nothing. Outside a form it sets the native
attribute, as in the demo; write your own message in errors.
A box that is not form data, such as a "show archived" filter, takes
formless and stays out of the submitted values.
Layout
By default the box comes first and the label follows. reverse swaps them, and
that is what turns a checkbox into a settings row.
<template>
<div
class="flex max-w-md flex-col divide-y divide-[color:var(--bb-border)] rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<!--
A settings row: `reverse` puts the label first and
`layout="label-fill"` gives it the free width, so every box sits on
the trailing edge. `compact` tightens the label-to-description gap.
-->
<BbCheckbox
id="layout-autoplay"
v-model="autoplay"
class="px-4 py-3"
compact
description="Starts muted. Pauses when the video scrolls out of view."
label="Autoplay videos"
layout="label-fill"
name="autoplay"
reverse
/>
<BbCheckbox
id="layout-captions"
v-model="captions"
class="px-4 py-3"
label="Always show captions"
layout="label-fill"
name="captions"
reverse
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckbox } from 'bitboss-ui';
const autoplay = ref(true);
const captions = ref(false);
</script>
layout decides how the two halves share the width. auto, the default,
sizes each to its content on one line. label-fill gives the label the free
width and input-fill the other way round; horizontal splits them evenly and
vertical stacks them. A two-token pattern like "x xxxxxxx" splits them by
token length, the first token sizing the element that renders first.
input-position and label-position (left, center, right) then align the
box and the text inside their columns.
compact tightens the gap between the label and the description, as on
the first row, for dense forms and settings panels.
hide-label keeps the label for screen readers and hides it on screen. It is
for a table's selection column, where visible text already labels the box, and
nothing else.
Coming from v2
reverse means the opposite of what it used to, and the compiler cannot see
it. The default layout is unchanged, so a checkbox with no reverse is fine.
What changed is the explicit value. In v2 reverse defaulted to true and
:reverse="false" produced the swap. In v3 reverse defaults to false, and
passing it opts into the swap. Delete reverse and :reverse="true",
rewrite :reverse="false" as a bare reverse, and grep for the prop on all
three single controls.
Custom markup
Sometimes the design is not a labelled row at all: a grid of selectable cards,
say. BbBaseCheckboxIcon gives you the library's box, and you own everything
around it.
<template>
<fieldset class="m-0 grid max-w-2xl gap-2 border-0 p-0 sm:grid-cols-3">
<legend class="sr-only">Teams to notify</legend>
<label
v-for="team in notifiable"
:key="team"
class="flex cursor-pointer items-center gap-3 rounded-[var(--bb-radius)] border p-3 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--bb-ring)]"
:class="
notified.includes(team)
? 'border-[color:var(--bb-primary)]'
: 'border-[color:var(--bb-border)]'
"
>
<!--
The glyph is aria-hidden and cannot be focused, so a real input has
to sit underneath it. `sr-only` keeps that input in the
accessibility tree; `display: none` would take it out of both the
tab order and the form. The glyph cannot see the input's focus, so
you pass it `focus-visible` yourself.
-->
<input
v-model="notified"
class="sr-only"
name="notify-card"
type="checkbox"
:value="team"
@blur="keyboardFocus = null"
@focus="onFocus(team, $event)"
>
<BbBaseCheckboxIcon
:checked="notified.includes(team)"
:focus-visible="keyboardFocus === team"
/>
<span class="flex flex-col">
<span class="text-sm font-medium">{{ team }}</span>
<span class="text-xs opacity-70">
{{ memberCount(team) }} members
</span>
</span>
</label>
</fieldset>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBaseCheckboxIcon } from 'bitboss-ui';
import type { Team } from '~/demo-data';
import { users } from '~/demo-data';
const notifiable: Team[] = ['Engineering', 'Design', 'Support'];
const notified = ref<Team[]>(['Engineering']);
// Only keyboard focus draws the ring, the same rule the library follows.
const keyboardFocus = ref<Team | null>(null);
const onFocus = (team: Team, event: FocusEvent) => {
const input = event.target as HTMLInputElement;
keyboardFocus.value = input.matches(':focus-visible') ? team : null;
};
const memberCount = (team: Team) =>
users.filter((user) => user.team === team).length;
</script>
BbBaseCheckboxIcon draws and nothing more: the rounded box, the animated check
and the indeterminate dash, with no input, no events and no state. You tell it
how to look with checked, indeterminate, disabled, readonly,
has-errors, has-warnings, focused and focus-visible.
Because it is aria-hidden and cannot be focused, it has to sit on a real
control. Copy the shape in the demo: a <label> wrapping an
<input type="checkbox"> hidden with sr-only. Never display: none, which
takes the input out of the tab order and out of the form. Keyboard, focus and
form submission come free. The icon cannot see the input's focus, so pass
focus-visible yourself, as the demo does, or a keyboard user gets no ring.
Tab into the cards to see it.
There is a smaller step before that one. The icon slot on BbCheckbox
replaces only the box visual and keeps the input, the label and the field
chrome. It receives the current state: checked, indeterminate, disabled,
readonly, hasErrors, hasWarnings, focused, focusVisible, value,
trueValue, falseValue and text.
Set the box's custom properties through pt:icon, so they land on the box
element and not on an ancestor:
<BbCheckbox
v-model="done"
label="Mark as done"
pt:icon="[--size:20px] [--r:6px] [--accent:#16a34a] [--check-color:white]"
/>
--size is the box edge (default 16px), --r the corner radius, --accent
the fill and border when checked, --check-color the checkmark stroke and
--border-color the unchecked border.
This is where v2's color prop went, removed with no replacement. Recolor
through --accent, or through the theme's --bb-primary when the whole product
should follow.