Internationalization
Localization in Noctis is one provider plus a set of locale-aware hooks, not a retrofit bolted onto a finished UI. So an RTL Persian build with Jalali dates is a prop you set, not a rewrite you schedule.
One provider, not two
<NoctisProvider> is the single root provider — it is the theme provider. It composes Base UI's CSPProvider (the nonce for injected styles), the design-tokens ThemeProvider (the engine seed), the locale/direction/labels context the i18n hooks read, and Base UI's DirectionProvider, nested in that order — outermost to innermost:
The trap that bites everyone: do not mount your own ThemeProvider beside it. <NoctisProvider> already wraps one, and a second one double-wraps the engine. Mount this provider once near the root and nothing else.
import { NoctisProvider } from "@stridge/noctis";
export function Root({ locale, theme, themeOverrides, nonce, children }) {
return (
<NoctisProvider
locale={locale}
theme={theme}
themeOverrides={themeOverrides}
nonce={nonce}
>
{children}
</NoctisProvider>
);
}NoctisProvider is exported from the main barrel — @stridge/noctis, never the /i18n subpath. The /i18n subpath re-exports only the formatter hooks and locale utilities (useNumberFormatter, useDateFormatter, isRTL, …).
The prop surface:
| Prop | Role |
|---|---|
locale | Required, controlled — the active BCP-47 tag; the app (e.g. next-intl) is the source of truth. |
direction | Reading direction. Defaults to the locale's via isRTL; pass it only to force an override. |
theme | The engine seed — the server-resolved theme input; omit for the default theme. |
themeOverrides | Generation-time per-primitive replacements that participate in derivation. |
messages | Override string dictionary for the localized-string hooks; defaults to the built-in en stub. |
nonce | CSP nonce stamped on the inline styles Base UI injects, so they stay valid under a strict CSP. |
children | The tree. |
How locale reaches a primitive
Two mechanisms carry the active locale down — translated labels and the raw locale string — and a given primitive uses one or both.
useInjectedLabels resolves a primitive's assistive strings against the bundled catalog. The set that consumes it: number-field, slider, toast, combobox, autocomplete, sheet, dialog, rail. A number field's increment button is announced as Increase in English and افزایش in Persian with no per-component wiring.
useInjectedLocale hands the raw locale string to Base UI roots that format their own values through Intl. The set that consumes it: number-field, combobox, autocomplete, slider, meter, progress.
Label resolution follows one precedence — explicit prop > the locale dictionary > the English default — and it is fail-safe. The formatter throws when a key is absent for the active locale; the merge catches it and keeps the English default, so a half-translated fa catalog silently falls back per-string and never crashes the render.
// Translated automatically from the active locale…
<NumberField.Increment />
// …or named explicitly — the prop always wins.
<NumberField.Increment aria-label="Step up" />The messages entry points
Three subpaths split the runtime from the data so the core stays string-free and tree-shakeable:
@stridge/noctis/i18n— the runtime: the formatter hooks and the locale utilities.@stridge/noctis/i18n/messages— an empty{ en: {} }fallback stub. It is not the catalogs;NoctisProvideruses it only when nomessagesprop is passed.@stridge/noctis-intl/messages/{en,fa}— the populated catalogs. These ship inside the primitives viauseInjectedLabels; a typical app imports nothing from here.
Right-to-left by construction
Direction is resolved once: <NoctisProvider> derives it from the locale (or the explicit direction prop), and the server renders <html lang dir> so the first paint is correct with no flash. Direction is derived, not detected — there is no browser auto-detection. The docs site is cookie-driven (NEXT_LOCALE), which keeps server and client in agreement and avoids a hydration mismatch.
Components never hardcode a side. Their styling is precompiled, hand-authored CSS that uses native CSS logical properties (margin-inline-start, padding-inline-*, inset-inline-*) — so switching to an RTL language re-mirrors the whole layout with no conditional styling and no per-locale overrides. (Tailwind's logical utilities are the rule only where Tailwind is authored — the docs and examples.) Directional glyphs mirror through the Icon primitive's directional flag, which stamps data-directional for the CSS mirror to key off.
The caveat is the whole difference: there are two RTL language sets, and they are not the same.
| Resolver | Where | Coverage |
|---|---|---|
isRTL (package helper) | @stridge/noctis-intl | ~19 languages, via Intl.Locale's getTextInfo → script → lang fallback (ar, he, fa, ur, ps, ku, dv, ug, yi, …). |
getDir (docs site) | apps/docs/src/i18n/config.ts | Only { ar, he, fa, ur }. |
So "ar/he/fa/ur resolve automatically" is true for the docs site's getDir config — the package helper isRTL resolves far more.
Formatter hooks
For dates, numbers, lists, and comparisons, reach for the formatter hooks rather than calling Intl directly — each reads the active locale from the provider and memoizes the underlying formatter.
| Hook | Returns | Use |
|---|---|---|
useNumberFormatter | NumberFormatter (wrapper class) | Currency, percent, unit, and decimal formatting. |
useDateFormatter | DateFormatter (wrapper class) | Localized dates and times, with an optional calendar override. |
useListFormatter | Intl.ListFormat (raw) | Conjunctions and disjunctions ("A, B, and C"). |
useCollator | Intl.Collator (raw) | Locale-aware sorting. |
useFilter | a search collator (Filter) | Accent-insensitive substring matching; inputs are NFC-normalized. |
NumberParser | string → number | Reads localized numeric input back into a number. |
import { useNumberFormatter, useDateFormatter } from "@stridge/noctis/i18n";
function Price({ amount }) {
const number = useNumberFormatter({ style: "currency", currency: "EUR" });
return <span>{number.format(amount)}</span>;
}
function Timestamp({ date }) {
const formatter = useDateFormatter({ dateStyle: "long" });
return <time>{formatter.format(date)}</time>;
}Dates and the Persian calendar
Because the formatters resolve through the active locale, a date rendered under fa arrives in the Persian (Jalali) calendar with Persian digits — Farvardin through Esfand, ۱۴۰۳ rather than 2024 — straight from the platform's own Intl calendar support. The locale carries it.
const formatter = useDateFormatter({ dateStyle: "long" });
// en → "March 20, 2024" · fa → "۱ فروردین ۱۴۰۳"
formatter.format(date);When the locale's default is not what you want, the escape hatch is an explicit override: useDateFormatter accepts a calendar option and useNumberFormatter accepts a numberingSystem option.
Adding a locale
Two independent surfaces can grow a locale; extend whichever the work touches.
The docs site's own chrome (page copy, navigation, controls):
- Add the BCP-47 tag to
localesinapps/docs/src/i18n/config.ts. The four RTL languagesgetDirknows (ar,he,fa,ur) resolve their direction automatically — no further edit. - Create
apps/docs/messages/<locale>.json, mirroringen.json's namespace structure. - Add the endonym to
ENDONYMSinsystem-controls.tsxso the locale appears in the picker. - The
NEXT_LOCALEcookie and the request config load it from there — no route changes.
The primitives' assistive strings (the @stridge/noctis package):
- Author
src/intl/<locale>.jsonin@stridge/noctis-intl, mirroringen.json's namespaces. The EnglishnumberField/toaststrings are seeded from Adobe's react-aria Apache-2.0 ICU corpus; only the Persian (fa) values are 100% original, so for a locale present in that corpus, seed the overlapping strings from there. - Run
pnpm messages:gento compile the ICU JSON into the committedsrc/messages/<locale>.tsmodule. - Commit the generated output. A freshness check fails the build if it drifts from its source.