Skip to content
UI Kit
Accent color

Language switcher

Language and region choice with a real link per language. Languages by their own name, never by a flag.

One of 3 components in Menu bars and switchers

5 variants

Variants

Dropdown

A quiet button in the header. The menu shows each language in its own name and in the page language, arrow keys move through it.

Open in Studio

Segmented

Two or three short codes, a marker glides to the chosen one. For sites with few languages.

Footer list

Every language as a link in the last row of the footer. The line under the current one draws in on hover.

Open in Studio

Suggestion banner

Offers the page in the visitor’s language, written in that language. Staying folds the banner away.

Open in Studio

Region picker

Regions as tabs, countries with their languages, a search that filters while you type.

Open in Studio

Details

What Language switcher brings along, read from the registry and the source.

Variants
5
Version
1.0.0 · since 1.0.0
File
components/ui/language-switcher.tsx
Brings along
utilskitbuttoniconmotion
npm packages
None
Languages
English and German, 7 texts of its own

Props

The LanguageSwitcherProps interface from components/ui/language-switcher.tsx, with the comments from the code.

  • variantoptional
    LanguageSwitcherVariant

    dropdownsegmentedinline-listsuggestion-bannerregion-picker

  • languagesoptional
    Language[]
  • valueoptional
    string

    Code of the current language

  • onChangeoptional
    (code: string) => void

    Called with the code before the link is followed

  • defaultOpenoptional
    boolean

    dropdown: open on first render (for previews)

  • titleoptional
    string

    inline-list, region-picker: a heading

  • descriptionoptional
    string

    region-picker: a line under the heading

  • regionsoptional
    LanguageRegion[]

    region-picker: the regions

  • countryoptional
    string

    region-picker: id of the current country, e.g. Deutschland

  • suggestionoptional
    { code: string; message: string; action: string; stay: string }

    suggestion-banner: the offer, written in the suggested language

Built-in texts

Language switcher writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
languageEnglish LanguageGerman Sprache
chooseEnglish Choose languageGerman Sprache wählen
currentEnglish Current languageGerman Aktuelle Sprache
searchEnglish Search country or languageGerman Land oder Sprache suchen
noneEnglish No country or language matches. Try the name in English or in your language.German Kein Land und keine Sprache passt. Versuch den Namen auf Englisch oder in deiner Sprache.
allEnglish All regionsGerman Alle Regionen
closeEnglish CloseGerman Schließen

Accessibility

Read from the source of the component.

  • Language switcher handles the keys Escape, Arrow up, Arrow down, Tab and Space itself.
  • Sets aria-checked, aria-controls, aria-current, aria-expanded, aria-haspopup, aria-label and aria-selected, so screen readers announce purpose and state.
  • Uses the roles menu, menuitemradio, region, status, tab and tablist.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 447 lines
components/ui/language-switcher.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Stagger, useScrollFade } from '@/components/ui/motion';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';

/*
 * Language and region choice. Every option is a real link to the page in that
 * language (hreflang, lang), so search engines and people without JavaScript
 * reach every version. Languages are shown by their own name, never by a
 * flag: a flag is a country, and Spanish is not Spain.
 *
 *   dropdown           a quiet button in the header, the menu lists the languages
 *   segmented          two or three short codes with a gliding marker
 *   inline-list        the languages as a row of links, for the footer
 *   suggestion-banner  offers the page in the visitor's language, in that language
 *   region-picker      regions, countries and their languages with search
 */

export type LanguageSwitcherVariant = 'dropdown' | 'segmented' | 'inline-list' | 'suggestion-banner' | 'region-picker';

export interface Language {
  /** BCP 47 code, e.g. de, en, fr-CH */
  code: string;
  /** The name in that language, e.g. Deutsch */
  label: string;
  /** The name in the language of the page, e.g. German */
  english?: string;
  /** Short code shown in compact places, default the code in capitals */
  short?: string;
  href: string;
}

export interface LanguageRegion {
  id: string;
  title: string;
  countries: { name: string; languages: Language[] }[];
}

