Language SwitcherMOL
One element that names the languages a page supports; the runtime writes a Combobox
or a Swap into it. It knows every language CLD3 detects - its name in itself, in
English and, through the browser, in any other language - and a flag for each, so a page lists only the tags it
translates. bind() ties it to defuss-i18n.
On this page (28)
§A list to pick from
§Eight languages
Each language in itself, and in the language shown when that differs. Eight languages and more bring the search field.
§Code
The code in place of the name, beside a round flag - the condensed form for footers and dense bars.
§Swap: one click per language
§Dialects
§Names in the language shown
data-names="ui" names every language in the one shown - pick Deutsch and the list reads Englisch, Französisch, Japanisch.
§Every CLD3 language
All 107 codes CLD3 detects, script variants included - type to search in any of the names.
§Flags: shapes, scales and finishes
§Finishes, light and dark
Flat, ring, shadow and glossy on the page's ground and on the dark palette - the ring takes the text color, flags dim a little in the dark.
§In a website header
§Website header
The full switcher at the end of the bar, its list opening toward the page; the compact header below keeps only the flag.
§Condensed in a footer
Small and quiet: the code and a round flag, or the flag alone. The list opens upward when there is no room below.
§In a toolbar
§Toolbar
Beside the editing tools: the code for an editor that writes several languages, or a one-click Swap with the flag alone.
§In a user menu
data-variant='menu': the languages as radio items of a Dropdown Menu - the arrows reach them, Enter or a click picks one, the check moves and the menu stays open. The root is the menu's radio group (role='group' and a name).
§With defuss-i18n
§Bound to defuss-i18n
bind() sets the locale on a pick and follows it back: the buttons below set the locale, the switcher moves along.
The table behind the names holds the 107 language codes CLD3 detects (df$.shadcn.languageSwitcher.languages()): the English name, the name in the language itself and the region whose flag stands for it. Every other pair - Japanese named in German, a dialect such as de-CH - comes from the browser's Intl.DisplayNames. The flags are flag-icons 7.2.3 (MIT) from jsDelivr; configure({ flags }) points them at your own copy.
df$.shadcn.languageSwitcher.name('de-AT'); // 'Österreichisches Deutsch'df$.shadcn.languageSwitcher.name('ja', 'de'); // 'Japanisch'df$.shadcn.languageSwitcher.region('pt-BR'); // 'br'df$.shadcn.languageSwitcher.choose('lang', 'fr'); // as a click: language-change firesconst unbind = df$.shadcn.languageSwitcher.bind('lang', locale); // defuss-i18n, both waysdf$.shadcn.languageSwitcher.configure({ flags: '/flags/{shape}/{region}.svg' });§States
Named states via the shared State API, driven per switcher through the bound api:
default- the language shown ({ value }); setState changes it quietly, withoutlanguage-changeopen- the Combobox's list shown
The first demo carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/language-switcher-{state}.png.
Machine contract - verified against language-switcher.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | — | The language shown - one of data-languages, mirrored on data-value. |
open | boolean | true, false | false | The Combobox's list shown - driven through the component's own State API. |
§API
Generated from language-switcher.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type LanguageSwitcherState = 'default' | 'open' - setState(name, config) takes the config of the state it names.
| State | Description | ||||||
|---|---|---|---|---|---|---|---|
default | The chosen language shown.
| ||||||
open | The list of languages shown (the Combobox face).
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends LanguageSwitcherState>(name: S, config?: LanguageSwitcherStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.languageSwitcherApi.setState<S extends LanguageSwitcherState>(el: HTMLElement, name: S, config?: LanguageSwitcherStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.languageSwitcherApi.getState(el: HTMLElement): { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.languageSwitcherApi.render(state: { name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.languageSwitcherApi.store(el: HTMLElement): Store<{ name: LanguageSwitcherState; config: LanguageSwitcherStateConfigs[LanguageSwitcherState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.languageSwitcherApi.commit<S extends LanguageSwitcherState>(el: HTMLElement, name: S, config?: LanguageSwitcherStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.languageSwitcherStates: LanguageSwitcherState[] | The declared states, 'default' first: default, open. |
df$.shadcn.languageSwitcher
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
languages(): LanguageInfo[] | Every language the switcher knows: the 107 CLD3 codes with their English name, their own name, the region of their flag and their direction. Returns | |||||||||
name(tag: string, locale?: string): string | A language's name in a locale - the preseeded names for a CLD3 code in itself and in English, the browser's (CLDR) names for every other pair, the preseeded ones when the browser has none.
Returns | |||||||||
region(tag: string): string | The region whose flag stands for a language: its own region, else the preseeded one.
Returns | |||||||||
choose(target: string | HTMLElement, tag: string): boolean | Chooses a language of data-languages as a click would: the face changes and language-change fires.
Returns | |||||||||
bind(target: string | HTMLElement, i18n: LanguageSwitcherI18n): (() => void) | Ties the switcher to a defuss-i18n instance both ways: a pick sets its locale, a locale set elsewhere moves the switcher (quietly - no language-change).
Returns | |||||||||
configure(options: LanguageSwitcherOptions): void | Where the flags come from - set it before the first switcher draws (to self-host the SVGs).
|
Events
| Event | Description | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
language-change | Fires when the user (or choose()) picks another language: the one chosen, the one before, its own name and its direction - bind() turns it into the i18n locale.
|
Types
| Type | Description | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
LanguageInfo | One language the switcher knows.
| ||||||||||||||||||
LanguageSwitcherChangeDetail | What language-change carries.
| ||||||||||||||||||
LanguageSwitcherI18n | What a defuss-i18n instance (or any locale store of that shape) offers bind().
| ||||||||||||||||||
LanguageSwitcherOptions | configure()'s options.
|
§CSS view file
/* -- Language Switcher component ------------------------------------- The runtime writes a shipped Combobox (a Swap, or radio items for a Dropdown Menu) into the element; these rules size it to its content and draw the flags. A flag is the language's code (::before) under its SVG (::after): a flag that fails to load leaves the code readable. Shape, scale and finish are attributes on the root. */@layer components { .language-switcher { --language-switcher-flag-height: 1rem; display: inline-flex; align-items: center; vertical-align: middle; /* -- Flag scale ----------------------------------------------- */ &[data-flag-size="xs"] { --language-switcher-flag-height: 0.75rem; } &[data-flag-size="sm"] { --language-switcher-flag-height: 0.875rem; } &[data-flag-size="lg"] { --language-switcher-flag-height: 1.25rem; } &[data-flag-size="xl"] { --language-switcher-flag-height: 1.75rem; } /* -- Flag shapes ---------------------------------------------- */ &[data-flag-shape="rounded"] .language-switcher-flag { border-radius: calc(var(--language-switcher-flag-height) * 0.25); } &[data-flag-shape="square"] .language-switcher-flag { inline-size: var(--language-switcher-flag-height); } &[data-flag-shape="squircle"] .language-switcher-flag { inline-size: var(--language-switcher-flag-height); border-radius: 32%; } &[data-flag-shape="circle"] .language-switcher-flag { inline-size: var(--language-switcher-flag-height); border-radius: 50%; } /* -- Flag finishes -------------------------------------------- */ &[data-flag-style="ring"] .language-switcher-flag { outline: 1px solid color-mix(in oklch, var(--foreground) 22%, transparent); outline-offset: -1px; } &[data-flag-style="shadow"] .language-switcher-flag { box-shadow: 0 0 0 1px var(--border), var(--shadow-sm); } &[data-flag-style="glossy"] .language-switcher-flag { --language-switcher-gloss: linear-gradient( 160deg, oklch(1 0 0 / 0.55), oklch(1 0 0 / 0) 42%, oklch(0 0 0 / 0) 58%, oklch(0 0 0 / 0.2) ); outline: 1px solid oklch(0 0 0 / 0.12); outline-offset: -1px; } /* the code shown instead of a name (a condensed footer, a toolbar) */ &[data-display="code"] .language-switcher-label { font-family: var(--font-mono); font-size: 0.75rem; letter-spacing: 0.04em; } } .language-switcher-flag { position: relative; display: inline-block; flex: none; inline-size: calc(var(--language-switcher-flag-height) * 4 / 3); block-size: var(--language-switcher-flag-height); border-radius: 2px; overflow: hidden; background-color: var(--muted); transition: scale 150ms ease; &::before { content: attr(data-code); position: absolute; inset: 0; display: grid; place-items: center; font-family: var(--font-mono); font-size: calc(var(--language-switcher-flag-height) * 0.5); font-weight: 600; line-height: 1; color: var(--muted-foreground); } &::after { content: ""; position: absolute; inset: 0; background-image: var(--language-switcher-gloss, none), var(--language-switcher-flag, none); background-position: center; background-size: cover; background-repeat: no-repeat; } /* a flag in the dark is a little dimmer, so it does not glare beside the text */ :where(.dark) &::after { filter: brightness(0.92) saturate(0.95); } } .language-switcher-icon { flex: none; width: 1rem; height: 1rem; } .language-switcher-label { white-space: nowrap; } /* -- The Combobox face ------------------------------------------ */ .language-switcher-combobox { display: inline-block; & .combobox-trigger { width: auto; justify-content: flex-start; gap: 0.5rem; } & .combobox-value { display: inline-flex; align-items: center; gap: 0.5rem; flex: none; font-weight: 500; } /* a language is always chosen: no clear button, the chevron stays */ & .combobox-clear { display: none !important; } &.combobox:has(.combobox-value) .combobox-trigger > .combobox-chevron { display: block; } & .combobox-content { width: max-content; min-width: max(anchor-size(width), 13rem); max-width: min(22rem, calc(100vw - 1rem)); position-try-fallbacks: flip-inline, flip-block, flip-block flip-inline; } /* a short list needs no search box; it stays for the keys (arrows, Enter) */ & .combobox-search[data-quiet] { position: absolute; width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } } /* the list opens toward the start edge where the switcher sits at the end (a header's right side) */ .language-switcher[data-align="end"] .combobox-content { left: auto; right: anchor(right); } .language-switcher-option { gap: 0.625rem; &[data-highlighted] .language-switcher-flag, &:hover .language-switcher-flag { scale: 1.08; } } .language-switcher-name { white-space: nowrap; } .language-switcher-alt { margin-inline-start: auto; padding-inline-start: 1rem; font-size: 0.75rem; color: var(--muted-foreground); white-space: nowrap; &[hidden] { display: none; } .language-switcher-option[data-highlighted] &, .language-switcher-option:hover & { color: inherit; opacity: 0.75; } } /* -- The Menu face: radio items in a Dropdown Menu ----------------- */ .language-switcher[data-variant="menu"] { display: block; } .language-switcher-menu { display: contents; } .language-switcher-item { gap: 0.625rem; &[data-highlighted] .language-switcher-flag, &:hover .language-switcher-flag { scale: 1.08; } & .language-switcher-alt { color: var(--muted-foreground); } } /* -- The Swap face ---------------------------------------------- */ .language-switcher-swap { & .swap-on, & .swap-off { gap: 0.375rem; padding-inline: 0.375rem; } & .language-switcher-label { font-weight: 500; } } /* -- Accessibility ---------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .language-switcher-flag { transition: none; } :is(.language-switcher-option, .language-switcher-item) .language-switcher-flag { scale: none !important; } } @media (prefers-contrast: more) { .language-switcher-flag { outline: 1px solid var(--foreground); outline-offset: -1px; } .language-switcher-alt { color: var(--foreground); } } @media (forced-colors: active) { /* the flag keeps its own colors; a system-color frame marks its edge */ .language-switcher-flag { forced-color-adjust: none; outline: 1px solid CanvasText; outline-offset: -1px; } }}§JavaScript view file
The preseeded languages, the names (Intl.DisplayNames), the Combobox and Swap faces it writes, the State API and bind() for defuss-i18n.
// -- Language Switcher --------------------------------------------------------------------------------// Picks the page's language from the languages it supports (data-languages: BCP 47 tags - "en de", "en-GB en-US// pt-BR pt-PT"). It knows every language CLD3 detects (107 codes): the English name, the name in the language itself// and the region whose flag stands for it - the names in any other language come from the browser's// Intl.DisplayNames (CLDR), the preseeded names when the browser has none. A tag with a region is that dialect: its// name says so ("British English") and its flag is the region's. Three faces, each of shipped components the runtime// writes into the element: a Combobox (the default - a searchable list, each language in its own name and in the// page's), a Swap (data-variant="swap" - one click moves on to the next language) and radio items for a Dropdown// Menu (data-variant="menu"). data-display shows the// language's name (label), its code (code) or the flag alone (icon). Flags are SVGs (flag-icons, MIT, pinned on// jsDelivr - configure({ flags }) to self-host) in a shape, a scale and a finish of data-flag-shape / -size / -style;// data-flag="none" leaves them out. bind(el, i18n) ties it to a defuss-i18n instance both ways.//// VERIFIED: (language-switcher.e2e) the Combobox lists only the supported languages, a pick changes the state and// fires language-change, the Swap moves on with each click, and bind() follows and drives a defuss-i18n instance.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js -// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();/** default = the chosen language shown; open = the Combobox's list shown (the Swap has no list). */const languageSwitcherStates = ['default', 'open'];/** setState() configs per state. */export interface LanguageSwitcherStateConfigs { /** The chosen language shown. */ default: { /** the language: one of data-languages (a BCP 47 tag) */ value?: string; }; /** The list of languages shown (the Combobox face). */ open: { /** the language: one of data-languages (a BCP 47 tag) */ value?: string; };}/** What language-change carries. */interface LanguageSwitcherChangeDetail { /** the language chosen now (a BCP 47 tag) */ value: string; /** the language before */ previous: string; /** its name in itself ("Deutsch") */ name: string; /** its writing direction */ dir: 'ltr' | 'rtl';}/** One language the switcher knows. */export interface LanguageInfo { /** its CLD3 code (a BCP 47 tag; iw is Hebrew, he works too) */ code: string; /** its name in English */ english: string; /** its name in itself */ native: string; /** the region whose flag stands for it (ISO 3166, lowercase; a subdivision like gb-wls); '' for none */ region: string; /** its writing direction */ dir: 'ltr' | 'rtl';}/** What a defuss-i18n instance (or any locale store of that shape) offers bind(). */export interface LanguageSwitcherI18n { /** the locale shown now */ readonly locale: string; /** switches the locale */ setLocale(locale: string): unknown; /** follows locale changes; returns the function that stops it */ subscribe?(listener: (snapshot: { locale: string }) => void): () => void;}/** configure()'s options. */export interface LanguageSwitcherOptions { /** the flag URL pattern: {shape} becomes 4x3 or 1x1, {region} the region (default: flag-icons 7.2.3 on jsDelivr) */ flags?: string;}// -- the languages CLD3 detects: code, English name, its own name, the flag's region ------------------------------// VERIFIED: (github.com/google/cld3 README, 2026-11-09) the supported-languages table lists these 107 codes, script// variants (-Latn) included; iw is the code CLD3 gives Hebrew. The regions are a convention for the flag only - a// language is not a country, which is why every flag can be switched off or replaced per language.const SEED = `af Afrikaans|Afrikaans|zaam Amharic|አማርኛ|etar Arabic|العربية|sabg Bulgarian|български|bgbg-Latn Bulgarian (Latin)|bulgarski|bgbn Bangla|বাংলা|bdbs Bosnian|bosanski|baca Catalan|català|es-ctceb Cebuano|Binisaya|phco Corsican|corsu|frcs Czech|čeština|czcy Welsh|Cymraeg|gb-wlsda Danish|dansk|dkde German|Deutsch|deel Greek|Ελληνικά|grel-Latn Greek (Latin)|Ellinika|gren English|English|gbeo Esperanto|Esperanto|es Spanish|español|eset Estonian|eesti|eeeu Basque|euskara|es-pvfa Persian|فارسی|irfi Finnish|suomi|fifil Filipino|Filipino|phfr French|français|frfy Western Frisian|Frysk|nlga Irish|Gaeilge|iegd Scottish Gaelic|Gàidhlig|gb-sctgl Galician|galego|es-gagu Gujarati|ગુજરાતી|inha Hausa|Hausa|nghaw Hawaiian|ʻŌlelo Hawaiʻi|ushi Hindi|हिन्दी|inhi-Latn Hindi (Latin)|Hindi|inhmn Hmong|Hmoob|lahr Croatian|hrvatski|hrht Haitian Creole|Kreyòl ayisyen|hthu Hungarian|magyar|huhy Armenian|հայերեն|amid Indonesian|Bahasa Indonesia|idig Igbo|Asụsụ Igbo|ngis Icelandic|íslenska|isit Italian|italiano|itiw Hebrew|עברית|ilja Japanese|日本語|jpja-Latn Japanese (Latin)|Nihongo|jpjv Javanese|Basa Jawa|idka Georgian|ქართული|gekk Kazakh|қазақ тілі|kzkm Khmer|ខ្មែរ|khkn Kannada|ಕನ್ನಡ|inko Korean|한국어|krku Kurdish|Kurdî|iqky Kyrgyz|кыргызча|kgla Latin|Latina|valb Luxembourgish|Lëtzebuergesch|lulo Lao|ລາວ|lalt Lithuanian|lietuvių|ltlv Latvian|latviešu|lvmg Malagasy|Malagasy|mgmi Māori|Māori|nzmk Macedonian|македонски|mkml Malayalam|മലയാളം|inmn Mongolian|монгол|mnmr Marathi|मराठी|inms Malay|Bahasa Melayu|mymt Maltese|Malti|mtmy Burmese|မြန်မာ|mmne Nepali|नेपाली|npnl Dutch|Nederlands|nlno Norwegian|norsk|nony Nyanja|Chichewa|mwpa Punjabi|ਪੰਜਾਬੀ|inpl Polish|polski|plps Pashto|پښتو|afpt Portuguese|português|ptro Romanian|română|roru Russian|русский|ruru-Latn Russian (Latin)|Russkiy|rusd Sindhi|سنڌي|pksi Sinhala|සිංහල|lksk Slovak|slovenčina|sksl Slovenian|slovenščina|sism Samoan|Gagana Sāmoa|wssn Shona|chiShona|zwso Somali|Soomaali|sosq Albanian|shqip|alsr Serbian|српски|rsst Southern Sotho|Sesotho|lssu Sundanese|Basa Sunda|idsv Swedish|svenska|sesw Swahili|Kiswahili|tzta Tamil|தமிழ்|inte Telugu|తెలుగు|intg Tajik|тоҷикӣ|tjth Thai|ไทย|thtr Turkish|Türkçe|truk Ukrainian|українська|uaur Urdu|اردو|pkuz Uzbek|oʻzbekcha|uzvi Vietnamese|Tiếng Việt|vnxh Xhosa|isiXhosa|zayi Yiddish|ייִדיש|ilyo Yoruba|Èdè Yorùbá|ngzh Chinese|中文|cnzh-Latn Chinese (Pinyin)|Zhōngwén|cnzu Zulu|isiZulu|za`;const RTL = new Set(['ar', 'fa', 'iw', 'he', 'ps', 'sd', 'ur', 'yi']);const LANGUAGES: LanguageInfo[] = SEED.split('\n').map((line) => { const sp = line.indexOf(' '); const code = line.slice(0, sp); const [english, native, region] = line.slice(sp + 1).split('|'); return { code, english, native, region, dir: RTL.has(code) ? 'rtl' : 'ltr' };});const BY_CODE = new Map(LANGUAGES.map((l) => [l.code.toLowerCase(), l]));const ALIAS: Record<string, string> = { he: 'iw', nb: 'no', tl: 'fil' };const settings: Required<LanguageSwitcherOptions> = { flags: 'https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/flags/{shape}/{region}.svg',};const resolve = (t) => (typeof t === 'string' ? (dfDollar('#' + CSS.escape(t)).get(0) ?? dfDollar(t).get(0)) : t);const esc = (v: unknown): string => String(v ?? '').replace( /[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c], );/** a tag's parts: the language, the script, the region (lowercase) */function partsOf(tag: string): { language: string; script: string; region: string } { try { const l = new Intl.Locale(tag); return { language: l.language, script: l.script ?? '', region: (l.region ?? '').toLowerCase() }; } catch { const [language = '', ...rest] = tag.split(/[-_]/); return { language: language.toLowerCase(), script: rest.find((p) => p.length === 4) ?? '', region: (rest.find((p) => p.length === 2) ?? '').toLowerCase(), }; }}/** the preseeded entry of a tag: itself, its language + script, its language (aliases followed) */function seedOf(tag: string): LanguageInfo | undefined { const { language, script } = partsOf(tag); const lang = ALIAS[language] ?? language; return ( BY_CODE.get(tag.toLowerCase()) ?? (script ? BY_CODE.get(`${lang}-${script}`.toLowerCase()) : undefined) ?? BY_CODE.get(lang) );}const capitalize = (name: string, locale: string): string => { if (!name) return name; let first: string; try { first = name.charAt(0).toLocaleUpperCase(locale); } catch { first = name.charAt(0).toUpperCase(); } return first + name.slice(1);};const displayCache = new Map<string, Intl.DisplayNames | null>();function displayNames(locale: string, type: 'language' | 'region'): Intl.DisplayNames | null { const key = `${type}:${locale}`; if (!displayCache.has(key)) { let d: Intl.DisplayNames | null = null; try { d = new Intl.DisplayNames([locale], { type, fallback: 'none' }); // a browser without the locale's names falls back to its own language - those are not the names asked for if (new Intl.Locale(d.resolvedOptions().locale).language !== partsOf(locale).language) d = null; } catch { d = null; } displayCache.set(key, d); } return displayCache.get(key) ?? null;}/** a CLDR name, or undefined - of() throws on a malformed code (`en_US`), which must not stop the switcher drawing * VERIFIED: (bun, 2026-11-09) Intl.DisplayNames#of('en_US') throws RangeError "argument is not a language id" */function cldrName(locale: string, type: 'language' | 'region', code: string): string | undefined { try { return displayNames(locale, type)?.of(code); } catch { return undefined; }}/** a language's name in a locale: the preseeded names of a CLD3 code in itself and in English (the same in every * browser), the browser's CLDR names for every other pair, the preseeded ones (+ the region) when it has none */function nameIn(tag: string, locale: string): string { const seed = seedOf(tag); const { region } = partsOf(tag); const self = partsOf(locale).language === partsOf(tag).language; // a preseeded code without a region named in itself or in English: the seed is the reference if (seed && !region && seed.code.toLowerCase() === tag.toLowerCase().replace(/^he$/, 'iw')) { if (self) return capitalize(seed.native, tag); if (partsOf(locale).language === 'en') return seed.english; } const n = cldrName(locale, 'language', tag.replace(/^iw\b/, 'he')); if (n && n.toLowerCase() !== tag.toLowerCase()) return capitalize(n, locale); const base = seed ? (self ? capitalize(seed.native, tag) : seed.english) : tag; if (!region) return base; const regionName = cldrName(locale, 'region', region.toUpperCase()) ?? region.toUpperCase(); return `${base} (${regionName})`;}/** the region whose flag stands for a tag: data-flags' override, the tag's own region, else the seed's */function regionFor(el: HTMLElement, tag: string): string { for (const pair of (el.dataset.flags || '').split(/\s+/).filter(Boolean)) { const [t, r] = pair.split(':'); if (t && t.toLowerCase() === tag.toLowerCase()) return (r || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); } return (partsOf(tag).region || seedOf(tag)?.region || '').replace(/[^a-z0-9-]/g, '');}// -- the markup the runtime writes ----------------------------------------------------------------------------const languagesOf = (el: HTMLElement): string[] => { const list = (el.dataset.languages || 'en').split(/[\s,]+/).filter(Boolean); return list.length ? list : ['en'];};const valueOf = (el: HTMLElement, languages = languagesOf(el)): string => { const v = (el.dataset.value || '').toLowerCase(); return languages.find((l) => l.toLowerCase() === v) ?? languages[0];};const nextOf = (el: HTMLElement, tag: string): string => { const languages = languagesOf(el); return languages[(languages.indexOf(tag) + 1) % languages.length];};const codeLabel = (tag: string): string => { const { language, region } = partsOf(tag); return (region ? `${language}-${region}` : language).toUpperCase();};const dirOf = (tag: string): 'ltr' | 'rtl' => (RTL.has(partsOf(tag).language) ? 'rtl' : 'ltr');const idPart = (tag: string): string => tag.toLowerCase().replace(/[^a-z0-9-]/g, '-');/** a flag: the SVG laid over the language's code (a missing or blocked flag shows the code) */function flagHtml(el: HTMLElement, tag: string): string { if (el.dataset.flag === 'none') return ''; const region = regionFor(el, tag); const shape = ['square', 'circle', 'squircle'].includes(el.dataset.flagShape || '') ? '1x1' : '4x3'; const url = region ? settings.flags.replace('{shape}', shape).replace('{region}', region) : ''; const image = region ? ` data-region="${esc(region)}" style="--language-switcher-flag: url('${esc(url)}')"` : ''; return `<span class="language-switcher-flag" data-code="${esc(partsOf(tag).language.toUpperCase())}"${image} aria-hidden="true"></span>`;}const GLOBE = '<svg class="language-switcher-icon" aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';const CHEVRON = '<svg class="combobox-chevron" aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>';/** what the trigger or a Swap face shows for a language: its flag (the globe without one) and its name or code */function faceHtml(el: HTMLElement, tag: string): string { const display = el.dataset.display || 'label'; const flag = flagHtml(el, tag) || (display === 'icon' ? GLOBE : ''); const text = display === 'icon' ? '' : display === 'code' ? codeLabel(tag) : el.dataset.names === 'ui' ? nameIn(tag, valueOf(el)) : nameIn(tag, tag); return `${flag}${text ? `<span class="language-switcher-label" lang="${esc(tag)}" dir="${dirOf(tag)}">${esc(text)}</span>` : ''}`;}/** the switcher's name: data-label, else the root's aria-label (translatable with defuss-i18n), else "Language" */const labelOf = (el: HTMLElement): string => el.dataset.label || el.getAttribute('aria-label') || 'Language';const ariaOf = (el: HTMLElement, tag: string): string => `${labelOf(el)}: ${nameIn(tag, tag)}`;/** an option's two names, each with its language: in itself and in the language shown (data-names orders them) */function namesOf( el: HTMLElement, tag: string, ui: string,): { name: string; lang: string; alt: string; altLang: string } { const own = nameIn(tag, tag), shown = nameIn(tag, ui); if (el.dataset.names === 'native') return { name: own, lang: tag, alt: '', altLang: tag }; if (el.dataset.names === 'ui') return { name: shown, lang: ui, alt: shown === own ? '' : own, altLang: tag }; return { name: own, lang: tag, alt: shown === own ? '' : shown, altLang: ui };}const optionInner = (el: HTMLElement, tag: string, ui: string): string => { const n = namesOf(el, tag, ui); return `${flagHtml(el, tag)}<span class="language-switcher-name" lang="${esc(n.lang)}" dir="${dirOf(n.lang)}">${esc(n.name)}</span><span class="language-switcher-alt" lang="${esc(n.altLang)}" dir="${dirOf(n.altLang)}"${n.alt ? '' : ' hidden'}>${esc(n.alt)}</span>`;};/** the Combobox face: a trigger and a searchable list of the supported languages */function comboboxHtml(el: HTMLElement, uid: string): string { const languages = languagesOf(el); const value = valueOf(el, languages); const search = el.dataset.search || 'Search languages...'; const options = languages .map( (tag) => `<div role="option" id="${uid}-${idPart(tag)}" class="combobox-item language-switcher-option" data-value="${esc(tag)}" aria-selected="${tag === value}">${optionInner(el, tag, value)}</div>`, ) .join(''); const icon = (el.dataset.display || 'label') === 'icon'; return `<div class="combobox language-switcher-combobox" data-ce-chrome${el.dataset.size ? ` data-size="${esc(el.dataset.size)}"` : ''}><button type="button" class="btn combobox-trigger" data-variant="${esc(el.dataset.buttonVariant || 'ghost')}"${icon ? ' data-size="icon"' : ''} aria-haspopup="listbox" aria-expanded="false" aria-label="${esc(ariaOf(el, value))}" aria-controls="${uid}-popover"><span class="combobox-value">${faceHtml(el, value)}</span>${icon ? '' : CHEVRON}</button><div id="${uid}-popover" class="combobox-content language-switcher-content" popover><div class="combobox-search"${languages.length > 7 ? '' : ' data-quiet'}><input class="combobox-search-input" type="text" role="combobox" autocomplete="off" aria-expanded="true" aria-controls="${uid}-listbox" aria-activedescendant="" aria-autocomplete="list" placeholder="${esc(search)}" aria-label="${esc(search)}"></div><div id="${uid}-listbox" role="listbox" class="combobox-listbox" aria-label="${esc(labelOf(el))}"><div class="combobox-empty" hidden>${esc(el.dataset.empty || 'No language found.')}</div>${options}</div></div></div>`;}/** the Swap face: the language shown on one side, the next one on the other - a click turns it over */function swapHtml(el: HTMLElement): string { const value = valueOf(el); return `<label class="swap language-switcher-swap"${el.dataset.swap === 'fade' ? '' : ` data-variant="${esc(el.dataset.swap || 'flip')}"`} data-ce-chrome${el.dataset.size ? ` data-size="${esc(el.dataset.size)}"` : ''}><input type="checkbox" role="button" aria-label="${esc(swapLabel(el, value))}"><span class="swap-off">${faceHtml(el, value)}</span><span class="swap-on">${faceHtml(el, nextOf(el, value))}</span></label>`;}/** the Menu face: one radio item per language for a Dropdown Menu, which moves through them, checks the one picked * and closes nothing - the root (role="group") is their group */function menuHtml(el: HTMLElement): string { const value = valueOf(el); return `<div class="language-switcher-menu" data-ce-chrome>${languagesOf(el) .map( (tag) => `<button type="button" class="dropdown-item dropdown-radio language-switcher-item" role="menuitemradio" tabindex="-1" aria-checked="${tag === value}" data-value="${esc(tag)}">${optionInner(el, tag, value)}</button>`, ) .join('')}</div>`;}const swapLabel = (el: HTMLElement, value: string): string => { const next = nextOf(el, value); return `${ariaOf(el, value)} - ${el.dataset.next || 'next'}: ${nameIn(next, next)}`;};// -- drawing: the whole face when its attributes change, the value in place -------------------------------------const STRUCTURE = [ 'languages', 'variant', 'display', 'names', 'flag', 'flags', 'flagShape', 'size', 'buttonVariant', 'search', 'empty', 'next', 'swap',] as const;const structureOf = (el: HTMLElement): string => STRUCTURE.map((k) => el.dataset[k] ?? '').join('|');const chromeOf = (el: HTMLElement): HTMLElement | undefined => dfDollar(el).children('[data-ce-chrome]').get(0) as HTMLElement | undefined;/** draws the whole face the attributes ask for */function draw(el: HTMLElement): void { const uid = el.id ? `${el.id}-ls` : (el._lsId ??= `language-switcher-${++sequence}`); const focused = el.contains(document.activeElement); dfDollar(el).children('[data-ce-chrome]').remove(); const variant = el.dataset.variant; dfDollar(el).append( dfDollar(variant === 'swap' ? swapHtml(el) : variant === 'menu' ? menuHtml(el) : comboboxHtml(el, uid)), ); el._lsStructure = structureOf(el); el._lsValue = valueOf(el); if (focused) ( dfDollar(el).find('.combobox-trigger, .swap > input, .language-switcher-item[aria-checked="true"]').get(0) as | HTMLElement | undefined )?.focus();}let sequence = 0;/** shows the value and the names without drawing the face again (focus, the open list and the Swap's side stay) */function update(el: HTMLElement): void { if (el._lsStructure !== structureOf(el) || !chromeOf(el)) return draw(el); const value = valueOf(el); const changed = el._lsValue !== value; el._lsValue = value; const chrome = dfDollar(chromeOf(el)); if (el.dataset.variant === 'swap') { const input = chrome.find(':scope > input').get(0) as HTMLInputElement; if (changed) { // the side showing gets the value, the side turned away the language after it const [shown, hidden] = input.checked ? ['.swap-on', '.swap-off'] : ['.swap-off', '.swap-on']; chrome.find(`:scope > ${shown}`).html(faceHtml(el, value)); chrome.find(`:scope > ${hidden}`).html(faceHtml(el, nextOf(el, value))); } dfDollar(input).attr('aria-label', swapLabel(el, value)); return; } if (el.dataset.variant === 'menu') { if (!changed) return; // the items stay (focus, the menu's highlight); the check and the names follow the language shown chrome.find('.language-switcher-item').each((_i, item: HTMLElement) => { const tag = item.dataset.value || ''; dfDollar(item) .attr('aria-checked', String(tag === value)) .html(optionInner(el, tag, value)); }); return; } chrome.find('.combobox-trigger').attr('aria-label', ariaOf(el, value)); chrome.find('[role="listbox"]').attr('aria-label', labelOf(el)); if (!changed) return; chrome.find('.combobox-value').html(faceHtml(el, value)).attr('data-placeholder', null); // the options stay (the Combobox holds them); their names follow the language shown chrome.find('.language-switcher-option').each((_i, option: HTMLElement) => { const tag = option.dataset.value || ''; dfDollar(option) .attr('aria-selected', String(tag === value)) .html(optionInner(el, tag, value)); });}/** chooses a language of data-languages; announce = fire language-change */function choose(el: HTMLElement, tag: string, announce = true): boolean { const languages = languagesOf(el); const next = languages.find((l) => l.toLowerCase() === String(tag).toLowerCase()); if (!next) return false; const previous = valueOf(el, languages); if (el.dataset.value !== next) dfDollar(el).attr('data-value', next); update(el); if (announce && next !== previous) // Fires when the user (or choose()) picks another language: the one chosen, the one before, its own name and // its direction - bind() turns it into the i18n locale. el.dispatchEvent( new CustomEvent<LanguageSwitcherChangeDetail>('language-change', { bubbles: true, detail: { value: next, previous, name: nameIn(next, next), dir: dirOf(next) }, }), ); return true;}// -- the state ----------------------------------------------------------------------------------------------const popoverOf = (el: HTMLElement): HTMLElement | undefined => dfDollar(el).find(':scope > .language-switcher-combobox > .combobox-content').get(0) as HTMLElement | undefined;const isOpen = (el: HTMLElement): boolean => { try { return !!popoverOf(el)?.matches(':popover-open'); } catch { return false; }};/** The markup of a state: the language chosen is data-value. The list ('open') is the runtime's own chrome. */function applyMarkup(el: HTMLElement, _name: string, config: { value?: string } | undefined) { const languages = languagesOf(el); const value = languages.find((l) => l.toLowerCase() === String(config?.value ?? '').toLowerCase()); if (value) dfDollar(el).attr('data-value', value);}/** UI side of a state: the language, then the list shown (open) or hidden (default) */function triggerStateChange(el: HTMLElement, name: string, config: { value?: string }) { if (config?.value) choose(el, config.value, false); const popover = popoverOf(el); if (!popover) return; if (name === 'open' && !isOpen(el)) (dfDollar(el).find('.combobox-trigger').get(0) as HTMLElement)?.click(); else if (name !== 'open' && isOpen(el)) popover.hidePopover();}export const languageSwitcherApi = componentState({ component: 'language-switcher', states: languageSwitcherStates, mergeConfig: true, apply: (el, state) => triggerStateChange(el, state.name, state.config as { value?: string }), // the user picks and opens the list without setState read: (el) => ({ name: isOpen(el) ? 'open' : 'default', config: { value: valueOf(el) } }), markup: (el, state) => applyMarkup(el, state.name, state.config as { value?: string }),});df$.languageSwitcherApi = languageSwitcherApi;df$.languageSwitcherStates = languageSwitcherStates;// -- init --------------------------------------------------------------------------------------------------function init() { dfDollar('.language-switcher:not([data-init])').each((_i, el: HTMLElement) => { el.dataset.init = ''; // the language shown is part of the markup from the start (render() writes it in every state) if (!el.dataset.value || valueOf(el) !== el.dataset.value) dfDollar(el).attr('data-value', valueOf(el)); draw(el); // the Combobox: a pick is the language (a pick of the shown one changes nothing) dfDollar(el).on('combobox:change', (e: Event) => { const value = (e as CustomEvent<{ values: string[] }>).detail?.values?.[0]; el._lsValue = ''; if (!value || !choose(el, value)) update(el); languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) }); }); // the Swap: every click turns it over to the next language dfDollar(el).on('change', (e: Event) => { const input = e.target as HTMLInputElement; if (!input.matches('.language-switcher-swap > input') || input.closest('.language-switcher') !== el) return; choose(el, nextOf(el, valueOf(el))); languageSwitcherApi.commit(el, 'default', { value: valueOf(el) }); }); // the Menu: an item picked - through the Dropdown Menu (click, Enter, Space) or a click outside one const pick = (item: EventTarget | null | undefined) => { const it = item as HTMLElement | null; if (!it?.matches?.('.language-switcher-item') || it.closest('.language-switcher') !== el) return; el._lsValue = ''; if (!choose(el, it.dataset.value || '')) update(el); languageSwitcherApi.commit(el, 'default', { value: valueOf(el) }); }; dfDollar(el).on('dropdown:select', (e: Event) => pick((e as CustomEvent<{ item: HTMLElement }>).detail?.item)); dfDollar(el).on('click', (e: Event) => { const item = (e.target as HTMLElement | null)?.closest?.('.language-switcher-item'); if (item && !item.closest('[role="menu"]')) pick(item); }); // a checkbox in the role of a button: Enter turns it over too dfDollar(el).on('keydown', (e: KeyboardEvent) => { const input = e.target as HTMLInputElement; if (e.key !== 'Enter' || !input.matches?.('.language-switcher-swap > input')) return; e.preventDefault(); input.click(); }); // the list opening and closing is the state el.addEventListener( 'toggle', () => languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) }), true, ); // attributes the page changes (a translation writes data-label, a page adds a language) show at once new MutationObserver(() => update(el)).observe(el, { attributes: true, attributeFilter: OBSERVED }); bindComponent(el, languageSwitcherApi, { name: 'default', config: { value: valueOf(el) } }); });}const OBSERVED = [ 'data-value', 'data-label', 'aria-label', ...STRUCTURE.map((k) => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())),];// -- df$.shadcn.languageSwitcher: the imperative surface --------------------------------------------------------df$.languageSwitcher = { /** * Every language the switcher knows: the 107 CLD3 codes with their English name, their own name, the region of * their flag and their direction. * @returns a copy of the list */ languages: (): LanguageInfo[] => LANGUAGES.map((l) => ({ ...l })), /** * A language's name in a locale - the preseeded names for a CLD3 code in itself and in English, the browser's * (CLDR) names for every other pair, the preseeded ones when the browser has none. * @param tag - the language (a BCP 47 tag: "de", "de-AT", "zh-Latn") * @param locale - the language to name it in (default: the language itself) * @returns the name, capitalized for the start of a label */ name: (tag: string, locale?: string): string => nameIn(tag, locale ?? tag), /** * The region whose flag stands for a language: its own region, else the preseeded one. * @param tag - the language (a BCP 47 tag) * @returns the region (ISO 3166, lowercase; '' for a language without one, like Esperanto) */ region: (tag: string): string => partsOf(tag).region || seedOf(tag)?.region || '', /** * Chooses a language of data-languages as a click would: the face changes and language-change fires. * @param target - the .language-switcher element, its id or a selector * @param tag - the language * @returns false when the switcher does not offer it */ choose: (target: string | HTMLElement, tag: string): boolean => { const el = resolve(target); if (!el || !choose(el, tag)) return false; languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) }); return true; }, /** * Ties the switcher to a defuss-i18n instance both ways: a pick sets its locale, a locale set elsewhere moves the * switcher (quietly - no language-change). * @param target - the .language-switcher element, its id or a selector * @param i18n - the instance (createI18n) - anything with locale, setLocale() and subscribe() * @returns a function that unties them */ bind: (target: string | HTMLElement, i18n: LanguageSwitcherI18n): (() => void) => { const el = resolve(target); if (!el || !i18n) return () => {}; const follow = (locale: string) => { if (locale && locale.toLowerCase() !== valueOf(el).toLowerCase() && choose(el, locale, false)) languageSwitcherApi.commit(el, isOpen(el) ? 'open' : 'default', { value: valueOf(el) }); }; follow(i18n.locale); const onChange = (e: Event) => { const value = (e as CustomEvent<LanguageSwitcherChangeDetail>).detail.value; if (value !== i18n.locale) i18n.setLocale(value); }; el.addEventListener('language-change', onChange); const stop = i18n.subscribe?.((snapshot) => follow(snapshot.locale)); return () => { el.removeEventListener('language-change', onChange); stop?.(); }; }, /** * Where the flags come from - set it before the first switcher draws (to self-host the SVGs). * @param options - the flag URL pattern */ configure: (options: LanguageSwitcherOptions): void => { if (options?.flags) settings.flags = options.flags; },};init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub