Components

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

Placement
Align

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

PropertyValueWhy
RecipelocaleSwitcher({ placement, align, scale })Visual layer only — www uses <details>; business uses a button listbox with the same classes
Triggerrounded-xl, bg-surface-muted, no borderA chip, not a form control — matches www footer tests
IconLucide Languages, 20px, stroke 1.75Same icon path as www; import from lucide-react in apps
Panelrounded-2xl, shadow-popover, no borderOpenAI-style floating list — not a bordered Select menu
Placementup default · down for headersFooter has no space below; nav bars do
Overflowshrink-0 on rootIcon + locale name stay visible; the shell's lockup and nav take min-w-0. No icon-only compact mode
Current localefont-medium, aria-current="page" on linksNo checkmarks, no indent hacks — weight is the signal
StringsApp-ownedfooter.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

PropTypeDefaultNotes
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-label copy are required at the wrapper — same rule as Select placeholder.
  • CLDR locale names. Endonym + region in parentheses when variants coexist: English (United States), English (United Kingdom), Português (Portugal), Português (Brasil) — not American English, not flags.
  • Links on www, buttons in SPAs. Static sites navigate with real hrefs and hreflang; client apps call their own locale setter.
  • No auto-flip. placement and align are 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. root is shrink-0. The shell yields: lockup and nav take min-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)

Sporting Scouter Design System
Every token on this site is generated from theme.css.