Skip to content
Twilight React Playground
ثيم رائدaren

Translations and right-to-left

Beginner10 min

Show text in the page language with t(), and build layouts that flip correctly between Arabic and English.

A Salla store usually sells in Arabic and English, and often more languages. Two things change with the language: the words, and the direction the page reads in. Arabic runs right to left (RTL), English left to right (LTR).

This page is en and ltr right now. The ar / en pill (top bar, or the ☰ menu on a phone) switches it, and every live demo on the page follows.

Words: t()

Never write "Cart" in a component. Write a key, and t from useTranslation() returns the text for that key in the page language. The texts come from two places: Salla's shared messages, which every store has, and your theme's own locales/*.json files.

app/components/CartTitle.tsx
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';

export function CartTitle({ count }: { count: number }) {
  const { t, locale, direction } = useTranslation();
  return (
    <h1 lang={locale} dir={direction}>
      {t('common.titles.cart', 'Cart')} ({count})
    </h1>
  );
}
t() from useTranslation(), reading Salla's messages and the playground's own locales/*.json for this page's language.Try this: pick common.titles.cart, then set the namespace to theme: only the theme files are searched and the key comes back. Then switch the ar / en pill (top bar, or the ☰ menu on a phone).
Storefront canvas · en · LTR

Hello from the theme's own translations

locale
en
direction
ltr (isRTL: false)
languageName
English
ready
true
Controls
Picks the plural form of playground.items.
The second argument of t(): shown for a missing key.
useTranslation() with no argument, or useTranslation("theme").
What a theme writes
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';

export function Label() {
  const { t } = useTranslation();
  return <span>{t('playground.greeting')}</span>;
}
Your own texts
// locales/en.json
{
  "lookbook": {
    "title": "This season's lookbook"
  }
}

// locales/ar.json
{
  "lookbook": {
    "title": "إطلالات الموسم"
  }
}

// vite.config.ts: twilightReact({ localesDir: './locales' })
// In a component: t('lookbook.title')
Placeholders and plurals
import { formatSallaPlural } from '@salla.sa/twilight-theme-engine/utils';

// Salla's messages use :name placeholders, which t() does not fill in:
t('common.elements.search_about').replace(':word', query); // "Search for (:word)" → "Search for (sofa)"

// …and Salla-style plurals, which t() returns whole:
formatSallaPlural(t('lookbook.items'), 3); // "{0} none|{1} one item|[2,*] :count items" → "3 items"

The language lives in the address

Every page exists once per language: /ar/cart and /en/cart. The first part of the path is the locale, and it decides the language of the whole request, on the server as well as in the browser.

  • An address without a locale gets one: on a store with several languages /cart becomes /ar/cart, and a first segment that is not a supported code is prefixed the same way.
  • A store with a single language has no locale segment: /en/cart becomes /cart.
  • Links from the engine's Link component take a path without the locale, <Link to="/cart">, and add the current one.

Direction: think start and end, not left and right

The engine sets dir="rtl" or dir="ltr" on the page, and the browser flips text, flex rows and grids for you. What it cannot flip is CSS that names a side: margin-left stays on the left in Arabic too.

Below is one cart row. Switch dir to see it in both directions, and switch CSS between physical properties (left, right) and logical ones (start, end).

dir CSS
كرسي جلدتخفيض
التوصيل خلال 3 أيام
228.85 ر.س
cart-row.css (physical)
.cart-row   { border-left: 4px solid; }
.cart-text  { margin-left: 12px; text-align: left; }
.badge      { margin-left: 8px; }
.cart-price { margin-left: auto; }

With physical properties the Arabic row keeps its spacing on the wrong side: the text touches the image, the badge touches the name, the price leaves the far end, and the coloured border sits at the end instead of the start. With logical properties the one stylesheet is right in both directions: inline-start means left in English and right in Arabic.

In engine terms
  • useTranslation(ns?) from @salla.sa/twilight-theme-engine/i18n returns react-i18next's t, i18n and ready, plus locale, direction, isRTL, isLTR and languageName (src/providers/I18nProvider.tsx).
  • One i18next instance per request, with two namespaces: app (Salla's messages, rootBeforeLoad loads them) searched first, then theme (your locales/*.json, from virtual:twilight/theme-translations, merged by TwilightProvider's translations prop). Only the page language is loaded.
  • SUPPORTED_LOCALES lists 39 codes and isLocale(value) checks one; RTL_LOCALES is ar, fa, he and ur. The generated {-$locale} route redirects an unknown segment to /ar/… and removes the segment on a store that is not multilingual.
  • On localhost and the preview host the store's username comes first (/<username>/ar/cart); the router removes it before matching and adds it back to the links it builds, which is why a path is never built from window.location.
  • app/routes/__root.tsx renders <html lang={ctx.locale} dir={ctx.dir}> from getTwilightContext(), so the direction is already right in the server's HTML.
  • Tailwind's rtl: and ltr: variants work too; the engine's own layout uses them (rtl:lg:ml-8 ltr:lg:mr-8).
  • Reference: useTranslation, theme translations, SUPPORTED_LOCALES and isLocale, formatSallaPlural.
Check yourself

Which rule keeps a badge 8px after the product name in both Arabic and English?