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
- Use
Intlfor every locale-sensitive value. Never hardcodeJan 15, 2026or$1,234.56or5 items. UseIntl.DateTimeFormat,Intl.NumberFormat,Intl.RelativeTimeFormat,Intl.ListFormat. - Detect locale from the browser, not from IP.
navigator.languagesis the right surface. The user has set their preference; honour it. - 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-onlyPass 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 fallbackFor 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-DELists ("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-DEFor 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-Languageexclusively. Sync with the user's runtimenavigator.languagesfor 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:
- Logical CSS properties. Every component uses
padding-inline,margin-inline-end,text-align: start— notpadding-left,margin-right,text-align: left. Logical properties flip in RTL automatically. - No directional iconography in component recipes. Chevrons that imply direction (
›,‹) are rendered witharia-hiddenand don't carry meaning that breaks under flip. - 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 type | Translation 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 docs | Not translated; rendered inside <pre> with translate="no" |
| Date / number / currency strings | Not 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
new Intl.DateTimeFormat(navigator.languages[0]).format(date).${date.getMonth()+1}/${date.getDate()}/${date.getFullYear()}. US-only; breaks for every other locale.translate="no". The renderer auto-wraps <code>.--fg-muted get auto-translated. Translation engines mangle them.padding-inline-start and margin-inline-end. They flip correctly in RTL.padding-left and margin-right. They stay LTR even in RTL contexts.navigator.languages. The user has set their preference; honour it.Reference
- Intl API on MDN — full surface.
- Logical CSS properties on MDN — for RTL-safe layout.
- Web Interface Guidelines / locale — the rules above mirror WIG's locale section.
@repo/internationalization— Matter's translation strings package.