Internationalization

Set the active locale for astryx components, load locale catalogs, coexist with your own i18n library, swap languages at runtime, and test translations with the pseudo locale.

Quick Start

Internationalization ships with @astryxdesign/core. There is nothing to install. Wrap your app in <InternationalizationProvider> and set the active locale; astryx components pick up localized strings from that provider.

Wrap your app
tsx
import {InternationalizationProvider} from '@astryxdesign/core/i18n';
​
function App() {
return (
<InternationalizationProvider locale="en">
<YourApp />
</InternationalizationProvider>
);
}

The provider always has the built-in English catalog. Pass additional catalogs through messages when you enable another locale.

Load an astryx locale catalog
tsx
import {InternationalizationProvider} from '@astryxdesign/core/i18n';
import fr from '@astryxdesign/core/locales/fr.json';
​
<InternationalizationProvider locale="fr" messages={{fr}}>
<App />
</InternationalizationProvider>;

Astryx ships English today, with first-party translations for other locales on the roadmap. Until a locale is available from @astryxdesign/core/locales/*, apps can pass a local catalog with the same shape. See @astryxdesign/core/locales/en.json for the current key inventory. Missing keys fall back through the locale chain to English (for example, pt-BR walks to pt, then to shipped en).

Locale catalogs only affect astryx strings. Your app can continue using its own i18n system for product copy.

Runtime language swap

Re-render <InternationalizationProvider> with a new locale prop and every astryx string updates live. No reload, no separate API call.

Toggle between locales
tsx
const [locale, setLocale] = useState<'en' | 'fr'>('en');
​
<InternationalizationProvider locale={locale} messages={{fr}}>
<Button
label={locale === 'en' ? 'Français' : 'English'}
onClick={() => setLocale(l => (l === 'en' ? 'fr' : 'en'))}
/>
<App />
</InternationalizationProvider>;

Persisting the user's choice (localStorage, cookie, URL segment, account setting) is up to the consumer. Astryx reads whatever locale you pass in.

Text direction (RTL)

Astryx tracks text direction ('ltr' or 'rtl') alongside the locale. By default the direction is derived from the locale you pass to <InternationalizationProvider> via `Intl.Locale.getTextInfo()`, so RTL locales such as Arabic (ar), Hebrew (he), Farsi (fa), and Urdu (ur) resolve to 'rtl' automatically.

Direction derived from locale
tsx
import {InternationalizationProvider} from '@astryxdesign/core/i18n';
​
// direction resolves to 'rtl' automatically from the Arabic locale
<InternationalizationProvider locale="ar">
<App />
</InternationalizationProvider>;

Pass the optional dir prop to force a direction. This overrides the locale-derived default — useful for RTL layout testing under an English catalog, or to skip derivation when you already know the direction.

Explicit direction override
tsx
// force RTL layout while keeping English strings
<InternationalizationProvider locale="en" dir="rtl">
<App />
</InternationalizationProvider>;

There's one more step: tell the browser about the direction too. Add a dir attribute to your page — usually on the <html> tag. This is what makes text align to the correct side, punctuation and mixed-language text flow correctly, and layouts mirror. The provider handles astryx components; the dir attribute handles everything else on the page.

Astryx doesn't set dir for you — you set it, alongside the same direction you pass to the provider. If your app is server-rendered (like Next.js), the getLocaleDirection() helper computes the direction from a locale so you can set it while the page renders:

Set <html dir> in a Next.js root layout
tsx
import {getLocaleDirection} from '@astryxdesign/core/i18n';
​
export default function RootLayout({children, params}) {
const {locale} = params;
return (
<html lang={locale} dir={getLocaleDirection(locale)}>
<body>{children}</body>
</html>
);
}

In a plain client app, set the same attribute on <html> whenever the locale changes. (getLocaleDirection() safely returns 'ltr' for anything it doesn't recognize, so you can call it with any locale string.)

To make just one part of a left-to-right page right-to-left — say an Arabic quote or a comment thread — wrap that part in its own <InternationalizationProvider dir="rtl"> and add dir="rtl" to the element around it. (One current limitation: pop-up overlays like menus and dialogs opened from inside that region aren't mirrored yet; that's coming in later RTL work.)

Overriding astryx's default text

Use overrides to change individual strings without shipping a full catalog. Overrides are keyed by locale and merged on top of the built-in and user-supplied catalogs.

Change one string in English
tsx
<InternationalizationProvider
locale="en"
overrides={{en: {'@astryx.pagination.next': 'Next →'}}}
>
<App />
</InternationalizationProvider>

Overrides win over both bundled English and any messages catalog for the same key. Use them for brand voice tweaks or one-off wording changes.

Using astryx with your own i18n library

Astryx components render astryx strings through astryx's provider. Consumer components render consumer strings through whatever i18n library you already use: react-intl, i18next, next-intl, LinguiJS, and so on. The two systems coexist and read from the same source of truth for the active locale.

Astryx + react-intl side by side
tsx
import {InternationalizationProvider} from '@astryxdesign/core/i18n';
import {Selector} from '@astryxdesign/core/Selector';
import {Button} from '@astryxdesign/core/Button';
import {FormattedMessage, IntlProvider, useIntl} from 'react-intl';
import astryxFr from './locales/astryx/fr.json'; // astryx's UI, in French
import appFr from './locales/app/fr.json'; // your app strings, in French
​
function Pricing() {
// Consumer strings — resolved by react-intl.
const intl = useIntl();
​
return (
<section>
<h1><FormattedMessage id="pricing.heading" /></h1>
​
{/* Astryx Selector — trigger placeholder, search-box placeholder,
clear-button aria-label all resolved by
<InternationalizationProvider>. Options come from react-intl. */}
<Selector
label={intl.formatMessage({id: 'pricing.region.label'})}
options={[
{value: 'na', label: intl.formatMessage({id: 'pricing.region.na'})},
{value: 'eu', label: intl.formatMessage({id: 'pricing.region.eu'})},
]}
hasSearch
hasClear
/>
​
<Button label={intl.formatMessage({id: 'pricing.cta.subscribe'})} />
</section>
);
}
​
export default function App() {
return (
// Same locale, two providers reading their own catalogs.
<IntlProvider locale="fr" messages={appFr}>
<InternationalizationProvider locale="fr" messages={{fr: astryxFr}}>
<Pricing />
</InternationalizationProvider>
</IntlProvider>
);
}

