registry:ui · Interactive
language-switcher — anchors, not buttons
Locales are links, not JavaScript state: every entry carries its own href, so the switcher works on fully prerendered sites with zero hydration owed. pair is the bilingual segmented group after the openspecui reference; menu is the dropdown for three or more locales — hard-shadow list on the terminal surface, closing on select, outside click, or Escape.
language-switcher
Locale switching in two variants, shown on the terminal bezel surface they were born on — open the menu: the list drops with a hard offset shadow and the current locale in brand hue.
<LanguageSwitcher
variant="pair"
current="en"
locales={[/* { code, label, href } */]}
/>law
Why href, not onclick
A locale switch is navigation, not state mutation. Each locale knows the localized path of the current page, so the anchor carries the full destination and the switcher stays a pure link — crawlable, restorable, and functional before hydration (or without it entirely).
-
localesis data:{ code, label, href }— pair renders the first two, menu renders all of them - menu semantics: a bare-disclosure BUTTON trigger (no aria-haspopup), a NAV
landmark, and locale anchors carrying
aria-current="page"— navigation, not listbox selection (the a11y section below teaches the same served truth) - styling speaks
currentColorand the terminal tokens — it drops onto any bezel or themed surface unchanged - pair caps itself at two entries by design; the bilingual case is a distinct visual pattern, not a truncated menu
types
Types
Two variants split by locale count: pair for the bilingual case, menu for three or more.
language-switcher · types
<script lang="ts">
import LanguageSwitcher from '@ui/language-switcher.svelte';
</script>
<div class="flex flex-wrap items-start gap-6">
<div class="flex flex-col gap-3 border border-border p-4"><span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">pair</span><LanguageSwitcher variant="pair" current="en" locales={[
{ code: 'en', label: 'EN', href: '#language-switcher-demo' },
{ code: 'zh', label: '中文', href: '#language-switcher-demo' },
]} /><span class="text-muted-foreground text-[12.5px]">segmented group — caps at two entries by design</span></div>
<div class="flex flex-col gap-3 border border-border p-4"><span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">menu</span><LanguageSwitcher variant="menu" current="en" ariaLabel="Language" locales={[
{ code: 'en', label: 'English', href: '#language-switcher-demo' },
{ code: 'zh', label: '简体中文', href: '#language-switcher-demo' },
{ code: 'ja', label: '日本語', href: '#language-switcher-demo' },
{ code: 'de', label: 'Deutsch', href: '#language-switcher-demo' },
]} /><span class="text-muted-foreground text-[12.5px]">nav disclosure — three or more locales</span></div>
</div>usage
Usage
locales is data — every entry carries the localized href of the current page.
<script lang="ts">
import LanguageSwitcher from '@ui/language-switcher.svelte';
</script>
<!-- two locales: the segmented pair (active fills brand hue) -->
<LanguageSwitcher
variant="pair"
current="en"
locales={[
{ code: 'en', label: 'EN', href: '/en/current-page' },
{ code: 'zh', label: '中文', href: '/zh/current-page' },
]}
/>
<!-- three or more: the dropdown menu (current in brand hue) -->
<LanguageSwitcher
variant="menu"
current="en"
locales={[
{ code: 'en', label: 'English', href: '/en/current-page' },
{ code: 'zh', label: '简体中文', href: '/zh/current-page' },
{ code: 'ja', label: '日本語', href: '/ja/current-page' },
]}
/>a11y
Accessibility
pair is a group of links; menu is a nav-landmark popover of real anchors — links navigate, so no listbox/option fiction (honesty pass, 2026-09-02).
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus through the locale anchors (pair) or the trigger then the open list (menu) |
| Enter | Follows the focused locale anchor — navigation, not state |
| Escape | Closes the menu; outside click closes it too |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-label | ariaLabel ("Language") | Accessible name for the menu trigger and pair group |
| aria-expanded | true | false | On the menu trigger — a bare disclosure, no haspopup (the panel is navigation, not a select). |
| role | group / navigation | pair is a link group; the menu panel is a nav landmark (aria-label) of plain anchors — the current locale carries aria-current="page". |
theming
Theming
Bezel-born: the switcher speaks currentColor and terminal surface tokens, so it inherits the surrounding chrome — no jx density tokens of its own.
axes
Universal props
The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — each axis takes named steps, auto (inherit the ambient context; stamps nothing), an exact number (px · coefficient · dp · hue per axis), or query() for responsive/container-conditional values. Migrated contract: the structural pair/menu literal keeps its slot; density joins FRESH (the no-density-prop era ends with the axis surface).
LanguageSwitcher · universal props
Pure anchor navigation; the axes forward through the bezel.
<LanguageSwitcher size="medium">…</LanguageSwitcher>api
API
Props from the LanguageSwitcher Props interface; SwitcherLocale is the data contract. Persistence: a locale click writes its code to localStorage key `lang` — the site's language bootstrap reads the same key.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| variant | 'pair' | 'menu' | 'pair' · Own default, not ambient | Segmented bilingual pair or dropdown menu. Defaults: literal slot — own 'pair' (a structural selector, never a paint rung). |
| locales * | readonly SwitcherLocale[] | — | { code, label, href } entries; pair renders the first two, menu renders all. |
| current * | string | — | Active locale code; matched against entry codes. |
| ariaLabel | string | 'Language' | Accessible name for the trigger / group. |
| storage key | 'lang' | target locale code | Written on every locale click (try/catch, silent on storage failure); navigation stays pure anchors — the site's language-negotiation bootstrap reads this key. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |