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.0.4
Stability
stable
License
MIT
Related
Choicebox
Radio Rows
Label Selector
Color Swatch Selector
3 of 5

Radio Tabs — Segmented single-choice control built from visually-hidden native radios that submit in a normal form with no JavaScript.

Preview

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

Installation

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

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

radio-tabs.md
# 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

manifest knowledge + registry-derived coverage

Props

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

radio-tabs radio-tabs-item

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

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 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
root radio-tabs-item
Theming hooks
radio-tabs radio-tabs-item

Accessibility

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

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

resources/views/components/ui/radio-tabs.blade.php Blade
{{-- 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>
resources/views/components/ui/radio-tabs/item.blade.php Blade
@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