Skip to content
Brok UI

Loading…

No results

Organization Switcher

Open source

A server-authorized organization switcher: a trigger naming the current organization opens a menu of the others and a pick submits its stable id; long-list search, loading, error and empty states, an optional create link, and the original inline radio form as a variant.

Version
v2.0.3
Stability
stable
License
MIT
Related
Dropdown Menu
Avatar
Form
Command

Preview

Loading
Variant
previews.components.organization-switcher.default.blade.php Blade
@php
    $organizations = [
        ['id' => 'org_jml', 'name' => 'JML Agency', 'secondary' => __('12 members'), 'initials' => 'JM'],
        ['id' => 'org_studio', 'name' => 'North Studio', 'secondary' => __('4 members'), 'initials' => 'NS'],
        ['id' => 'org_lab', 'name' => 'Product Lab', 'secondary' => __('8 members'), 'initials' => 'PL'],
    ];
@endphp
<div class="w-full max-w-xs">
    <x-ui.organization-switcher :organizations="$organizations" current="org_jml" action="#" create-url="#" />
</div>
Menu Current
Inline Current

Installation

terminal
php artisan ui:add organization-switcher

Note

This component ships an Alpine behavior module at resources/js/ui/organization-switcher.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './organization-switcher.js';

Registry contract

php artisan ui:add organization-switcher 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/organization-switcher.blade.php
Registry dependencies
form input avatar badge button skeleton alert dropdown
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.

organization-switcher.md
# Brok UI: Organization Switcher (`organization-switcher`)

A server-authorized organization switcher: a trigger naming the current organization opens a menu of the others and a pick submits its stable id; long-list search, loading, error and empty states, an optional create link, and the original inline radio form as a variant.

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

## Install

```bash
php artisan ui:add organization-switcher
```

## Usage

```blade
@php
    $organizations = [
        ['id' => 'org_jml', 'name' => 'JML Agency', 'secondary' => __('12 members'), 'initials' => 'JM'],
        ['id' => 'org_studio', 'name' => 'North Studio', 'secondary' => __('4 members'), 'initials' => 'NS'],
        ['id' => 'org_lab', 'name' => 'Product Lab', 'secondary' => __('8 members'), 'initials' => 'PL'],
    ];
@endphp
<div class="w-full max-w-xs">
    <x-ui.organization-switcher :organizations="$organizations" current="org_jml" action="#" create-url="#" />
</div>
```

## Props

- `organizations` (list<Organization>, default `[]`) — Up to 250 server-authorized organizations with stable IDs.
- `current` (string|null, default `null`) — Current stable organization ID.
- `action` (url|null, default `null`) — Application switch endpoint.
- `method` (HTTP method, default `POST`) — Switch form method.
- `name` (string, default `organization_id`) — Submitted stable ID field name.
- `label` (string, default `Switch organization`) — Accessible switcher label.
- `searchLabel` (string, default `Search organizations`) — Search input label.
- `emptyLabel` (string, default `No organizations available`) — Empty state label.
- `loading` (boolean, default `false`) — Loading presentation.
- `error` (string|null, default `null`) — Server error message.
- `variant` (menu|inline, default `menu`) — menu: trigger + menu, a pick submits. inline: the radio list with a Switch button.
- `createUrl` (url|null, default `null`) — Footer link to create an organization (menu mode).
- `createLabel` (string, default `null`) — Label of the footer link; defaults to "Create organization".
- `searchAfter` (int, default `7`) — Show the filter box when the list is longer than this.

## Use when

- Use to orient users and help them move across pages, sections, or commands.
- Let a member switch among organizations that the server already authorized.

## Avoid when

- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- Do not send unauthorized organizations to the browser or use this component as an authorization boundary.
- Switching light, dark or system appearance rather than organizations; use theme-switcher instead.

## Anti-patterns

- Hiding primary wayfinding in novelty interactions

## Rules

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

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

Examples

