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, not merely 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;
document.documentElement.dir = getReadingDirection(tag); // "ar-EG" -> "rtl"
},
);
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 quick 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; return null/undefined to fall through to hope-ui's built-in 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. So a component
that reported its own locale mid-hydration would leave markup contradicting its own state, with no
warning and no replaced node: a Sunday-first grid driven by a Monday-first model, where clicking 20
selects the 21st.
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 very first read is already the visitor's locale, and nothing re-renders.
Detection still costs one re-render just 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. |
At a glance
| 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 }Read the current locale + direction accessors and the reactive t(key, …params) message resolver. Works with no provider mounted — it falls back to the detected browser locale and the built-in catalog. direction drives keyboard navigation; it is never written to the DOM (set dir on your document root for that). |