Radio
Native radio control with token-driven focus and disabled states, plus a neutral fieldset-based group with legend, orientation, help text and Laravel validation wiring.
Preview
<brok:radio.group name="preview-radio" label="Shipping method" hint="Choose one delivery speed." class="max-w-md text-start">
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="standard" checked />
{{ __('Standard shipping') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="express" />
{{ __('Express shipping') }}
</label>
</brok:radio.group>
Tone options
Orientation options
Installation
php artisan ui:add radio
Registry contract
php artisan ui:add radio
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/radio.blade.php -
resources/views/components/ui/radio/group.blade.php
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Radio (`radio`)
Native radio control with token-driven focus and disabled states, plus a neutral fieldset-based group with legend, orientation, help text and Laravel validation wiring.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add radio
```
## Usage
```blade
<brok:radio.group name="preview-radio" label="Shipping method" hint="Choose one delivery speed." class="max-w-md text-start">
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="standard" checked />
{{ __('Standard shipping') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="express" />
{{ __('Express shipping') }}
</label>
</brok:radio.group>
```
## Props
- `invalid` (bool, default `false`) — Declared by @props in the shipped Blade source.
- `disabled` (bool, default `false`) — Disables the whole fieldset, including every radio inside it.
- `tone` (neutral|primary, default `neutral`) — Fill of the checked dot, set on each <x-ui.radio>: neutral uses the foreground colour, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand primary.
- `orientation` (vertical|horizontal, default `vertical`) — Documented catalog control used by the preview workbench.
- `name` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `label` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `hint` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `required` (bool, default `false`) — Declared by @props in the registry Blade source.
## Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Choosing exactly one option from a small, visible set of mutually exclusive values.
- Grouping radios under a labelled fieldset with shared validation, hint text and orientation.
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- Prefer this over a select when the option set is small and should remain visible for comparison.
- Selecting zero or more options; use checkbox instead.
- Presenting more than a handful of options or options that need progressive disclosure; use select or combobox.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:radio>` tag (or `<x-ui.radio>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/radio
- Registry JSON (files, props, contract): https://brokui.dev/r/open/radio.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Horizontal
<brok:radio.group name="preview-plan" label="Billing cycle" orientation="horizontal" class="max-w-md text-start">
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="monthly" checked />
{{ __('Monthly') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="yearly" />
{{ __('Yearly') }}
</label>
</brok:radio.group>
Target Size
{{-- Radios outside a label, named by aria-label, in a compact density picker.
Each dot draws at 16px, but an invisible hit area reaches 4px past it on
every side, so the pointer target is 24x24 (WCAG 2.5.8) and the row
layout does not move. --}}
<div role="radiogroup" aria-label="{{ __('Row density') }}" class="flex items-center gap-4 text-sm text-foreground">
@foreach (['compact' => __('Compact'), 'comfortable' => __('Comfortable')] as $value => $label)
<span class="inline-flex items-center gap-2">
<x-ui.radio name="target-density" value="{{ $value }}" data-testid="target-{{ $value }}" :aria-label="$label" :checked="$value === 'compact'" />
<span aria-hidden="true">{{ $label }}</span>
</span>
@endforeach
</div>
<div class="flex flex-col gap-6 text-start sm:flex-row">
<brok:radio.group name="preview-radio-neutral" label="Invoice schedule (neutral, the default)" class="max-w-md">
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="monthly" checked />
{{ __('Monthly') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="quarterly" />
{{ __('Quarterly') }}
</label>
</brok:radio.group>
<brok:radio.group name="preview-radio-primary" label="Invoice schedule (primary)" class="max-w-md">
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="monthly" tone="primary" checked />
{{ __('Monthly') }}
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<brok:radio value="quarterly" tone="primary" />
{{ __('Quarterly') }}
</label>
</brok:radio.group>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| invalid | bool | false | Declared by @props in the shipped Blade source. |
| disabled | bool | false | Disables the whole fieldset, including every radio inside it. |
| tone | neutral | primary | neutral | Fill of the checked dot, set on each <x-ui.radio>: neutral uses the foreground colour, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand primary. |
| orientation | vertical | horizontal | vertical | Documented catalog control used by the preview workbench. |
| name | mixed | null | null | Declared by @props in the registry Blade source. |
| label | mixed | null | null | Declared by @props in the registry Blade source. |
| hint | mixed | null | null | Declared by @props in the registry Blade source. |
| required | bool | false | Declared by @props in the registry Blade source. |
Slots
default— The individual radio controls, typically composed with labels.x-ui.radio.group— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Inside a wrapping field with a name, a radio self-wires aria-invalid and aria-describedby off the shared error bag.
- The group renders a native fieldset/legend and shows either the validation error or the hint text, never both at once.
- Disabling the group disables every radio inside it via the native fieldset disabled behavior.
- Each <x-ui.radio> takes tone="neutral" (the default, the foreground colour) or tone="primary" (the brand primary) for its selected dot; invalid still wins with the destructive colour.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized.
Guidance
Choose one option from a small visible set.
Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Choosing exactly one option from a small, visible set of mutually exclusive values.
- Grouping radios under a labelled fieldset with shared validation, hint text and orientation.
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- Prefer this over a select when the option set is small and should remain visible for comparison.
- Selecting zero or more options; use checkbox instead.
- Presenting more than a handful of options or options that need progressive disclosure; use select or combobox.
Use instead
- Select or combobox for long option sets
Anti-patterns
- Hiding a small comparable set in a dropdown
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The group uses a native fieldset and legend so assistive tech announces the group label before each option.
- The error message is rendered with role="alert" and linked via aria-describedby so it is announced on validation failure.
- The dot draws at 16px, but an invisible ::before reaches 4px past it on every side, so the pointer target is at least 24x24 (WCAG 2.5.8 Target Size) without changing the drawn size or the layout. Firefox does not render ::before on a native control, so there the target stays 16px unless a clickable label wraps it.
- A radio with a name (its own, or shared by radio.group or field) gets the id {name}-{value}, so radios that share a name never repeat an id; a value that is not id-safe adds a short hash, and an explicit id is kept as given.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Livewire can update this component through forwarded wire:* attributes.
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:radio
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping <brok:field name="…"> shares its name down via @aware, so the
radio self-wires aria-invalid/aria-describedby off the shared $errors bag.
Inert with no field ancestor and no own name — fully backward-compatible.
(A radio group's name is normally set by the consumer on each radio; @aware
just lets a single field-wrapped radio inherit it.) --}}
@aware([
'name' => null,
'htmlName' => null,
'required' => false,
])
@props([
'invalid' => false,
'disabled' => false,
// Fill of the selected dot. neutral (default) uses the foreground colour,
// so a product that keeps primary for its one main action and for focus is
// not diluted; primary uses the brand primary.
'tone' => 'neutral',
])
@php
// Use the native control tinted with the tone's accent (no extra border —
// drawing our own border on top of the native widget produced a doubled
// "outer ring"). accent-color renders a clean filled radio in modern browsers.
// An invisible ::before reaches 4px past the dot on every side: a 24x24
// pointer target (WCAG 2.5.8) with the drawn size and layout unchanged.
// `relative` anchors the hit area unless the caller positions the dot itself.
$positioned = preg_match('/(^|\s)(absolute|fixed|sticky)(\s|$)/', (string) $attributes->get('class')) === 1;
$base = ($positioned ? '' : 'relative ').'size-4 shrink-0 cursor-pointer transition-colors before:absolute before:-inset-1 '
.'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background '
.'disabled:cursor-not-allowed disabled:opacity-50';
// Laravel-native wiring (mirrors input/checkbox).
$awareName = $name ?? null;
$awareHtmlName = $htmlName ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$resolvedHtmlName = $awareName
? ($awareHtmlName ?? collect(explode('.', $awareName))->reduce(
fn ($carry, $segment, $index) => $index === 0 ? $segment : $carry.'['.$segment.']',
''
))
: $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
$isInvalid = $invalid || $hasBagError;
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
// Radios share a name, so the id comes from the name and the value
// (plan-pro): a group never repeats an id. A value that is not id-safe
// keeps a readable slug plus a short hash. An explicit id wins.
$radioValue = $attributes->get('value');
$defaultId = null;
if (filled($fieldName)) {
$valueKey = (string) $radioValue;
$idSuffix = preg_match('/^[A-Za-z0-9_-]+$/', $valueKey) === 1
? $valueKey
: ltrim(trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $valueKey), '-').'-'.substr(md5($valueKey), 0, 6), '-');
$defaultId = $radioValue === null ? $fieldName : $fieldName.'-'.$idSuffix;
}
$resolvedId = $attributes->get('id', $defaultId);
// Full class strings per tone so Tailwind's JIT can see every utility.
$tones = [
'neutral' => 'accent-foreground',
'primary' => 'accent-primary',
];
$tone = isset($tones[$tone]) ? $tone : 'neutral';
$state = $isInvalid ? 'accent-destructive' : $tones[$tone];
$classes = trim($base.' '.$state);
@endphp
<input
type="radio"
data-slot="radio"
data-tone="{{ $tone }}"
@if (filled($resolvedHtmlName)) name="{{ $resolvedHtmlName }}" @endif
@if (filled($resolvedId)) id="{{ $resolvedId }}" @endif
@disabled($disabled)
@if ($required) required @endif
@if ($isInvalid) aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->except(['name', 'id'])->merge(['class' => $classes]) }}
/>
@props([
'name' => null,
'label' => null,
'hint' => null,
'required' => false,
'disabled' => false,
'orientation' => 'vertical',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$radioGroup = $styles['radioGroup'];
$resolvedOrientation = in_array($orientation, ['vertical', 'horizontal'], true) ? $orientation : 'vertical';
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$message = ($bag && filled($name)) ? $bag->first($name) : null;
$hasError = filled($message);
$descriptionId = filled($name) && filled($hint) && ! $hasError ? $name.'-description' : null;
$errorId = filled($name) && $hasError ? $name.'-error' : null;
$describedBy = $errorId ?? $descriptionId;
@endphp
<fieldset
data-slot="radio-group"
data-orientation="{{ $resolvedOrientation }}"
@if ($hasError) data-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
@disabled($disabled)
{{ $attributes->except('name')->merge(['class' => $radioGroup['root']]) }}
>
@if (filled($label))
<legend data-slot="radio-group-label" class="text-sm font-medium leading-none text-foreground">
{{ __($label) }}
@if ($required)
<span class="text-destructive" aria-hidden="true">*</span>
<span class="sr-only">{{ __('(required)') }}</span>
@endif
</legend>
@endif
<div data-slot="radio-group-items" class="{{ $radioGroup['items'][$resolvedOrientation] }}">
{{ $slot }}
</div>
@if ($hasError)
<p data-slot="radio-group-error" id="{{ $errorId }}" role="alert" class="break-words text-sm font-medium text-destructive">
{{ $message }}
</p>
@elseif (filled($hint))
<p data-slot="radio-group-description" @if ($descriptionId) id="{{ $descriptionId }}" @endif class="break-words text-sm text-muted-foreground">
{{ __($hint) }}
</p>
@endif
</fieldset>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.6 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0