Skip to content
Brok UI

Loading…

No results

Choice

Open source

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.

Version
v1.4.4
Stability
stable
License
MIT
Related
Radio Rows
Radio Tabs
Label Selector
Color Swatch Selector
1 of 5

Choicebox — The option card: a label around a real radio or checkbox (native control, keyboard, focus ring, invalid state, plain form submission) that styles itself off :has(:checked), with the control at the end, the start (a row) or hidden, sm/md sizes, an icon slot and a badge; choicebox.group renders a set from an options array. Supersedes radio-rows.

Preview

Disabled
Size
Indicator
Tone
previews.components.choicebox.default.blade.php Blade
{{-- Radio cards in a group: the group shares `name` (and type, size, indicator)
     down, so a card only states its own value. The native radio sits at the end. --}}
<x-ui.choicebox.group name="plan" label="{{ __('Plan') }}" class="w-full max-w-md">
    <x-ui.choicebox value="pro" checked :title="__('Pro')" :description="__('Everything in Starter plus advanced analytics and priority support.')" :badge="__('Popular')" />
    <x-ui.choicebox value="starter" :title="__('Starter')" :description="__('For individuals getting started, up to 3 projects.')" />
    <x-ui.choicebox value="enterprise" disabled :title="__('Enterprise')" :description="__('Custom limits, SSO and a dedicated account manager.')" />
</x-ui.choicebox.group>

Size options

Sm Current
Md Current

Indicator options

Start Current
End Current
None Current

Tone options

Neutral Current
Card Current
Icon Current
Primary Current

Installation

terminal
php artisan ui:add choicebox

Registry contract

php artisan ui:add choicebox 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/choicebox.blade.php
  • blade resources/views/components/ui/choicebox/group.blade.php
Registry dependencies
radio checkbox badge
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

choicebox.md
# Brok UI: Choicebox (`choicebox`)

The option card: a label around a real radio or checkbox (native control, keyboard, focus ring, invalid state, plain form submission) that styles itself off :has(:checked), with the control at the end, the start (a row) or hidden, sm/md sizes, an icon slot and a badge; choicebox.group renders a set from an options array. Supersedes radio-rows.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add choicebox
```

## Usage

```blade
{{-- Radio cards in a group: the group shares `name` (and type, size, indicator)
     down, so a card only states its own value. The native radio sits at the end. --}}
<x-ui.choicebox.group name="plan" label="{{ __('Plan') }}" class="w-full max-w-md">
    <x-ui.choicebox value="pro" checked :title="__('Pro')" :description="__('Everything in Starter plus advanced analytics and priority support.')" :badge="__('Popular')" />
    <x-ui.choicebox value="starter" :title="__('Starter')" :description="__('For individuals getting started, up to 3 projects.')" />
    <x-ui.choicebox value="enterprise" disabled :title="__('Enterprise')" :description="__('Custom limits, SSO and a dedicated account manager.')" />
