Option Group
Size, material or plan chips for a product form: visually-hidden native radios or checkboxes inside styled labels, with a sold-out state, no JavaScript.
Preview
<div class="flex w-full max-w-md flex-col gap-6">
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">{{ __('Size') }}</legend>
<x-ui.option-group name="size" :options="['XS', 'S', 'M', 'L', 'XL']" selected="M" :unavailable="['XS']" />
</fieldset>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">{{ __('Material, multiple, small') }}</legend>
<x-ui.option-group
name="material"
type="multiple"
size="sm"
:selected="['cotton', 'linen']"
:options="[
['value' => 'cotton', 'label' => __('Organic cotton')],
['value' => 'linen', 'label' => __('Linen')],
['value' => 'wool', 'label' => __('Merino wool')],
['value' => 'silk', 'label' => __('Silk')],
]"
/>
</fieldset>
</div>
Installation
php artisan ui:add option-group
Registry contract
php artisan ui:add option-group
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/option-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: Option Group (`option-group`)
Size, material or plan chips for a product form: visually-hidden native radios or checkboxes inside styled labels, with a sold-out state, 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 option-group
```
## Usage
```blade
<div class="flex w-full max-w-md flex-col gap-6">
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">{{ __('Size') }}</legend>
<x-ui.option-group name="size" :options="['XS', 'S', 'M', 'L', 'XL']" selected="M" :unavailable="['XS']" />
</fieldset>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">{{ __('Material, multiple, small') }}</legend>
<x-ui.option-group
name="material"
type="multiple"
size="sm"
:selected="['cotton', 'linen']"
:options="[
['value' => 'cotton', 'label' => __('Organic cotton')],
['value' => 'linen', 'label' => __('Linen')],
['value' => 'wool', 'label' => __('Merino wool')],
['value' => 'silk', 'label' => __('Silk')],
]"
/>
</fieldset>
</div>
```
## Props
- `options` (array, default `[]`) — Chips to render: strings ('S', 'M') or arrays ['value','label','disabled']. `value` falls back to `label`.
- `selected` (string|array|null, default `null`) — Initially checked value(s). A string for `single`, an array for `multiple`.
- `unavailable` (array, default `[]`) — Values rendered sold out: disabled, struck through, and announced as '(sold out)'.
- `type` (single|multiple, default `single`) — `single` renders radios; `multiple` renders checkboxes and posts `name[]`.
- `name` (string, default `option`) — Form name shared by every input in the group. Give two groups on one page distinct names.
- `size` (sm|md, default `md`) — Chip height — the 32px or 40px control tier.
- `label` (string|null, default `null`) — Accessible name for the group. Give it one whenever no visible <legend> names the group.
- `model` (string|null, default `null`) — Alpine/Livewire property bound with x-model, for client state instead of a form post.
- `disabled` (bool, default `false`) — Disables every chip in the group.
## Use when
- Use for a small set of mutually exclusive options that users should compare visibly before choosing.
- Picking one product option from a short, visible set — a size, a length, a material, a plan term.
- Some options are sold out and must stay visible but unpickable: pass them in `unavailable`.
- The choice must post in a plain form (radios/checkboxes) or bind to Alpine/Livewire via `model`.
## Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The options are colours — use <x-ui.color-swatch>.
- The set is long or searchable — use <x-ui.select>.
- The control toggles a view rather than collects a value — use <x-ui.segmented-nav> or <x-ui.toggle-group>.
## Anti-patterns
- Hiding a small comparable set in a dropdown
## Rules
- Use the `<brok:option-group>` tag (or `<x-ui.option-group>`) 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/option-group
- Registry JSON (files, props, contract): https://brokui.dev/r/open/option-group.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<x-ui.option-group name="size" :options="['S', 'M', 'L']" selected="M" :label="__('Size')" disabled />
Long Content
<div class="max-w-xs">
<x-ui.option-group
name="term"
selected="annual"
:label="__('Billing term')"
:options="[
['value' => 'monthly', 'label' => __('Monthly, cancel any time')],
['value' => 'annual', 'label' => __('Annual, two months free when billed once a year')],
['value' => 'lifetime', 'label' => __('Lifetime licence for the whole organisation')],
]"
/>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | array | [] | Chips to render: strings ('S', 'M') or arrays ['value','label','disabled']. `value` falls back to `label`. |
| selected | string | array | null | null | Initially checked value(s). A string for `single`, an array for `multiple`. |
| unavailable | array | [] | Values rendered sold out: disabled, struck through, and announced as '(sold out)'. |
| type | single | multiple | single | `single` renders radios; `multiple` renders checkboxes and posts `name[]`. |
| name | string | option | Form name shared by every input in the group. Give two groups on one page distinct names. |
| size | sm | md | md | Chip height — the 32px or 40px control tier. |
| label | string | null | null | Accessible name for the group. Give it one whenever no visible <legend> names the group. |
| model | string | null | null | Alpine/Livewire property bound with x-model, for client state instead of a form post. |
| disabled | bool | false | Disables every chip in the group. |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Server-rendered Blade, no JavaScript: selection is drawn from :checked through peer-checked.
- Arrow keys move between radios natively; Space toggles a checkbox.
- Declares registry capability flags: a11y, authoredStateFixtures, 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 product option from a short, visible set — a size, a length, a material, a plan term.
- Some options are sold out and must stay visible but unpickable: pass them in `unavailable`.
- The choice must post in a plain form (radios/checkboxes) or bind to Alpine/Livewire via `model`.
Avoid when
- Do not hide a small option set in a dropdown when recognition and comparison matter.
- The options are colours — use <x-ui.color-swatch>.
- The set is long or searchable — use <x-ui.select>.
- The control toggles a view rather than collects a value — use <x-ui.segmented-nav> or <x-ui.toggle-group>.
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
- Each chip is a real labelled input: the label text is the accessible name, sold-out chips append '(sold out)'.
- The chip boundary is border-input, so the control edge meets the 3:1 non-text floor; the selected fill is primary on primary-foreground.
- 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="option-group-{{ $record->id }}">
<div class="flex w-full max-w-md flex-col gap-6">
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">{{ __('Size') }}</legend>
<x-ui.option-group name="size" :options="['XS', 'S', 'M', 'L', 'XL']" selected="M" :unavailable="['XS']" />
</fieldset>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">{{ __('Material, multiple, small') }}</legend>
<x-ui.option-group
name="material"
type="multiple"
size="sm"
:selected="['cotton', 'linen']"
:options="[
['value' => 'cotton', 'label' => __('Organic cotton')],
['value' => 'linen', 'label' => __('Linen')],
['value' => 'wool', 'label' => __('Merino wool')],
['value' => 'silk', 'label' => __('Silk')],
]"
/>
</fieldset>
</div>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'options' => [],
'selected' => null,
'unavailable' => [],
'type' => 'single',
'name' => 'option',
'size' => 'md',
'label' => null,
'model' => null,
'disabled' => false,
])
{{--
Option group: the size / material / plan chips of a product form.
Each chip is a visually-hidden native <input> (radio for `single`, checkbox
for `multiple`) inside a styled <label>, so the choice posts in a plain
<form> with no JavaScript, arrow keys move between radios, and the selected
chip is drawn purely from :checked (peer-checked). Pass `model` to bind the
group to an Alpine/Livewire property instead.
`unavailable` values render as sold out: the input is disabled, the chip is
struck through and its accessible name says so — the option stays visible
so the shopper knows it exists, but it cannot be picked.
--}}
@php
$type = $type === 'multiple' ? 'multiple' : 'single';
$size = in_array($size, ['sm', 'md'], true) ? $size : 'md';
$inputType = $type === 'multiple' ? 'checkbox' : 'radio';
$inputName = $type === 'multiple' && ! str_ends_with($name, '[]') ? $name.'[]' : $name;
$selectedValues = array_map('strval', (array) $selected);
$unavailableValues = array_map('strval', (array) $unavailable);
// Options are strings ("S", "M") or arrays (['value','label','disabled']).
$normalized = [];
foreach ($options as $key => $option) {
if (is_array($option)) {
$value = (string) ($option['value'] ?? $option['label'] ?? $key);
$normalized[] = [
'value' => $value,
'label' => (string) ($option['label'] ?? $value),
'disabled' => ! empty($option['disabled']),
];
} else {
$normalized[] = ['value' => (string) $option, 'label' => (string) $option, 'disabled' => false];
}
}
$sizes = [
'sm' => 'h-control-h-sm min-w-control-h-sm px-3 text-xs',
'md' => 'h-control-h-md min-w-control-h-md px-4 text-sm',
];
// The chip's edge is its affordance, so it draws with border-input (the 3:1
// floor). Selected fills with primary; the sold-out strike lives on the
// label text so the chip outline stays intact.
$chip = 'inline-flex select-none items-center justify-center whitespace-nowrap rounded-md border border-input bg-background font-medium text-foreground transition-colors motion-reduce:transition-none '
.'hover:border-foreground/40 '
.'peer-checked:border-primary peer-checked:bg-primary peer-checked:text-primary-foreground '
.'peer-focus-visible:ring-[length:var(--ring-width)] peer-focus-visible:ring-ring peer-focus-visible:ring-offset-[length:var(--ring-offset-width)] peer-focus-visible:ring-offset-background '
.'peer-disabled:cursor-not-allowed peer-disabled:border-border peer-disabled:text-muted-foreground peer-disabled:hover:border-border '
.$sizes[$size];
@endphp
<div
role="group"
data-slot="option-group"
data-type="{{ $type }}"
data-size="{{ $size }}"
@if ($label !== null) aria-label="{{ $label }}" @endif
{{ $attributes->merge(['class' => 'flex flex-wrap gap-2']) }}
>
@foreach ($normalized as $option)
@php
$soldOut = in_array($option['value'], $unavailableValues, true);
$isDisabled = $disabled || $soldOut || $option['disabled'];
$isChecked = in_array($option['value'], $selectedValues, true);
@endphp
<label
data-slot="option-group-item"
@if ($soldOut) data-unavailable="true" @endif
class="{{ $isDisabled ? 'inline-flex cursor-not-allowed' : 'inline-flex cursor-pointer' }}"
>
<input
type="{{ $inputType }}"
class="peer sr-only"
name="{{ $inputName }}"
value="{{ $option['value'] }}"
@if (filled($model)) x-model="{{ $model }}" @endif
@checked($isChecked)
@disabled($isDisabled)
/>
<span class="{{ $chip }}">
<span @class(['line-through decoration-1' => $soldOut])>{{ $option['label'] }}</span>
@if ($soldOut)
<span class="sr-only">{{ __('(sold out)') }}</span>
@endif
</span>
</label>
@endforeach
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.2 - 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