error.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.organization-switcher :organizations="[]" error="{{ __('The organization list could not be loaded.') }}" />
</div>
inline.blade.php Blade
{{-- `variant="inline"`: the radio list with a Switch button, for a settings page. --}}
@php
    $organizations = [
        ['id' => 'org_jml', 'name' => 'JML Agency', 'secondary' => __('12 members'), 'initials' => 'JM'],
        ['id' => 'org_studio', 'name' => 'North Studio', 'secondary' => __('4 members'), 'initials' => 'NS'],
        ['id' => 'org_lab', 'name' => 'Product Lab', 'secondary' => __('8 members'), 'initials' => 'PL'],
    ];
@endphp
<div class="w-full max-w-md">
    <x-ui.organization-switcher variant="inline" :organizations="$organizations" current="org_jml" action="#" />
</div>
loading.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.organization-switcher :organizations="[]" :loading="true" />
</div>
long-content.blade.php Blade
<div class="flex flex-col gap-6">
    <div class="max-w-xs">
        <x-ui.organization-switcher :organizations="[['id' => 'org_long', 'name' => __('A deliberately long organization name that must truncate safely'), 'secondary' => __('A long secondary workspace identifier'), 'initials' => 'LO']]" current="org_long" />
    </div>
    {{-- The inline form in a 300px column, as in a phone navigation sheet: names truncate
         instead of widening the column. --}}
    <div class="w-[300px] max-w-full" data-testid="organization-switcher-narrow">
        <x-ui.organization-switcher
            variant="inline"
            :organizations="[
                ['id' => 'org_long_inline', 'name' => __('Vandenberghe-Oosterhuis Architectuur en Stedenbouwkundig Adviesbureau'), 'secondary' => __('billing-and-procurement-operations@vandenberghe-oosterhuis.example'), 'initials' => 'VO'],
                ['id' => 'org_short_inline', 'name' => __('North Studio'), 'initials' => 'NS'],
            ]"
            current="org_long_inline"
            action="#"
        />
    </div>
</div>
long-list.blade.php Blade
@php
    $organizations = collect(range(1, 18))->map(fn ($number) => [
        'id' => 'org_'.$number,
        'name' => __('Organization :number', ['number' => $number]),
        'secondary' => __('Workspace :number', ['number' => $number]),
        'initials' => 'O'.$number,
    ])->all();
@endphp
<div class="w-full max-w-xs">
    <x-ui.organization-switcher :organizations="$organizations" current="org_3" action="#" />
</div>
states.blade.php Blade
<div class="grid w-full max-w-2xl gap-6 md:grid-cols-2">
    <x-ui.organization-switcher :organizations="[]" />
    <x-ui.organization-switcher :organizations="[]" :loading="true" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
organizations list<Organization> [] Up to 250 server-authorized organizations with stable IDs.
current string | null null Current stable organization ID.
action url | null null Application switch endpoint.
method HTTP method POST Switch form method.
name string organization_id Submitted stable ID field name.
label string Switch organization Accessible switcher label.
searchLabel string Search organizations Search input label.
emptyLabel string No organizations available Empty state label.
loading boolean false Loading presentation.
error string | null null Server error message.
variant menu | inline menu menu: trigger + menu, a pick submits. inline: the radio list with a Switch button.
createUrl url | null null Footer link to create an organization (menu mode).
createLabel string null Label of the footer link; defaults to "Create organization".
searchAfter int 7 Show the filter box when the list is longer than this.

Slots

Default Blade slot only.

Data slots

Stable hooks for CSS overrides and browser tests.

organization-switcher organization-switcher-option

Behavior

  • Adds a local text filter when more than `searchAfter` organizations are present.
  • Menu mode submits the picked stable id at once (each row is a submit button bound to the form); inline mode submits from the Switch button.
  • Relies on server re-authorization of every switch.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Navigation and orientation

Orient users and move between destinations.

Use when

  • Use to orient users and help them move across pages, sections, or commands.
  • Let a member switch among organizations that the server already authorized.

Avoid when

  • Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
  • Do not send unauthorized organizations to the browser or use this component as an authorization boundary.
  • Switching light, dark or system appearance rather than organizations; use theme-switcher instead.

