Skip to content
Foundationsi18n & locale

i18n & locale

Matter ships English-first today. The platform is internationally consumed (entities across US jurisdictions; partner integrations across global brands), so every surface must format dates, numbers, currency, and lists in a locale-aware way even when the UI strings are in English. This page covers the contract.

The reader is either authoring a new component, reviewing a PR that touches user-facing copy, or wiring an embedded consumer that will translate Matter strings into another language.

The three rules

  1. Use Intl for every locale-sensitive value. Never hardcode Jan 15, 2026 or $1,234.56 or 5 items. Use Intl.DateTimeFormat, Intl.NumberFormat, Intl.RelativeTimeFormat, Intl.ListFormat.
  2. Detect locale from the browser, not from IP. navigator.languages is the right surface. The user has set their preference; honour it.
  3. Mark brand names and code tokens translate="no". "Matter" stays "Matter" in every locale. So does --fg-muted, ent_a1b2c3, npm install, and every API endpoint.

Dates

// Right
new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
// → "Jan 15, 2026" in en-US, "15 янв. 2026 г." in ru-RU

// Wrong
date.toLocaleDateString();                // implementation-specific
date.toString();                          // RFC 2822 — wrong format for users
`${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;  // US-only

Pass the locale explicitly. navigator.language for the user's primary; navigator.languages for the full preference list:

const locale = typeof navigator !== "undefined"
  ? navigator.languages?.[0] ?? navigator.language ?? "en-US"
  : "en-US";  // server-render fallback

For relative times ("12s ago", "3 days ago"):

const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
rtf.format(-12, "second");  //"12 seconds ago"
rtf.format(-3, "day");      //"3 days ago"

Numbers and currency

// Numbers
new Intl.NumberFormat(locale).format(1234567);
// → "1,234,567" in en-US, "1.234.567" in de-DE, "1 234 567" in fr-FR

// Currency
new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(1234.56);
// → "$1,234.56" in en-US, "1.234,56 $" in de-DE

// Compact (4.2K, 1.3M)
new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }).format(4200);
// → "4.2K" in en-US, "4,2 Tsd." in de-DE

Lists ("X, Y, and Z")

const lf = new Intl.ListFormat(locale, { style: "long", type: "conjunction" });
lf.format(["Jane", "Mike", "Series Seed"]);
// → "Jane, Mike, and Series Seed" in en-US
// → "Jane, Mike und Series Seed" in de-DE

For disjunctions ("X or Y"), pass type: "disjunction".

Locale-aware sorting

const collator = new Intl.Collator(locale, { sensitivity: "base" });
entities.sort((a, b) => collator.compare(a.legal_name, b.legal_name));

Default .sort() orders by UTF-16 code units — German ä sorts after z, which is wrong. Intl.Collator knows the right rules per locale.

Don't translate brand names or code tokens

Wrap brand names, code identifiers, and API tokens in translate="no":

<!-- Don't get translated -->
<code translate="no">--fg-muted</code>
<code translate="no">ent_a1b2c3</code>
<span translate="no">Matter</span>
<code translate="no">npm install @matter/components</code>

Without translate="no", browser-native translation (Google Translate, Safari Translate) and screen-reader translation features can mangle these into nonsense. The design site's MDX renderer auto-wraps <code> with translate="no" (see mdx-components.tsx).

When you author MDX or product copy, use backticks for code (`--fg-muted`) — the renderer handles the rest.

Detecting locale

// Preferred: navigator.languages (full preference list)
function detectLocale(): string {
  if (typeof navigator === "undefined") return "en-US";
  return navigator.languages?.[0] ?? navigator.language ?? "en-US";
}

Never:

  • Detect locale from IP. IP-to-country is unreliable (VPNs, travel) and disrespects the user's explicit preference.
  • Detect locale from timezone. Same reasons.
  • Use server-side Accept-Language exclusively. Sync with the user's runtime navigator.languages for in-app preferences that may have changed.

For SSR-friendly locale resolution in Next.js: read accept-language server-side for first paint, then sync to navigator.languages on the client for subsequent renders.

RTL preparedness

Matter doesn't currently ship Arabic, Hebrew, or other RTL locales — but the surface is designed to support them when needed:

  1. Logical CSS properties. Every component uses padding-inline, margin-inline-end, text-align: start — not padding-left, margin-right, text-align: left. Logical properties flip in RTL automatically.
  2. No directional iconography in component recipes. Chevrons that imply direction (, ) are rendered with aria-hidden and don't carry meaning that breaks under flip.
  3. Layout grids are direction-agnostic. Flex and Grid both honour dir="rtl" on the document.

When an RTL locale ships, the work is:

  • Add the locale to @repo/internationalization.
  • Set dir="rtl" on <html> based on the locale.
  • Sweep the design site for directional iconography that needs an RTL variant (the chevron on BetaPill is the canonical example — flip via CSS, no JS).

What translators see

When Matter ships translated UI, translators consume strings via @repo/internationalization. The string surface follows these rules:

String typeTranslation behaviour
User-facing copy (button labels, headings, body text)Translated
Brand names (Matter, the product names)Not translated; wrapped in translate="no"
API resource IDs (ent_a1b2c3, tok_…)Not translated; wrapped in translate="no"
Token names (--fg-muted)Not translated; wrapped in translate="no"
Code snippets in docsNot translated; rendered inside <pre> with translate="no"
Date / number / currency stringsNot translated; generated via Intl

Server-side rendering

For SSR-rendered locale-sensitive content, use a known locale from the request rather than guessing:

// Server component
import { headers } from "next/headers";

async function ServerFormattedDate({ date }: { date: Date }) {
  const acceptLanguage = (await headers()).get("accept-language") ?? "";
  const locale = acceptLanguage.split(",")[0]?.split(";")[0]?.trim() || "en-US";
  return <time dateTime={date.toISOString()}>
    {new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date)}
  </time>;
}

Add suppressHydrationWarning only when the server and client locale will differ legitimately — and verify the hydration mismatch doesn't surface in the user-visible content. For most cases, the server-render locale and the client locale match.

Anti-patterns

Donew Intl.DateTimeFormat(navigator.languages[0]).format(date).
Don't${date.getMonth()+1}/${date.getDate()}/${date.getFullYear()}. US-only; breaks for every other locale.
Do — wrap brand names and code tokens in translate="no". The renderer auto-wraps <code>.
Don't — let "Matter" or --fg-muted get auto-translated. Translation engines mangle them.
Do — use padding-inline-start and margin-inline-end. They flip correctly in RTL.
Don't — use padding-left and margin-right. They stay LTR even in RTL contexts.
Do — detect locale from navigator.languages. The user has set their preference; honour it.
Don't — guess locale from IP. VPNs and travel make IP unreliable; users hate having their preference overridden.

Reference

On this page