Skip to content

i18n

GolemUI is i18n-aware out of the box but library-agnostic — it doesn’t ship or depend on any specific translation library. You bring your own (i18next, ParaglideJS, typesafe-i18n, FormatJS, your own JSON loader…) and adapt it to the small I18nTranslator contract from @golemui/core.

Every visible string in a form definition can be either a plain string or a Localizable:

type Localizable =
| string
| { key: string; default?: string; params?: Record<string, unknown> };

The active translator resolves { key, default } shapes at render time. Plain strings render as-is.

import type { TranslationKey, I18nParams } from '@golemui/core';
interface I18nTranslator {
/** Current BCP 47 language tag (e.g. 'en-US', 'es', 'fr-CA'). */
get lang(): string;
/** Resolve a key to a localized string; fall back to `defaultValue` if unknown. */
translate(
key: TranslationKey,
params?: I18nParams,
defaultValue?: string,
): string;
/** Subscribe to language-change events. Returns an unsubscribe function. */
subscribe(listener: (lang: string) => void): () => void;
}

Three properties, no opinions about how strings are stored or compiled. Any library you can wrap into those four members will work.

Here’s the same I18nTranslator implemented against three popular libraries. Pick the one closest to what you already use, or treat it as a template for your own.

import type { I18nTranslator } from '@golemui/core';
import i18next, { type Resource } from 'i18next';
export function createI18nextTranslator(resources: Resource): I18nTranslator {
i18next.init({ fallbackLng: 'en', resources });
return {
get lang() {
return i18next.language;
},
translate(key, params, defaultValue) {
return i18next.t(key, { ...params, defaultValue });
},
subscribe(listener) {
const onChange = (lng: string) => listener(lng);
i18next.on('languageChanged', onChange);
return () => i18next.off('languageChanged', onChange);
},
};
}

Switch language at runtime: i18next.changeLanguage('es').

ParaglideJS compiles your messages into typed functions, so the adapter calls a tiny lookup helper instead of a generic t(). The runtime exports getLocale() / setLocale() and tracks subscribers via a small store you wrap.

import type { I18nTranslator } from '@golemui/core';
import { getLocale, setLocale } from './paraglide/runtime';
import * as m from './paraglide/messages';
const listeners = new Set<(lang: string) => void>();
export const paraglideTranslator: I18nTranslator = {
get lang() {
return getLocale();
},
translate(key, params, defaultValue) {
const fn = (m as Record<string, (p?: Record<string, unknown>) => string>)[
key
];
return fn ? fn(params) : (defaultValue ?? key);
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
// Wherever you change locale, also fan out to GolemUI subscribers:
export function changeLanguage(lang: string) {
setLocale(lang as Parameters<typeof setLocale>[0]);
listeners.forEach((l) => l(lang));
}

Keys here are the names of compiled message functions (hello_world, errors_required, …). Pair them with structured Localizable values: { key: 'errors_required', default: 'Required' }.

typesafe-i18n compiles a typed LL accessor per locale. You wrap its i18nObject and read keys with LL[key]({ params }).

import type { I18nTranslator } from '@golemui/core';
import { i18nObject } from './i18n/i18n-util';
import { loadLocale } from './i18n/i18n-util.sync';
let lang = 'en' as const;
let LL = (() => {
loadLocale(lang);
return i18nObject(lang);
})();
const listeners = new Set<(lang: string) => void>();
export const typesafeTranslator: I18nTranslator = {
get lang() {
return lang;
},
translate(key, params, defaultValue) {
const node = (LL as any)[key];
if (typeof node === 'function') return node(params);
return defaultValue ?? key;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
export function changeLanguage(newLang: 'en' | 'es' | 'fr') {
loadLocale(newLang);
lang = newLang;
LL = i18nObject(lang);
listeners.forEach((l) => l(lang));
}

The typesafe types catch missing keys at compile time — your Localizable { key } strings are validated against the generated dictionary.

The translator goes inside the config object as the localization property. Below uses the i18next adapter; the wiring is identical for any other adapter you build.

The form definition references translation keys through Localizable shapes ({ key, default }). In the Programmatic path you pass them as object literals to gui.*; in the JSON path you write them inline.

import { gui } from '@golemui/gui-shared';
import { GuiForm } from '@golemui/gui-react';
import { createI18nextTranslator } from './i18n/i18next-translator';
const config = {
formDef: [
gui.inputs.textInput('username', {
label: { key: 'username.label', default: 'Username' },
}),
],
localization: createI18nextTranslator({
en: { translation: { username: { label: 'Username' } } },
es: { translation: { username: { label: 'Usuario' } } },
}),
};
export function MyForm() {
return <GuiForm config={config} />;
}

The JSON form references the same translation keys inline:

{
"form": [
{
"kind": "input",
"type": "textinput",
"path": "username",
"label": { "key": "username.label", "default": "Username" }
}
]
}
import { GuiForm } from '@golemui/gui-react';
import formDef from './my-form.json';
import { createI18nextTranslator } from './i18n/i18next-translator';
const config = {
formDef,
localization: createI18nextTranslator({
en: { translation: { username: { label: 'Username' } } },
es: { translation: { username: { label: 'Usuario' } } },
}),
};
export function MyForm() {
return <GuiForm config={config} />;
}

Here’s the same wiring rendered live — switch the language dropdown to confirm the label retranslates without any reset or remount:

  • Widget labels, placeholders, hints (where the prop accepts Localizable).
  • Validator error messages (messages: { required: { key, default } }).
  • Repeater labels, accordion section labels, tab labels.
  • Date and currency formatting honor the active locale (via Intl).

The demo below combines a translated label, placeholder, hint, validator message, and locale-aware date/currency formatting. Switch the language dropdown and watch every piece update at once:

When the active language uses a right-to-left script — Arabic, Hebrew, Persian, Urdu, and others — GolemUI flips the form to dir="rtl" automatically, and back to dir="ltr" when the user picks an LTR language. Layout, labels, validation messages, and date formatting all follow the active direction without any extra wiring on your side.

The params field in a Localizable shape accepts bare expressions alongside static values. A param value that starts with $form, $meta, $errors, or $formIsInvalid is evaluated as a live expression — the same expression language used in string interpolation {{...}} slots, but without the {{}} delimiters. Any other value is passed through as a static string.

import { gui } from '@golemui/gui-shared';
gui.displays.alert({
uid: 'greeting',
text: {
key: 'user.greeting',
params: {
hello: 'Hola',
fullName: "$form.firstName + ' ' + $form.lastName",
n: '$form.count + 1',
status: '$meta.connectionStatus',
},
},
});
{
"kind": "display",
"type": "alert",
"props": {
"text": {
"key": "user.greeting",
"params": {
"hello": "Hola",
"fullName": "$form.firstName + ' ' + $form.lastName",
"n": "$form.count + 1",
"status": "$meta.connectionStatus"
}
}
}
}

Given a translation bundle entry user.greeting = "{{hello}}, {{fullName}}! Items: {{n}}, Status: {{status}}." and form data { firstName: 'Jane', lastName: 'Doe', count: 4 } with meta { connectionStatus: 'online' }, this renders as:

Hola, Jane Doe! Items: 5, Status: online.

Expressions re-evaluate reactively — the translated string updates as the user changes form data.