</x-ui.choicebox.group>
```

## Props

- `type` (string, default `radio`) — Underlying control: radio for single-select, checkbox for multi-select.
- `name` (mixed|null, default `null`) — Field name; shared from a wrapping group or field via @aware, or set per card.
- `value` (mixed|null, default `null`) — Submitted value for this card's control.
- `title` (mixed|null, default `null`) — Card headline shown beside the badge.
- `description` (mixed|null, default `null`) — Supporting text shown under the title.
- `checked` (bool, default `false`) — Initial checked state; overridden by old() input when a matching field name is flashed.
- `disabled` (bool, default `false`) — Disables the control and dims the card.
- `disabledReason` (string|null, default `null`) — Why a disabled card cannot be picked. Shown under the description in full foreground (still readable on the dimmed card) and tied to the control with aria-describedby. Ignored while the card is enabled; choicebox.group takes it per option as disabledReason.
- `badge` (mixed|null, default `null`) — Optional badge rendered beside the title.
- `size` (md|sm, default `md`) — md is a surface-sized card (rounded-lg, p-6); sm is control-sized (rounded-md, p-4).
- `indicator` (string, default `end`) — Where the native control sits: end (classic card), start (a row), or none (card border alone shows selection).
- `tone` (neutral|primary, default `neutral`) — Colour of the checked card: neutral uses a foreground border and ring on a faint neutral tint, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand border, ring and tint. The card's radio or checkbox and its icon chip follow the tone; choicebox.group shares it with every card.
- `options` (array, default `[]`) — Declared by @props in the registry Blade source.
- `label` (mixed|null, default `null`) — 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.
- Presenting a small set of options as tappable cards with a title, description, icon or badge.
- Letting the whole card area, not just a small control, respond to selection.

## Avoid when

- Do not hide a small option set in a dropdown when recognition and comparison matter.
- Listing more than a handful of plain-text options; use radio or checkbox directly.
- Building a dense inline row of pill-style choices; use radio-tabs.

## Anti-patterns

- Hiding a small comparable set in a dropdown

## Rules

- Use the `<brok:choicebox>` tag (or `<x-ui.choicebox>`) 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/choicebox
- Registry JSON (files, props, contract): https://brokui.dev/r/open/choicebox.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

borderless.blade.php Blade
{{-- `indicator="none"`: the card border alone shows the selection. --}}
<x-ui.choicebox.group name="theme" value="system" indicator="none" class="grid w-full max-w-md grid-cols-3 gap-2" :options="[
    ['value' => 'light', 'title' => __('Light')],
    ['value' => 'dark', 'title' => __('Dark')],
    ['value' => 'system', 'title' => __('System')],
]" />
checkbox.blade.php Blade
{{-- `type="checkbox"`: several cards can be on and each submits as `addons[]`.
     Cards composed by hand (here with an icon slot) mark their own `checked`;
     the `options` array derives it from the group's `value`. --}}
<x-ui.choicebox.group name="addons" type="checkbox" label="Add-ons" class="w-full max-w-md">
    <x-ui.choicebox value="backups" checked :title="__('Daily backups')" :description="__('Thirty days of point-in-time restore.')" badge="$4/mo">
        <x-slot:icon>
            <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5" /><path d="M3 12a9 3 0 0 0 18 0" /></svg>
        </x-slot:icon>
    </x-ui.choicebox>
    <x-ui.choicebox value="cdn" :title="__('Global CDN')" :description="__('Edge caching in 30 regions.')" badge="$9/mo">
        <x-slot:icon>
            <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>
        </x-slot:icon>
    </x-ui.choicebox>
</x-ui.choicebox.group>
disabled-reason.blade.php Blade
{{-- A template picker: an option that cannot be picked says why. The reason is
     read with the radio through aria-describedby. --}}
<x-ui.choicebox.group name="template" label="{{ __('Template') }}" size="sm" indicator="start" class="w-full max-w-md">
    <x-ui.choicebox value="blank" checked :title="__('Blank page')" :description="__('Start from an empty page.')" />
    <x-ui.choicebox value="project-brief" :title="__('Project brief')" :description="__('Goal, scope, deliverables and the people involved.')" />
    <x-ui.choicebox value="meeting-note" disabled :title="__('Meeting note')" :description="__('Attendees, agenda, decisions and follow-ups.')" :disabled-reason="__('Only in a client workspace. This page is in the internal workspace.')" />
</x-ui.choicebox.group>
rows.blade.php Blade
{{-- `indicator="start"` and `size="sm"`: control-sized rows for a settings list. --}}
<x-ui.choicebox.group name="visibility" value="team" indicator="start" size="sm" class="w-full max-w-md" :options="[
    ['value' => 'private', 'title' => __('Private'), 'description' => __('Only you can see this project.')],
    ['value' => 'team', 'title' => __('Team'), 'description' => __('Everyone in your workspace can view it.')],
    ['value' => 'public', 'title' => __('Public'), 'description' => __('Anyone with the link can view it.')],
]" />
tones.blade.php Blade
{{-- tone: the checked card is neutral by default (a foreground border and ring
     on a faint tint), so the brand colour stays free for the primary action.
     tone="primary" on the group (or a card) brings the brand border back. --}}
<div class="flex w-full max-w-md flex-col gap-6">
    <x-ui.choicebox.group name="billing" label="{{ __('Billing interval (neutral, the default)') }}" size="sm">
        <x-ui.choicebox value="monthly" checked :title="__('Monthly')" :description="__('Pay each month, cancel any time.')" />
        <x-ui.choicebox value="yearly" :title="__('Yearly')" :description="__('Two months free, billed once a year.')" />
    </x-ui.choicebox.group>

    <x-ui.choicebox.group name="billing-primary" label="{{ __('Billing interval (primary)') }}" size="sm" tone="primary">
        <x-ui.choicebox value="monthly" checked :title="__('Monthly')" :description="__('Pay each month, cancel any time.')" />
        <x-ui.choicebox value="yearly" :title="__('Yearly')" :description="__('Two months free, billed once a year.')" />
    </x-ui.choicebox.group>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
type string radio Underlying control: radio for single-select, checkbox for multi-select.
name mixed | null null Field name; shared from a wrapping group or field via @aware, or set per card.
value mixed | null null Submitted value for this card's control.
title mixed | null null Card headline shown beside the badge.
description mixed | null null Supporting text shown under the title.
checked bool false Initial checked state; overridden by old() input when a matching field name is flashed.
disabled bool false Disables the control and dims the card.
disabledReason string | null null Why a disabled card cannot be picked. Shown under the description in full foreground (still readable on the dimmed card) and tied to the control with aria-describedby. Ignored while the card is enabled; choicebox.group takes it per option as disabledReason.
badge mixed | null null Optional badge rendered beside the title.
size md | sm md md is a surface-sized card (rounded-lg, p-6); sm is control-sized (rounded-md, p-4).
indicator string end Where the native control sits: end (classic card), start (a row), or none (card border alone shows selection).
tone neutral | primary neutral Colour of the checked card: neutral uses a foreground border and ring on a faint neutral tint, so a product that keeps primary for its one main action and focus is not diluted; primary uses the brand border, ring and tint. The card's radio or checkbox and its icon chip follow the tone; choicebox.group shares it with every card.
options array [] Declared by @props in the registry Blade source.
label mixed | null null Declared by @props in the registry Blade source.

Slots

  • default — Extra content rendered under the description.
  • icon — Leading icon shown before the title and description.
  • x-ui.choicebox.group — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

choicebox-reason {{ $attributes->get(

Behavior

  • The whole card is a label wrapping a real radio or checkbox, so it submits in a plain form with no JavaScript and the native control carries keyboard, focus ring, and invalid state.
  • The card styles its border, ring, and background off :has(:checked), so a pick reads as one change without extra JS.
  • tone="neutral" (the default) draws the checked card in the foreground colour; tone="primary" uses the brand primary. The inner radio or checkbox gets the same tone, and data-tone on the card reports it.
  • Selection is restored from old() input after a failed redirect-back when the card shares a field name.
  • A disabled card with disabled-reason renders the reason (data-slot=choicebox-reason) under the description and adds its id to the control's aria-describedby, after any error id.
  • wire:model or x-model on choicebox.group binds the group value (one value for radio, an array for type="checkbox") through x-modelable; every Livewire modifier works, .blur fires when focus leaves the group, and a server-side change or reset re-checks the cards after a Livewire update. An unbound group renders no JavaScript.
  • wire:model or x-model on a single card reaches the card's native radio or checkbox, not the label.
  • 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.
  • Presenting a small set of options as tappable cards with a title, description, icon or badge.
  • Letting the whole card area, not just a small control, respond to selection.

Avoid when

  • Do not hide a small option set in a dropdown when recognition and comparison matter.
  • Listing more than a handful of plain-text options; use radio or checkbox directly.
  • Building a dense inline row of pill-style choices; use radio-tabs.

Use instead

  • Select or combobox for long option sets

Anti-patterns

  • Hiding a small comparable set in a dropdown
Anatomy
root
Theming hooks
choicebox-reason {{ $attributes->get(

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
managed
  • Each card renders a real radio or checkbox input, so screen readers announce role, label, and checked state exactly as a native control would.
  • When indicator is none the control is visually hidden but stays in the accessibility tree and keyboard tab order.
  • Give a disabled option a disabled-reason instead of replacing its description: a screen reader hears the option, that it is unavailable, and why.
  • In a named group every control gets its own id from the field name and the value (plan-pro), so ids never repeat; a value that is not id-safe adds a short hash.
  • 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="choicebox-{{ $record->id }}">
    {{-- Radio cards in a group: the group shares `name` (and type, size, indicator)
         down, so a card only states its own value. The native radio sits at the end. --}}
    <x-ui.choicebox.group name="plan" label="{{ __('Plan') }}" class="w-full max-w-md">
        <x-ui.choicebox value="pro" checked :title="__('Pro')" :description="__('Everything in Starter plus advanced analytics and priority support.')" :badge="__('Popular')" />
        <x-ui.choicebox value="starter" :title="__('Starter')" :description="__('For individuals getting started, up to 3 projects.')" />
        <x-ui.choicebox value="enterprise" disabled :title="__('Enterprise')" :description="__('Custom limits, SSO and a dedicated account manager.')" />
    </x-ui.choicebox.group>
</div>

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:choicebox
        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/choicebox.blade.php Blade
{{-- A wrapping <x-ui.choicebox.group name="…"> or <x-ui.field name="…"> shares
     its name (and type, size, indicator) down via @aware, so a card never repeats
     them. Inert with no ancestor and no own `name` — fully backward-compatible. --}}
@aware([
    'name' => null,
    'type' => 'radio',
    'size' => 'md',
    'indicator' => 'end',
    'tone' => 'neutral',
])

@props([
    'type' => 'radio',
    'name' => null,
    'value' => null,
    'title' => null,
    'description' => null,
    'checked' => false,
    'disabled' => false,
    // Why a disabled option cannot be picked ("Needs a client first"). Shown
    // under the description and read with the control through
    // aria-describedby. Ignored while the card is enabled.
    'disabledReason' => null,
    'badge' => null,
    // md: surface-sized card (rounded-lg, p-6); sm: control-sized (rounded-md, p-4).
    'size' => 'md',
    // Where the native control sits: end (the classic card), start (a row), none.
    'indicator' => 'end',
    // Colour of the checked card. neutral (default): a foreground border and
    // ring on a faint neutral tint, so a product that keeps primary for its one
    // main action and for focus is not diluted. primary: the brand border,
    // ring and tint. The card's radio or checkbox follows the same tone.
    'tone' => 'neutral',
])

@php
    $awareName = $name ?? null;
    $fieldName = $awareName ?? $attributes->get('name');
    $type = $type === 'checkbox' ? 'checkbox' : 'radio';
    $size = in_array($size, ['sm', 'md'], true) ? $size : 'md';
    $indicator = in_array($indicator, ['start', 'end', 'none'], true) ? $indicator : 'end';

    // Full class strings per tone so Tailwind's JIT can see every utility: the
    // checked card (border, ring, tint) and the checked icon chip.
    $tones = [
        'neutral' => [
            'card' => 'has-[:checked]:border-foreground has-[:checked]:bg-foreground/[0.03] has-[:checked]:ring-1 has-[:checked]:ring-foreground',
            'icon' => 'group-has-[:checked]:bg-foreground group-has-[:checked]:text-background',
        ],
        'primary' => [
            'card' => 'has-[:checked]:border-primary has-[:checked]:bg-primary/[0.03] has-[:checked]:ring-1 has-[:checked]:ring-primary',
            'icon' => 'group-has-[:checked]:bg-primary group-has-[:checked]:text-primary-foreground',
        ],
    ];
    $tone = isset($tones[$tone]) ? $tone : 'neutral';

    $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. A radio card is
    // checked when its value equals the flashed scalar; a checkbox card when its
    // value is in the flashed array. Falls back to the explicit `checked` prop.
    $isChecked = $checked;
    if (filled($fieldName) && $value !== null && app('session')->hasOldInput($fieldName)) {
        $old = old($fieldName);
        $isChecked = $type === 'checkbox'
            ? in_array((string) $value, array_map('strval', (array) $old), true)
            : (string) $old === (string) $value;
    }

    $describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;
    // A stable id from the card's own data, so a render is deterministic.
    $reasonId = ($disabled && filled($disabledReason))
        ? 'choicebox-reason-'.substr(md5($fieldName.'|'.$value.'|'.$disabledReason), 0, 10)
        : null;
    $describedBy = trim(($describedBy ?? '').' '.($reasonId ?? '')) ?: null;
    $controlName = filled($fieldName) ? ($type === 'checkbox' ? $fieldName.'[]' : $fieldName) : null;

    // One id per option, from the field name and the value: a named group no
    // longer repeats the field name as every control's id. A value that is not
    // id-safe keeps a readable slug plus a short hash, so ids stay unique.
    $valueKey = (string) $value;
    $idSuffix = preg_match('/^[A-Za-z0-9_-]+$/', $valueKey) === 1
        ? $valueKey
        : trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $valueKey), '-').'-'.substr(md5($valueKey), 0, 6);
    $controlId = filled($fieldName) ? $fieldName.'-'.ltrim($idSuffix, '-') : null;

    // wire:model / x-model on the card bind its native control, not the label.
    $modelAttributes = $attributes->whereStartsWith(['wire:model', 'x-model'])->getAttributes();

    // Border, ring, background and the control move on one 150ms curve so a pick
    // reads as one change; hover and press make the card feel pressable.
    $shape = $size === 'sm' ? 'rounded-md p-4' : 'rounded-lg p-6';
    $card = 'group relative flex items-start gap-3 border border-border bg-card text-start '.$shape.' '
        .'transition-[border-color,box-shadow,background-color,scale] duration-150 ease-out motion-reduce:transition-none '
        .$tones[$tone]['card'].' '
        .($disabled
            ? 'cursor-not-allowed opacity-60'
            : 'cursor-pointer hover:border-input hover:bg-muted/40 active:scale-[0.995]');
@endphp

{{--
    Choicebox — the option card. The whole card is a <label> around a real
    <x-ui.radio> or <x-ui.checkbox>, so it submits in a plain <form> with no
    JavaScript, the native control carries keyboard, focus ring and invalid
    state, and the card styles itself off :has(:checked). `indicator` puts
    the control at the end (card), the start (row), or hides it (`none`, the
    card border alone shows selection). Optional leading `icon` slot and a
    trailing `badge`. Group cards with <x-ui.choicebox.group>.
--}}
<label
    data-slot="{{ $attributes->get('data-slot', 'choicebox') }}"
    {{ $attributes->except(['name', 'data-slot', ...array_keys($modelAttributes)])->merge(['class' => $card]) }}
    data-type="{{ $type }}"
    data-size="{{ $size }}"
    data-indicator="{{ $indicator }}"
    data-tone="{{ $tone }}"
    @if ($hasBagError) data-invalid="true" @endif
>
    @php
        $controlAttributes = [
            'id' => $controlId,
            'name' => $controlName,
            'value' => $value,
            'checked' => $isChecked,
            'disabled' => $disabled,
            'tone' => $tone,
            'aria-describedby' => $describedBy,
            'class' => $indicator === 'none' ? 'sr-only' : 'mt-0.5',
            // A bound choicebox.group finds and syncs its cards' controls by this marker.
            'data-choicebox-control' => true,
        ] + $modelAttributes;
    @endphp

    @if ($indicator !== 'end')
        @if ($type === 'checkbox')
            <x-ui.checkbox :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
        @else
            <x-ui.radio :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
        @endif
    @endif

    @if (isset($icon))
        <span class="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors duration-150 ease-out motion-reduce:transition-none {{ $tones[$tone]['icon'] }}" aria-hidden="true">
            {{ $icon }}
        </span>
    @endif

    <span class="flex min-w-0 flex-1 flex-col gap-1">
        @if ($title || $badge)
            <span class="flex items-center gap-2">
                @if ($title)
                    <span class="text-sm font-medium leading-snug text-foreground">{{ $title }}</span>
                @endif
                @if ($badge)
                    <x-ui.badge variant="secondary" size="sm">{{ $badge }}</x-ui.badge>
                @endif
            </span>
        @endif
        @if ($description)
            <span class="text-sm leading-normal text-muted-foreground">{{ $description }}</span>
        @endif
        @if ($reasonId)
            {{-- Full foreground: the dimmed card leaves it at the muted tone and
                 still above 4.5:1, where muted text would drop below it. --}}
            <span id="{{ $reasonId }}" data-slot="choicebox-reason" class="text-sm leading-normal text-foreground">{{ $disabledReason }}</span>
        @endif
        {{ $slot }}
    </span>

    @if ($indicator === 'end')
        @if ($type === 'checkbox')
            <x-ui.checkbox :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
        @else
            <x-ui.radio :attributes="new \Illuminate\View\ComponentAttributeBag($controlAttributes)" :invalid="$hasBagError" />
        @endif
    @endif
</label>
resources/views/components/ui/choicebox/group.blade.php Blade
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware; the group
     adopts it, forwards name/type/size/indicator/tone to every card (each reads them
     via @aware) and self-wires aria-invalid/aria-describedby off the shared
     $errors bag. --}}
@aware([
    'name' => null,
])

@props([
    'name' => null,
    // Radio groups take one value; checkbox groups an array of values.
    'value' => null,
    'type' => 'radio',
    'size' => 'md',
    'indicator' => 'end',
    // Checked colour of every card (neutral | primary), read by each card via @aware.
    'tone' => 'neutral',
    // Declarative cards: [['value', 'title', 'description', 'badge', 'disabled', 'disabledReason']];
    // the card matching `value` is checked. Hand-composed cards mark `checked`
    // themselves (a parent's computed state cannot reach a child through @aware).
    'options' => [],
    'label' => null,
])

@php
    $awareName = $name ?? null;
    $fieldName = $awareName ?? $attributes->get('name');
    $type = $type === 'checkbox' ? 'checkbox' : 'radio';

    $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;
    $selected = array_map('strval', $type === 'checkbox' ? (array) $resolvedValue : ($resolvedValue === null ? [] : [$resolvedValue]));

    $describedBy = (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null;

    // wire:model / x-model on the group bind its value (a scalar for radio, an
    // array for checkbox). The root becomes modelable and keeps every card's
    // native control checked from the bound value, so a server-side change or
    // reset shows after a Livewire update. Without a binding the group stays
    // plain HTML with no JavaScript.
    $bound = $attributes->whereStartsWith(['wire:model', 'x-model'])->isNotEmpty();
    $initialChoice = $type === 'checkbox' ? $selected : ($selected[0] ?? null);
@endphp

<div
    role="{{ $type === 'checkbox' ? 'group' : 'radiogroup' }}"
    data-slot="{{ $attributes->get('data-slot', 'choicebox-group') }}"
    data-type="{{ $type }}"
    @if ($label) aria-label="{{ __($label) }}" @endif
    @if ($hasBagError) data-invalid="true" aria-invalid="true" @endif
    @if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
    @if ($bound)
        x-data="{ choice: @js($initialChoice) }"
        x-modelable="choice"
        @if ($type === 'checkbox')
            x-effect="const picked = (Array.isArray(choice) ? choice : []).map(String); $el.querySelectorAll('[data-choicebox-control]').forEach((control) => { control.checked = picked.includes(control.value) })"
            x-on:input="if ($event.target.hasAttribute('data-choicebox-control')) { choice = Array.from($el.querySelectorAll('[data-choicebox-control]:checked'), (control) => control.value) }"
        @else
            x-effect="const picked = choice === null || choice === undefined ? null : String(choice); $el.querySelectorAll('[data-choicebox-control]').forEach((control) => { control.checked = control.value === picked })"
            x-on:input="if ($event.target.hasAttribute('data-choicebox-control')) { choice = $event.target.value }"
        @endif
        {{-- .blur: blur does not bubble, so the group reports one when focus leaves it.
             Safari does not focus a radio or checkbox on click, so focus never
             entered the group and a mouse pick was never sent; focus it here. --}}
        x-on:change="if ($event.target.hasAttribute('data-choicebox-control') && document.activeElement !== $event.target) { $event.target.focus({ preventScroll: true }) }"
        x-on:focusout="if (! $el.contains($event.relatedTarget)) { $el.dispatchEvent(new FocusEvent('blur')) }"
    @endif
    {{ $attributes->except(['name', 'data-slot'])->merge(['class' => 'flex flex-col gap-2']) }}
>
    @if (! empty($options))
        @foreach ($options as $option)
            <x-ui.choicebox
                :value="$option['value']"
                :title="$option['title'] ?? null"
                :description="$option['description'] ?? null"
                :badge="$option['badge'] ?? null"
                :disabled="$option['disabled'] ?? false"
                :disabled-reason="$option['disabledReason'] ?? null"
                :checked="in_array((string) $option['value'], $selected, true)"
            />
        @endforeach
    @else
        {{ $slot }}
    @endif
</div>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.4.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