Lang Switcher

@use 'sprucecss/scss/spruce' as *;

.lang-switcher-demo {
  align-items: start;
  display: flex;
  flex-wrap: wrap;
  gap: spacer('l');
  justify-content: space-between;
  min-block-size: 18rem;

  .lang-switcher--upward {
    align-self: end;
  }
}

.lang-switcher {
  $this: &;
  display: inline-block;
  position: relative;

  &__control {
    @include clear-btn;
    @include transition($property: 'background-color, color');
    align-items: center;
    border-radius: config('border-radius', $btn);
    color: color('heading');
    display: inline-flex;
    font-size: config('font-size-sm', $typography);
    font-weight: 700;
    gap: spacer('xxs');
    padding: spacer('xxs') spacer('xs');
    text-transform: uppercase;

    &:hover,
    &[aria-expanded='true'] {
      background-color: color-alpha('primary', 0.1);
    }

    &:focus-visible {
      @include short-ring('button');
    }

    &[aria-expanded='true'] #{$this}__chevron {
      rotate: 180deg;
    }
  }

  &__icon,
  &__chevron {
    block-size: 1em;
    color: color('primary');
    flex-shrink: 0;
    inline-size: 1em;
  }

  &__chevron {
    @include transition($property: 'rotate');
    block-size: 0.85em;
    inline-size: 0.85em;
  }

  &__panel {
    @include clear-list;
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('md');
    box-shadow: shadow('lg');
    inset-block-start: calc(100% + #{spacer('xxs')});
    inset-inline-start: 0;
    min-inline-size: 11rem;
    padding: spacer('xxs');
    position: absolute;
    z-index: z-index('dropdown');

    &[hidden] {
      display: none;
    }

    > li {
      margin: 0 !important;
    }
  }

  &__link {
    align-items: center;
    border-radius: radius('sm');
    color: color('heading');
    display: flex;
    font-size: config('font-size-sm', $typography);
    gap: spacer('xs');
    padding: spacer('xs') spacer('s');
    text-decoration: none;

    &:hover,
    &:focus-visible {
      background-color: color-alpha('primary', 0.1);
      color: color('primary');
      outline: none;
    }

    &[aria-current='true'] {
      font-weight: 700;
    }
  }

  &__code {
    color: color('muted');
    font-size: 0.75rem;
    font-weight: 700;
    inline-size: 1.5rem;
    text-transform: uppercase;
  }

  &__check {
    block-size: 1rem;
    color: color('primary');
    inline-size: 1rem;
    margin-inline-start: auto;
  }

  // In a footer (or near the bottom of the viewport): open upward and toward the start.
  &--upward {
    #{$this}__panel {
      inset-block: auto calc(100% + #{spacer('xxs')});
      inset-inline: auto 0;
    }

    #{$this}__chevron {
      rotate: 180deg;
    }

    #{$this}__control[aria-expanded='true'] #{$this}__chevron {
      rotate: 0deg;
    }
  }
}
<div class="lang-switcher-demo">
    <nav class="lang-switcher" aria-label="Language" data-lang-switcher>
        <button class="lang-switcher__control" type="button" aria-expanded="false" aria-controls="lang-switcher-panel">
            <svg class="lang-switcher__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><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>
            <span class="lang-switcher__current">en</span>
            <span class="sr-only">English, change language</span>
            <svg class="lang-switcher__chevron" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m6 9 6 6 6-6"/></svg>
        </button>
        <ul class="lang-switcher__panel" id="lang-switcher-panel" hidden>
            <li>
                <a class="lang-switcher__link" href="/" hreflang="en" lang="en" aria-current="true">
                    <span class="lang-switcher__code" aria-hidden="true">en</span>
                    English
                    <svg class="lang-switcher__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="de" lang="de">
                    <span class="lang-switcher__code" aria-hidden="true">de</span>
                    Deutsch
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="fr" lang="fr">
                    <span class="lang-switcher__code" aria-hidden="true">fr</span>
                    Français
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="hu" lang="hu">
                    <span class="lang-switcher__code" aria-hidden="true">hu</span>
                    Magyar
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="ar" lang="ar">
                    <span class="lang-switcher__code" aria-hidden="true">ar</span>
                    العربية
                </a>
            </li>
        </ul>
    </nav>
    <!-- In a footer: open upward -->
    <nav class="lang-switcher lang-switcher--upward" aria-label="Language" data-lang-switcher>
        <button class="lang-switcher__control" type="button" aria-expanded="false" aria-controls="lang-switcher-panel-footer">
            <svg class="lang-switcher__icon" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><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>
            <span class="lang-switcher__current">en</span>
            <span class="sr-only">English, change language</span>
            <svg class="lang-switcher__chevron" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m6 9 6 6 6-6"/></svg>
        </button>
        <ul class="lang-switcher__panel" id="lang-switcher-panel-footer" hidden>
            <li>
                <a class="lang-switcher__link" href="/" hreflang="en" lang="en" aria-current="true">
                    <span class="lang-switcher__code" aria-hidden="true">en</span>
                    English
                    <svg class="lang-switcher__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="de" lang="de">
                    <span class="lang-switcher__code" aria-hidden="true">de</span>
                    Deutsch
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="fr" lang="fr">
                    <span class="lang-switcher__code" aria-hidden="true">fr</span>
                    Français
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="hu" lang="hu">
                    <span class="lang-switcher__code" aria-hidden="true">hu</span>
                    Magyar
                </a>
            </li>
            <li>
                <a class="lang-switcher__link" href="#" hreflang="ar" lang="ar">
                    <span class="lang-switcher__code" aria-hidden="true">ar</span>
                    العربية
                </a>
            </li>
        </ul>
    </nav>