Keep the two providers in sync on locale, and each library owns its own catalog. Astryx never sees your app strings, and your i18n library never sees astryx internals. Runtime locale swap works the same way: re-render both providers with a new locale prop and the whole tree updates live.

Reusing your i18n runtime with `translator`

Pass a translator to <InternationalizationProvider> to format astryx's strings with the i18n runtime you already ship, instead of the bundled intl-messageformat. A translator is one method: format(message, values?, locale?).

Format astryx strings with react-intl
tsx
'use client';
​
import {useMemo, type ReactNode} from 'react';
import {
InternationalizationProvider,
type Translator,
} from '@astryxdesign/core/i18n';
import {IntlProvider, ReactIntlErrorCode, useIntl} from 'react-intl';
import appFr from './locales/app/fr.json';
​
// A translator holds a function, so it cannot cross the server/client
// boundary — this wrapper has to be a client component.
function AstryxViaReactIntl({children}: {children: ReactNode}) {
const intl = useIntl();
const translator: Translator = useMemo(
() => ({
// `message` is the ICU string astryx already resolved, not a key, so it
// goes in as react-intl's `defaultMessage`. Reusing it as the `id` lets
// an app catalog override any astryx string by keying on the same text —
// `"Go to next page": "Page suivante"` in `appFr` just works.
//
// The cast is react-intl's typing, not astryx's: it accepts a narrower
// value type than `Record<string, unknown>`, and its rich-text overload
// returns ReactNode[]. Astryx needs a string back.
format: (message, values) =>
intl.formatMessage(
{id: message, defaultMessage: message},
values as Record<string, string | number | Date>,
),
}),
[intl],
);
​
return (
<InternationalizationProvider locale={intl.locale} translator={translator}>
{children}
</InternationalizationProvider>
);
}
​
export default function App() {
return (
<IntlProvider
locale="fr"
messages={appFr}
// Astryx messages are not in `appFr`, so react-intl reports each one as
// a missing translation before falling back to `defaultMessage`. That
// fallback is the intended path here — drop the noise.
onError={error => {
if (error.code !== ReactIntlErrorCode.MISSING_TRANSLATION) {
console.error(error);
}
}}
>
<AstryxViaReactIntl>
<YourApp />
</AstryxViaReactIntl>
</IntlProvider>
);
}

