Skip to content
Brok UI

Loading…

No results

Option Group

Open source

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.

Version
v1.0.2
Stability
stable
License
MIT
Related
Color Swatch Selector
Toggle Group
Choicebox
Product Card

Preview

Size
Material, multiple, small
Size
Disabled
previews.components.option-group.default.blade.php 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>
Sm Current
Md Current

Installation

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

  • blade 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.

option-group.md
# 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

disabled.blade.php Blade
<x-ui.option-group name="size" :options="['S', 'M', 'L']" selected="M" :label="__('Size')" disabled />
long-content.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

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

option-group option-group-item

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

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.
  • 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
option-group option-group-item
Theming hooks
option-group

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

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

resources/views/components/ui/option-group.blade.php Blade
@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