Use instead

  • Visible links and local navigation

Anti-patterns

  • Hiding primary wayfinding in novelty interactions
Anatomy
organization-switcher organization-switcher-option
Theming hooks
Uses semantic form, avatar, badge, input, and loading tokens.

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Home End
Focus
managed
  • Menu mode: a labelled menu button, roving focus over rows, the current organization marked with aria-current and a visible check.
  • Inline mode: native radio keyboard behavior inside a labelled fieldset.
  • 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="organization-switcher-{{ $record->id }}">
    @php
        $organizations = [
            ['id' => 'org_jml', 'name' => 'JML Agency', 'secondary' => __('12 members'), 'initials' => 'JM'],
            ['id' => 'org_studio', 'name' => 'North Studio', 'secondary' => __('4 members'), 'initials' => 'NS'],
            ['id' => 'org_lab', 'name' => 'Product Lab', 'secondary' => __('8 members'), 'initials' => 'PL'],
        ];
    @endphp
    <div class="w-full max-w-xs">
        <x-ui.organization-switcher :organizations="$organizations" current="org_jml" action="#" create-url="#" />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:organization-switcher
        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 file ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/organization-switcher.blade.php Blade
@props([
    'organizations' => [],
    'current' => null,
    'action' => null,
    'method' => 'POST',
    'name' => 'organization_id',
    'label' => 'Switch organization',
    'searchLabel' => 'Search organizations',
    'emptyLabel' => 'No organizations available',
    'loading' => false,
    'error' => null,
    // menu (default): a trigger naming the current organization that opens a
    // menu of the others — picking one submits. inline: the original
    // radio-list form with a Switch button.
    'variant' => 'menu',
    // Optional footer link in menu mode ("Create organization").
    'createUrl' => null,
    'createLabel' => null,
    // Long lists get a filter box above the options.
    'searchAfter' => 7,
])

@php
    // Accept a string, a backed enum or a Stringable for `variant`.
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $organizations = array_slice(array_values(array_filter(
        (array) $organizations,
        static fn ($organization): bool => is_array($organization) && filled($organization['id'] ?? null),
    )), 0, 250);
    $variant = in_array($variant, ['menu', 'inline'], true) ? $variant : 'menu';
    $currentOrganization = collect($organizations)->first(static fn (array $o): bool => (string) $o['id'] === (string) $current);
    $initialsOf = static fn (array $o): string => (string) ($o['initials'] ?? mb_strtoupper(mb_substr((string) ($o['name'] ?? $o['id']), 0, 2)));
    $searchable = count($organizations) > (int) $searchAfter;
    $formId = (string) ($attributes->get('id') ?: 'organization-switcher-'.\Illuminate\Support\Str::random(6));
    $createLabel ??= __('Create organization');
@endphp

@if ($variant === 'menu')
{{-- Menu mode: the trigger names the current organization; the menu lists every
     authorized one and a pick submits its stable id (each row is a submit button
     bound to the form by id, because the anchored panel teleports to <body>).
     The server re-authorizes on every switch — the list is never a boundary. --}}
<div
    data-slot="organization-switcher"
    data-variant="menu"
    data-authorization="server-filtered"
    x-data="{ query: '' }"
    {{ $attributes->except('id')->merge(['class' => 'min-w-0']) }}
