---
title: "Internationalisation"
description: "RTL, locale formatting, and the hard part. That part is translating a status vocabulary without changing what it means clinically."
url: "https://opsinjs.pensievelabs.org/handbook/internationalisation"
source: "https://opsinjs.pensievelabs.org/handbook/internationalisation.md"
section: "Handbook"
kind: "handbook"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["internationalisation mechanics", "rtl", "right to left", "locale formatting", "translating status"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="handbook" />

## The short version [#the-short-version]

<NotBuiltYet />

Three separate problems travel under one word, and they have three different
difficulties:

1. **Direction.** Right-to-left is largely solved by CSS logical properties and
   by not assuming that "next" is to the right. Logical properties mean
   `margin-inline-start` rather than `margin-left`.
2. **Formatting.** Numbers, dates, times and units differ by locale. `Intl` does
   this correctly; hand-rolled formatting does not.
3. **Status language.** This is the hard one. The four clinical status levels
   are an ordinal vocabulary with fixed meanings, and a translation that shifts
   one level's intensity has changed what the product tells someone about their
   health.

opsinjs does not ship a `[lang]` route segment, and that is a recorded decision
rather than an oversight: see
[ADR 0005](../project/decisions/0005-no-lang-segment-yet.mdx). Unit systems are
treated as a **correctness** surface rather than a localisation one, and ship
now. See [Unit systems](../health/unit-systems.mdx).

## How it works [#how-it-works]

### Direction [#direction]

Use logical properties throughout: `padding-inline`, `margin-block`,
`inset-inline-start`, `text-align: start`. Tailwind's `ps-*`, `pe-*`, `ms-*`,
`me-*` and `start-*`/`end-*` utilities map onto them. Set `dir` on the root and
the browser does the rest for text; what it does not do is flip your icons, your
progress direction or your chart axes.

A `RangeBar` is a directional component: in RTL the low end is on the right, and
the marker position, the labels and any leading edge all move with it.

### Formatting [#formatting]

`Intl.NumberFormat`, `Intl.DateTimeFormat` and `Intl.RelativeTimeFormat` handle
grouping separators, decimal separators, ordinal forms and date order. They also
handle units, which is where most hand-rolled code goes wrong: `72 kg` is not
formatted the same way in every locale, and the space is not always a space.

Formatting rules for health values are canonical in
[Numbers, units and precision](../health/numbers-units-precision.mdx) and are
locale-independent. Those rules cover precision, rounding and when to show a
decimal. Locale decides presentation; the health page decides how many digits
are honest.

### Status language [#status-language]

The four levels are ordinal. A translation must preserve:

* **The order.** Each level is more urgent than the one before.
* **The distance.** If two adjacent levels translate to near-synonyms, the
  vocabulary has collapsed from four levels to three.
* **The absence of a verdict.** No level may translate to a word meaning
  "healthy", "safe", "normal" or "bad". This constraint is stated in
  [Clinical status semantics](../health/clinical-status-semantics.mdx) and does
  not survive a generic translation workflow.

Status strings therefore need review by someone who speaks the language **and**
understands the clinical intent. A translation memory match is not enough.

## Do this [#do-this]

* **Write CSS with logical properties from the start.** Retrofitting is a large,
  boring change with a long tail of missed cases.
* **Format with `Intl`**, and pass an explicit locale rather than relying on the
  runtime default, which differs between server and client and will produce a
  hydration mismatch.
* **Test in RTL early**, with real content. Set `dir="rtl"` on the root and read
  the page; problems are obvious and cheap to fix at that point.
* **Treat status strings as a special asset class** with their own review.
* **Keep units and locale separate.** A reader in Germany may still want stones,
  and a reader in the UK may want kilograms.

## Not this [#not-this]

* **Do not concatenate translated fragments.** "Your " + metric + " is " +
  status produces sentences that are ungrammatical in most languages. Use whole
  sentences with parameters.
* **Do not use `left`/`right` in CSS** where `start`/`end` will do.
* **Do not flip everything in RTL.** Clocks, media playback controls and
  logos do not mirror; arrows and progress do.
* **Do not translate a unit symbol.** `mmHg` and `mmol/L` are international; a
  translated abbreviation is a safety problem.
* **Do not assume text length.** German and Finnish translations run
  substantially longer than English; a status pill sized to fit "watch" will
  clip.
* **Do not localise a medication name.** See
  [Medications](../patterns/ask-users-for/medications.mdx).

## Gotchas [#gotchas]

* **`Intl` on the server and the client can disagree** if the locale is not
  passed explicitly, producing a hydration mismatch reported as a text-content
  error.
* **Numerals are not universal.** Some locales use different digit glyphs;
  `Intl` handles it, string interpolation does not.
* **`text-align: right` in an RTL layout is not `end`.** It is genuinely right,
  and it will look like a bug that only appears in one language.
* **Logical properties and `transform` do not interact.** A translated element
  still moves in physical space; a slide-in sheet needs its direction chosen
  from the writing direction.
* **Screen readers announce direction changes.** A Latin-script medication name
  in an RTL sentence is mixed-direction content, and it needs explicit `dir` on
  the span to avoid the punctuation moving.
* **Date order is not the only difference.** Some calendars are not Gregorian,
  and a date picker built on a fixed twelve-month year will be wrong.

## Related [#related]

* [Unit systems](../health/unit-systems.mdx) covers units as correctness, not
  localisation.
* [Numbers, units and precision](../health/numbers-units-precision.mdx) has the
  canonical formatting rules.
* [ADR 0005: no lang segment yet](../project/decisions/0005-no-lang-segment-yet.mdx)
  has the decision and the recipe for retrofitting it.
* [Clinical status semantics](../health/clinical-status-semantics.mdx) has the
  meanings a translation must preserve.