export interface LanguageSwitcherProps {
  variant?: LanguageSwitcherVariant;
  languages?: Language[];
  /** Code of the current language */
  value?: string;
  /** Called with the code before the link is followed */
  onChange?: (code: string) => void;
  /** dropdown: open on first render (for previews) */
  defaultOpen?: boolean;
  /** inline-list, region-picker: a heading */
  title?: string;
  /** region-picker: a line under the heading */
  description?: string;
  /** region-picker: the regions */
  regions?: LanguageRegion[];
  /** region-picker: id of the current country, e.g. Deutschland */
  country?: string;
  /** suggestion-banner: the offer, written in the suggested language */
  suggestion?: { code: string; message: string; action: string; stay: string };
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    language: 'Language',
    choose: 'Choose language',
    current: 'Current language',
    search: 'Search country or language',
    none: 'No country or language matches. Try the name in English or in your language.',
    all: 'All regions',
    close: 'Close',
  },
  de: {
    language: 'Sprache',
    choose: 'Sprache wählen',
    current: 'Aktuelle Sprache',
    search: 'Land oder Sprache suchen',
    none: 'Kein Land und keine Sprache passt. Versuch den Namen auf Englisch oder in deiner Sprache.',
    all: 'Alle Regionen',
    close: 'Schließen',
  },
};

type Texts = (typeof TEXTS)['en'];

const shortOf = (l: Language) => l.short ?? l.code.toUpperCase();

/** Keeps the choice in the component for previews, then follows the link */
function useChoice(p: LanguageSwitcherProps) {
  const [value, setValue] = useState(p.value ?? p.languages?.[0]?.code);
  useEffect(() => setValue(p.value ?? p.languages?.[0]?.code), [p.value]); // eslint-disable-line react-hooks/exhaustive-deps
  const pick = (code: string) => {
    setValue(code);
    p.onChange?.(code);
  };
  return [value, pick] as const;
}

const linkProps = (l: Language) => ({ href: l.href, hrefLang: l.code, lang: l.code });

/* ------------------------------------------------------------------ */
/* dropdown                                                            */
/* ------------------------------------------------------------------ */