>
    {{-- The form carries no fields of its own: every option row submits into it by id. --}}
    <x-ui.form :id="$formId" :action="$action" :method="$method" class="hidden" />
    <x-ui.dropdown :anchored="true" class="w-full">
        <x-ui.dropdown.trigger
            aria-label="{{ __($label) }}"
            class="flex h-10 w-full items-center gap-2 rounded-md border border-border bg-background px-2 text-start text-sm text-foreground shadow-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background motion-reduce:transition-none"
        >
            @if ($loading)
                <x-ui.skeleton class="size-6 shrink-0 rounded-full" />
                <x-ui.skeleton class="h-4 w-32" />
            @elseif ($currentOrganization)
                <x-ui.avatar :src="$currentOrganization['avatar'] ?? null" :fallback="$initialsOf($currentOrganization)" size="sm" class="shrink-0" />
                <span class="flex min-w-0 flex-1 flex-col leading-tight">
                    <span class="truncate font-medium">{{ $currentOrganization['name'] ?? $currentOrganization['id'] }}</span>
                    @if (filled($currentOrganization['secondary'] ?? null))
                        <span class="truncate text-xs text-muted-foreground">{{ $currentOrganization['secondary'] }}</span>
                    @endif
                </span>
            @else
                <span class="min-w-0 flex-1 truncate text-muted-foreground">{{ __($label) }}</span>
            @endif
            <svg class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 15 5 5 5-5M7 9l5-5 5 5" /></svg>
        </x-ui.dropdown.trigger>

        <x-ui.dropdown.content align="start" class="w-72">
            <x-ui.dropdown.label>{{ __($label) }}</x-ui.dropdown.label>

            @if (filled($error))
                <p role="alert" class="mx-1 mb-1 rounded-sm bg-destructive-soft px-2 py-2 text-xs text-destructive-text">{{ $error }}</p>
            @elseif ($loading)
                <div class="space-y-1 p-1" aria-busy="true" aria-label="{{ __('Loading organizations') }}">
                    <x-ui.skeleton class="h-9 w-full" />
                    <x-ui.skeleton class="h-9 w-full" />
                    <x-ui.skeleton class="h-9 w-full" />
                </div>
            @elseif ($organizations === [])
                <p class="px-2 py-3 text-center text-xs text-muted-foreground">{{ __($emptyLabel) }}</p>
            @else
                @if ($searchable)
                    <div class="p-1 pb-2">
                        <label class="sr-only" for="{{ $formId }}-search">{{ __($searchLabel) }}</label>
                        <input
                            id="{{ $formId }}-search"
                            type="search"
                            x-model="query"
                            x-on:keydown="if ($event.key.length === 1 || $event.key === 'Home' || $event.key === 'End') $event.stopPropagation()"
                            placeholder="{{ __($searchLabel) }}"
                            autocomplete="off"
                            class="h-8 w-full min-w-0 rounded-sm border border-input bg-background px-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
                        />
                    </div>
                @endif
                <div class="max-h-72 overflow-y-auto">
                    @foreach ($organizations as $organization)
                        @php
                            $id = (string) $organization['id'];
                            $organizationName = (string) ($organization['name'] ?? $id);
                            $isCurrent = $id === (string) $current;
                            $search = mb_strtolower($organizationName.' '.($organization['secondary'] ?? ''));
                        @endphp
                        {{-- A raw menuitem (the dropdown.item recipe) so the row keeps its own
                             data-slot; a component tag would print dropdown-item first. --}}
                        <button
                            type="submit"
                            name="{{ $name }}"
                            value="{{ $id }}"
                            form="{{ $formId }}"
                            role="menuitem"
                            tabindex="-1"
                            data-slot="organization-switcher-option"
                            @if ($isCurrent) aria-current="true" @endif
                            x-show="query === '' || {{ \Illuminate\Support\Js::from($search) }}.includes(query.toLowerCase())"
                            x-bind:aria-disabled="query !== '' && !{{ \Illuminate\Support\Js::from($search) }}.includes(query.toLowerCase()) ? 'true' : null"
                            @click="closeAndFocus()"
                            class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-start text-sm text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground motion-reduce:transition-none"
                        >
                            <x-ui.avatar :src="$organization['avatar'] ?? null" :fallback="$initialsOf($organization)" size="sm" class="shrink-0" />
                            <span class="flex min-w-0 flex-1 flex-col leading-tight">
                                <span class="truncate font-medium">{{ $organizationName }}</span>
                                @if (filled($organization['secondary'] ?? null))
                                    <span class="truncate text-xs text-muted-foreground">{{ $organization['secondary'] }}</span>
                                @endif
                            </span>
                            @if ($isCurrent)
                                <svg class="size-4 shrink-0 text-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7" /></svg>
                                <span class="sr-only">{{ __('Current') }}</span>
                            @endif
                        </button>
                    @endforeach
                </div>
            @endif

            @if ($createUrl)
                <x-ui.dropdown.separator />
                <x-ui.dropdown.item :href="$createUrl">
                    <span class="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-dashed border-input text-muted-foreground" aria-hidden="true">
                        <svg class="size-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14" /></svg>
                    </span>
                    <span class="truncate">{{ $createLabel }}</span>
                </x-ui.dropdown.item>
            @endif
        </x-ui.dropdown.content>
    </x-ui.dropdown>
