Components · App chrome
Language switcher
A menu-triggered control for choosing the interface language, listing the supported set and marking the current one. New in v1.5. It is UI only — no i18n runtime is implied, and localization and RTL specifics remain an open gap in the spec.
Preview
Build with the skill
No package to install — hand this to your AI to generate or migrate the component in your stack.
Apply the Zetta "Language switcher" component to this project using the zetta-design-md skill.
WHEN TO USE
- Migrating or fixing existing code (the common case): audit the current implementation against the `language-switcher` spec (variants: trigger, menu, item, item-active) and CORRECT every deviation — replace off-spec values, don't only add new ones.
- New component: build it to the `language-switcher` spec in this project's stack.
AUDIT & FIX (when the component already exists)
Compare it to the skill's spec and fix, don't just append: hardcoded colors/hex, off-scale radius, wrong fonts, dark/navy fills, shadows used in place of borders (or missing borders), brand lime used as a CTA or interaction (must be primary indigo), and any missing or wrong hover / focus / active / disabled / invalid state or missing 2px focus ring. Replace each with the correct Zetta token.
ALWAYS
- Pull the authoritative spec and tokens from the skill — never guess or hardcode values.
- Use this project's own component conventions and framework idioms.
- Honor the Zetta guardrails (brand = identity only, primary = interaction, shadows for overlays) and verify in Light, Dark, and Accessibility. Anatomy & rules
Trigger (in header):
🌐 English ⌄ ← language-switcher-trigger (globe + current language + chevron)
Open menu:
╭─────────────────────────╮
│ ✓ English │ ← language-switcher-item-active (aria-current="true")
│ Français │ ← language-switcher-item
│ Español │
│ Bahasa Indonesia │
╰─────────────────────────╯ ← language-switcher-menu
| State | Visual |
|---|---|
| Closed | Trigger at rest, showing the current language label |
| Trigger hover | {colors.primary-bg} fill, {colors.primary} text |
| Trigger focus | Focus ring ({colors.border-focus}) |
| Open | language-switcher-menu panel visible, languages listed |
| Item hover | {colors.primary-bg} fill on hovered language |
| Item focus | Focus outline on the language item |
| Active language | {colors.primary-bg} fill, check icon prefix, aria-current="true" |
When to use
- Global language selection in the app header or website navbar.
- Any surface where the user should be able to change the interface language.
When not to use
- Selecting a value for a form field (e.g. a "preferred language" setting inside a form) — use Select.
- Switching between app themes — use Theme Switcher.
Do
- Show the current language on the trigger (endonym — the language's own name: "Français", not "French").
- Mark the active language with both a
checkicon andaria-current="true"— never color alone. - Keep the language list short and stable; order consistently across sessions.
- Place it in the header near the theme switcher and user menu.
Don't
- Don't rely on a flag icon to denote a language — flags map to countries, not languages.
- Don't reload or navigate abruptly on selection without preserving the user's place.
- Language labels use the endonym in that language's own script:
English,Français,Español,Bahasa Indonesia. - Trigger
aria-label: "Language: [current language]". - Menu
aria-label: "Select language". - Do not translate the endonyms — they are always shown in their own language.
Keyboard
| Key | Behavior |
|---|---|
Tab |
Move focus to the language-switcher trigger |
Enter / Space |
Open the menu; focus moves to the active (or first) language |
Up / Down Arrow |
Move between languages in the open menu |
Home / End |
Jump to the first / last language |
Enter |
Select the focused language and close the menu |
Escape |
Close the menu without changing the language; focus returns to the trigger |
Accessibility
- Trigger:
aria-haspopup="menu"andaria-expandedreflecting open state;aria-label="Language: [current language]". - Menu semantics:
role="menu"on the panel,role="menuitemradio"on each language (single-choice list),aria-label="Select language"on the menu. - Active language: the current language carries
aria-current="true"(andaria-checked="true"on itsmenuitemradio) and a visiblecheckicon — state is conveyed by icon + text, not color alone. - Language metadata: set
langon each item to its language tag (en,fr,es,id) so assistive tech pronounces each endonym correctly. - Focus management: on open, focus moves to the active language; on close, focus returns to the trigger.
- Selection announcement: on change, announce the new language via a visually hidden
aria-live="polite"region ("Language set to Français").
Spec
Values and token references resolved straight from the Zetta spec.
language-switcher-trigger
- display
- flex
- alignItems
- center
- gap
- {spacing.xs}
- height
- 32px
- paddingX
- {spacing.sm}
- borderRadius
- {rounded.base}
- backgroundColor
- transparent
- borderColor
- transparent
- borderWidth
- 1px
- textColor
- {colors.muted}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 500
- iconSize
- {icons.sizeMap.md}
- chevronSize
- {icons.sizeMap.sm}
- cursor
- pointer
language-switcher-trigger-hover
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
language-switcher-trigger-active
- backgroundColor
- {colors.surface-pressed}
language-switcher-trigger-focus
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- 2px
language-switcher-menu
- backgroundColor
- {colors.popover}
- borderColor
- {colors.hairline}
- borderWidth
- 1px
- borderRadius
- {rounded.base}
- shadow
- {elevation.shadow.overlay}
- padding
- {spacing.xs}
- minWidth
- 180px
- zIndex
- {elevation.z-index.popup}
language-switcher-item
- display
- flex
- alignItems
- center
- gap
- {spacing.sm}
- paddingX
- {spacing.sm}
- paddingY
- {spacing.xs}
- borderRadius
- {rounded.sm}
- fontFamily
- Geist
- fontSize
- 14px
- fontWeight
- 400
- lineHeight
- 1.40
- textColor
- {colors.ink}
- iconColor
- {colors.muted}
- iconSize
- {icons.sizeMap.md}
- cursor
- pointer
- minHeight
- 32px
language-switcher-item-hover
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
language-switcher-item-focus
- outline
- 2px solid {colors.border-focus}
- outlineOffset
- -2px
language-switcher-item-active
- backgroundColor
- {colors.primary-bg}
- textColor
- {colors.primary}
- iconColor
- {colors.primary}
- fontWeight
- 500