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
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
The commit model does not decide this. A switch is equally correct as a live
toggle and as a field inside a form that commits on Save, and the library ships
a validated BbSwitch from bitboss-ui/validated for exactly that.
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.
Default
v-model holds the state and updates on every toggle, whether the user clicks
the control or presses Space on it.
<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="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.
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, structural equality, and submit-when-false plus name when a classic
form post has to carry the unchecked case. See
what the model holds.
Saving on toggle
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.
Off
<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="text-sm opacity-70">
{{ saving ? 'Saving…' : maintenance ? 'On' : 'Off' }}
</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 it 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.
Inside a submit-gated form, do none of this. Bind v-model and let the form
commit.
What a surface must not do is both. A switch that already applied the moment it was flipped, sitting under a Save button that implies it had not, leaves the user unable to 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 live state: checked, focused, focusVisible, disabled,
readonly, indeterminate, hasErrors, 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.
-->
<template #icon="{ checked, focusVisible }">
<span
class="glyph"
:class="{ 'glyph--on': checked, 'glyph--focus': focusVisible }"
>
<BbIcon :icon="checked ? 'lucide:moon' : '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.
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 rounded-[var(--bb-radius)] border"
>
<label
v-for="flag in flags"
:key="flag.key"
class="flex cursor-pointer items-center justify-between gap-3 p-3"
>
<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".
-->
<input
v-model="flag.enabled"
class="sr-only"
:name="flag.key"
role="switch"
type="checkbox"
>
<BbBaseSwitchIcon :checked="flag.enabled" />
</label>
</div>
</template>
<script setup lang="ts">
import { reactive } 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',
},
]);
</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". Pass focus-visible yourself, 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 */
--color: #16a34a; /* track fill when on */
--radius: 6px; /* default 999px */
}
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.