On this page

i18n

A framework-agnostic internationalization engine. It mirrors the router design: a small core (I18nCore) with an optional global singleton (createI18n / useI18n) and no DOM coupling, so you bind it to the UI however you like.

Use when you need runtime locale switching in a ranui app. Call createI18n once, then read strings with useI18n().t(key, params) and switch languages with setLocale. It has no framework or DOM dependency, so it works in plain JS, any framework, and SSR.

The engine ships as its own ranui/i18n entry: importing it registers no custom elements, so a page that only needs translation never pulls in the component library. The same exports are also available from the top-level ranui barrel.

Quick start

Create the i18n singleton once at startup, then translate anywhere:

import { createI18n, useI18n } from 'ranui/i18n';

createI18n({
  // Each locale is a FLAT dictionary — keys are looked up verbatim, not nested.
  messages: {
    en: { 'hero.title': 'Hi {name}', 'nav.home': 'Home' },
    zh: { 'hero.title': '你好 {name}', 'nav.home': '首页' },
  },
  fallbackLocale: 'en', // used when a key is missing in the active locale
  persist: true, // remember the choice under localStorage key 'ran-locale'
  detectNavigator: true, // seed the initial locale from the browser's language preferences
});

const i18n = useI18n();

i18n.t('hero.title', { name: 'Ada' }); // → "Hi Ada"
i18n.setLocale('zh'); // persists and notifies subscribers
i18n.t('hero.title', { name: 'Ada' }); // → "你好 Ada"

t(key) looks up messages[activeLocale][key], then messages[fallbackLocale][key], and finally returns the key itself if neither exists. {param} placeholders in the string are interpolated from the second argument. Because lookup is a flat map access, keys are literal strings: write 'hero.title' as one key, not a nested { hero: { title } } object.

Parameters (interpolation)

Yes, messages take runtime parameters. Put {name}-style placeholders in the string and pass the values as the second argument to t(); each {param} is replaced by the matching value:

createI18n({
  messages: {
    en: {
      'cart.summary': '{count} items · ${total}',
      greeting: 'Welcome back, {user}!',
    },
    zh: {
      'cart.summary': '{count} 件商品 · ¥{total}',
      greeting: '欢迎回来,{user}!',
    },
  },
});

const i18n = useI18n();
i18n.t('cart.summary', { count: 3, total: 59.9 }); // → "3 items · $59.9"
i18n.t('greeting', { user: 'Ada' }); // → "Welcome back, Ada!"

Details:

  • Placeholder syntax is {word} (letters, digits, _). Values may be strings or numbers: numbers are stringified.
  • A placeholder with no matching key is left as-is ({oops} stays literally in the output), which makes missing params easy to spot rather than silently blank.
  • Interpolation runs after locale fallback, so the same params work no matter which locale actually resolved the string.
  • There is no built-in pluralization or number/date formatting; compose those with Intl.NumberFormat / Intl.PluralRules and pass the formatted string in as a param.

Escaping literal braces

A lone { or }, or a spaced group like { color: red }, is not a placeholder and passes through untouched, so CSS, JSON, and code fragments inside a message are safe by default. The only ambiguous case is a literal {word} you want to show verbatim. To escape it, double the braces (the same convention as Rust format!, Python str.format, and .NET String.Format):

const i18n = useI18n(); // messages below assumed registered