Astryx still owns the lookup. overrides, then messages, then the parent locale (pt-BR → pt), then the shipped en catalog — the translator only sees the message that chain produced, so you do not have to load astryx's catalog into your runtime's store. Every astryx string comes through, including the ones with nothing to interpolate (values is undefined for those). That is most of the catalog — 162 of 197 shipped strings — so keying your catalog on the English text covers all of them, and your runtime's missing-translation path runs on every one. Make sure that path is cheap.

What you gain over the two-provider pattern is one runtime: your ICU formats, number and date configuration, and timezone apply to astryx's strings too. What you take on is the adapter itself. Astryx checks only that you returned a string — anything else warns once in development and falls back to the message astryx resolved, so a stray ReactNode[] never reaches an aria-label. It does not catch exceptions: an adapter that throws takes the render with it, deliberately, because a broken adapter is a bug you want to see.

Two shapes to keep in mind. A translator holds a function, so it can only be passed from a client component — in the Next.js App Router, keep the wrapper in a 'use client' module; a server-rendered subtree stays on the bundled runtime. And a nested InternationalizationProvider replaces the translator rather than inheriting it, the same as messages and overrides — pass it again if you nest one to scope a locale.

The same shape adapts any ICU-capable runtime. i18next interpolates {{mustache}} by default, so add i18next-icu before wiring it up. Omit translator and nothing changes: the bundled runtime keeps formatting astryx strings exactly as it does today, and the two-provider pattern above stays a valid alternative when you want runtime isolation.

Using astryx as your i18n library

For production apps with substantial localization needs, we recommend a dedicated i18n library such as react-intl, i18next, next-intl, or LinguiJS. If your app is small or you do not want another runtime, you can resolve your own strings through astryx too. Keep app keys in a separate namespace from @astryx.*, and include your own en catalog because astryx's built-in English fallback only contains astryx component strings.

Translate app strings with astryx
tsx
import {Button} from '@astryxdesign/core/Button';
import {
InternationalizationProvider,
useTranslator,
type Catalog,
type MessagesByLocale,
} from '@astryxdesign/core/i18n';
​
const en: Catalog = {
'@myapp.actions.save': {defaultMessage: 'Save'},
};
​
const fr: Catalog = {
'@myapp.actions.save': {defaultMessage: 'Enregistrer'},
};
​
const messages: MessagesByLocale = {en, fr};
​
function SaveButton() {
const t = useTranslator();
return <Button label={t('@myapp.actions.save')} />;
}
​
export default function App() {
return (
<InternationalizationProvider locale="fr" messages={messages}>
<SaveButton />
</InternationalizationProvider>
);
}

Catalog types a single locale file; MessagesByLocale types the map passed to messages. A catalog entry uses the same {defaultMessage, description?} shape as @astryxdesign/core/locales/en.json.

Testing your translations

Astryx generates a pseudo locale that wraps every string in ⟦…⟧ and replaces letters with accented look-alikes. Switch to it in development to catch hardcoded astryx strings and layout issues caused by longer text.

Turn on pseudo-localization
tsx
import {InternationalizationProvider} from '@astryxdesign/core/i18n';
import pseudo from '@astryxdesign/core/locales/pseudo.json';
​
<InternationalizationProvider locale="pseudo" messages={{pseudo}}>
<App />
</InternationalizationProvider>;

For contributors

Developers

Astryx component authors read strings with useTranslator() rather than hardcoding user-facing text.

Read an astryx string
tsx
import {useTranslator} from '@astryxdesign/core/i18n';
​
function SaveButton() {
const t = useTranslator();
return <button>{t('@astryx.actions.save')}</button>;
}

Astryx's own strings live in packages/core/locales/en.json. New user-facing strings must go through useTranslator; this is enforced by the @astryx/no-hardcoded-i18n-string ESLint rule. See the AI contribution guide for the alias-and-resolve pattern used when adding new keys.

Component authors read the ambient text direction with useDirection(). Reach for it only when CSS logical properties can't express the mirroring — swapping a directional icon, mirroring behavioral logic (slider math, keyboard nav), or direction-specific geometry. It returns 'ltr' when called outside a provider, matching the silent-fallback behavior of useTranslator.

useDirection() in a component
tsx
import {useDirection} from '@astryxdesign/core/i18n';
​
function NextButton() {
const direction = useDirection();
const icon = direction === 'rtl' ? 'chevronLeft' : 'chevronRight';
return <Icon icon={icon} />;
}

Translators

Crowdin is the preferred way to contribute — join a language, translate strings in the web UI, and your work syncs back to the repo without opening a PR. Direct PRs against packages/core/locales/*.json also work if you prefer that flow.