Skip to content
Brok UI

Loading…

No results

Radio

Open source Core

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.

Version
v1.4.6
Stability
stable
License
MIT
Related
Radio Rows
Radio Tabs
Checkbox

Preview

Shipping method

Choose one delivery speed.

Disabled
Tone
Orientation
previews.components.radio.default.blade.php 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>

Tone options

Neutral Current
Primary Current

Orientation options

Vertical Current
Horizontal Current

Installation

terminal
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.

  • blade resources/views/components/ui/radio.blade.php
  • blade 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.

radio.md
# 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.blade.php Blade
<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.blade.php Blade
{{-- 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>
tones.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

radio radio-group radio-group-description radio-group-error radio-group-items radio-group-label

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

Visible exclusive selection

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
root radio-group radio-group-label radio-group-items radio-group-error radio-group-description
Theming hooks
radio radio-group radio-group-description radio-group-error radio-group-items radio-group-label

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Safe

Livewire can update this component through forwarded wire:* attributes.

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/radio.blade.php Blade
{{-- 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]) }}
/>
resources/views/components/ui/radio/group.blade.php Blade
@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