Skip to content
HotLayout Docs

Localization and RTL

This page is for the app developer, and for whoever translates screens. It explains where the translations of a screen live, which text a user sees when a translation is missing, how the writing direction is chosen, and how dates from your data are formatted.

The short version: translations are inside the document, you pass the user's locale as the platform gives it, and the renderer picks the language, the formats and the direction.

Related pages: index · quickstart for Flutter · quickstart for React · compatibility · data dictionary · widgets

Translations live in the document#

A document can carry several languages. They are stored in one optional section, localization:

{
  "schemaVersion": "9.1.0",
  "documentId": "reward-catalog",
  "localization": {
    "defaultLocale": "en",
    "locales": ["en", "de", "ar"],
    "strings": {
      "de": { "catalog/hero-title/content": "Wähle deine Prämie" },
      "ar": { "catalog/hero-title/content": "اختر جائزتك" }
    }
  }
}
Field Meaning
defaultLocale The source language: the language the text inside the nodes is written in.
locales All languages of the document, including defaultLocale. At most 8. The order belongs to the editor; a renderer never takes "the next language in the list".
strings Language → (place key → text).

A document without localization has one language. A renderer that does not know the section draws the node text as it is.

There is no separate translation file and no translation service to call. When the document is published, its translations are published with it.

The source language#

The text of the source language is not in strings. It is the literal inside the node:

{ "id": "hero-title", "type": "Text", "props": { "content": { "kind": "literal", "value": "Choose your reward" } } }

So strings always has one language fewer than locales. Keeping the source text in two places would create two sources of truth for one line.

Language tags#

A tag in the document is a two-letter language code with an optional two-letter region: en, de, pt-BR. The pattern is ^[a-z]{2}(-[A-Z]{2})?$. Script subtags and extensions are not accepted in a document. The locale a device reports can be longer and spelled differently (pt_BR, zh-Hant-TW); how it is matched to the document's tags is described in Which language a device gets.

Translation keys#

A key names a place in the document, not a text:

<screenId>/<nodeId>/<path to the field>

catalog/hero-title/content
catalog/item-row/trailing.text
  • The three parts are separated by /. The path to the field uses ..
  • The screen is part of the key because a node id is unique within its screen, not within the document.
  • Because the key is a place, a manager can fix a typo in the source text without losing the translations.
  • Only literal text is translated. A value bound to data (kind: 'binding') has no key: translate it where the data comes from, in your backend.

A missing translation shows the source text#

The policy key is missingTranslation and its value is fall-back-to-default-locale.

When the requested language has no entry for a place, the renderer shows the node's literal — the text in the source language. It does not show:

  • an empty space,
  • the key,
  • the text of another language.

The lookup of a string has exactly two steps: the translation in the language the document is shown in, then the literal. There are no intermediate steps: the language is chosen once for the whole document (see below), and a string missing in it does not go looking in a third language.

A partly translated document is a normal working state, not a broken document.

The builder marks an untranslated line on its canvas with a warning placeholder, so that a manager cannot overlook it. This is the editor's surface only. An app shows the source text, exactly as the policy says.

Which language a device gets#

Since schema 9.1.0 the contract defines how the locale of a device is matched to the languages of a document. Before that every renderer compared the two literally, so a phone set to ar-EG did not find a document's ar and showed the source language, left to right.

The host passes the locale as the platform gives it — ar-EG, pt_BR, uk-UA. The renderer does the rest:

  1. The spelling is normalised. _ equals -, and case does not matter: ar_EG, ar-eg and AR-EG are one tag. The document's own tags are compared the same way. Policy key: localeNormalization: underscore-as-hyphen-case-insensitive.
  2. Subtags are dropped from the end until a language of the document matches. This is the "Lookup" algorithm of RFC 4647: ar-EG → ar; zh-Hant-TW → zh-Hant → zh. A single-letter subtag left at the end (the marker of an extension, as in en-US-u-ca-gregory) is dropped together with the one before it. Policy key: localeMatching: bcp47-lookup-truncate.
  3. Never the other way round. A device that asks for ar does not get a document's ar-EG, and pt-BR does not get pt-PT. Guessing a region would show people the wrong text, silently. Policy key: localeChildFallback: none.
  4. No match: the source language. The same holds for an empty or malformed locale. Policy key: unmatchedLocale: use-default-locale.
  5. One answer per document. The language is resolved once, not per string. Policy key: localeResolution: once-per-document.