</div>
@else
<div
    data-slot="organization-switcher"
    data-variant="inline"
    data-authorization="server-filtered"
    {{ $attributes->except('id')->merge(['class' => 'min-w-0']) }}
>
<x-ui.form :id="$formId" :action="$action" :method="$method" x-data="{ query: '' }" class="min-w-0 space-y-3">
    <div class="min-w-0 break-words">
        <p class="text-sm font-medium text-foreground">{{ __($label) }}</p>
        <p class="text-xs text-muted-foreground">{{ __('Only organizations supplied by the server are shown.') }}</p>
    </div>

    @if (filled($error))
        <x-ui.alert variant="destructive" role="alert">{{ $error }}</x-ui.alert>
    @endif

    @if ($loading)
        <div class="space-y-2" aria-busy="true" aria-label="{{ __('Loading organizations') }}">
            <x-ui.skeleton class="h-10 w-full" />
            <x-ui.skeleton class="h-10 w-full" />
        </div>
    @elseif ($organizations === [])
        <p class="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">{{ __($emptyLabel) }}</p>
    @else
        @if ($searchable)
            <label class="sr-only" for="organization-search">{{ __($searchLabel) }}</label>
            <x-ui.input
                id="organization-search"
                type="search"
                x-model="query"
                :placeholder="$searchLabel"
                autocomplete="off"
            />
        @endif

        {{-- min-w-0: a fieldset's minimum inline size defaults to its content, so a long
             organization name would widen a narrow sheet instead of truncating. --}}
        <fieldset class="min-w-0 max-h-72 space-y-1 overflow-y-auto rounded-md border border-border p-1">
            <legend class="sr-only">{{ __($label) }}</legend>
            @foreach ($organizations as $organization)
                @php
                    $id = (string) ($organization['id'] ?? '');
                    $organizationName = (string) ($organization['name'] ?? $id);
                    $search = mb_strtolower($organizationName.' '.($organization['secondary'] ?? ''));
                @endphp
                @if ($id !== '')
                    <label
                        data-slot="organization-switcher-option"
                        x-show="query === '' || @js($search).includes(query.toLowerCase())"
                        class="hover:bg-secondary focus-within:ring-ring flex min-w-0 cursor-pointer items-center gap-3 rounded-md p-2 focus-within:ring-2"
                    >
                        <input type="radio" name="{{ $name }}" value="{{ $id }}" @checked($id === (string) $current) class="accent-primary size-4 shrink-0" />
                        <x-ui.avatar :src="$organization['avatar'] ?? null" :fallback="$initialsOf($organization)" size="sm" class="shrink-0" />
                        <span class="min-w-0 flex-1">
                            <span class="block truncate text-sm font-medium text-foreground">{{ $organizationName }}</span>
                            @if (filled($organization['secondary'] ?? null))
                                <span class="block truncate text-xs text-muted-foreground">{{ $organization['secondary'] }}</span>
                            @endif
                        </span>
                        @if ($id === (string) $current)
                            <x-ui.badge variant="secondary" size="sm" class="shrink-0">{{ __('Current') }}</x-ui.badge>
                        @endif
                    </label>
                @endif
            @endforeach
        </fieldset>
        <x-ui.button type="submit">{{ __('Switch') }}</x-ui.button>
    @endif
</x-ui.form>
</div>
@endif

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
2.0.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
2026-09-18
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0