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/i18n

Set 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.

MethodKindDescription
t(key, vars?)readerTranslate. Fills {placeholders}; picks a plural by vars.count. Dotted keys ("nav.home") walk nested tables. A missing key returns the key.
has(key)readerWhether key resolves (current or fallback locale).
locale()readerThe current locale tag.
n(value, options?)readerA number via Intl.NumberFormat.
d(value, options?)readerA date via Intl.DateTimeFormat.
list(values, options?)readerA list ("a, b, and c") via Intl.ListFormat.
setLocale(locale)writerSwitch locale; every reader binding updates.
add(locale, messages)writerMerge 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.