A language of the document is one that has at least one translated string. A code listed in locales with an empty strings table is not a language: it is never matched, so it neither shows source text under a foreign direction nor decides the date format. A tag longer than 100 characters is not a tag, and gives the source language.

The same answer decides two more things:

  • Writing direction follows the language of the text that is actually shown, not the locale that was asked for. ar-EG on a document without Arabic shows the source language left to right. Policy key: directionFollows: shown-translation-locale.
  • Date formats take the requested locale when a translation was found (ar-EG gives Egyptian formats next to Arabic text) and the source language when it was not. Only the well-formed part of a tag is used for dates: ja_JP_JP formats as ja-JP. Policy key: formattingLocale: requested-when-matched-else-default.
Device locale Languages of the document (source first) Text Formats Direction
ar-EG, ar_EG, AR-eg en, ar ar ar-EG right-to-left
pt-BR en, pt, pt-PT pt pt-BR left-to-right
pt-BR en, pt, pt-BR pt-BR pt-BR left-to-right
ar en, ar-EG en (source) en left-to-right
kk-KZ ru, kk kk kk-KZ left-to-right
he-IL en, he he he-IL right-to-left
en-GB en, ar en (source, and a match) en-GB left-to-right
de-DE, empty, ar--EG en, ar en (source) en left-to-right

A document without localization names no language: there is nothing to look up, and formats and direction follow the locale the host passed.

A regional translation without its base language#

Rule 3 has a consequence for the person who translates. A document that has ar-EG but no ar shows Arabic to devices set to Egypt only; a device set to ar-SA, or to plain ar, gets the source language. The builder says so in its Languages section and, earlier, next to the field where the tag is typed. It is a warning, not an error: the document stays valid, and a translation for one region only can be a deliberate choice.

To cover every region of a language, add the base language (ar) and keep the regional one for the texts that differ.

Known limit#

zh-TW falls to zh (usually Simplified Chinese), although Taiwan writes Traditional. A correct answer needs script data (CLDR "likely subtags"), which is not the same on every platform, so the contract does not use it. Give the document a zh-TW translation if you serve Taiwan.

Test your renderer against the shared table#

fixtures/locale/locale-cases.json lists the cases — requested, available, source and the expected translation, formatting, directionLocale, direction. The builder, the React renderer and the Flutter renderer are checked against this one file.

Choosing the language in Flutter#

You pass the locale as the locale argument of renderDocument. Pass the device locale as the platform gives it; the renderer applies the rule above.

When the device lists several preferred languages, let the document walk the list:

    // The document matches the device languages against its own list:
    // `ar-EG` finds `ar`. A user's choice wins over the device.
    final locale =
        widget.locale ??
        document.localization?.localeFor(WidgetsBinding.instance.platformDispatcher.locales);

document.localization is a DocumentLocalization? — null for a one-language document. localeFor walks the device's preferred languages in order and stops at the first one the document has a language for by the rule above. It returns that requested locale in canonical spelling, not the document's own tag: for a device set to pt-BR and a document with pt the result is pt-BR. This is deliberate — the value goes to renderDocument, where the requested locale also formats dates. If nothing matches it returns defaultLocale.

Which translation is shown is a separate question, answered by shownLocale or, in full, by localeResolution:

void describeLocale(UiDocument document, String deviceLocale) {
  final answer = document.localeResolution(deviceLocale);
  debugPrint('text: ${answer.translation}'); // ar — the document's own tag
  debugPrint('formats: ${answer.formatting}'); // ar-EG — the requested locale
  debugPrint('direction: ${directionOfLocale(answer.directionLocale)}'); // TextDirection.rtl
  debugPrint('found: ${answer.matched}');
}

Members you may need:

Member Returns
document.locales The languages a user could switch between. Empty when there are fewer than two.
document.localization?.localeFor(deviceLocales) The locale to pass to renderDocument: the first device locale the document has a language for, as the requested canonical tag (ar-EG); otherwise defaultLocale.
document.shownLocale(requested) The language the document will actually be shown in — the document's own tag found for requested (ar for ar-EG), otherwise defaultLocale. This is the key under strings. null when the document has no localization.
document.localeResolution(requested) A LocaleResolution: translation (the document's tag), formatting (the locale dates are formatted in), directionLocale (the locale whose script decides the direction) and matched.
document.localization?.has(locale) Whether the document declares exactly this tag. No matching is applied.

What each locale value does:

You pass Text shown
a language from locales, or a longer tag of it (ar-EG for ar) its translations; the source text where one is missing
defaultLocale, or a longer tag of it the source text
a language the document has no match for the source text
null the source text

Changing the language does not reset the screen stack. The language is not part of the document.

Choosing the language in React#

Pass the locale as the locale prop of <HotlayoutScreen> — navigator.language, or the Accept-Language winner when you render on a server. The renderer applies the rule above and sets dir and lang on its own root element only. When your own page has to speak the language the screen is actually shown in, ask resolveLocale(requested, available, source) — the same function the component uses. The example is in the React quickstart. The React renderer has one text of its own — the accessible name of the back arrow: labels={{ back: '…' }}.

The renderer's own words#

Four short texts do not come from the document, so its translations cannot cover them: the accessibility label of the back arrow in an AppBar, the caption under an image that failed to load, and the two notices shown in place of a document this renderer cannot read: updateRequired for a document of a newer major version (an app update cures it) and screenUnavailable for an older or unreadable one (the user can do nothing, so it gives no advice). They are English by default. Pass them in the language of your app as the labels argument of renderDocument:

const germanLabels = RendererLabels(
  back: 'Zurück',
  imageUnavailable: 'Bild nicht verfügbar',
  updateRequired: 'Bitte aktualisieren Sie die App, um diesen Bildschirm zu sehen.',
  screenUnavailable: 'Dieser Bildschirm ist gerade nicht verfügbar.',
);

Writing direction#

No list of right-to-left languages#

Neither the contract nor the renderers keep a list of RTL languages. The answer comes from CLDR, the Unicode locale data:

Platform Source
Flutter Bidi.isRtlLanguage from the intl package
Web new Intl.Locale(locale).textInfo.direction

Both read the same data, so two renderers cannot disagree about a language. A hand-written list would have to be kept identical in every renderer and would drift silently on the first language someone forgot.

Direction is a property of the script, not of the language. Kurdish written in Latin script (ku) is left-to-right; Kurdish in Arabic script (ckb) is right-to-left. A list of languages cannot express that.

In the Flutter renderer the functions are directionOfLocale(String? locale), which returns a TextDirection, and isRtlLocale(String? locale). Both are exported from package:hotlayout_flutter/hotlayout_flutter.dart. An unknown, empty or missing tag gives left-to-right. You need them only for your own widgets around the document: the document sets its own direction.

Two independent directions#

Surface Direction follows
Your app's own interface the language of your app
The document the language the document is shown in

An Arabic document inside an English app is drawn right-to-left; the rest of your app stays as it is.

The Flutter renderer wraps the whole document — every screen and every modal layer — in one Directionality. There is nothing to set in your app. Its direction is that of the language the document is shown in (LocaleResolution.directionLocale):

  • a device set to ar-EG on a document with ar gives Arabic text, right-to-left;
  • a right-to-left locale the document has no translation for gives the source language and its direction;
  • with locale: null the document is drawn in the direction of its source language;
  • a document without localization takes the direction of the locale you pass.

Direction is not a field of the document. The same screen must work in both directions, depending on the language it is shown in.

What flips#

Almost everything flips without help. Alignment tokens are logical — start, center, end, never left or right — so existing documents mirror without edits.

Icons are the exception. The contract names the icons that mirror in a right-to-left script, in the x-mirroredIcons block of screen-schema.json (29 names since schema 9.3.0; read the list from the artifact):

"x-mirroredIcons": ["chevron", "arrow-left", "arrow-right", "chevron-left", "chevron-right", "log-in", "log-out", "undo", "redo", "reply", "send", "external-link", "trending-up", "list", "…"]
  • This is a statement about the meaning of the name: this icon points in the direction of reading. The drawing in icons.json is the left-to-right one; the renderer mirrors it. arrow-left points right in Arabic — to the start of the line.
  • Objects and states do not mirror (star, lock, truck, plane), nor do playback controls (play, skip-forward), clockwise rotation (refresh-cw), the numbered list (list-ordered — it has digits in it) and the question mark (circle-help).
  • Mirror exactly once. A drawing made from the icon set is not mirrored by the platform on its own, unlike a font glyph.
  • Mirror by reflection, not by a 180° rotation.

How each renderer does it:

Platform What to do
Flutter The renderer draws icons from the contract's icon set and mirrors the 29 icons listed in x-mirroredIcons itself, once, when the document is shown in a right-to-left language. Nothing to do in your app.
Web (@hotlayout/react) The package draws every icon from the embedded set as a mask and reflects it once, around the center of its box, for the names in x-mirroredIcons when the screen is shown in a right-to-left language. Nothing to do in your app. If you write your own renderer: the drawing is not mirrored by the browser (unlike the character ›, U+203A, which the browser mirrors itself), so apply the flip yourself, exactly once.

Dates and times from data#

A date in your data is an ISO 8601 string. A binding can say what it means to a person:

{ "kind": "binding", "path": "offer.endsAt", "fallback": "—", "format": "date" }
format Shows CLDR skeleton
date year, short month, day yMMMd
time hours and minutes in the convention of the language jm
dateTime both yMMMd + jm

The list is closed. A binding without format shows the value exactly as it arrived.

Which language#

A date follows the same answer as the texts of the screen:

  1. Document with localization, and a translation was found for the requested locale (exactly or by dropping subtags): the requested locale. ar-EG on a document with ar gives Egyptian formats.
  2. Document with localization, no translation found: defaultLocale — the language the texts are shown in.
  3. Document without localization: the locale the host asked for.
  4. The host asked for nothing: the device language.

Step 2 keeps a date from appearing in a language, or in digits, that are nowhere else on the screen.

  • fallback is never formatted. It is text a person typed.
  • format does nothing on numbers and booleans.
  • Dates without format are not detected and reformatted. A renderer that did so would change screens that are already built.

Known differences between renderers#

The contract promises the intent and the set of fields, not identical bytes:

  • CLDR data in a browser and in an app can come from different releases, so a month abbreviation can differ.
  • dateTime is joined differently: a browser writes Oct 31, 2026, 9:59 PM, Flutter writes Oct 31, 2026 9:59 PM.
  • Digits follow the platform's data for the locale you pass: ar-EG writes Arabic-Indic digits, plain ar writes Latin ones on current platforms.

What is the same in every renderer, because the contract fixes it:

  • A string without a time or a zone (2026-10-31) is not a date: it is shown as it came. Send the full form, for example 2026-10-31T23:59:59.000Z. See the backend guide.
  • For a language the platform has no date data for, the value is shown as it came.
  • The calendar is Gregorian in every language.

Sentences and plural words in Flutter#

A template is translated by <screenId>/<nodeId>/<field>.template, each plural form by <screenId>/<nodeId>/<field>.values.<name>.forms.<category>. The translation may reorder the places; a misspelt place stays on screen with its braces. Plural forms follow the language they are written in: if forms.other is translated, the translated forms and that language's rules are used; otherwise the source forms with the source language's rules — forms of two languages are never mixed. zero, one, two are CLDR categories, not the numbers 0, 1, 2 (English 0 is other). A missing plural form is always other, never a neighbouring form. When a value has no data and its fallback text is a plain decimal number (0, -2, 1.5), the word agrees with that number; with any other fallback (—) the word is other.

Numbers, money and days use the formatting locale (ar-EG digits next to ar text); a value without format is shown as it came. In Flutter the characters come from the intl package's CLDR data: plain ar gives Latin digits, ar-EG Arabic-Indic. If the platform has no number data for the formatting locale, numbers and money are shown as they came and "days left" in plain digits without separators. The same holds for dates: with no date data for the locale, the value is shown as it came. Dates always use the Gregorian calendar. A fraction picks its plural form by the CLDR rules with fraction digits (fr 1.5 is one, en 1.5 is other).

A sign at the start of a translated right-to-left sentence (−20%, +100 نقطة) is placed by the bidirectional algorithm, which can put it after the number. The renderers add nothing; if the place matters, the translator writes a direction mark (U+200E) before the sign.

Icons beside text#

iconPosition is logical: start is before the text in reading direction, so in a right-to-left language it is on the right. The renderers swap the sides by the direction of the document; directional icons (arrows, chevrons) are also mirrored, objects (a gift, a clock) are only moved. Translate the label of an icon button by its place key (<screenId>/<nodeId>/label) — it is what a screen-reader user hears. An icon button left without a label is named by its icon in English ("chevron left"), in every language.

Checklist#

  • You pass a locale to renderDocument: the device locale as it is, the result of localeFor, or the user's choice.
  • A device locale with a region (ar-EG) shows the document's ar translation, right-to-left.
  • A language your document lacks shows the source language, not an empty screen.
  • A screen in a right-to-left language is laid out right-to-left and the chevron points the other way.
  • A bound date with format appears in the language of the screen and in the device's time zone.
  • Your backend sends dates with time and zone.