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.
Preview
@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>
Installation
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:
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.
-
resources/views/components/ui/organization-switcher.blade.php
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: 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
<div class="w-full max-w-xs">
<x-ui.organization-switcher :organizations="[]" error="{{ __('The organization list could not be loaded.') }}" />
</div>
{{-- `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>
<div class="w-full max-w-xs">
<x-ui.organization-switcher :organizations="[]" :loading="true" />
</div>
Long Content
<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
@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>
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
<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.
@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