Locale Switcher
A compact language control: the trigger names the current locale and a menu of single-choice items, each in its own native name and lang, submits the pick through a form or a Livewire method.
Preview
{{-- Settings: the trigger names the current language in full. Each pick
posts `locale` to the action; the server validates it and redirects. --}}
<div class="w-full max-w-xs">
<x-ui.locale-switcher
:locales="[
['code' => 'en', 'label' => 'English'],
['code' => 'nl', 'label' => 'Nederlands'],
['code' => 'de', 'label' => 'Deutsch'],
['code' => 'fr', 'label' => 'Français'],
['code' => 'ja', 'label' => '日本語'],
['code' => 'ar', 'label' => 'العربية'],
]"
current="en"
action="/settings/locale"
/>
</div>
Installation
php artisan ui:add locale-switcher
Note
This component ships an Alpine behavior module at
resources/js/ui/locale-switcher.js. Import it once from your bundle so it registers on alpine:init:
import './locale-switcher.js';
Registry contract
php artisan ui:add locale-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/locale-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: Locale Switcher (`locale-switcher`)
A compact language control: the trigger names the current locale and a menu of single-choice items, each in its own native name and lang, submits the pick through a form or a Livewire method.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add locale-switcher
```
## Usage
```blade
{{-- Settings: the trigger names the current language in full. Each pick
posts `locale` to the action; the server validates it and redirects. --}}
<div class="w-full max-w-xs">
<x-ui.locale-switcher
:locales="[
['code' => 'en', 'label' => 'English'],
['code' => 'nl', 'label' => 'Nederlands'],
['code' => 'de', 'label' => 'Deutsch'],
['code' => 'fr', 'label' => 'Français'],
['code' => 'ja', 'label' => '日本語'],
['code' => 'ar', 'label' => 'العربية'],
]"
current="en"
action="/settings/locale"
/>
</div>
```
## Props
- `locales` (array, default `[]`) — Locales to offer: [['code' => 'de', 'label' => 'Deutsch'], …] or ['de' => 'Deutsch']. The label is the language's own name. Invalid codes are dropped.
- `current` (string|null, default `null`) — Code of the current locale (null uses the app locale); its item is checked and submits nothing.
- `action` (string|null, default `null`) — Form mode: URL the choice is sent to.
- `method` (string, default `POST`) — Form verb; PUT, PATCH and DELETE are spoofed by the form primitive.
- `name` (string, default `locale`) — Field name that carries the chosen code.
- `hidden` (array, default `[]`) — Extra hidden fields for the form, for example ['redirect' => url()->current()].
- `wire` (string|null, default `null`) — Livewire mode: the component method called with the code (wire:click="setLocale('de')"). Wins over action. Only a plain method name is accepted.
- `label` (string, default `Language`) — Accessible name of the trigger and heading of the menu.
- `display` (label|code, default `label`) — Trigger content: the native name (settings, full width, menu aligned to the start) or the short code with a globe (sidebar footer, narrow rail, menu aligned to the end).
## Use when
- Use to orient users and help them move across pages, sections, or commands.
- People choose the interface language from a short list, in a sidebar footer, a header or a settings page.
- The app stores the locale server-side (session, user record) and needs a form post or a Livewire call to change it.
## Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The choice is a form value saved with other settings; use select inside that form.
- The language is part of the URL (/de/...); link to the translated pages instead.
## Anti-patterns
- Hiding primary wayfinding in novelty interactions
## Rules
- Use the `<brok:locale-switcher>` tag (or `<x-ui.locale-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/locale-switcher
- Registry JSON (files, props, contract): https://brokui.dev/r/open/locale-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
Long Content
<div class="w-56">
<x-ui.locale-switcher
:locales="[
['code' => 'de-CH', 'label' => 'Deutsch (Schweiz, mit einer sehr langen Bezeichnung)'],
['code' => 'en', 'label' => 'English'],
['code' => 'invalid code!', 'label' => 'Dropped'],
]"
current="de-CH"
action="/locale"
label="Interface language used across the whole workspace"
/>
</div>
{{-- Sidebar footer: `display="code"` keeps the trigger short; the anchored
menu flips above the trigger at the bottom of the viewport. --}}
<div class="flex h-80 w-64 flex-col justify-end rounded-lg border border-border bg-card p-4">
<div class="flex items-center justify-between gap-2 border-t border-border pt-4">
<span class="truncate text-sm text-muted-foreground">{{ __('you@example.com') }}</span>
<x-ui.locale-switcher
:locales="['en' => 'English', 'nl' => 'Nederlands', 'de' => 'Deutsch', 'pt_BR' => 'Português (Brasil)']"
current="nl"
action="/locale"
:hidden="['redirect' => '/dashboard']"
display="code"
/>
</div>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| locales | array | [] | Locales to offer: [['code' => 'de', 'label' => 'Deutsch'], …] or ['de' => 'Deutsch']. The label is the language's own name. Invalid codes are dropped. |
| current | string | null | null | Code of the current locale (null uses the app locale); its item is checked and submits nothing. |
| action | string | null | null | Form mode: URL the choice is sent to. |
| method | string | POST | Form verb; PUT, PATCH and DELETE are spoofed by the form primitive. |
| name | string | locale | Field name that carries the chosen code. |
| hidden | array | [] | Extra hidden fields for the form, for example ['redirect' => url()->current()]. |
| wire | string | null | null | Livewire mode: the component method called with the code (wire:click="setLocale('de')"). Wins over action. Only a plain method name is accepted. |
| label | string | Language | Accessible name of the trigger and heading of the menu. |
| display | label | code | label | Trigger content: the native name (settings, full width, menu aligned to the start) or the short code with a globe (sidebar footer, narrow rail, menu aligned to the end). |
Slots
Default Blade slot only.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- The trigger opens an anchored menu (menu button pattern): arrow keys move between items, Enter or Space picks, Escape closes and returns focus. The panel teleports to body and flips above the trigger when there is no room below.
- Items are menuitemradio with aria-checked; the current locale is checked and picking it only closes the menu.
- Form mode: every item is a submit button bound to a hidden form by id, with name and value, so no script builds the request; CSRF and method spoofing come from the form primitive.
- Livewire mode: items call the given method with the code and the menu closes.
- 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.
- People choose the interface language from a short list, in a sidebar footer, a header or a settings page.
- The app stores the locale server-side (session, user record) and needs a form post or a Livewire call to change it.
Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The choice is a form value saved with other settings; use select inside that form.
- The language is part of the URL (/de/...); link to the translated pages instead.
Use instead
- Visible links and local navigation
Anti-patterns
- Hiding primary wayfinding in novelty interactions
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- Each native name carries its own lang attribute, so screen readers pronounce Deutsch or 日本語 correctly.
- The trigger's accessible name starts with the label and contains the visible text (WCAG 2.5.3 Label in Name).
- The server must validate the posted code against the supported locales; the list is not a boundary.
- 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="locale-switcher-{{ $record->id }}">
{{-- Settings: the trigger names the current language in full. Each pick
posts `locale` to the action; the server validates it and redirects. --}}
<div class="w-full max-w-xs">
<x-ui.locale-switcher
:locales="[
['code' => 'en', 'label' => 'English'],
['code' => 'nl', 'label' => 'Nederlands'],
['code' => 'de', 'label' => 'Deutsch'],
['code' => 'fr', 'label' => 'Français'],
['code' => 'ja', 'label' => '日本語'],
['code' => 'ar', 'label' => 'العربية'],
]"
current="en"
action="/settings/locale"
/>
</div>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Locale switcher. A compact trigger names the current locale; its menu lists
the locales passed in (code + native name) as single-choice items. A pick
submits the code through a form (`action`) or calls a Livewire method
(`wire`). Each native name carries its own `lang` so assistive technology
pronounces it correctly. The anchored menu flips above the trigger when
there is no room below, so it works in a sidebar footer.
--}}
@props([
// [['code' => 'en', 'label' => 'English'], …] or ['en' => 'English', …].
// `label` is the language's own name ("Deutsch", "日本語").
'locales' => [],
// Current locale code; defaults to the app locale.
'current' => null,
// Form mode: the URL the choice posts to, the verb, and the field name.
'action' => null,
'method' => 'POST',
'name' => 'locale',
// Extra hidden fields for the form, for example ['redirect' => url()->current()].
'hidden' => [],
// Livewire mode: a method on the surrounding component, called with the
// code (wire:click="setLocale('de')"). Wins over `action`.
'wire' => null,
// Accessible name of the control and heading of the menu.
'label' => 'Language',
// Trigger content: the native name ('label') or the short code ('code')
// for a narrow rail or sidebar footer.
'display' => 'label',
])
@php
$normalized = [];
foreach ((array) $locales as $key => $locale) {
[$code, $native] = is_array($locale)
? [(string) ($locale['code'] ?? ''), (string) ($locale['label'] ?? $locale['native'] ?? $locale['code'] ?? '')]
: [(string) $key, (string) $locale];
if (preg_match('/^[A-Za-z]{2,3}(?:[_-][A-Za-z0-9]{2,8})*$/', $code) === 1) {
$normalized[] = ['code' => $code, 'label' => $native !== '' ? $native : $code, 'lang' => str_replace('_', '-', $code)];
}
}
$locales = $normalized;
$current = (string) ($current ?? app()->getLocale());
$currentLocale = collect($locales)->first(static fn (array $l): bool => strcasecmp($l['code'], $current) === 0)
?? ['code' => $current, 'label' => mb_strtoupper($current), 'lang' => str_replace('_', '-', $current)];
$short = static fn (array $l): string => mb_strtoupper(explode('-', $l['lang'])[0]);
$display = in_array($display, ['label', 'code'], true) ? $display : 'label';
// A short code trigger usually sits at the end of a footer row.
$align = $display === 'code' ? 'end' : 'start';
// Only a plain method name reaches wire:click.
$wire = is_string($wire) && preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $wire) === 1 ? $wire : null;
$formId = (string) ($attributes->get('id') ?: 'locale-switcher-'.\Illuminate\Support\Str::random(6));
$label = __($label);
// The accessible name keeps the visible text (WCAG 2.5.3).
$triggerName = $display === 'code'
? $label.': '.$currentLocale['label'].' ('.$short($currentLocale).')'
: $label.': '.$currentLocale['label'];
@endphp
<div
data-slot="locale-switcher"
data-display="{{ $display }}"
{{ $attributes->except('id')->merge(['class' => $display === 'code' ? 'inline-flex min-w-0' : 'min-w-0']) }}
>
@unless ($wire)
{{-- Every option submits into this form by id: the anchored menu teleports to <body>. --}}
<x-ui.form :id="$formId" :action="$action" :method="$method" class="hidden">
@foreach ((array) $hidden as $field => $value)
<input type="hidden" name="{{ $field }}" value="{{ $value }}" />
@endforeach
</x-ui.form>
@endunless
<x-ui.dropdown :anchored="true" class="{{ $display === 'code' ? '' : 'w-full' }}">
<x-ui.dropdown.trigger
aria-label="{{ $triggerName }}"
class="{{ $display === 'code' ? 'h-9 gap-1 px-2' : 'h-10 w-full gap-2 px-3' }} rounded-md border border-input bg-background 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"
>
<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"><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>
@if ($display === 'code')
<span class="font-medium">{{ $short($currentLocale) }}</span>
@else
<span class="min-w-0 flex-1 truncate" lang="{{ $currentLocale['lang'] }}">{{ $currentLocale['label'] }}</span>
<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>
@endif
</x-ui.dropdown.trigger>
<x-ui.dropdown.content :align="$align" class="w-56">
<x-ui.dropdown.label>{{ $label }}</x-ui.dropdown.label>
@if ($locales === [])
<p class="px-2 py-3 text-center text-xs text-muted-foreground">{{ __('No other languages are available.') }}</p>
@else
<x-ui.dropdown.radio-group :label="$label" class="max-h-72 overflow-y-auto">
@foreach ($locales as $locale)
@php $isCurrent = strcasecmp($locale['code'], $currentLocale['code']) === 0; @endphp
{{-- A raw menuitemradio (the dropdown radio-item recipe): it must be a
submit button bound to the form, which the recipe's fixed
type="button" cannot be. The current locale submits nothing. --}}
<button
type="{{ $isCurrent || $wire ? 'button' : 'submit' }}"
@if (! $isCurrent && ! $wire) form="{{ $formId }}" name="{{ $name }}" value="{{ $locale['code'] }}" @endif
@if (! $isCurrent && $wire) wire:click="{{ $wire }}({{ \Illuminate\Support\Js::from($locale['code']) }})" @endif
role="menuitemradio"
aria-checked="{{ $isCurrent ? 'true' : 'false' }}"
tabindex="-1"
lang="{{ $locale['lang'] }}"
data-slot="locale-switcher-option"
data-locale="{{ $locale['code'] }}"
@click="closeAndFocus()"
class="group relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-2 pe-2 ps-8 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"
>
<span class="pointer-events-none absolute inset-y-0 start-2 flex items-center opacity-0 group-aria-checked:opacity-100" aria-hidden="true">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7" /></svg>
</span>
<span class="min-w-0 flex-1 truncate">{{ $locale['label'] }}</span>
<span class="shrink-0 text-xs text-muted-foreground" aria-hidden="true">{{ mb_strtoupper($locale['lang']) }}</span>
</button>
@endforeach
</x-ui.dropdown.radio-group>
@endif
</x-ui.dropdown.content>
</x-ui.dropdown>
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.1 - 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