Use it for
Reach for BbCheckboxGroup when the answer is several choices out of one set:
which teams get the alert, which scopes the token carries. You hand it your
rows, it renders one box per row.
Use something else when
BbCheckbox: it is one yes/no standing aloneBbRadioGroup: exactly one choice out of the setBbSwitchGroup: they are on/off states, not answersBbSelect: the set is long, searchable or paged. Forty checkboxes are a scroll, not a control
Also see: Options and items, Fetching data, Coherence.
Do not hand-stack BbCheckbox. The group adds a fieldset, shared validation,
item mapping, and selection limits.
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.
Options from your data
Pass your domain objects as they are, and name the two fields that matter: the one the user reads, and the one you want in the model.
Model: [2, 5]
<template>
<div class="flex max-w-sm flex-col gap-3">
<!--
`items` takes the User objects exactly as the fixture holds them.
`item-text` and `item-value` say which field is shown and which one
lands in the model: here the id, not the whole record. `item-props`
adds a second line per option, and `vertical` stacks the list.
-->
<BbCheckboxGroup
id="notify-engineers"
v-model="notified"
description="They get an email whenever a deploy fails."
:item-props="{ description: 'email' }"
item-text="fullName"
item-value="id"
:items="engineers"
legend="Notify on failed deploys"
list-layout="vertical"
name="notify"
/>
<p class="text-sm opacity-70">
Model: <code>[{{ notified.join(', ') }}]</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import { usersByTeam } from '~/demo-data';
const engineers = usersByTeam[0]!.users.slice(0, 5);
// One array for the whole set. `multiple` defaults to true, so the model is an
// array; a non-array value such as `null` throws on mount.
const notified = ref<number[]>([2, 5]);
</script>
Objects with a label (or text) and a value key need no accessors. For any other shape use item-text and item-value; without item-text each label is the
object as JSON. Give every option a unique item-value, ideally a primitive id: each value
appears once in the list.
item-props adds a muted second line to each option, and list-layout="vertical" stacks the options once they carry one.
The default model is an array. For one exclusive value, use
BbRadioGroup instead of
:multiple="false".
The group renders a real <fieldset>, and legend is required: it names
the set for assistive technology. Use hide-legend when a nearby heading
already shows the same name. hide-label instead hides every option label.
The group takes one description and one hint; use separate
checkboxes when individual options need different explanations.
Locked and capped options
An option that exists but cannot be chosen is a predicate over the item, not a flag inside it.
1 on the rotation
<template>
<div class="flex max-w-2xl flex-col gap-3">
<!--
Availability is read off the row with a predicate: nothing on the User
object marks it, `selectable` asks the question and the item answers.
`item-props` puts the reason on the locked rows themselves.
-->
<BbCheckboxGroup
id="oncall-members"
v-model="onCall"
description="Deactivated members cannot take a shift."
:item-props="rowDetail"
item-text="fullName"
item-value="id"
:items="engineers"
legend="On-call rotation"
list-layout="tiles"
name="oncall"
pt:list="[--tile-min-width:12rem]"
:selectable="isActive"
/>
<p class="m-0 text-sm opacity-70">{{ onCall.length }} on the rotation</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import type { User } from '~/demo-data';
import { usersByTeam } from '~/demo-data';
// Active members first, so the locked rows sit together at the end.
const engineers = [...usersByTeam[0]!.users].sort(
(a, b) => Number(b.active) - Number(a.active)
);
const isActive = (member: User) => member.active;
// The second line says why a row is locked, right where the user looks.
const rowDetail = (member: User) => ({
description: member.active ? member.jobTitle : 'Deactivated',
});
const onCall = ref<number[]>([4]);
</script>
selectable takes a boolean for the whole set or a predicate per item.
Rejected options stay visible and disabled, so give them a reason on screen:
here the second line comes from item-props.
To freeze a group that already carries a selection, use
readonly: the options stay focusable and
the values still submit. disabled locks every option, ticked ones included, and
keeps them out of a native post.
max caps how many options may be on at once.
2 of 3 selected
<template>
<div class="flex max-w-md flex-col gap-3">
<BbCheckboxGroup
id="digest-topics"
v-model="topics"
description="Pick up to three. Untick one to make room for another."
list-layout="horizontal"
:items="categories"
legend="Topics in your digest"
:max="3"
name="topic"
/>
<p class="text-sm opacity-70">{{ topics.length }} of 3 selected</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import { categories } from '~/demo-data';
// Primitive items need no accessors at all: the string is both the text and
// the value.
const topics = ref<string[]>(['Engineering', 'Design']);
</script>
At the cap, unchecked options disable while checked ones stay removable. State
the limit in description and show a count; the component renders neither.
The cap works alongside a selectable predicate: the predicate locks the
options it rejects, and max caps the rest.
Coming from v2disabled on an item → selectable
v2's groups honoured a disabled field on the item object. In v3 it is
ordinary domain data with no UI meaning, so options that used to render locked
become tickable until you move the rule into selectable. Grep the code that builds your
items for disabled: and move each one into selectable:
- :items="channels.map((c) => ({ ...c, disabled: !c.configured }))"
+ :items="channels"
+ :selectable="(c) => c.configured"
Validating the whole set
Inside a BbForm the group joins the form under its
name, and rules validates the whole array as one field.
Without a name it joins under a slug of the legend, so pass one for a clean key.
<template>
<!--
Inside a BbForm the group joins the form under its `name`, and `rules`
validates the whole array as one field. `rules="required"` fails on an
empty array. The form holds the value
(bound once, on the form), and `@submit` runs only when the set passes.
-->
<BbForm
:model="alert"
class="flex max-w-md flex-col gap-4"
@submit="createAlert"
>
<BbCheckboxGroup
id="alert-teams"
description="Each team gets the alert in its on-call channel."
:items="teams"
legend="Teams this alert reaches"
name="teams"
rules="required"
/>
<BbButton
class="self-start"
type="submit"
variant="primary"
>
Create alert
</BbButton>
<p class="m-0 text-sm opacity-70" role="status">{{ status }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbCheckboxGroup, BbForm } from 'bitboss-ui';
import { delay, teams } from '~/demo-data';
// The group starts as an empty array on its own; the seed only spells out
// the shape of the values.
const alert = ref({ teams: [] as string[] });
const status = ref('');
// In an app this lives once in a validators module, not in the component.
// An empty array fails `required`, whatever its length would be.
defineRule('required', (value: unknown) => required(value) || 'Required.');
async function createAlert(values: Record<string, unknown>) {
await delay(null, 600);
status.value = `Alert created for ${(values.teams as string[]).join(', ')}.`;
}
</script>
There is no required prop here: "at least one" is a rule, and
rules="required" (a rule you register once)
fails on an empty array. It also marks the legend
(the asterisk). Submit with nothing ticked: the message shows once
focus leaves the group or on submit, then clears as soon as a box is ticked
(validation-mode changes when).
Outside a form, compute the one errors message for the set yourself:
<BbCheckboxGroup
v-model="scopes"
:errors="submitted && !scopes.length ? 'Choose at least one scope.' : []"
:items="available"
legend="Token scopes"
/>
When the options change
Give items a function instead of an array and the group loads its own options.
dependencies says what makes it load them again.
Model: [milan]
<template>
<div class="flex max-w-sm flex-col gap-5">
<BbRadioGroup
id="coherence-country"
v-model="country"
list-layout="horizontal"
item-text="name"
item-value="code"
:items="shippingCountries"
legend="Country"
/>
<!--
`items` is a function, so the group loads its own options. It reloads
whenever anything in `dependencies` changes, and `enforce-coherence`
drops any selected city the new list no longer contains. Switch the
country and watch the model empty itself.
-->
<BbCheckboxGroup
id="coherence-cities"
v-model="hubs"
:dependencies="[country]"
enforce-coherence
item-text="name"
item-value="id"
:items="loadCities"
legend="Distribution hubs"
loading-text="Loading hubs…"
name="hub"
empty-text="No hubs in this country."
/>
<p class="text-sm opacity-70">
Model: <code>[{{ hubs.join(', ') }}]</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup, BbRadioGroup } from 'bitboss-ui';
import type { City } from '~/demo-data';
import { cities, countries, delay } from '~/demo-data';
const shippingCountries = countries.slice(0, 3);
const country = ref<string>('IT');
const hubs = ref<string[]>(['milan']);
// A local fixture behind a timer stands in for the request. The provider is
// called on mount and again on every dependency change.
const loadCities = (): Promise<City[]> =>
delay(
cities.filter((city) => city.countryCode === country.value),
500
);
</script>
The provider runs on mount and when dependencies change.
deps-debounce-time groups rapid changes. Add enforce-coherence when stale
selections must be removed after options reload. loading-text and
empty-text (or the #loading and #empty slots) cover the wait and an
empty result.
Decorating an option
The label slot replaces the text of every option, and receives enough to
decide what each one looks like.
1 item in the bundle
<template>
<div class="flex max-w-md flex-col gap-3">
<BbCheckboxGroup
id="bundle-items"
v-model="bundle"
item-text="name"
item-value="id"
:items="desks"
legend="Items in the starter bundle"
name="bundle"
>
<!--
One template for every option. `item` is the row you passed in, so
the badge is decided from the data rather than from a second array
kept in step by hand.
-->
<template #label="{ item, text }">
<span>{{ text }}</span>
<BbBadge
v-if="item.status === 'low-stock'"
class="ms-2"
variant="secondary"
>
Low stock
</BbBadge>
</template>
</BbCheckboxGroup>
<p class="text-sm opacity-70">
{{ bundle.length }} {{ bundle.length === 1 ? 'item' : 'items' }} in the
bundle
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbCheckboxGroup } from 'bitboss-ui';
import { products } from '~/demo-data';
const desks = products.filter((product) => product.category === 'Furniture');
const bundle = ref<number[]>([101]);
</script>
The scope is { item, text, checked, description }. Keep text in the rendered label so the
option retains an accessible name. Use separate checkboxes when only one option
needs unique markup.
Coming from v2
#option:prepend and #option:append are gone. Everything they did moves into
#label, which can put content on either side of the text. Note that #prepend
and #append still exist and are unrelated. Those inject once into the options
container, before the first option and after the last.
Pick values with the tabs look
variant="pill" or
variant="line" gives the group a tab strip's look, for a multi-pick that reads
as a strip: opening days, channels, filters. pill draws one pill per run of
adjacent picks, so Mon–Wed reads as one block: ticking a neighbour stretches
it, unticking a middle option splits it. line draws a bar under each pick
across a baseline, and adjacent bars join.
<template>
<div class="flex flex-col gap-4">
<BbCheckboxGroup v-model="days" :items="weekdays" legend="Open on" variant="pill" />
<BbCheckboxGroup
v-model="channels"
:items="['Email', 'SMS', 'Push']"
legend="Notify by"
variant="line"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
// `pill` draws one pill per run of adjacent picks: tick Thu and the Mon–Wed
// pill grows, untick Tue and it splits. `line` draws a bar under each pick.
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const days = ref(['Mon', 'Tue', 'Wed', 'Fri']);
const channels = ref(['Email', 'Push']);
</script>
It is still a checkbox group: native checkboxes, max, forms and validation
work. The box is hidden (an icon slot shows before the label) and the strip
never wraps: it scrolls. list-layout="vertical" stacks it into a column and
"vertical-stretch" spans the field. pt:indicator reaches every pill or bar,
and a registered checkboxVariants: ['brand'] sets the strip's tokens on
.bb-checkbox-group__list--brand, drawing pills per run as pill does.
Layout and CSS
list-layout arranges the options; layout splits the legend from them.
<template>
<div class="flex max-w-xl flex-col gap-8">
<!--
`layout` splits the legend from the options, not the options from each
other: the two-token pattern gives the legend one share of the width
and the options five. `compact` tightens the label spacing.
-->
<BbCheckboxGroup
id="layout-teams"
v-model="scope"
compact
:items="teams"
layout="x xxxxx"
legend="Teams"
name="export-team"
/>
<!--
The box is the row's input cell, so aligning it is a class on
`pt:icon`. By default it centres on the whole label; `self-start`
pins it to the first line of a label that wraps. `legend-mode="inside"`
draws the fieldset border and sets the legend on it.
-->
<BbCheckboxGroup
id="layout-contents"
v-model="contents"
class="max-w-sm"
:items="exportContents"
legend="Include in the export"
legend-mode="inside"
list-layout="vertical"
name="export-content"
pt:icon="self-start mt-0.5"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import { teams } from '~/demo-data';
const exportContents = [
'Archived projects, including the ones closed more than a year ago',
'Comments and review threads on every task',
'File attachments, which can add several gigabytes to the download',
];
const scope = ref<string[]>(['Engineering', 'Design']);
const contents = ref<string[]>([exportContents[1]!]);
</script>
list-layout takes horizontal (the default, a wrapping row), vertical,
vertical-stretch (every row as wide as the list) and tiles.
compact tightens the label spacing. reverse, legend-position
and input-position arrange the legend against the options, and
legend-mode="inside" draws the fieldset border with the legend on it.
To align the box, put a class on pt:icon: self-start pins it to the first
line of a label that wraps.
Style the rows through each group's own classes, and list all three when a rule applies to every group:
.bb-checkbox-group__label,
.bb-radio-group__label,
.bb-switch-group__label {
font-weight: 500;
}
For control colours and geometry, use the box variables documented on
BbCheckbox.
Coming from v2bb-cr-container → one block per group
labelPosition is now legendPosition, with no behaviour change. A
leftover label-position falls through to the DOM and the alignment stops
applying; a development build warns about the rename, and npx bitboss-ui check --fix rewrites it.
In v2 one CSS block, bb-cr-container, served all three groups, so a rule
written for checkboxes hit radios too. Each group now has its own block, and
nothing in v3 renders the old prefix. Search styles and test selectors:
| v2 | v3 |
|---|---|
.bb-cr-container | .bb-checkbox-group__list, .bb-radio-group__list, .bb-switch-group__list |
.bb-cr-container--horizontal | .bb-…-group__list--horizontal |
.bb-cr-container--vertical | .bb-…-group__list--vertical-stretch (--vertical rows keep their own width) |
.bb-cr-container--errors | .bb-…-group__list--errors |
.bb-cr-container__container | merged into .bb-…-group__list |
.bb-cr-container__loading-container | .bb-…-group__loading |
.bb-cr-container__no-data-container | .bb-…-group__empty |
.bb-cr-container-option | .bb-…-group__item |
.bb-cr-container-option__text | .bb-…-group__label |
| — | .bb-…-group__list--tiles, --warnings and .bb-…-group__item--selected, all new |