Skip to content
Brok UI

Loading…

No results

Locale Switcher

Open source

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.

Version
v1.0.1
Stability
stable
License
MIT
Related
Dropdown Menu
Organization Switcher
Native Select

Preview

previews.components.locale-switcher.default.blade.php 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>

Installation

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

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/locale-switcher.blade.php
Registry dependencies
dropdown form
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.

locale-switcher.md
# 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.blade.php Blade
<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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

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

locale-switcher locale-switcher-option

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

Navigation and orientation

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
root form trigger menu option
Theming hooks
trigger menu checked item

Accessibility

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

resources/views/components/ui/locale-switcher.blade.php Blade
{{--
    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