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
Details
What Language switcher brings along, read from the registry and the source.
- Category
- Menu bars and switchers
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/language-switcher.tsx
- npm packages
- None
- Languages
- English and German, 7 texts of its own
- Studio
- 4 of 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The LanguageSwitcherProps interface from components/ui/language-switcher.tsx, with the comments from the code.
variantoptionalLanguageSwitcherVariantdropdownsegmentedinline-listsuggestion-bannerregion-picker
languagesoptionalLanguage[]valueoptionalstringCode of the current language
onChangeoptional(code: string) => voidCalled with the code before the link is followed
defaultOpenoptionalbooleandropdown: open on first render (for previews)
titleoptionalstringinline-list, region-picker: a heading
descriptionoptionalstringregion-picker: a line under the heading
regionsoptionalLanguageRegion[]region-picker: the regions
countryoptionalstringregion-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
| Key | English | German |
|---|---|---|
| language | English Language | German Sprache |
| choose | English Choose language | German Sprache wählen |
| current | English Current language | German Aktuelle Sprache |
| search | English Search country or language | German Land oder Sprache suchen |
| none | English 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. |
| all | English All regions | German Alle Regionen |
| close | English Close | German 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
'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.