Use it for
Reach for BbRadioGroup when exactly one option out of a small, visible set is
the answer: a priority, a shipping speed, a plan. Up to about seven options that
a reader wants to compare at a glance belong here.
Use something else when
BbSelect: the set runs past that, or the list is searchable, paged or fetchedBbCheckboxGroup: the choices are not exclusiveBbRadio: an escape hatch for options that have to live inside markup of your own
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Everything this group shares with the other two is on
BbCheckboxGroup. That covers the items
mapping, the fieldset props and selectable. It also covers the provider and
enforce-coherence, the label slot and the layout. The field surface is
BbCheckbox's. What follows is only what
an exclusive group has to answer.
One exclusive choice
The model holds one resolved value, not an array, and it starts empty as null.
Model: null
<template>
<div class="flex max-w-sm flex-col gap-3">
<BbRadioGroup
id="ticket-priority"
v-model="priority"
description="Sets the response time the customer is promised."
:items="priorities"
legend="Priority"
/>
<p class="text-sm opacity-70">
Model: <code>{{ priority === null ? 'null' : `'${priority}'` }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRadioGroup } from 'bitboss-ui';
import { priorities } from '~/demo-data';
// One value, not an array — and `null` for "nothing chosen yet", which is the
// only state in which the group is empty. Choosing again replaces the value;
// nothing the user can do puts it back to null.
const priority = ref<string | null>(null);
</script>
items and the accessors work as they do on the checkbox group. An array of
strings needs nothing. An array of objects needs item-text and item-value.
There is no multiple prop here and no max.
The thing to design around is that the user cannot go back to empty. Radios
have no un-choose gesture: once an option is picked, only another option can
replace it. If "no answer" is still legitimate after the first click, make it an
option of its own. A "No preference" row whose value is null does the job.
Seed the model with null when nothing is chosen, and with a real value when a
sensible default exists. A pre-selected default that quietly commits the user to
something they never read is worse than an empty group.
One tab stop, then the arrows
The options are native radios, so the browser's grouping behaviour comes for free. The group adds the ARIA role the native markup lacks.
Tab reaches each group once. Inside it, the arrow keys move focus and change the choice together — selected: Engineering · Developer
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap gap-10">
<!--
Neither group is given a `name`. Each generates one for itself, so
the two sets never clear each other — which is what makes the prop
optional in v3.
-->
<BbRadioGroup
id="member-team"
v-model="team"
:items="teams"
legend="Team"
/>
<BbRadioGroup
id="member-role"
v-model="role"
:items="userRoles"
legend="Role"
/>
</div>
<p class="text-sm opacity-70">
Tab reaches each group once. Inside it, the arrow keys move focus and
change the choice together — selected: {{ team }} · {{ role }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRadioGroup } from 'bitboss-ui';
import { teams, userRoles } from '~/demo-data';
const team = ref<string>('Engineering');
const role = ref<string>('Developer');
</script>
Tab reaches the whole group once, landing on the chosen option or on the first one. Inside it, the arrow keys move focus and the selection together, and wrap at the ends. That coupling is what makes a radio group an exclusive choice. It is why a locked option has to be skipped rather than merely greyed.
The options container carries role="radiogroup", which a <fieldset> cannot
supply. The component claims the role only when it can name it, from legend or
from an aria-label you pass. An unnamed radiogroup is worse than no role at
all. That is another reason legend is required, and why hide-legend is the
right way to drop it visually.
readonly is the one field state that behaves differently here. The radio
role supports no aria-readonly, so readonly can only be announced on the
group. A hand-composed set of BbRadio has no group to announce it on.
Coming from v2
name is no longer required. Omit it and the group generates one, unique to
that instance and stable for its lifetime. Two unnamed groups on one page never
clear each other's selection. Pass a name when the value has to post under a
known key in a native form submission. The relaxation is specific to the group:
a set of bare BbRadio still needs an explicit shared name.
Locked options
selectable rejects an option per item, and in a radio group a rejected option
disappears from the keyboard path entirely.
Inviting as Developer
<template>
<div class="flex max-w-sm flex-col gap-3">
<!--
The roles above your own stay on screen so the user can see the ladder,
but the predicate makes them unpickable — and the arrow keys skip
straight past them.
-->
<BbRadioGroup
id="invite-role"
v-model="role"
description="You are an Admin, so you can grant any role below Admin."
:items="userRoles"
legend="Role for the new member"
:selectable="atOrBelowAdmin"
/>
<p class="text-sm opacity-70">Inviting as {{ role }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRadioGroup } from 'bitboss-ui';
import type { UserRole } from '~/demo-data';
import { userRoles } from '~/demo-data';
// Owner and Admin sit above the current user; everything below is grantable.
const atOrBelowAdmin = (candidate: UserRole) =>
userRoles.indexOf(candidate) > userRoles.indexOf('Admin');
const role = ref<UserRole>('Developer');
</script>
Arrow keys skip disabled radios, so a keyboard user never lands on one. That
makes the description do real work: name the rule that locks them, once, for
the set. "You are an Admin, so you can grant any role below Admin" is worth more
than three greyed rows.
Keeping such options visible is usually the right call, because the ladder above
you is information. When the locked options carry nothing, filter them out of
items and show a shorter list.
The predicate signature and the migration from v2's per-item disabled field
are on BbCheckboxGroup. The short
version matters here too: a disabled field inside your item objects does
nothing, silently, so grep for it.
disabled on the group does not lock the option already chosen: it stays
reachable by keyboard, though choosing it again changes nothing. Use readonly
when you need the set genuinely frozen.
Requiring a choice
This is the only one of the three groups with a required prop, and it marks
the field rather than validating it.
<template>
<form class="flex max-w-sm flex-col gap-4" @submit.prevent="submit">
<!--
`required` marks the field. The message that tells the user what to do
comes from `errors`, which is a separate channel — set both.
-->
<BbRadioGroup
id="stock-status"
v-model="status"
:errors="errors"
:item-text="statusLabel"
:items="productStatuses"
legend="Stock status"
legend-mode="inside"
required
/>
<BbButton class="self-start" type="submit" variant="primary">
Update product
</BbButton>
<p class="text-sm opacity-70" role="status">{{ result }}</p>
</form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbRadioGroup } from 'bitboss-ui';
import type { ProductStatus } from '~/demo-data';
import { productStatusLabels, productStatuses } from '~/demo-data';
// An accessor function, for when the text is not a field on the item.
const statusLabel = (value: ProductStatus) => productStatusLabels[value];
const status = ref<ProductStatus | null>(null);
const attempted = ref(false);
const result = ref('Submit without choosing to see the message.');
const errors = computed(() =>
attempted.value && status.value === null ? 'Choose a stock status.' : []
);
function submit() {
attempted.value = true;
if (status.value === null) return;
result.value = `Saved as “${statusLabel(status.value)}”.`;
}
</script>
required reaches the native radios, so assistive technology announces the
group as required. With the requiredAsterisk plugin option on, the legend
picks up its marker. What it does not do is produce a message. The sentence
the user reads comes from errors, which you control. Set both, and let the
message appear after a failed submit.
required exists here and not on the other two groups for a reason. One value
is either present or absent, so "required" is a complete rule. On a checkbox or
switch group the same question becomes "at least one". That is an application
rule, with no flag behind it. See
validating the whole set.
Inside a validated form, import the group from bitboss-ui/validated and give
it rules="required". The field's name comes from legend, and validate-on
defaults to ['inactive'].
Selection tiles and CSS
A radio group is the component people most often want to stop looking like radio buttons. Compose the two per-option slots, then style the option element itself.
<template>
<BbRadioGroup
id="appearance-tiles"
v-model="theme"
class="tiles"
input-direction="horizontal"
item-text="label"
item-value="id"
:items="themes"
legend="Appearance"
>
<!-- The dot becomes the tile's picture. -->
<template #icon="{ item }">
<BbIcon :icon="item.icon" size="lg" />
</template>
<template #label="{ text }">
<span class="text-sm">{{ text }}</span>
</template>
</BbRadioGroup>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbIcon, BbRadioGroup } from 'bitboss-ui';
type Appearance = { id: string; label: string; icon: string };
const themes: Appearance[] = [
{ id: 'light', label: 'Light', icon: 'lucide:sun' },
{ id: 'dark', label: 'Dark', icon: 'lucide:moon' },
{ id: 'system', label: 'System', icon: 'lucide:monitor' },
];
const theme = ref('system');
</script>
<style scoped>
/*
* The options row is a plain wrapping flexbox and each option is content-sized,
* so `flex: 1 1 0` on the option is what makes the tiles share the row evenly.
* There is no grid to opt into.
*/
.tiles :deep(.bb-base-radio-group__container) {
flex-wrap: nowrap;
gap: 0.5rem;
max-width: 24rem;
}
.tiles :deep(.bb-base-radio-group-option) {
align-items: center;
border: var(--bb-border-w) solid var(--bb-border);
border-radius: var(--bb-radius);
flex: 1 1 0;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem 0.5rem;
}
/* The chosen tile paints itself from the modifier the component sets. */
.tiles :deep(.bb-base-radio-group-option--selected) {
background: color-mix(in oklab, var(--bb-primary) 8%, var(--bb-panel));
border-color: var(--bb-primary);
}
</style>
icon replaces the dot and label replaces the text, and both receive the
option's item alongside the live state. A tile is built from your own data,
with no second array to keep in step. Everything underneath stays a real radio:
the name, the exclusivity and the arrow keys are untouched.
Two facts about width decide whether a tile row looks right. The options
container is a wrapping flexbox and each option is inline-flex, so options are
content-sized. Add flex: 1 1 0 on the option to make them share the row
evenly, and note there is no grid to opt into.
The control column is a grid whose computed width defaults to auto. If the row
should span the full width, size the BbRadioGroup itself, and do not reach for
direction="vertical" just to pick up width: 100%.
Paint the chosen tile from .bb-base-radio-group-option--selected, which the
component sets for you, rather than from a :has() selector. It is stable and
it is what the library tests against.
Coming from v2
This group's CSS block used to be the shared bb-cr-container, and it is now
bb-base-radio-group with the same suffixes. Nothing renders the old prefix and
nothing warns, so the rules simply stop matching. The full before-and-after
table is on BbCheckboxGroup, along
with the labelPosition → legendPosition rename that applies here too.
The group exposes no custom properties of its own. The dot is BbBaseRadioIcon,
which owns --color, --size, --space and --ring-color on its own element.
That is the surface documented on
BbRadio, and where v2's removed
color prop went.