function Dropdown(p: LanguageSwitcherProps & { t: Texts }) {
  const { t } = p;
  const languages = p.languages ?? [];
  const [value, pick] = useChoice(p);
  const [open, setOpen] = useState(!!p.defaultOpen);
  const id = useId();
  const rootRef = useRef<HTMLDivElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const current = languages.find((l) => l.code === value) ?? languages[0];

  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('pointerdown', onDown);
    return () => document.removeEventListener('pointerdown', onDown);
  }, [open]);

  const items = () => [...(rootRef.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? [])];
  const onKey = (e: React.KeyboardEvent) => {
    const list = items();
    const i = list.indexOf(document.activeElement as HTMLElement);
    if (e.key === 'Escape') {
      setOpen(false);
      buttonRef.current?.focus();
    } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      if (!open) setOpen(true);
      const next = e.key === 'ArrowDown' ? (i + 1) % list.length : (i - 1 + list.length) % list.length;
      requestAnimationFrame(() => (items()[i < 0 ? 0 : next] ?? items()[0])?.focus());
    } else if (e.key === 'Tab' && open) setOpen(false);
  };

  if (!current) return null;
  return (
    <div ref={rootRef} onKeyDown={onKey} className={cn('relative inline-block', p.className)}>
      <button
        ref={buttonRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={id}
        aria-label={`${t.choose}, ${t.current} ${current.label}`}
        onClick={() => setOpen((v) => !v)}
        className="group/lang inline-flex h-10 items-center gap-2 rounded-lg border border-border bg-background px-3 text-[14px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent aria-expanded:bg-accent max-sm:h-11"
      >
        <Icon name="globe" size={16} className="text-muted-foreground transition-transform duration-500 ease-[var(--ease-kit)] group-hover/lang:rotate-[24deg] group-aria-expanded/lang:rotate-[24deg]" />
        <span className="relative grid overflow-hidden">
          <span key={current.code} className="kit-rise [--kit-delay:0ms]">
            {shortOf(current)}
          </span>
        </span>
        <Icon name="chevron-down" size={14} className={cn('text-muted-foreground transition-transform duration-300 ease-[var(--ease-kit)]', open && 'rotate-180')} />
      </button>
      <div
        id={id}
        role="menu"
        aria-label={t.language}
        inert={!open}
        className={cn(
          'absolute right-0 top-[calc(100%+8px)] z-30 w-64 origin-top-right rounded-xl border border-border bg-popover p-1.5 shadow-[var(--highlight),var(--shadow-lg)] transition-[transform,opacity] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
          open ? 'translate-y-0 scale-100 opacity-100' : 'pointer-events-none -translate-y-1 scale-[0.97] opacity-0',
        )}
      >
        {languages.map((l, i) => {
          const on = l.code === value;
          return (
            <a
              key={l.code}
              {...linkProps(l)}
              role="menuitemradio"
              aria-checked={on}
              onClick={() => {
                pick(l.code);
                setOpen(false);
              }}
              className={cn('flex min-h-11 items-center gap-3 rounded-lg px-2.5 py-1.5 outline-none transition-[background-color,transform,opacity] duration-300 ease-[var(--ease-kit)] hover:bg-accent focus-visible:bg-accent', open ? 'translate-y-0 opacity-100' : '-translate-y-1 opacity-0')}
              style={{ transitionDelay: open ? `${40 + i * 30}ms` : '0ms' }}
            >
              <span className="grid h-7 w-9 shrink-0 place-items-center rounded-md bg-muted text-[11.5px] font-semibold text-muted-foreground">{shortOf(l)}</span>
              <span className="min-w-0 flex-1">
                <span className="block text-[14px] font-medium text-foreground">{l.label}</span>
                {l.english && l.english !== l.label && <span className="block text-[12.5px] text-muted-foreground">{l.english}</span>}
              </span>
              <Icon name="check" size={16} className={cn('text-primary transition-[opacity,transform] duration-300', on ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
            </a>
          );
        })}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* segmented                                                           */
/* ------------------------------------------------------------------ */

function Segmented(p: LanguageSwitcherProps & { t: Texts }) {
  const { t } = p;
  const languages = p.languages ?? [];
  const [value, pick] = useChoice(p);
  const listRef = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<{ x: number; w: number } | null>(null);
  useLayoutEffect(() => {
    const a = listRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
    if (a) setMark({ x: a.offsetLeft, w: a.offsetWidth });
  }, [value, languages.length]);
  return (
    <nav aria-label={t.language} className={cn('inline-block', p.className)}>
      <div ref={listRef} className="relative inline-flex h-10 items-center rounded-lg border border-border bg-surface p-1 shadow-[var(--highlight)] max-sm:h-11">
        {mark && <span aria-hidden className="absolute inset-y-1 left-0 rounded-md bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: mark.w, transform: `translateX(${mark.x}px)` }} />}
        {languages.map((l) => {
          const on = l.code === value;
          return (
            <a
              key={l.code}
              {...linkProps(l)}
              aria-current={on ? 'true' : undefined}
              aria-label={l.label}
              title={l.label}
              onClick={() => pick(l.code)}
              className={cn('relative z-10 grid h-full min-w-11 place-items-center rounded-md px-3 text-[13px] font-semibold tracking-[0.02em] transition-colors duration-300', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', !mark && on && 'bg-card shadow-[var(--shadow-sm)]')}
            >
              {shortOf(l)}
            </a>
          );
        })}
      </div>
    </nav>
  );
}

/* ------------------------------------------------------------------ */
/* inline-list                                                         */
/* ------------------------------------------------------------------ */

function InlineList(p: LanguageSwitcherProps & { t: Texts }) {
  const { t } = p;
  const languages = p.languages ?? [];
  const [value, pick] = useChoice(p);
  return (
    <nav aria-label={p.title ?? t.language} className={cn('flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-5', p.className)}>
      <span className="flex items-center gap-2 text-[13px] font-medium text-foreground">
        <Icon name="languages" size={16} className="text-muted-foreground" />
        {p.title ?? t.language}
      </span>
      <Stagger as="ul" step={50} className="-ml-2.5 flex flex-wrap gap-x-1 gap-y-1 sm:ml-0">
        {languages.map((l) => {
          const on = l.code === value;
          return (
            <li key={l.code}>
              <a
                {...linkProps(l)}
                aria-current={on ? 'true' : undefined}
                onClick={() => pick(l.code)}
                className={cn(
                  'relative inline-flex min-h-11 items-center rounded-md px-2.5 text-[14px] transition-colors sm:min-h-9',
                  'after:absolute after:inset-x-2.5 after:bottom-1.5 after:h-px after:origin-left after:bg-current after:transition-transform after:duration-300 after:ease-[var(--ease-kit)] after:content-[""] sm:after:bottom-1',
                  on ? 'font-medium text-foreground after:scale-x-100' : 'text-muted-foreground after:scale-x-0 hover:text-foreground hover:after:scale-x-100',
                )}
              >
                {l.label}
              </a>
            </li>
          );
        })}
      </Stagger>
    </nav>
  );
}

/* ------------------------------------------------------------------ */
/* suggestion-banner                                                   */
/* ------------------------------------------------------------------ */

function SuggestionBanner(p: LanguageSwitcherProps & { t: Texts }) {
  const { t } = p;
  const s = p.suggestion;
  const target = p.languages?.find((l) => l.code === s?.code);
  const [closed, setClosed] = useState(false);
  if (!s || !target) return null;
  return (
    <div
      role="region"
      aria-label={t.language}
      lang={target.code}
      inert={closed}
      className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', closed ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100', p.className)}
    >
      <div className="min-h-0 overflow-hidden">
        <div className="kit-rise flex flex-col gap-3 rounded-xl border border-border bg-card p-3 pl-4 shadow-[var(--highlight),var(--shadow-md)] sm:flex-row sm:items-center sm:gap-4">
          <span className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
            <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-primary">
              <Icon name="languages" size={16} />
            </span>
            <span className="text-pretty pt-1 text-[14px] leading-snug text-foreground sm:pt-0">{s.message}</span>
          </span>
          <span className="flex shrink-0 items-center gap-2">
            <Button size="sm" {...linkProps(target)} onClick={() => p.onChange?.(target.code)} className="max-sm:flex-1">
              {s.action}
            </Button>
            <Button size="sm" variant="ghost" onClick={() => setClosed(true)} className="max-sm:flex-1">
              {s.stay}
            </Button>
          </span>
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* region-picker                                                       */
/* ------------------------------------------------------------------ */

const norm = (s: string) => s.toLocaleLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');

function RegionPicker(p: LanguageSwitcherProps & { t: Texts }) {
  const { t } = p;
  const regions = p.regions ?? [];
  const [tab, setTab] = useState('all');
  const [query, setQuery] = useState('');
  const [current, setCurrent] = useState({ country: p.country, code: p.value });
  const [tabsRef, fade] = useScrollFade<HTMLDivElement>();
  const [mark, setMark] = useState<{ x: number; w: number } | null>(null);
  useLayoutEffect(() => {
    const a = tabsRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
    if (a) setMark({ x: a.offsetLeft, w: a.offsetWidth });
  }, [tab, regions.length]);

  const q = norm(query.trim());
  const shown = useMemo(
    () =>
      regions
        .filter((r) => tab === 'all' || r.id === tab)
        .flatMap((r) => r.countries.map((c) => ({ ...c, region: r.title })))
        .filter((c) => !q || norm(c.name).includes(q) || c.languages.some((l) => norm(l.label).includes(q) || norm(l.english ?? '').includes(q))),
    [regions, tab, q],
  );
  const tabs = [{ id: 'all', title: t.all }, ...regions.map((r) => ({ id: r.id, title: r.title }))];

  return (
    <div className={cn('rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)]', p.className)}>
      <div className="flex flex-col gap-4 border-b border-border p-5 sm:p-6 lg:flex-row lg:items-end lg:justify-between">
        <div>
          {p.title && <h2 className="text-balance text-h3 font-semibold text-foreground">{p.title}</h2>}
          {p.description && <p className="mt-1.5 max-w-[34rem] text-pretty text-[14.5px] text-muted-foreground">{p.description}</p>}
        </div>
        <label className="relative block lg:w-72">
          <span className="sr-only">{t.search}</span>
          <Icon name="search" size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
          <input
            type="search"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder={t.search}
            className="h-11 w-full rounded-lg border border-input bg-background pl-10 pr-3 text-[14.5px] text-foreground shadow-[var(--shadow-sm)] placeholder:text-subtle-foreground transition-[border-color,box-shadow] focus-visible:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] focus-visible:shadow-[0_0_0_4px_var(--glow)] focus-visible:outline-none"
          />
        </label>
      </div>
      <div ref={tabsRef} role="tablist" aria-label={t.all} className={cn('kit-no-scrollbar relative flex overflow-x-auto border-b border-border px-3 sm:px-4', fade)}>
        {mark && <span aria-hidden className="absolute bottom-0 left-0 h-0.5 rounded-full bg-primary transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: mark.w, transform: `translateX(${mark.x}px)` }} />}
        {tabs.map((r) => (
          <button
            key={r.id}
            type="button"
            role="tab"
            aria-selected={tab === r.id}
            onClick={() => setTab(r.id)}
            className={cn('relative h-12 shrink-0 whitespace-nowrap px-3 text-[14px] font-medium transition-colors', tab === r.id ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
          >
            {r.title}
          </button>
        ))}
      </div>
      <div className="p-3 sm:p-4">
        {shown.length === 0 ? (
          <p role="status" className="kit-rise px-3 py-10 text-center text-[14.5px] text-muted-foreground">
            {t.none}
          </p>
        ) : (
          <Stagger key={`${tab}|${q}`} as="ul" step={35} className="grid grid-cols-2 gap-1 lg:grid-cols-3">
            {shown.map((c) => {
              const here = current.country === c.name;
              return (
                <li key={`${c.region}-${c.name}`} className={cn('rounded-xl p-3 transition-colors', here ? 'bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] ring-1 ring-[color-mix(in_oklab,var(--primary)_30%,var(--border))]' : 'hover:bg-surface')}>
                  <p className="flex items-center justify-between gap-2 text-[14.5px] font-medium text-foreground">
                    {c.name}
                    {here && <Icon name="check" size={15} className="text-primary" />}
                  </p>
                  <div className="mt-1 flex flex-wrap gap-x-3">
                    {c.languages.map((l) => {
                      const on = here && current.code === l.code;
                      return (
                        <a
                          key={l.code}
                          {...linkProps(l)}
                          aria-current={on ? 'true' : undefined}
                          onClick={() => {
                            setCurrent({ country: c.name, code: l.code });
                            p.onChange?.(l.code);
                          }}
                          className={cn('inline-flex min-h-9 items-center text-[13.5px] underline-offset-4 transition-colors hover:underline max-sm:min-h-11', on ? 'font-medium text-primary' : 'text-muted-foreground hover:text-foreground')}
                        >
                          {l.label}
                        </a>
                      );
                    })}
                  </div>
                </li>
              );
            })}
          </Stagger>
        )}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* LanguageSwitcher                                                    */
/* ------------------------------------------------------------------ */

const VARIANTS: Record<LanguageSwitcherVariant, (p: LanguageSwitcherProps & { t: Texts }) => React.ReactNode> = {
  dropdown: Dropdown,
  segmented: Segmented,
  'inline-list': InlineList,
  'suggestion-banner': SuggestionBanner,
  'region-picker': RegionPicker,
};

/**
 * Language and region choice with real links per language.
 *   <LanguageSwitcher variant="dropdown" value="de" languages={[{ code: 'de', label: 'Deutsch', href: '/de/' }, …]} />
 */
export function LanguageSwitcher({ variant = 'dropdown', labels, ...props }: LanguageSwitcherProps) {
  const t = useTexts(TEXTS, labels);
  const View = VARIANTS[variant] ?? Dropdown;
  return <View {...props} t={t} />;
}

Frequent questions

How do I install Language switcher?

With aiw add @ai-website/language-switcher. The command puts components/ui/language-switcher.tsx into your project, the code is yours from then on. It brings along button, icon and motion. It needs no further npm packages.

Which variants does Language switcher have?

5 variants, Dropdown, Segmented, Footer list, Suggestion banner and Region picker. You pick one with the variant prop, for example variant="segmented".

Can I translate the texts of Language switcher?

Yes. Language switcher carries 7 texts of its own in English and German, such as “Language”, “Choose language” and “Current language”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Language switcher?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Language switcher with aiw_components and install it with aiw_add @ai-website/language-switcher. Name the variant in your prompt, for example variant="dropdown", and the assistant writes your content straight into the props.

Can I adjust Language switcher without code?

Yes, in the Studio. 4 of 5 variants are editable there, texts, lists and switches. The Studio writes the matching LanguageSwitcher code to copy.

Often searched

Jump to

Copied to the clipboard