Lang
@ailuracode/alpine-lang
Reactive current-language store for Alpine.js. Detects the browser language, exposes current / base / region plus the full navigator.languages list, and lets you change the language dynamically so every Alpine expression reacts in real time.
The plugin only manages the current language — it does not translate content. Pair it with any i18n library (i18next, vue-i18n-style helpers, plain dictionaries, etc.) and react to language changes through the manager’s typed change event.
Install
pnpm add @ailuracode/alpine-lang @ailuracode/alpine-core alpinejsQuick start
import Alpine from "alpinejs";import { langPlugin, createLang } from "@ailuracode/alpine-lang";
Alpine.plugin(langPlugin({ fallback: "en", // used when navigator.language / navigator.languages are unavailable normalize: true, // lower-case + normalize "_" to "-"}));
Alpine.start();The plugin registers $store.lang and the $lang magic. Both expose the same six reactive fields plus the four commands (is / includes / set / reset).
Store API
State
| Property | Type | Description |
|---|---|---|
current |
string |
Normalized full language tag (e.g. "es-ec") |
base |
string |
Base subtag of the current language (e.g. "es"); equals current when no region is present |
region |
string | null |
Region subtag (e.g. "ec"); null when no region is present |
languages |
readonly string[] |
Snapshot of navigator.languages, normalized |
fallback |
string |
Configured fallback (normalized when normalize: true) |
isDetected |
boolean |
true when the initial language came from navigator |
Methods
| Method | Description |
|---|---|
is(value) |
true when value matches current exactly or by base subtag |
includes(value) |
true when any tag in navigator.languages matches value (exact or by base) |
set(language) |
Update the current language; recalculates base / region |
reset() |
Re-detect from navigator.language / navigator.languages (or fallback) |
set() is a no-op when the value is empty or unchanged, so Alpine bindings do not re-fire needlessly.
Avoiding name collisions
If your application already owns a $store.lang — or another toolkit plugin registers on that name — rename the integration surface without touching the controller:
Alpine.plugin(langPlugin({ storeKey: "i18n" })); // → $store.i18nThe exposed constant DEFAULT_LANG_STORE_KEY keeps the rename discoverable from TypeScript.
HTML examples
Reactive content switching
<p x-show="$store.lang.is('es')">Hola mundo</p><p x-show="$store.lang.is('en')">Hello world</p><p x-show="$store.lang.is('fr')">Bonjour le monde</p>
<button @click="$store.lang.set('es')">Español</button><button @click="$store.lang.set('en')">English</button><button @click="$store.lang.set('fr')">Français</button>When set() is called, every <p> whose visibility depends on $store.lang.is(...) updates automatically — no reload required.
Inspect the current language
<dl class="text-sm"> <dt>current</dt><dd x-text="$store.lang.current"></dd> <dt>base</dt><dd x-text="$store.lang.base"></dd> <dt>region</dt><dd x-text="$store.lang.region ?? '—'"></dd> <dt>languages</dt> <dd> <template x-for="tag in $store.lang.languages" :key="tag"> <span x-text="tag"></span> </template> </dd></dl>Reset to the browser language
<button @click="$store.lang.reset()">Reset to browser language</button>Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
fallback |
string |
"en" |
Used when neither navigator.language nor navigator.languages is available. Normalized when normalize: true. |
normalize |
boolean |
true |
Lower-case the language tag and convert underscores to dashes (pt_BR → pt-br). |
Reacting to language changes
$store.lang re-renders every binding on change. For side effects — load translations, persist the tag, sync <html lang> — wire them through the headless manager’s typed change event:
import { createLang } from "@ailuracode/alpine-lang";
const lang = createLang({ fallback: "en",});
// Multiple subscribers, runtime subscription, returns Unsubscribe.const stop = lang.on("change", (detail) => { // detail: { current, base, region, languages, fallback, isDetected, source, previous } // source is "initialization" | "user" | "reset". localStorage.setItem("lang", detail.current); document.documentElement.lang = detail.current; loadMessages(detail.current); // your i18n loader});
// Bootstrap a saved language without firing a synthetic event (set() emits only on real transitions).const saved = localStorage.getItem("lang");if (saved) lang.set(saved);
// later, on teardownstop();The manager is a singleton per document (matching theme, scroll, etc.). Alpine.plugin(langPlugin(...)) and createLang(...) both reach the same instance, so you can subscribe from any module without coordinating with the Alpine startup sequence.
Pairing with i18n libraries
Use the plugin as a single source of truth for the current language. Hand the value to your i18n layer:
import { createI18n } from "vue-i18n"; // or i18next, etc.import { createLang } from "@ailuracode/alpine-lang";
const i18n = createI18n({ legacy: false });const lang = createLang({ fallback: "en" });
lang.on("change", (detail) => { i18n.global.locale.value = detail.current;});The plugin never touches translation tables — it only owns the current language tag.
SSR considerations
- The plugin never throws when
window/navigatorare unavailable. - On the server it uses
fallbackuntil the client hydrates. - The store is registered on
Alpine.plugin(...)and thechangeevent is not fired until the client hydrates (unless you callset()explicitly during SSR). - For deterministic HTML output during SSR, render only
lang.fallback/lang.base(they are stable across server and client) and letregion/languagespopulate after hydration.
Helpers
normalizeLanguageTag(value) and parseLanguageTag(value) are exported alongside langPlugin for advanced use cases (custom adapters, custom stores, etc.).
import { normalizeLanguageTag, parseLanguageTag } from "@ailuracode/alpine-lang";
normalizeLanguageTag("EN_us"); // "en-us"parseLanguageTag("es-EC"); // { base: "es", region: "EC" }License
MIT