i18n.t('use {{ and }} for literal braces'); // → "use { and } for literal braces"
i18n.t('the {{count}} token'); // → "the {count} token"  (not interpolated)
i18n.t('{{{name}}}', { name: 'Ada' }); // → "{Ada}"  (value wrapped in literal braces)
In the message Output
{{ {
}} }
{name} the name param, or {name} if absent
{ name } { name } (spaces → not a placeholder)
{ { (lone brace)

Escaping is applied in the same left-to-right pass as interpolation and works whether or not you pass params, so {{/}} always mean literal braces.

Doubling is the same convention used by Rust format!, Python str.format, and .NET String.Format, so it needs no new escape character. If you need real plural/gender/number grammar, format with Intl.* and pass the result in as a param.

Reacting to locale changes

onChange fires after every setLocale; use it to re-render the strings you've painted:

const i18n = useI18n();

const unsubscribe = i18n.onChange((locale) => {
  document.documentElement.lang = locale;
  repaintStrings(); // re-run your t() calls
});

// later, when the view unmounts
unsubscribe();

Adding messages lazily

Load a locale's dictionary on demand (e.g. code-split per language) and merge it in:

const i18n = useI18n();

const { default: fr } = await import('./locales/fr.js');
i18n.addMessages('fr', fr); // merges into any existing 'fr' dictionary
i18n.setLocale('fr');

Built-in component text

The few strings a component writes itself (accessible names for the player's controls, the copy and download buttons on r-markdown, r-math and r-mermaid, validation messages, the token meter's limit flag) follow the reader's language. ranui uses the app's i18n locale when one is registered, otherwise the nearest lang attribute (usually <html lang>). It ships these strings in Chinese, Japanese, Korean, Spanish, Portuguese, German and Persian; any other language gets English.

To change one, define ranui.<key> in your own dictionary. It takes precedence over the built-in table:

createI18n({
  messages: {
    en: { 'ranui.player.error': 'This video is unavailable' },
    zh: { 'ranui.player.error': '视频暂时无法播放' },
  },
});

Each component's keys and their English text are in its own components/<name>/text.ts in the ranui source. An attribute set on the element, such as label or label-*, still wins over both. Text is resolved when it is written: when the element connects, or when the state it describes changes. After a runtime locale switch, an element already on the page keeps its text until it next writes it; to apply the switch at once, re-apply the labels as shown below.

Localizing component text

Every user-visible string is also an input: an attribute, a property, an option, or slotted content. What you pass there wins over the built-in text, so the other way to localize ranui is to pass t() output in where the string already goes:

const i18n = useI18n(); // messages below assumed registered

modal.setAttribute('title', i18n.t('dialog.deleteProject.title'));
themeSwitch.setAttribute('label-dark', i18n.t('theme.dark'));

Most components have no text of their own at all: it arrives through slots and attributes you already write. These are the strings that have nowhere else to come from, and the inputs that replace them; the table shows the English text:

Component Built-in English Override with
Modal.confirm / Modal.open title Confirm, buttons OK / Cancel the title, okText, cancelText options
Modal.info / .success / .warning / .error titles Info / Success / Warning / Error the title option
<r-theme-switch> aria-labels Theme, System theme, Light theme, Dark theme label, label-system, label-light, label-dark
<r-voice-button> aria-labels Start voice input / Stop voice input; hints Release to keep · slide up to cancel, Release to cancel label, active-label, hold-hint, cancel-hint
<r-reasoning> header label Reasoning label
<r-token-meter> label Context label
<r-colorpicker> aria-labels Choose color, Hue, Alpha opacity label, hue-label, alpha-label
<r-player> error heading Playback failed, retry button Retry label-error, label-retry
<r-markdown> / <r-math> / <r-mermaid> toolbar labels such as Copy code and Download label-copy, label-download and the other label-*

A practical pattern is to re-apply them from one place on every locale change, so the same code runs at startup and after a switch:

const i18n = useI18n();

const applyLabels = () => {
  document.querySelectorAll('r-voice-button').forEach((el) => {
    el.setAttribute('label', i18n.t('voice.start'));
    el.setAttribute('active-label', i18n.t('voice.stop'));
  });
};

applyLabels();
i18n.onChange(applyLabels);

Remember to keep document.documentElement.lang in step too: it is what the browser, screen readers and :lang() selectors go by.

API

createI18n(config) creates and registers the global singleton (call once); useI18n() returns it, or null if createI18n hasn't run yet.

I18nConfig

Field Type Default Description
messages LocaleMessages {} locale → { key → string }. Each dictionary is flat.
locale string fallback Initial locale (overridden by a persisted choice when on).
fallbackLocale string 'en' Locale consulted when a key is missing in the active locale.
persist boolean false Persist the active locale to localStorage.
storageKey string 'ran-locale' localStorage key used when persist is on.
detectNavigator boolean false Seed the initial locale from the browser's language preferences. Reads the whole ordered navigator.languages list, so a reader whose first choice has no dictionary still gets their second rather than the fallback.

I18nCore methods

Method Returns Description
t(key, params?) string Translate; falls back to fallback locale, then the key.
setLocale(locale) void Switch locale; persists (if on) and notifies subscribers.
getLocale() string The active locale.
onChange(handler) () => void Subscribe to locale changes; returns an unsubscribe function.
addMessages(locale, dict) void Merge more messages into a locale.
getMessages(locale?) MessageDict Read a locale's dictionary (defaults to the active locale).
availableLocales string[] Locales that have a dictionary registered.
destroy() void Remove all subscribers.

Types

type MessageDict = Record<string, string>; // flat: 'hero.title' → 'Hi {name}'
type LocaleMessages = Record<string, MessageDict>; // locale → MessageDict
type TranslateParams = Record<string, string | number>;

SSR

The core is SSR-safe: localStorage and navigator access is guarded, so createI18n / t run without throwing during server rendering. Persistence and navigator detection simply no-op on the server and take effect once the code runs in the browser.