Noctis

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 one providerNoctisProvider composes one nested tree — CSP nonce, theme engine, locale, then reading direction — wrapping your app. There is never a second ThemeProvider to add: it is already in here.
EXTERNAL
CSPProviderCSP nonce
PLATFORM
ThemeProviderthe engine seed
ON-CHAIN
LocaleContextlocale + direction
NEUTRAL
DirectionProviderreading direction
USER
childrenyour app
NoctisProvider composes one nested tree — CSP nonce, theme engine, locale, then reading direction — wrapping your app. There is never a second ThemeProvider to add: it is already in here.

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.

TSX
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:

PropRole
localeRequired, controlled — the active BCP-47 tag; the app (e.g. next-intl) is the source of truth.
directionReading direction. Defaults to the locale's via isRTL; pass it only to force an override.
themeThe engine seed — the server-resolved theme input; omit for the default theme.
themeOverridesGeneration-time per-primitive replacements that participate in derivation.
messagesOverride string dictionary for the localized-string hooks; defaults to the built-in en stub.
nonceCSP nonce stamped on the inline styles Base UI injects, so they stay valid under a strict CSP.
childrenThe 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.

TSX
// 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; NoctisProvider uses it only when no messages prop is passed.
  • @stridge/noctis-intl/messages/{en,fa} — the populated catalogs. These ship inside the primitives via useInjectedLabels; 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.

ResolverWhereCoverage
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.tsOnly { 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.

HookReturnsUse
useNumberFormatterNumberFormatter (wrapper class)Currency, percent, unit, and decimal formatting.
useDateFormatterDateFormatter (wrapper class)Localized dates and times, with an optional calendar override.
useListFormatterIntl.ListFormat (raw)Conjunctions and disjunctions ("A, B, and C").
useCollatorIntl.Collator (raw)Locale-aware sorting.
useFiltera search collator (Filter)Accent-insensitive substring matching; inputs are NFC-normalized.
NumberParserstring → numberReads localized numeric input back into a number.
TSX
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.

TSX
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):

  1. Add the BCP-47 tag to locales in apps/docs/src/i18n/config.ts. The four RTL languages getDir knows (ar, he, fa, ur) resolve their direction automatically — no further edit.
  2. Create apps/docs/messages/<locale>.json, mirroring en.json's namespace structure.
  3. Add the endonym to ENDONYMS in system-controls.tsx so the locale appears in the picker.
  4. The NEXT_LOCALE cookie and the request config load it from there — no route changes.

The primitives' assistive strings (the @stridge/noctis package):

  1. Author src/intl/<locale>.json in @stridge/noctis-intl, mirroring en.json's namespaces. The English numberField/toast strings 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.
  2. Run pnpm messages:gen to compile the ICU JSON into the committed src/messages/<locale>.ts module.
  3. Commit the generated output. A freshness check fails the build if it drifts from its source.