hope-uiearly preview

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: ltr or rtl, derived from the locale.
  • Messages: a resolver (t) that turns a message key into a localized string.

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:

CodeLanguage
enEnglish (the fallback for any unlisted locale)
arArabic (right-to-left)
daDanish
deGerman
elGreek
esSpanish
fiFinnish
frFrench
itItalian
plPolish
ptPortuguese
svSwedish

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:

app.tsx
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:

Follow a language switcher
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:

app.tsx
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:

Server-rendered shell
<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.

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, …).

Per-key override / add a language
<I18nProvider
  locale={locale()}
  messages={{
    "fr-FR": { "common.close": "Fermer la fenêtre" },
    "ja-JP": { "common.close": "閉じる" },
  }}
>
  <App />
</I18nProvider>
Delegate to your app's pipeline
<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.

OrderSource
1translate — your app pipeline (via the translate prop)
2messages — the per-locale, per-key override map
3Built-in catalog for the locale (English per missing key)
4The 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.

Which form to use:

FormUnder 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 providerSafe, 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

PropDefaultType
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.