Locale switcher
The locale switcher is a disclosure, not tabs and not a Select. It matches the footer control in sportingscouter/www: a borderless muted chip with the Lucide Languages icon, a drop-up panel with shadow only, and locale links marked by weight — never checkmarks. The recipe is localeSwitcher({ placement, align, scale }); labels, routes and open/close behaviour stay in the app.
The component
English (United States)
On www the panel opens up from the footer — there is no room below. In app shells that sit under a header, use placement="down" and align="end" instead. No chevron on the trigger: the Languages icon and the current locale name are enough — and they stay on screen at every width. The chip is shrink-0; the lockup and nav take min-w-0. A header that clips this control is a layout bug, not a compact variant.
Specification
| Property | Value | Why |
|---|---|---|
| Recipe | localeSwitcher({ placement, align, scale }) | Visual layer only — www uses <details>; business uses a button listbox with the same classes |
| Trigger | rounded-xl, bg-surface-muted, no border | A chip, not a form control — matches www footer tests |
| Icon | Lucide Languages, 20px, stroke 1.75 | Same icon path as www; import from lucide-react in apps |
| Panel | rounded-2xl, shadow-popover, no border | OpenAI-style floating list — not a bordered Select menu |
| Placement | up default · down for headers | Footer has no space below; nav bars do |
| Overflow | shrink-0 on root | Icon + locale name stay visible; the shell's lockup and nav take min-w-0. No icon-only compact mode |
| Current locale | font-medium, aria-current="page" on links | No checkmarks, no indent hacks — weight is the signal |
| Strings | App-owned | footer.languages.* on www; shell.locale.* in business — nothing defaults in ui |
Structure — www (links)
<details class={ls.root()} data-locale-switcher>
<summary class={ls.trigger()} aria-label={t('footer.languageLabel')}>
<Languages class={ls.icon()} aria-hidden />
<span class={ls.label()}>{t(`footer.languages.${locale}`)}</span>
</summary>
<div class={ls.panel({ placement: 'up', align: 'start' })}>
<nav class={ls.menu()} aria-label={t('footer.languageLabel')}>
<ul class={ls.list()}>
{Object.entries(alternates).map(([loc, path]) => (
<li>
<a
href={path}
hreflang={loc}
lang={loc}
aria-current={loc === locale ? 'page' : undefined}
class={ls.link({ class: loc === locale ? ls.linkCurrent() : undefined })}
>
{t(`footer.languages.${loc}`)}
</a>
</li>
))}
</ul>
</nav>
</div>
</details>Outside-click closes the disclosure on www — register the listener on the toggle event and defer with queueMicrotask so the opening click does not immediately close it. See SiteFooter.astro in sportingscouter/www.
Structure — app (listbox)
<div class={ls.root()}>
<button
type="button"
class={ls.trigger({ scale: 'sm' })}
aria-label={t('shell.locale.label')}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
>
<Languages class={ls.icon()} aria-hidden />
<span class={ls.label()}>{t(`shell.locale.${current}`)}</span>
</button>
{open ? (
<div
id={listboxId}
role="listbox"
class={ls.panel({ placement: 'down', align: 'end' })}
>
<ul class={ls.list()}>
{locales.map((locale) => (
<li key={locale}>
<button
type="button"
role="option"
aria-selected={locale === current}
class={ls.option({ class: locale === current ? ls.optionCurrent() : undefined })}
onClick={() => selectLocale(locale)}
>
{t(`shell.locale.${locale}`)}
</button>
</li>
))}
</ul>
</div>
) : null}
</div>Props
| Prop | Type | Default | Notes |
|---|---|---|---|
placement | 'up' | 'down' | 'up' | Explicit — no viewport flip. Footer → up; header → down |
align | 'start' | 'end' | 'start' | Explicit — no collision detection. Footer → start; header rail → end |
scale | 'md' | 'sm' | 'md' | sm tightens padding for compact app headers |
Rules
- Not tabs. Horizontal locale pills were rejected on www — they read as country filters, not language choice, and they wrap badly in the footer.
- Not Select. The trigger is not Input-shaped; the panel has no border. Reusing Select would lie about both.
- No chevron. The Languages icon and the current label are the affordance. A second arrow is noise.
- No checkmarks. Current locale is
font-medium(links:aria-current="page"; listbox:aria-selected). - Strings stay in the app. Locale display names and
aria-labelcopy are required at the wrapper — same rule as Selectplaceholder. - CLDR locale names. Endonym + region in parentheses when variants coexist:
English (United States),English (United Kingdom),Português (Portugal),Português (Brasil)— notAmerican English, not flags. - Links on www, buttons in SPAs. Static sites navigate with real
hrefs andhreflang; client apps call their own locale setter. - No auto-flip.
placementandalignare static CSS — not Radix positioning, not Floating UI. Pick from shell context: footer →up/start; header →down/end. - Never clip the trigger. Icon and current locale name stay visible at every width.
rootisshrink-0. The shell yields: lockup and nav takemin-w-0. No icon-only compact mode — a header that shears the chip is a layout bug.
See also: Select (what this is not) · Tabs (also not this) · Layers (z-dropdown)
theme.css.