Choice
Pick-one and pick-many controls built on native radios and checkboxes — a card, a full-width row, a segmented tab, a chip or a colour swatch.
Preview
<div class="flex flex-col gap-6 text-start">
<x-ui.radio-tabs name="view" aria-label="{{ __('View mode') }}">
<x-ui.radio-tabs.item value="list" checked>{{ __('List') }}</x-ui.radio-tabs.item>
<x-ui.radio-tabs.item value="grid">{{ __('Grid') }}</x-ui.radio-tabs.item>
<x-ui.radio-tabs.item value="board">{{ __('Board') }}</x-ui.radio-tabs.item>
</x-ui.radio-tabs>
</div>
Installation
php artisan ui:add radio-tabs
Registry contract
php artisan ui:add radio-tabs
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-tabs.blade.php -
resources/views/components/ui/radio-tabs/item.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 Tabs (`radio-tabs`)
Segmented single-choice control built from visually-hidden native radios that submit in a normal form with no JavaScript.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add radio-tabs
```
## Usage
```blade
<div class="flex flex-col gap-6 text-start">
<x-ui.radio-tabs name="view" aria-label="{{ __('View mode') }}">
<x-ui.radio-tabs.item value="list" checked>{{ __('List') }}</x-ui.radio-tabs.item>
<x-ui.radio-tabs.item value="grid">{{ __('Grid') }}</x-ui.radio-tabs.item>
<x-ui.radio-tabs.item value="board">{{ __('Board') }}</x-ui.radio-tabs.item>
</x-ui.radio-tabs>
</div>
```
## Props
- `name` (mixed|null, default `null`) — Declared by @props in the shipped Blade source.
- `value` (mixed, default `null`) — Submitted value for this segment's hidden radio input.
- `options` (array, default `[]`) — Declared by @props in the shipped Blade source.
- `disabled` (false|true, default `false`) — Documented catalog control used by the preview workbench.
- `checked` (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.
- Picking one value from 2 to 5 short options inside a form, styled as a segmented pill row.
- Needing the selection to submit as a normal form field, including a fallback with JavaScript disabled.
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- Switching a view or filter that is not a form value; use segmented-nav instead.
- Offering many options or options with descriptions; use radio, choicebox, or select.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:radio-tabs>` tag (or `<x-ui.radio-tabs>`) 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-tabs
- Registry JSON (files, props, contract): https://brokui.dev/r/open/radio-tabs.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Declared by @props in the shipped Blade source. |
| value | mixed | null | Submitted value for this segment's hidden radio input. |
| options | array | [] | Declared by @props in the shipped Blade source. |
| disabled | false | true | false | Documented catalog control used by the preview workbench. |
| checked | bool | false | Declared by @props in the registry Blade source. |
Slots
default— Segment label content.x-ui.radio-tabs.item— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Each segment wraps a visually hidden native radio input; the active segment is shown purely via CSS peer-checked styling with no JavaScript required.
- Selection is restored from old() input after a failed redirect-back when the group shares a field name.
- Inside a wrapping field with a name, the group self-wires aria-invalid and aria-describedby off the shared error bag.
- 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.
- Picking one value from 2 to 5 short options inside a form, styled as a segmented pill row.
- Needing the selection to submit as a normal form field, including a fallback with JavaScript disabled.
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- Switching a view or filter that is not a form value; use segmented-nav instead.
- Offering many options or options with descriptions; use radio, choicebox, or select.
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 renders with role="radiogroup" and each segment is a real radio input, so screen readers announce it like a native radio group.
- A validation error on the group sets aria-invalid and aria-describedby so the error is announced.
- 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
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="radio-tabs-{{ $record->id }}">
<div class="flex flex-col gap-6 text-start">
<x-ui.radio-tabs name="view" aria-label="{{ __('View mode') }}">
<x-ui.radio-tabs.item value="list" checked>{{ __('List') }}</x-ui.radio-tabs.item>
<x-ui.radio-tabs.item value="grid">{{ __('Grid') }}</x-ui.radio-tabs.item>
<x-ui.radio-tabs.item value="board">{{ __('Board') }}</x-ui.radio-tabs.item>
</x-ui.radio-tabs>
</div>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:radio-tabs
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 <x-ui.field name="…"> shares its name down via @aware; the group
adopts it (and forwards it to each segment, which also reads it via @aware)
and self-wires aria-invalid/aria-describedby off the shared $errors bag.
With no field ancestor the explicit `name` prop is used unchanged —
fully backward-compatible. --}}
@aware([
'name' => null,
])
@props([
'name' => null,
'value' => null,
'options' => [],
])
@php
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
// Restore selection from old() after a failed redirect-back; fall back to the
// passed `value` when there is no flashed input.
$resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;
$describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
@endphp
{{--
Segmented single-choice control. Each segment is a visually-hidden native
<input type="radio"> inside a styled <label>, so it submits in a normal
<form> with NO JavaScript and the active segment is shown purely via CSS
(peer-checked). Segments inherit the group `name` via @aware; the default
selection is set from `value` on the `:options` path, or by marking a
composed segment with `checked`. Consumers wanting client state (no form)
can add `x-model` directly to the inputs.
--}}
<div
role="radiogroup"
data-slot="radio-tabs"
@if ($hasBagError) data-invalid="true" aria-invalid="true" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->except(['name'])->merge(['class' => 'inline-flex items-center gap-1 rounded-md bg-muted p-1 text-muted-foreground']) }}
>
@if (! empty($options))
@foreach ($options as $optionValue => $optionLabel)
<x-ui.radio-tabs.item :value="$optionValue" :checked="$resolvedValue !== null && (string) $optionValue === (string) $resolvedValue">{{ __($optionLabel) }}</x-ui.radio-tabs.item>
@endforeach
@else
{{ $slot }}
@endif
</div>
@aware(['name' => null])
@props([
'value',
'disabled' => false,
'checked' => false,
])
@php
// The native radio is the "peer": visually hidden but focusable, so arrow-key
// navigation, form submission and :checked all come for free. Active / idle /
// focus styling is driven entirely off peer state via CSS — no JavaScript.
//
// Default selection is server-rendered via the `checked` prop. The
// <x-ui.radio-tabs :options> path sets it automatically from the group
// `value`; @aware cannot carry the group value into a segment because the
// segment's own `value` attribute shadows it (documented @aware caveat), so
// hand-composed segments mark their default with `checked`.
// The styled element is the <label> that WRAPS the radio, so we key off
// :has(:checked) / :has(:focus-visible) on the label — peer-* only reaches
// later siblings, never the input's own parent.
$segment = 'relative inline-flex cursor-pointer select-none items-center justify-center '
.'whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium transition-all '
.'motion-reduce:transition-none text-muted-foreground '
.'has-[:checked]:bg-background has-[:checked]:text-foreground has-[:checked]:shadow-sm '
.'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background';
if ($disabled) {
$segment .= ' opacity-50 cursor-not-allowed';
}
@endphp
<label
data-slot="radio-tabs-item"
@if ($disabled) aria-disabled="true" @endif
{{ $attributes->merge(['class' => $segment]) }}
>
<input
type="radio"
class="peer sr-only"
name="{{ $name }}"
value="{{ $value }}"
@checked($checked)
@disabled($disabled)
/>
{{ $slot }}
</label>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.4 - 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