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 ownBbTabs: the segmented strip switches what the page shows (Board / Table) rather than picking a value a form submits
Also see: Options and items.
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.
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.
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, and neither does an array of { label, value } objects. Objects of any other shape need 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.
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.
-->
<BbRadioGroup
id="member-team"
v-model="team"
:items="teams.slice(0, 4)"
legend="Team"
list-layout="vertical"
/>
<BbRadioGroup
id="member-role"
v-model="role"
:items="userRoles.slice(1, 5)"
legend="Role"
list-layout="vertical"
/>
</div>
<p class="m-0 text-sm opacity-70">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 legend or the legend slot
gives it a name (an aria-label does not count). 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-md 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. `item-props` gives every role its one-line scope.
-->
<BbRadioGroup
id="invite-role"
v-model="role"
description="You are an Admin, so you can grant any role below Admin."
:item-props="{ description: 'scope' }"
item-text="role"
item-value="role"
:items="roles"
legend="Role for the new member"
list-layout="vertical"
:selectable="belowAdmin"
/>
<p class="m-0 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';
const scopes: Record<UserRole, string> = {
Owner: 'Billing, deletion and every setting',
Admin: 'Members, roles and integrations',
Developer: 'Repositories, deploys and API keys',
Designer: 'Design files and the component library',
Analyst: 'Dashboards and exports',
Editor: 'Content in shared workspaces',
Viewer: 'Read-only access',
};
type RoleOption = { role: UserRole; scope: string };
const roles: RoleOption[] = userRoles.map((role) => ({
role,
scope: scopes[role],
}));
// Owner and Admin sit above the current user; everything below is grantable.
const belowAdmin = (option: RoleOption) =>
userRoles.indexOf(option.role) > 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. Keep the locked options visible when the ladder above the user is
information, and filter them out of items when it is not.
A disabled field inside your item objects is plain data, not a switch: lock
options with selectable. The predicate and the migration from v2 are on
BbCheckboxGroup.
To freeze the choice, use readonly: it stays focusable and submitted.
disabled on the group locks every option, the chosen one included, and keeps
the value out of a native post.
Requiring a choice
This is the only one of the three groups with a required prop, and it is for
a group outside a form.
<template>
<!--
Inside a BbForm the group joins under its `name`, and `rules="required"`
holds the submit until an option is chosen.
-->
<BbForm
:model="product"
class="flex max-w-lg flex-col gap-4"
@submit="update"
>
<BbRadioGroup
id="stock-status"
:item-text="(status: ProductStatus) => productStatusLabels[status]"
:items="productStatuses"
legend="Stock status"
name="status"
rules="required"
/>
<BbButton
class="self-start"
type="submit"
variant="primary"
>
Update product
</BbButton>
<p class="m-0 text-sm opacity-70" role="status">{{ result }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbForm, BbRadioGroup } from 'bitboss-ui';
import type { ProductStatus } from '~/demo-data';
import { delay, productStatusLabels, productStatuses } from '~/demo-data';
// In an app this lives once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
// Nothing chosen yet: a radio group holds one value or null.
const product = ref({ status: null as ProductStatus | null });
const result = ref('');
async function update(values: Record<string, unknown>) {
await delay(null, 600);
result.value = `Saved as “${productStatusLabels[values.status as ProductStatus]}”.`;
}
</script>
Inside a BbForm the group joins the form under its name,
and rules="required", a rule you register once, holds the
submit until an option is chosen. Submit without choosing to see the message.
The rule also marks the legend. The required prop alone would only mark it:
the form posts novalidate, so the attribute is a hint that checks nothing.
Outside a form, required reaches the native radios: assistive technology
announces the group as required, and a plain <form> blocks the submit. Pass
errors yourself after a failed submit.
On a checkbox or switch group the same question becomes "at least one", which is a rule on the array rather than a flag. See validating the whole set.
Pick a value with the tabs look
A strip that picks a value a form or a filter submits (a status filter, a
period) is still a radio group, so give the group a tab strip's look instead of
reaching for BbTabs, which switches what the page
shows. variant="pill" draws a
muted strip with a raised pill under the pick, and variant="line" a baseline
with a bar under it.
<template>
<div class="flex flex-col gap-4">
<BbRadioGroup v-model="status" hide-legend :items="statuses" legend="Status" variant="pill" />
<BbRadioGroup v-model="period" hide-legend :items="periods" legend="Period" variant="line" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbRadioGroup } from 'bitboss-ui';
// A tab strip's two looks on a value picker. It is still a radio group: the
// arrow keys move and pick, and a form submits the value.
const statuses = ['All', 'Open', 'Closed', 'Archived'];
const status = ref('Open');
const periods = ['Day', 'Week', 'Month', 'Year'];
const period = ref('Week');
</script>
The strip, its options and the indicator measure exactly as a tab strip's, and
the indicator slides the same way. It stays a radio group: the arrow keys pick,
"2 of 4" is announced, and the legend, errors, required and compact work.
The dot is hidden (an icon slot shows before the label), and the strip never
wraps: it scrolls. list-layout="vertical" stacks it into a column and
"vertical-stretch" spans the field; the other layouts and item-layout do
not apply.
pt:indicator reaches the pill or the bar, and pt:<part>:pill or
pt:<part>:line styles a part in one look only. The strip's tokens are named
and valued as a tab strip's (--list-bg, --indicator-bg, --active-fg, …):
set them on pt:list for one group. For a look of your own app-wide, register
radioVariants: ['brand'] in the build plugin and set the same tokens on
.bb-radio-group__list--brand.
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 through
pt:item.
<template>
<BbRadioGroup
id="appearance-tiles"
v-model="theme"
item-layout="vertical"
item-value="id"
:items="themes"
legend="Appearance"
list-layout="tiles"
name="appearance"
pt:item="justify-items-center gap-1.5 rounded-(--bb-radius) border border-(--bb-border) px-2 py-3"
pt:item:checked="border-(--bb-primary) bg-(--bb-primary)/10"
pt:item:focus-visible="outline-2 outline-offset-2 outline-(--bb-ring)"
pt:list="max-w-sm"
>
<!--
Layout is two props (`list-layout="tiles"`, `item-layout="vertical"`);
the tile's look is the `pt:item` states. The icon slot replaced the dot
that drew the focus ring, so the tile shows keyboard focus itself.
Here 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>
icon replaces the dot and label replaces the text, and both receive the
option's item alongside the current 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 props do the layout, so pt only has to give the tile its look.
list-layout="tiles" makes the options equal-size boxes
that share the row and wrap as the field narrows, and
item-layout="vertical" stacks the picture over the caption.
The default horizontal list sizes each option to its content.
A tile is its input, edge to edge: the whole tile, padding included, is the
click and hover target (a tooltip whose activator is the tile's input shows
anywhere on it), and a link or a button inside keeps its own click. The tile's
text is not selectable with the mouse, and a click listener on a plain element
inside the label slot receives nothing: listen on the group's @change. The
same holds for BbCheckboxGroup and BbSwitchGroup tiles.
Style the tile through pt:item and its states: pt:item:checked paints the chosen tile and
pt:item:focus-visible the one with keyboard focus, so nothing names a
library class or reaches for :has(). The focus ring belongs on the tile
because the icon slot replaced the dot that drew it.
Coming from v2
This group's CSS block used to be the shared bb-cr-container. The options
are now .bb-radio-group__list and each row is .bb-radio-group__item. Nothing
renders the old prefix, so those rules stop matching; npx bitboss-ui check
reports each leftover class in your stylesheets.
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 --accent, --size, --space, --border-color and --ring-color on its own element.
That is the surface documented on
BbRadio, and where v2's removed
color prop went.