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
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
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 states, the value and the layout of
BbRadio and BbSwitch.
They behave identically, and those pages link back here.
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"
/>
<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"
/>
<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"
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.
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" />
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.
required sets the native attribute and puts the box into the browser's own
validation. For app-driven validation use errors: you control the wording, the
timing and the announcement.
Select all
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-sm flex-col gap-2 border-0 p-0">
<!--
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"
/>
<BbCheckboxGroup
id="scope-list"
v-model="notified"
class="pl-5"
hide-legend
:items="teams"
legend="Teams to notify"
name="notify-team"
/>
<p class="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.
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 gap-2">
<!--
A settings row: `reverse` puts the label first, `direction="horizontal"`
gives the two halves equal width, and `input-position` pins the box to
the trailing edge of its half.
-->
<BbCheckbox
id="layout-autoplay"
v-model="autoplay"
direction="horizontal"
input-position="right"
label="Autoplay videos"
name="autoplay"
reverse
/>
<BbCheckbox
id="layout-captions"
v-model="captions"
direction="horizontal"
input-position="right"
label="Always show captions"
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>
direction decides how the two halves share the width. auto, the default,
sizes each to its content on one line. 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.
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 flex max-w-sm flex-col gap-2 border-0 p-0">
<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"
: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.
-->
<input
v-model="notified"
class="sr-only"
name="notify-team"
type="checkbox"
:value="team"
>
<BbBaseCheckboxIcon :checked="notified.includes(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']);
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-warning 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. Pass focus-visible yourself, or a keyboard user
gets no ring.
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 live state: checked, indeterminate, disabled,
readonly, hasErrors, focused, focusVisible, value and text.
The custom properties live on the box element, so target that element rather than an ancestor:
.bb-checkbox .bb-base-checkbox-icon {
--size: 20px; /* box edge, default 16px */
--r: 6px; /* corner radius, default 4px */
--color: #16a34a; /* fill and border when checked */
--check-color: white; /* the checkmark stroke */
--ring-color: var(--bb-ring);
}
This is where v2's color prop went, removed with no replacement. Recolor
through --color, or through the theme's --bb-primary when the whole product
should follow.