Skip to content

Radio

.bp-radio wraps a native <input type="radio"> with its visible label. The custom circle and animated dot are rendered entirely in CSS — no JavaScript required. Groups use a native <fieldset> + <legend> for built-in accessibility. Error state is surfaced via aria-invalid="true" on the fieldset.

Single radio

<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="single" value="a" />
<span class="bp-radio__label">Option A</span>
</label>

Group — vertical (default)

Contact preference
<fieldset class="bp-radio-group">
<legend class="bp-radio-group__legend">Contact preference</legend>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="contact" value="email" checked />
<span class="bp-radio__label">Email</span>
</label>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="contact" value="phone" />
<span class="bp-radio__label">Phone</span>
</label>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="contact" value="post" />
<span class="bp-radio__label">Post</span>
</label>
</fieldset>

Group — horizontal

Size
<fieldset class="bp-radio-group bp-radio-group--horizontal">
<legend class="bp-radio-group__legend">Size</legend>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="size" value="sm" />
<span class="bp-radio__label">Small</span>
</label>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="size" value="md" checked />
<span class="bp-radio__label">Medium</span>
</label>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="size" value="lg" />
<span class="bp-radio__label">Large</span>
</label>
</fieldset>

Group — error state

Contact preference
<fieldset class="bp-radio-group" aria-invalid="true" aria-describedby="contact-err">
<legend class="bp-radio-group__legend">Contact preference</legend>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="contact-err" value="email" />
<span class="bp-radio__label">Email</span>
</label>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="contact-err" value="phone" />
<span class="bp-radio__label">Phone</span>
</label>
<p class="bp-radio-group__error" id="contact-err" role="alert">Please select an option.</p>
</fieldset>
VariableDefaultDescription
--radio-size1.125remDiameter of the radio circle
--radio-dot-size0.5remDiameter of the inner checked dot
--radio-colorvar(--bp-primary)Checked border and dot fill color
--radio-border1px solid var(--bp-color-border)Unchecked border
--radio-bgvar(--bp-color-bg-elevated)Unchecked background color
--radio-gapvar(--bp-space-2)Gap between circle and label text
--radio-group-gapvar(--bp-space-3)Gap between items in a vertical group
--radio-group-gap-horizontalvar(--bp-space-6)Gap between items in a horizontal group
--radio-label-colorvar(--bp-color-text)Label text color
--radio-error-colorvar(--bp-color-error)Error message and border color

Custom accent color and size

Availability
<style>
.demo-radio--custom {
--radio-color: var(--bp-color-success);
--radio-size: 1.375rem;
--radio-dot-size: 0.625rem;
}
</style>
<fieldset class="bp-radio-group demo-radio--custom">
<legend class="bp-radio-group__legend">Availability</legend>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="avail" value="yes" checked />
<span class="bp-radio__label">Available</span>
</label>
<label class="bp-radio">
<input class="bp-radio__input" type="radio" name="avail" value="no" />
<span class="bp-radio__label">Unavailable</span>
</label>
</fieldset>
  • Always wrap radio groups in a <fieldset> with a <legend>. The legend is read by screen readers as the group label before announcing each option.
  • Individual radios use a wrapping <label> for implicit association — no for/id wiring needed.
  • Error state is signalled via aria-invalid="true" on the <fieldset> and aria-describedby pointing to the .bp-radio-group__error paragraph. The error paragraph carries role="alert" so screen readers announce it on insertion.
  • Keyboard: native radio behavior is preserved — Tab moves focus into the group, Arrow keys cycle options within the group.
  • The custom circle and dot are purely decorative CSS — the underlying <input> remains in the accessibility tree and reports the correct checked state.
  • Do not rely on border color alone to convey error — always pair with the visible error message.
APIAvailabilityUsed forWithout itPolyfill
appearance: none Widely available Baseline 2020 Removing the browser-native radio chromeNative radio circle renders instead of customNone needed
:focus-visible Widely available Baseline 2022 Showing focus ring only on keyboard navigationFocus ring shows on mouse click too (:focus fallback)None needed
:has() Newly available Baseline 2023 Disabling cursor on the whole .bp-radio wrapper when input is disabledCursor stays pointer on label text when input is disabledNone needed — cosmetic only
  • --_size, --_dot-size, --_color, --_border, --_bg, --_gap, --_label-color, --_group-gap, --_error-color — private resolved values, do not set directly.
  • The dot is a ::before pseudo-element on .bp-radio__label, not on the <input>. Inputs cannot reliably host pseudo-elements across browsers.
  • The dot is positioned with position: absolute and a calculated left offset: calc(-1 * gap - size/2 - dot-size/2), which centers it inside the adjacent input circle regardless of token values.
  • Checked/unchecked transition uses transform: scale(0 → 1) for the dot and border-color for the ring — both GPU-composited, no layout triggered.
  • The error cascade uses fieldset[aria-invalid="true"] .bp-radio__input to apply error styling to all inputs in the group from a single attribute on the fieldset.