Internationalization
hope-ui components carry their own accessible labels in the user’s language and adapt to reading direction — with no setup, and a single provider when you want to take control.What i18n covers
hope-ui components emit a small amount of user-facing text you never wrote — screen-reader labels
and live-region announcements, like a CloseButton’s accessible name or a calendar’s “Today”. The
i18n layer is what keeps that text accessible and localized, and it hands every component three
things through one context:
- Locale: the active BCP-47 language code (
en-US,fr-FR, …), which also feeds date/number formatting. - Reading direction:
ltrorrtl, derived from the locale. - Messages: a resolver (
t) that turns a message key into a localized string.
i18n localizes component chrome — the labels hope-ui supplies — not your own content. Your headings and copy stay yours to translate; hope-ui only makes sure the built-in affordances speak the same language.
See it work
Switch the locale below. The CloseButton renders with no aria-label, so it adopts the translated
accessible name for the active language; the surface’s reading direction flips for right-to-left
locales; and the readout shows exactly what any descendant receives from useLocale().
Changes saved
Your preferences were updated.
- locale
- en-US
- direction
- ltr
- CloseButton name
- «Close»
Zero configuration
Every component is localized out of the box. With no provider mounted, useLocale() falls back
to a default context that reads the browser locale and resolves against the built-in catalog — so a
CloseButton is already named “Close” (or “Fermer”, “Schließen”, …) with nothing to set up:
import { CloseButton } from "@hope-ui/components/close-button";
// No aria-label, no provider — still gets a localized accessible name.
<CloseButton />;This holds under server side rendering too: detection is hydration-aware rather than eager. See
Server side rendering for what that means and when to pass an explicit
locale anyway.
hope-ui ships built-in catalogs for these locales; any other locale falls back to English per key:
| Code | Language |
|---|---|
en | English (the fallback for any unlisted locale) |
ar | Arabic (right-to-left) |
da | Danish |
de | German |
el | Greek |
es | Spanish |
fi | Finnish |
fr | French |
it | Italian |
pl | Polish |
pt | Portuguese |
sv | Swedish |
Set the locale
To take control — pin a locale, follow your app’s language switcher, or make SSR deterministic — wrap
your app (or any subtree) in I18nProvider and pass a locale. Import it from @hope-ui/i18n:
import { I18nProvider } from "@hope-ui/i18n";
<I18nProvider locale="fr-FR">
<App />
</I18nProvider>;The locale prop is reactive — pass a signal-derived value and the whole tree re-localizes when
it changes. There is deliberately no setLocale; the prop is the only control:
const [locale, setLocale] = createSignal("en-US");
<I18nProvider locale={locale()}>
<App />
</I18nProvider>;Omit locale entirely and the provider tracks the browser/system language, updating live if it
changes.
Reading direction
Mirror the locale onto your document root. dir cascades, so that one write turns your whole app
around — your own layout and hope-ui’s alike. It belongs beside the provider that owns the locale, in
an effect:
import { getReadingDirection, I18nProvider } from "@hope-ui/i18n";
import { createEffect, createSignal } from "solid-js";
export function Root() {
const [locale, setLocale] = createSignal("ar-EG");
createEffect(
() => locale(),
(tag) => {
document.documentElement.lang = tag;
// "ar-EG" -> "rtl"
document.documentElement.dir = getReadingDirection(tag);
},
);
return (
<I18nProvider locale={locale()}>
<LanguageSwitcher onSelect={setLocale} />
<App />
</I18nProvider>
);
}An effect for two reasons: it re-runs, so switching language re-mirrors the page instead of leaving
it laid out in the previous direction, and it never runs on the server — where there is no
document to write to.
If the server already knows the locale (a route segment, a user setting), render it into the HTML shell as well, so the first paint is mirrored rather than waiting for hydration — see Server side rendering:
<html lang={locale} dir={getReadingDirection(locale)}>The locale handles the rest: RTL locales get reversed arrow keys in a calendar grid or a horizontal
listbox, plus localized names and numerals. Inside the provider you can read the direction directly
with useLocale().direction instead.
Components never write dir for you — so hope-ui can’t fight a direction your page already set. Set the locale but forget the dir and the layout won’t mirror while the arrow keys do; hope-ui warns in the dev console when it spots that.
To mirror one section only — two locales on one page — put dir on that subtree, or pass dir to a
single component.
Override or add messages
Two provider props overlay the built-in catalogs. Reach for messages for a per-key
override or to add a language hope-ui doesn’t ship; reach for translate to route messages
through your app’s own i18n pipeline (i18next, @solid-primitives/i18n, …).
<I18nProvider
locale={locale()}
messages={{
"fr-FR": { "common.close": "Fermer la fenêtre" },
"ja-JP": { "common.close": "閉じる" },
}}
>
<App />
</I18nProvider><I18nProvider
locale={locale()}
// Return a string to use it; null or undefined uses hope-ui's default.
translate={(key, params, locale) =>
myI18n.resolve(key, params, locale) ?? null
}
>
<App />
</I18nProvider>Resolution runs in a fixed order, and the first non-null wins — so a partial override is safe: anything you don’t provide falls through to the guaranteed English floor.
| Order | Source |
|---|---|
| 1 | translate — your app pipeline (via the translate prop) |
| 2 | messages — the per-locale, per-key override map |
| 3 | Built-in catalog for the locale (English per missing key) |
| 4 | The key itself (dev-warned once) — a last-resort safety net |
Server side rendering
A server has no navigator, so it can only render en-US. The browser that picks that markup up may
well be fr-FR, and hydration reuses the server’s DOM rather than re-deriving it.
A component that reported its own locale mid-hydration would therefore leave markup contradicting its own state, with no warning and no replaced node. In a calendar that means a Sunday-first grid driven by a Monday-first model: you click 20 and the 21st is selected.
hope-ui closes that by gating detection on the hydration pass. While one is in flight, every
component reports en-US/ltr — matching the markup it is hydrating — and the moment the pass ends,
the real locale takes over and locale-derived text re-renders. This applies with or without a
provider, so zero configuration is safe under server side rendering.
A client-only app pays nothing for the gate: with no hydration pass, the first read is already the visitor’s locale, and nothing re-renders.
Detection still costs one re-render immediately after hydration. Passing locale skips detection entirely — server and client render the same thing and nothing swaps. It’s the better default whenever the locale is something your app decides (a route segment, a user setting, a single-language site), and it matters most for date-heavy UI like Calendar, where that re-render rebuilds a whole grid.
Which form to use:
| Form | Under SSR |
|---|---|
<I18nProvider locale="fr-FR"> | Best. Fully deterministic — server and client render the same locale, no post-hydration re-render. Use it whenever the locale is something you decide rather than detect. |
<I18nProvider> | Safe. Renders en-US through hydration, then re-renders in the visitor’s locale. Use it to scope messages or a translate override while still following the browser. |
no provider | Safe, and identical to the row above — the same gate backs the zero-config default. Mount a provider to choose a locale, not to make one correct. |
API
| Prop | Default | Type |
|---|---|---|
I18nProvider.locale | — | stringReactive BCP-47 locale for descendants. Omit to track the browser/system language. Direction is derived from it. |
I18nProvider.messages | — | Record<locale, Record<key, string>>Per-locale, per-key override map. Add a language or replace a single string; supports {{param}} placeholders. |
I18nProvider.translate | — | (key, params, locale) => string | nullDelegate a key to your own i18n pipeline. Return null/undefined to fall through to the built-in default. |
useLocale() | — | { locale, direction, t }The current locale and direction accessors plus the reactive t(key, …params) message resolver. Works with no provider mounted, falling back to the detected browser locale. direction drives keyboard navigation and is never written to the DOM. |