Use it for
Reach for BbSwitch when the control is the on/off state of a thing: dark
mode, notifications, a beta flag, "anyone with the link can view".
Use something else when
BbCheckbox: it is an answer or a selection, like accepting terms or ticking "send me a copy"BbSwitchGroup: several related on/off settings bound to one array
Also see: Validated forms.
The commit model does not decide this. A switch fits both a live toggle and
a form that commits on Save: inside a BbForm it
joins the form by name and submits with the rest.
The field states, the value contract and the layout are identical to
BbCheckbox's, and are documented there.
Carry one thing over: reverse inverted its meaning in v3, and the label-first
settings row is where you will hit it.
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.
A settings panel
Give each setting its own BbSwitch and set layout="label-fill" with reverse, so the label reads first and every switch lines up on the trailing edge.
<template>
<div class="flex max-w-lg flex-col gap-6">
<fieldset
v-for="group in groups"
:key="group.title"
class="m-0 flex min-w-0 flex-col gap-2 border-0 p-0"
>
<legend class="mb-2 p-0">
<span class="block text-sm font-semibold">{{ group.title }}</span>
<span class="block text-xs text-[color:var(--bb-text-muted)]">
{{ group.summary }}
</span>
</legend>
<!--
One BbSwitch per field of the settings object. `layout="label-fill"`
gives the label the free width and `reverse` puts it first, so
every switch lines up on the trailing edge. `compact` tightens the
label-to-description gap on each row.
-->
<div
class="divide-y divide-[color:var(--bb-border)] rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<BbSwitch
v-for="row in group.rows"
:id="`settings-${row.key}`"
:key="row.key"
v-model="settings[row.key]"
class="px-4 py-3"
compact
:description="blockedBy(row) ? row.blockedReason : row.description"
:disabled="blockedBy(row)"
:label="row.label"
layout="label-fill"
:name="row.key"
reverse
/>
</div>
</fieldset>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { BbSwitch } from 'bitboss-ui';
type SettingKey =
| 'mentions'
| 'desktop'
| 'desktopSound'
| 'digest'
| 'publicProfile'
| 'onlineStatus'
| 'readReceipts';
type Row = {
key: SettingKey;
label: string;
description: string;
/** Another setting that has to be on before this one can change. */
requires?: SettingKey;
blockedReason?: string;
};
const settings = reactive<Record<SettingKey, boolean>>({
mentions: true,
desktop: false,
desktopSound: false,
digest: true,
publicProfile: true,
onlineStatus: true,
readReceipts: false,
});
const blockedBy = (row: Row) =>
row.requires !== undefined && !settings[row.requires];
const groups: { title: string; summary: string; rows: Row[] }[] = [
{
title: 'Notifications',
summary: 'Changes apply as soon as you flip them.',
rows: [
{
key: 'mentions',
label: 'Mentions and replies',
description: 'Email me when someone @mentions me or replies to my comment.',
},
{
key: 'desktop',
label: 'Desktop notifications',
description: 'Show a banner on this computer while the app is open.',
},
{
key: 'desktopSound',
label: 'Play a sound',
description: 'A short chime with every desktop banner.',
requires: 'desktop',
blockedReason: 'Turn on desktop notifications to choose a sound.',
},
{
key: 'digest',
label: 'Weekly digest',
description: 'A summary of open tasks, every Monday at 9:00.',
},
],
},
{
title: 'Privacy',
summary: 'Who can see you and your activity.',
rows: [
{
key: 'publicProfile',
label: 'Public profile',
description: 'Anyone with the link can see your name, role and avatar.',
},
{
key: 'onlineStatus',
label: 'Show online status',
description: 'Teammates see a green dot next to your name.',
},
{
key: 'readReceipts',
label: 'Read receipts',
description: 'Let senders know when you have opened their message.',
},
],
},
];
</script>
When a setting only makes sense after another one, set disabled and say why in description. compact tightens the gap between each label and its description, which keeps a long panel dense. When a description runs to two lines,
input-align="center" centres the switch on the label and description together instead of
on the first line. If the rows are one array of enabled values rather than separate fields, use BbSwitchGroup instead.
The smallest switch is a label and a v-model, which updates on every toggle, whether the user clicks the control or presses Space on it. description is always-visible helper text; hint shows only while the switch has focus, unless you pin it with persistent-hint.
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbSwitch
id="basic-2fa"
v-model="twoFactor"
description="Ask for a one-time code at every sign-in."
label="Two-factor authentication"
name="two-factor"
/>
<BbSwitch
id="basic-digest"
v-model="digest"
hint="Sent on Monday mornings."
label="Weekly digest email"
name="weekly-digest"
persistent-hint
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSwitch } from 'bitboss-ui';
const twoFactor = ref(true);
const digest = ref(false);
</script>
The control exposes role="switch" and its checked state on its own, so add no
ARIA of your own. label is required and is the accessible name. hide-label
keeps it for assistive technology only, for the rare row where adjacent text
already names the switch.
Besides update:modelValue the switch re-emits the native events: change,
click, input, focus, blur, keydown, mousedown, mouseup. Use them
for analytics or side effects, never to work out the new value.
States at a glance
Every field state a switch can show, next to each other.
<template>
<div class="grid max-w-2xl gap-x-8 gap-y-4 sm:grid-cols-2">
<!-- Each state is set as a prop; nothing here is wired to a request. -->
<BbSwitch
id="state-off"
v-model="state.off"
description="Default, not checked."
label="Off"
name="state-off"
/>
<BbSwitch
id="state-on"
v-model="state.on"
description="Default, checked."
label="On"
name="state-on"
/>
<BbSwitch
id="state-disabled"
v-model="state.disabled"
description="Out of the tab order, never submitted."
disabled
label="Disabled"
name="state-disabled"
/>
<BbSwitch
id="state-readonly"
v-model="state.readonly"
description="Focusable and submitted, but locked."
label="Read-only"
name="state-readonly"
readonly
/>
<BbSwitch
id="state-errors"
v-model="state.errors"
errors="Two-factor is required for admins."
label="With errors"
name="state-errors"
/>
<BbSwitch
id="state-warnings"
v-model="state.warnings"
label="With warnings"
name="state-warnings"
warnings="Guests will lose access at midnight."
/>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { BbSwitch } from 'bitboss-ui';
const state = reactive({
off: false,
on: true,
disabled: true,
readonly: true,
errors: false,
warnings: true,
});
</script>
Lock an admin-managed setting with readonly, not disabled: it stays focusable and still submits. The difference between errors and warnings is explained on BbCheckbox.
When the model is not a boolean
Point true-value and false-value at the two members of an enum and the model
holds whichever the current state maps to.
Stored value: private
<template>
<div class="flex max-w-sm flex-col gap-2">
<BbSwitch
id="enum-visibility"
v-model="visibility"
false-value="private"
label="Public profile"
name="visibility"
true-value="public"
/>
<p class="text-sm opacity-70">
Stored value: <code>{{ visibility }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSwitch } from 'bitboss-ui';
// The API this feeds takes 'public' | 'private', so the model holds exactly
// that: no boolean to translate on the way in or out.
const visibility = ref<'public' | 'private'>('private');
</script>
Do this whenever the API behind the switch speaks in names rather than booleans: a visibility setting, a plan tier, a retention mode. The translation layer you would otherwise write on both sides of the request stops existing.
The rest of the value contract is shared with BbCheckbox: any serializable
value, compared structurally. When a classic form post must carry the unchecked
case, set submit-when-false together with name. See
what the model holds.
Saving on toggle or on submit
A live switch, one with no Save button behind it, owns its own persistence, and the request can fail after the thumb has already moved.
<template>
<div class="flex max-w-sm flex-col gap-2">
<!--
Not v-model: the model is bound one way and the update is handled, so
the value written optimistically can be put back if the request fails.
-->
<BbSwitch
id="save-maintenance"
description="Visitors see a status page while this is on."
:errors="error"
label="Maintenance mode"
:model-value="maintenance"
name="maintenance"
:readonly="saving"
@update:model-value="save"
/>
<p class="m-0 text-sm opacity-70" role="status">
{{ saving ? 'Saving…' : '' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSwitch } from 'bitboss-ui';
import { delay } from '~/demo-data';
const maintenance = ref(false);
const saving = ref(false);
const error = ref<string | undefined>(undefined);
/** Stands in for the API: turning it on succeeds, turning it off is refused. */
const persist = async (value: boolean) => {
await delay(null, 400);
if (!value) throw new Error('A deployment is in progress.');
};
const save = async (value: boolean) => {
error.value = undefined;
saving.value = true;
maintenance.value = value;
try {
await persist(value);
} catch {
maintenance.value = !value;
error.value = 'A deployment is in progress. The setting was restored.';
} finally {
saving.value = false;
}
};
</script>
Bind :model-value and handle @update:model-value rather than using
v-model, because you need to put the value back. Write the value optimistically so
the control responds at once. Hold it readonly while the request is in flight,
so nobody queues conflicting toggles, and restore the previous value on failure.
In this demo turning it on succeeds and turning it off is refused, so flip it
off to see the revert. In a real application pair the failure with useToast,
because the user may not still be looking at that corner of the page.
Joined to a BbForm, do none of this. The switch joins
the form under its name, and the form submits it with everything else.
<template>
<!--
A submit-gated form: the switches write nothing on their own. Each one
joins the BbForm under its `name`, and the form submits them together.
"Show advanced options" only changes the view, so `formless` keeps it
out of the values.
-->
<BbForm
:model="token"
class="flex max-w-md flex-col gap-4"
@submit="create"
>
<BbTextInput
id="token-name"
label="Token name"
name="name"
:rules="named"
/>
<BbSwitch
id="token-read-only"
description="The token can read data but never change it."
label="Read-only access"
name="readOnly"
/>
<BbSwitch
id="token-advanced"
v-model="advanced"
formless
label="Show advanced options"
/>
<BbSwitch
v-if="advanced"
id="token-expires"
description="Revoked automatically after 30 days."
label="Expire the token"
name="expires"
/>
<BbButton
class="self-start"
type="submit"
variant="primary"
>
Create token
</BbButton>
<p class="m-0 text-sm opacity-70" role="status">
Submitted: <code>{{ submitted }}</code>
</p>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbForm, BbSwitch, BbTextInput } from 'bitboss-ui';
import { delay } from '~/demo-data';
// Real defaults are seeded; the name is left for the user to type.
const token = ref({ readOnly: true, expires: true });
const advanced = ref(false);
const submitted = ref('nothing yet');
const named = (value: string | null | undefined) =>
!!value?.trim() || 'Name the token.';
async function create(values: Record<string, unknown>) {
await delay(null, 500);
// One line, spaced: { "name": "…", "readOnly": true }
submitted.value = JSON.stringify(values, null, 1).replace(/\n\s*/g, ' ');
}
</script>
A switch that only changes the view, like "Show advanced options", would join
too and land in the submitted values. formless keeps it out. A field hidden
with v-if leaves the values too, so "Expire the token" is not sent while the
advanced options are closed. To keep its value, set keep-values-on-unmount on the form.
To make a joined switch mandatory, say an agreement that must be on, give it
rules="required". The
required prop is for a switch outside a form: on a joined one it is
a hint that marks the switch and checks nothing. The asterisk follows the rule.
What a surface must not do is both. Take a switch that applied the moment it was flipped, under a Save button that implies it had not: the user cannot tell what is already live. Pick one commit model per surface.
A switch driven from outside
Two props paint the control from state you own elsewhere, without either of them entering the model.
<template>
<div class="flex max-w-sm flex-col gap-3">
<!--
The master owns no state. `checked` and `indeterminate` are painted
from the children below, and `readonly` says so: it reports, it does
not switch anything.
-->
<BbSwitch
id="passive-master"
:checked="allOn"
hint="Some channels are on and some are off."
:indeterminate="someOn"
label="All notifications"
name="notify-all"
:persistent-hint="someOn"
readonly
/>
<div class="flex flex-col gap-3 pl-5">
<BbSwitch
v-for="channel in channels"
:id="`passive-${channel.key}`"
:key="channel.key"
v-model="channel.enabled"
:label="channel.label"
:name="channel.key"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { BbSwitch } from 'bitboss-ui';
const channels = reactive([
{ key: 'product', label: 'Product updates', enabled: true },
{ key: 'security', label: 'Security alerts', enabled: true },
{ key: 'marketing', label: 'Marketing emails', enabled: false },
]);
const onCount = computed(
() => channels.filter((channel) => channel.enabled).length
);
const allOn = computed(() => onCount.value === channels.length);
const someOn = computed(() => onCount.value > 0 && !allOn.value);
</script>
checked forces the checked visual regardless of modelValue, and re-syncs
whenever the prop changes. Interaction still emits update:modelValue for you
to handle. Passing it together with a v-model gives the switch two sources of
truth, and checked wins until you unset it.
indeterminate parks the thumb midway, for a master switch whose children
disagree. It is a visual flag only: the model keeps its value, and the component
re-asserts the state after clicks.
Coming from v2
checked used to accept the strings 'true' and 'false' as well. In v3 it is
a plain boolean, so checked="false" no longer compiles. That string was truthy
and turned the switch on, so this is a loud failure replacing a silent one.
Custom track and markup
There are two escape hatches here, and they are different sizes. Take the smaller one first.
The icon slot replaces the entire track and thumb, not a mark inside them,
while keeping the input, the label, the field chrome and role="switch". It
receives the current state: checked, focused, focusVisible, disabled,
readonly, indeterminate, hasErrors, hasWarnings, value, trueValue,
falseValue and text.
<template>
<BbSwitch id="icon-theme" v-model="dark" label="Dark mode" name="theme">
<!--
This replaces the track and the thumb, not a glyph inside them. What
is rendered here is the entire visual, so it has to say "on", "off"
and "focused" by itself. Both icons render and v-show picks one, so
the first flip does not wait for the other icon to load.
-->
<template #icon="{ checked, focusVisible }">
<span
class="glyph"
:class="{ 'glyph--on': checked, 'glyph--focus': focusVisible }"
>
<BbIcon v-show="checked" icon="lucide:moon" size="xs" />
<BbIcon v-show="!checked" icon="lucide:sun" size="xs" />
</span>
</template>
</BbSwitch>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbIcon, BbSwitch } from 'bitboss-ui';
const dark = ref(true);
</script>
<style scoped>
.glyph {
align-items: center;
background: var(--bb-muted);
border-radius: 999px;
color: var(--bb-text-muted);
display: inline-flex;
height: 1.5rem;
justify-content: center;
transition:
background var(--bb-transition-fast) var(--bb-ease),
color var(--bb-transition-fast) var(--bb-ease);
width: 1.5rem;
}
.glyph--on {
background: var(--bb-primary);
color: var(--bb-primary-fg);
}
.glyph--focus {
box-shadow:
0 0 0 2px var(--bb-panel),
0 0 0 4px var(--bb-ring);
}
</style>
Because the slot is the whole visual, what you put there has to communicate on, off and focused by itself. The common mistake is passing a bare icon and expecting the track to stay behind it: there is no track any more, so wrap the icon in a container you have styled.
To change only the label, say to append a BbBadge reading "Beta", fill the label slot instead. It receives text, and the accessible name stays.
When even the row is wrong, say a toolbar toggle or a compact inline control,
BbBaseSwitchIcon gives you the track and thumb on their own.
<template>
<div
class="flex max-w-sm flex-col divide-y divide-[color:var(--bb-border)] rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
>
<label
v-for="flag in flags"
:key="flag.key"
class="flex cursor-pointer items-center justify-between gap-3 p-3 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--bb-ring)] has-[:focus-visible]:ring-inset"
>
<span class="flex flex-col">
<span class="text-sm font-medium">{{ flag.label }}</span>
<span class="text-xs opacity-70">{{ flag.hint }}</span>
</span>
<!--
The glyph draws the state; the input owns focus, the keyboard and
the switch semantics. role="switch" is what makes a screen reader
announce "on"/"off" rather than "checked"/"unchecked". The glyph
cannot see the input's focus, so you pass it `focus-visible`.
-->
<input
v-model="flag.enabled"
class="sr-only"
:name="flag.key"
role="switch"
type="checkbox"
@blur="keyboardFocus = null"
@focus="onFocus(flag.key, $event)"
>
<BbBaseSwitchIcon
:checked="flag.enabled"
:focus-visible="keyboardFocus === flag.key"
/>
</label>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { BbBaseSwitchIcon } from 'bitboss-ui';
const flags = reactive([
{
key: 'block-editor',
label: 'Block editor',
enabled: true,
hint: 'Slash commands and drag-to-reorder',
},
{
key: 'thread-summaries',
label: 'Thread summaries',
enabled: false,
hint: 'Condenses long comment threads',
},
{
key: 'usage-analytics',
label: 'Usage analytics',
enabled: false,
hint: 'Anonymised retention metrics',
},
]);
// Only keyboard focus draws the ring, the same rule the library follows.
const keyboardFocus = ref<string | null>(null);
const onFocus = (key: string, event: FocusEvent) => {
const input = event.target as HTMLInputElement;
keyboardFocus.value = input.matches(':focus-visible') ? key : null;
};
</script>
BbBaseSwitchIcon has no input, no events and no state. It is aria-hidden and
cannot be focused, so it must sit on a real control: a <label> wrapping an
<input type="checkbox" role="switch"> hidden with sr-only, never with
display: none. That role="switch" is what makes a screen reader say "on" and
"off" rather than "checked". The track cannot see the input's focus, so pass
focus-visible yourself, as the demo does, or a keyboard user gets no ring.
The custom properties live on the track element, so target that element rather than an ancestor. Change the geometry freely, because everything else derives from it: the thumb stays centred and travels flush at any size.
.bb-switch .bb-base-switch-icon {
--w: 44px; /* track width, default 32px */
--h: 24px; /* track height, default 18px */
--thumb: 20px; /* thumb diameter, default 16px */
--accent: #16a34a; /* track fill when on */
--r: 6px; /* default 999px */
}
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.