</div>
document.querySelectorAll('[data-lang-switcher]').forEach((switcher) => {
  const control = switcher.querySelector('[aria-expanded]');
  const panel = document.getElementById(control.getAttribute('aria-controls'));
  const links = [...panel.querySelectorAll('a')];

  const toggle = (open, focusControl = false) => {
    control.setAttribute('aria-expanded', String(open));
    panel.hidden = !open;

    if (!open && focusControl) control.focus();
  };

  control.addEventListener('click', () => toggle(panel.hidden));

  // ArrowDown opens the list and moves into it, the arrows move between the languages.
  switcher.addEventListener('keydown', (event) => {
    const index = links.indexOf(document.activeElement);

    if (event.key === 'Escape' && !panel.hidden) {
      toggle(false, true);
    } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault();

      if (panel.hidden) toggle(true);

      const step = event.key === 'ArrowDown' ? 1 : -1;
      const start = index === -1 ? (step === 1 ? -1 : 0) : index;

      links[(start + step + links.length) % links.length].focus();
    }
  });

  // Close it when the focus or a click leaves the switcher.
  switcher.addEventListener('focusout', (event) => {
    if (!switcher.contains(event.relatedTarget)) toggle(false);
  });

  document.addEventListener('click', (event) => {
    if (!switcher.contains(event.target)) toggle(false);
  });
});

Lang Switcher

A compact language switcher for headers and footers: a disclosure button with a list of links to the translated pages, each in its own language.

Anatomy

  • .lang-switcher is a nav named "Language".
  • .lang-switcher__control is the button that shows the current language code and toggles the list (aria-expanded, aria-controls).
  • .lang-switcher__panel is the list of links (.lang-switcher__link); the current one has aria-current="true" and a check mark.
  • .lang-switcher--upward opens the list above the button and aligns it to the end, for footers.

Accessibility

  • It's a disclosure of plain links, not an ARIA menu: the links are announced and followed as links, and Tab works as usual. The focus isn't trapped.
  • Every language name is written in its own language (Deutsch, Français, العربية), with lang for the right pronunciation and hreflang for the target page. People looking for their language don't have to read the current one to find it.
  • The button has a visually hidden label ("English, change language"); the code (en) alone is not a good name.
  • ↓ / ↑ open the list and move between the languages, Esc closes it and returns the focus to the button. It also closes when the focus or a click moves outside.

Technical Details

  • Vanilla JavaScript, no dependencies. Without JavaScript, the list stays hidden, so render the links somewhere else as well (like the footer) if the switcher is the only way to change the language.
  • The colors, the radius and the shadow come from the tokens; every position uses logical properties, so the list mirrors in RTL.
  • Link to the translation of the current page, not to the home page of the other language, whenever you can.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.