Use it for
There are two reasons to reach for a lone BbRadio. Either the options must sit
inside other markup, such as a pricing card per option, or one option needs
a label the others do not have.
Use something else when
BbRadioGroup: almost always. It wraps the same buttons in a real<fieldset>, maps youritems, wiresnameand the model, and gives you one validation channel for the setBbSelect: the set is large or dynamicBbCheckbox: it is an independent yes/no
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 lone BbRadio is an escape hatch. This page is mostly about knowing whether
you are standing in front of one.
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, so an explicit value
you are migrating has to be flipped.
An exclusive group by hand
Three things make a set of BbRadio behave as one group: a shared name, the
same v-model, and a distinct value on each button.
Model value: 1
<template>
<!--
A hand-composed set has no fieldset semantics of its own, so the container
carries role="radiogroup" and its accessible name.
-->
<div
aria-label="Default assignee"
class="flex max-w-sm flex-col gap-2"
role="radiogroup"
>
<BbRadio
v-for="user in candidates"
:id="`assignee-${user.id}`"
:key="user.id"
v-model="assignee"
:label="user.fullName"
name="assignee"
:value="user.id"
/>
<p class="text-sm opacity-70">
Model value: <code>{{ assignee }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRadio } from 'bitboss-ui';
import { users } from '~/demo-data';
const candidates = users.slice(0, 3);
// One model for the whole set. Each button contributes a distinct `value`, and
// every button repeats the same `name` — that is what makes them one native
// group, and what gives you arrow-key navigation without writing any.
const assignee = ref<number>(candidates[0]!.id);
</script>
Selecting one deselects the others, because they share the model. And because
they are native radios sharing a name, the browser gives you roving focus for
free: Tab reaches the set once, and the arrow keys move focus and selection
between the buttons.
value is what lands in the model. Any serializable value works and is matched
structurally, but prefer stable primitive ids: they survive a refetch and are
cheap to submit.
What you do not get for free is the grouping semantics. The container is
yours, and so are its role="radiogroup" and its accessible name. A set of
radios with no wrapper is announced as three unrelated buttons.
Coming from v2
name is no longer required. Omit it and the component generates one, unique to
that button and stable for its lifetime, so a lone radio is never left unnamed.
Watch the group case, though: generated names differ per button. Without an
explicit shared name no native group forms, and you lose exclusivity and the
arrow keys with it.
Options inside other markup
The first reason to hand-compose: each option carries content that sits beside the radio rather than inside it.
<template>
<div
aria-label="Workspace plan"
class="grid max-w-sm gap-2"
role="radiogroup"
>
<!--
The price lives outside the radio, in the card. That is the whole
reason this is not a BbRadioGroup: `items` maps one label per option
and cannot put a second column beside it.
-->
<div
v-for="plan in plans"
:key="plan.value"
class="flex items-start justify-between gap-3 rounded-[var(--bb-radius)] border p-3"
:class="
selected === plan.value
? 'border-[color:var(--bb-primary)]'
: 'border-[color:var(--bb-border)]'
"
>
<BbRadio
:id="`plan-${plan.value}`"
v-model="selected"
:description="plan.description"
:label="plan.label"
name="plan"
:value="plan.value"
/>
<span class="shrink-0 pt-0.5 text-xs font-medium tabular-nums">
{{ plan.price }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRadio } from 'bitboss-ui';
type Plan = {
value: string;
label: string;
description: string;
price: string;
};
const plans: Plan[] = [
{
value: 'starter',
label: 'Starter',
description: 'One seat, community support.',
price: 'Free',
},
{
value: 'team',
label: 'Team',
description: 'Shared workspaces and role management.',
price: '€24 / mo',
},
{
value: 'scale',
label: 'Scale',
description: 'SSO, audit log and priority support.',
price: '€96 / mo',
},
];
const selected = ref('team');
</script>
BbRadioGroup maps one label and one value per item. When an option needs a
second column, a price or a thumbnail say, that mapping runs out and you want a
BbRadio per option inside your own card.
Keep description on the radio rather than in the card. It is wired into the
input's aria-describedby, so it is read as part of the option. The same text
in a sibling <p> is read as unrelated page content.
Per-option labels
The second reason: one option needs a badge, emphasis or a link that the others do not.
<template>
<div
aria-label="Merge method"
class="flex max-w-sm flex-col gap-2"
role="radiogroup"
>
<BbRadio
id="merge-commit"
v-model="method"
description="Every commit on the branch is kept, under a merge commit."
label="Create a merge commit"
name="merge-method"
value="merge"
/>
<BbRadio
id="merge-squash"
v-model="method"
description="Combine the branch into a single commit on main."
label="Squash and merge"
name="merge-method"
value="squash"
>
<!--
`text` is the label prop, so the accessible name stays the same
string whatever the slot renders around it.
-->
<template #label="{ text }">
<span class="inline-flex items-center gap-1.5">
{{ text }}
<BbBadge size="xs" variant="secondary">Recommended</BbBadge>
</span>
</template>
</BbRadio>
<BbRadio
id="merge-rebase"
v-model="method"
description="Reapply each commit on main, without a merge commit."
label="Rebase and merge"
name="merge-method"
value="rebase"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbRadio } from 'bitboss-ui';
const method = ref<'merge' | 'squash' | 'rebase'>('squash');
</script>
The label slot replaces the rendered label and receives
{ text, hasErrors, hasWarnings }, where text is the label prop. Keep
passing label even when you override it: that string is still the accessible
name.
Reserve this for the option that genuinely differs. If every option needs the
same decoration you have a uniform mapping again, and BbRadioGroup does it
with less markup.
Errors and locked options
errors lives on a button and the error belongs to the group, which is where
the missing fieldset starts to cost you something.
<template>
<div
aria-label="Sign-in method"
class="flex max-w-sm flex-col gap-2"
role="radiogroup"
>
<!--
`readonly` on a lone radio still submits and still takes focus, but
role="radio" carries no aria-readonly, so nothing announces it. The
description is what tells a screen reader why the option is stuck.
-->
<BbRadio
id="signin-sso"
v-model="method"
description="Enforced by your organisation."
label="Single sign-on"
name="signin"
readonly
value="sso"
/>
<BbRadio
id="signin-passkey"
v-model="method"
description="Not yet available on your plan."
disabled
label="Passkey"
name="signin"
value="passkey"
/>
<!--
The set has one error, so it goes on one button — the last, so the
message reads as belonging to the group. Repeating it on all three
would render and announce the same sentence three times.
-->
<BbRadio
id="signin-password"
v-model="method"
:errors="method ? undefined : 'Choose a sign-in method.'"
label="Email and password"
name="signin"
value="password"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRadio } from 'bitboss-ui';
const method = ref<string>('');
</script>
Put the message on one button, the last, so it reads as closing the set.
Repeated errors render the same sentence three times and announce it three
times. The price is that the message stays tied to that one option. If a
hand-composed group needs validation at all, move to BbRadioGroup.
disabled and readonly differ exactly as they do on
BbCheckbox. There is one radio-specific
catch: role="radio" supports no aria-readonly, so a lone readonly radio
cannot announce that it is locked. Say it in description, or use
BbRadioGroup, whose radiogroup does carry the attribute.
Custom markup
At the far end of the same slope, BbRadioGroup then BbRadio then this,
BbBaseRadioIcon gives you the library's dot inside a surface that is entirely
yours.
<template>
<fieldset class="m-0 flex max-w-sm flex-col gap-2 border-0 p-0">
<legend class="sr-only">Workspace plan</legend>
<label
v-for="plan in plans"
:key="plan.value"
class="flex cursor-pointer items-center gap-3 rounded-[var(--bb-radius)] border p-3"
:class="
selected === plan.value
? 'border-[color:var(--bb-primary)]'
: 'border-[color:var(--bb-border)]'
"
>
<!--
Native radios sharing one `name` keep exclusive selection and
arrow-key navigation. `sr-only` hides the input without taking it
out of the accessibility tree.
-->
<input
v-model="selected"
class="sr-only"
name="plan"
type="radio"
:value="plan.value"
>
<BbBaseRadioIcon :checked="selected === plan.value" />
<span class="flex flex-1 items-center justify-between gap-3">
<span class="text-sm font-medium">{{ plan.label }}</span>
<span class="text-xs font-medium tabular-nums opacity-70">
{{ plan.price }}
</span>
</span>
</label>
</fieldset>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBaseRadioIcon } from 'bitboss-ui';
const plans = [
{ value: 'starter', label: 'Starter', price: 'Free' },
{ value: 'team', label: 'Team', price: '€24 / mo' },
{ value: 'scale', label: 'Scale', price: '€96 / mo' },
];
const selected = ref('team');
</script>
BbBaseRadioIcon draws and nothing more: the ring and the scaling inner dot,
with no input, no events and no state. You pass checked, disabled,
readonly, has-errors, has-warning and focus-visible. There is no
indeterminate here, because a radio is binary per option.
It is aria-hidden and cannot be focused, so it must sit on real native radios
sharing one name. Those are what keep exclusivity and the arrow keys working.
Hide the input with sr-only rather than display: none, which would remove it
from the tab order and from the form. Pass focus-visible yourself, or a
keyboard user gets no ring.
There is a smaller step before that one. The icon slot on BbRadio replaces
only the dot visual and keeps the input, the label and the field chrome. It
receives the live state: checked, focused, focusVisible, disabled,
readonly, hasErrors, value and text.
The custom properties live on the dot element, so target that element rather than an ancestor:
.bb-radio .bb-base-radio-icon {
--size: 20px; /* ring diameter, default 16px */
--space: 3px; /* gap between ring and dot, default 2px */
--color: #16a34a; /* the dot */
--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.