Use it for
Use BbSwitchGroup for related states of things: storefront sections, feature flags, or notification channels.
Use something else when
BbCheckboxGroup: the rows are answers to a questionBbSwitch: there is one standalone stateBbRadioGroup: exactly one answer must be selected
Also see: Options and items.
The items, mapping, async provider, and coherence API is shared with BbCheckboxGroup. This page covers the switch-specific decisions.
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.
Save settings on every flip
For a live settings panel, apply the next array optimistically, hold the group readonly during the request, and restore the previous array on failure.
<template>
<div class="flex max-w-md flex-col gap-3">
<!--
`:model-value` and `@update:model-value` instead of `v-model`, because
the handler has to be able to put the old value back. The group is
readonly while the request is in flight so nobody queues two flips.
A refused save is reported in the status line, not in `errors`:
group errors paint every row red, and only one row failed.
`vertical-stretch` + `label-fill` + `item-reverse` make it a settings
list, with the switch on the trailing edge of each full-width row.
-->
<BbSwitchGroup
id="storefront-sections"
item-layout="label-fill"
:item-props="{ description: productCount }"
item-reverse
:items="sections"
legend="Sections visible in the storefront"
list-layout="vertical-stretch"
:model-value="visible"
name="section"
pt:item="px-4 py-2.5"
pt:list="[--row-gap:0px] divide-y divide-[color:var(--bb-border)] rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
:readonly="saving"
@update:model-value="onSave"
/>
<p
class="m-0 text-sm"
:class="refused ? 'text-[color:var(--bb-danger)]' : 'opacity-70'"
role="status"
>
{{ status }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSwitchGroup } from 'bitboss-ui';
import type { ProductCategory } from '~/demo-data';
import { delay, productCategories, products } from '~/demo-data';
const sections = productCategories.slice(0, 5);
const productCount = (category: ProductCategory) =>
`${products.filter((product) => product.category === category).length} products`;
const visible = ref<string[]>(['Furniture', 'Displays', 'Input Devices']);
const saving = ref(false);
const refused = ref(false);
const status = ref('');
const onSave = async (next: string[]) => {
const previous = visible.value;
// Optimistic: the switch moves now, and moves back only if the save fails.
visible.value = next;
saving.value = true;
refused.value = false;
status.value = 'Saving…';
await delay(null, 700);
saving.value = false;
if (previous.includes('Displays') && !next.includes('Displays')) {
visible.value = previous;
refused.value = true;
status.value = 'Displays has live campaigns and cannot be hidden.';
return;
}
status.value = `Saved: ${next.join(', ') || 'nothing visible'}.`;
};
</script>
Turn Displays off: the save is refused and the switch goes back. Bind :model-value with @update:model-value when rollback matters; the event carries the whole next array. Report a refused save in a status line or a toast rather than in errors, which paints every row of the group. Inside a BbForm do none of this: the group joins the form under its name and submits the array with everything else.
The settings-list shape comes from list-layout="vertical-stretch", item-layout="label-fill" and item-reverse; the second line of each row is item-props. When each setting is its own field rather than a member of one array, use separate BbSwitch rows.
Explain unavailable switches
max disables the switches that are off once the cap is reached, and leaves the ones that are on free to trade.
1 of 2 used
<template>
<div class="flex max-w-md flex-col gap-3">
<!--
At the cap, the switches that are off disable themselves and the ones
that are on stay live, so the user trades one team for another instead
of getting stuck. The description says why nothing else moves.
-->
<BbSwitchGroup
id="escalation-teams"
v-model="escalation"
description="Two teams at a time. Turn one off to pick another."
:items="teams"
legend="Escalate to"
:max="2"
name="escalate"
/>
<p class="m-0 text-sm opacity-70">{{ escalation.length }} of 2 used</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSwitchGroup } from 'bitboss-ui';
import { teams } from '~/demo-data';
// One slot left, so the page opens in the normal look and one flip reaches
// the cap.
const escalation = ref<string[]>(['Support']);
</script>
State the rule in description; an unexplained switch that does not move looks broken. To lock specific items, pass selectable a predicate, as on BbCheckboxGroup: the predicate locks the items it rejects and max caps the rest. To freeze the whole group, use readonly when the values must still submit and disabled when they must not.
Coming from v2disabled on an item → selectable
A disabled field on an item is ignored in v3. Move that condition into selectable.
Choose the model shape
Multiple mode is the default: the model is an array (unbound, it starts empty; null throws). :multiple="false" emits one bare value or null.
Model: 'IT'
<template>
<div class="flex max-w-sm flex-col gap-3">
<!--
`:multiple="false"` makes the group single-value: turning one on turns
the current one off, and turning the last one off emits null.
-->
<BbSwitchGroup
id="primary-residency"
v-model="residency"
description="Exactly one region holds the primary copy of your data."
item-text="name"
item-value="code"
:items="regions"
legend="Primary data residency"
:multiple="false"
name="residency"
/>
<p class="text-sm opacity-70">
Model:
<code>{{ residency === null ? 'null' : `'${residency}'` }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSwitchGroup } from 'bitboss-ui';
import { countries } from '~/demo-data';
const regions = countries.slice(0, 3);
// A bare value, not an array of one. Seed it accordingly.
const residency = ref<string | null>('IT');
</script>
Use stable primitive values, not whole domain objects. { label, value } items work as they are; for any other shape name the fields with item-value and item-text. An object with none of those fields becomes the model value whole, and its label is its JSON.
Coming from v2single-value mode now works
v2 accepted :multiple="false" and ignored it, so the model stayed an array. v3 honours it and emits a bare value or null. Audit existing models before upgrading.
Lay out the rows
Rows wrap on one line by default, as in Editor, where compact also tightens the spacing. list-layout stacks them (vertical, Daily digest) or turns them into equal boxes (tiles, Ship to).
<template>
<div class="flex max-w-xl flex-col gap-6">
<!-- The default: rows on one line, wrapping. `compact` tightens it. -->
<BbSwitchGroup
id="layout-editor"
v-model="editor"
compact
:items="['Word wrap', 'Minimap', 'Line numbers']"
legend="Editor"
name="editor"
/>
<BbSwitchGroup
id="layout-digest"
v-model="digest"
:items="['Mentions', 'Assigned tasks', 'Comments on my docs']"
legend="Daily digest"
list-layout="vertical"
name="digest"
/>
<!-- Equal boxes that wrap as the field narrows. -->
<BbSwitchGroup
id="layout-regions"
v-model="regions"
item-text="name"
item-value="code"
:items="countries.slice(0, 6)"
legend="Ship to"
list-layout="tiles"
name="regions"
pt:list="[--tile-min-width:9rem]"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSwitchGroup } from 'bitboss-ui';
import { countries } from '~/demo-data';
const editor = ref<string[]>(['Word wrap', 'Line numbers']);
const digest = ref<string[]>(['Mentions', 'Assigned tasks']);
const regions = ref<string[]>(['IT', 'FR']);
</script>