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 directionltr 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, 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:

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

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

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

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

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.

At a glance

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 }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).