Internationalization — @zoijs/i18n
A reactive locale, a message lookup with interpolation and plurals, and the platform's own Intl formatters. Switch the locale and every binding that read a translation updates in place — no provider, no context, no re-render. It depends only on @zoijs/core.
npm i @zoijs/i18nSet it up once#
import { createI18n } from "@zoijs/i18n";
export const i18n = createI18n({
locale: "en",
fallback: "en",
messages: {
en: {
hello: "Hello, {name}!",
items: { one: "{count} item", other: "{count} items" },
nav: { home: "Home" },
},
fr: {
hello: "Bonjour, {name} !",
items: { one: "{count} article", other: "{count} articles" },
nav: { home: "Accueil" },
},
},
});Use it in a component#
Wrap each translation in ${() => …} so it's a live binding — then setLocale() updates exactly those nodes:
import { html, mount } from "@zoijs/core";
import { i18n } from "./i18n.js";
function Bar() {
return html`
<p>${() => i18n.t("hello", { name: "Ada" })}</p>
<p>${() => i18n.t("items", { count: 3 })}</p>
<button onclick=${() => i18n.setLocale(i18n.locale() === "en" ? "fr" : "en")}>
${() => i18n.t("nav.home")}
</button>
`;
}
mount(Bar, "#app");The API#
Reader methods are reactive — read them inside ${() => …}. Two writers change state.
| Method | Kind | Description |
|---|---|---|
t(key, vars?) | reader | Translate. Fills {placeholders}; picks a plural by vars.count. Dotted keys ("nav.home") walk nested tables. A missing key returns the key. |
has(key) | reader | Whether key resolves (current or fallback locale). |
locale() | reader | The current locale tag. |
n(value, options?) | reader | A number via Intl.NumberFormat. |
d(value, options?) | reader | A date via Intl.DateTimeFormat. |
list(values, options?) | reader | A list ("a, b, and c") via Intl.ListFormat. |
setLocale(locale) | writer | Switch locale; every reader binding updates. |
add(locale, messages) | writer | Merge more messages (lazily-loaded bundles). |
Plurals are the platform's job#
A message can be an object keyed by CLDR plural category (one / other, plus zero / two / few / many where a language needs them). The right entry is selected by Intl.PluralRules for the active locale — so languages with complex rules just work, with no logic of your own:
messages: {
pl: { files: { one: "{count} plik", few: "{count} pliki", many: "{count} plików", other: "{count} pliku" } },
}
i18n.t("files", { count: 5 }); // → "5 plików"Numbers, dates, lists#
n, d, and list are memoized wrappers over Intl — same options, current locale:
i18n.n(1234.5, { style: "currency", currency: "EUR" }); // "€1,234.50"
i18n.d(new Date(), { dateStyle: "long" }); // "June 26, 2026"
i18n.list(["a", "b", "c"], { type: "conjunction" }); // "a, b, and c"Lazy-load locales#
Ship one locale, fetch others on demand. add() is reactive, so bindings update when the bundle lands:
async function load(locale) {
i18n.add(locale, await fetch(`/i18n/${locale}.json`).then((r) => r.json()));
i18n.setLocale(locale);
}Safe by default#
Translations come back as plain strings, which Zoijs renders as inert text. An interpolated value like <img onerror=…> is shown literally, never executed — so user-provided names or data in a message can't inject. For emphasis or links, compose in the template (html\<b>${() => i18n.t("x")}</b>\``) rather than putting markup in the string. See Security.
What it isn't#
No ICU message compiler, no global singleton, no provider/context, no build step, and no runtime dependencies — just a reactive locale and the platform's Intl.
Next: Testing.