Sprachwahl
Sprach- und Regionswahl mit einem echten Link je Sprache. Sprachen mit ihrem eigenen Namen, nie mit einer Flagge.
Eine von 3 Components in Menüleisten und Umschalter
Variants
Details
Was Sprachwahl mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Menüleisten und Umschalter
- Bereich
- Elements
- Variants
- 5
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/language-switcher.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 7 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle LanguageSwitcherProps aus components/ui/language-switcher.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Sprachwahl selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 7 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| language | Englisch Language | Deutsch Sprache |
| choose | Englisch Choose language | Deutsch Sprache wählen |
| current | Englisch Current language | Deutsch Aktuelle Sprache |
| search | Englisch Search country or language | Deutsch Land oder Sprache suchen |
| none | Englisch No country or language matches. Try the name in English or in your language. | Deutsch Kein Land und keine Sprache passt. Versuch den Namen auf Englisch oder in deiner Sprache. |
| all | Englisch All regions | Deutsch Alle Regionen |
| close | Englisch Close | Deutsch Schließen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Sprachwahl reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Tab und Leertaste.
- Setzt aria-checked, aria-controls, aria-current, aria-expanded, aria-haspopup, aria-label und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen menu, menuitemradio, region, status, tab und tablist.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 447 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Sprachwahl?
Mit aiw add @ai-website/language-switcher. Der Befehl legt die Datei components/ui/language-switcher.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und motion. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Sprachwahl?
5 Varianten, Dropdown, Umschalter, Liste im Fuß, Sprachhinweis und Regionswahl. Du wählst sie mit der Prop variant, zum Beispiel variant="segmented".
Kann ich die Texte von Sprachwahl übersetzen?
Ja. Sprachwahl bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Sprache“, „Sprache wählen“ und „Aktuelle Sprache“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Sprachwahl einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Sprachwahl mit aiw_components und installieren sie mit aiw_add @ai-website/language-switcher. Nenn im Prompt die Variante, etwa variant="dropdown", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Sprachwahl ohne Code anpassen?
Ja, im Studio. 4 von 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden LanguageSwitcher-Code zum Kopieren.