Daily Tips - i18n in Dash Pages and RTL Survival Guide 🌐

Hi there,

Adding internationalization to a web app sounds like one of those “we’ll get to it later” features — until a stakeholder asks for Arabic support on the same day, and suddenly you’re Googling “RTL CSS” while questioning your life choices.

This post/trick-tips walks through a practical i18n architecture — language detection, persistence, per-page translations, and full RTL support. No third-party libraries, no external service dependencies.


The Big Picture

  Browser
     │
     ├── localStorage("lang-storage")   ← survives refreshes
     ├── navigator.language             ← first-visit auto-detect
     │
     ▼
  dcc.Store(id="language")             ← the one source of truth
     │
     ├── routing_callback_inputs ──→ auto-injected into every page's layout()
     │                                     │
     │                                     ├── TRANSLATIONS dict lookup
     │                                     └── page_wrapper(content, language)
     │                                                │
     ├── nav callbacks (update_nav_labels)            ├── dir="rtl" / "ltr"
     │                                                └── textAlign
     └── clientside callbacks

One dcc.Store to rule them all. Language touches four layers: persistence, routing, page content, and layout direction. Let’s break each one down.


Trick 1: routing_callback_inputs — the magic that wires everything together

This is the foundation. Without it, every page has to manually figure out what language the user picked. With it, Dash does the plumbing for you.

# app.py (or wherever you create your Dash instance)
from dash import DashProxy, Input

app = DashProxy(
    use_pages=True,
    pages_folder="pages",
    routing_callback_inputs={
        "language": Input("language", "data"),  # 👈 the magic
    },
)

What this does: Dash Pages automatically grabs the value of dcc.Store(id="language") and passes it as the language keyword argument to every page’s layout() function. Zero boilerplate per page.

Your page signatures become:

# pages/dashboard.py
def layout(language: str = "en", **_kwargs):
    t = get_translations(language)
    return html.H1(t["title"])

Why **_kwargs? routing_callback_inputs can inject more than just language — theme, user_id, whatever you add later. **_kwargs is your polite way of saying “I’ll take what I need, ignore the rest.” Without it, adding a new routing input breaks every page signature. Ask me how I know.


Trick 2: The TRANSLATIONS Dictionary — one dict per page, no external dependencies

No gettext. No .po files. No third-party library. Just a Python dictionary where the outer key is the language code and the inner keys are semantic translation keys. Simple, readable, and surprisingly hard to mess up.

# pages/dashboard.py
TRANSLATIONS = {
    "en": {
        "title": "Hello World",
        "description": "This is a demo page.",
        "btn_submit": "Submit",
        "summary": "Total: {count} items",
    },
    "fr": {
        "title": "Bonjour le monde",
        "description": "Ceci est une page de démonstration.",
        "btn_submit": "Soumettre",
        "summary": "Total : {count} éléments",
    },
    "de": {
        "title": "Hallo Welt",
        "description": "Dies ist eine Demo-Seite.",
        "btn_submit": "Absenden",
        "summary": "Gesamt: {count} Elemente",
    },
    "ja": {
        "title": "こんにちは世界",
        "description": "これはデモページです。",
        "btn_submit": "送信",
        "summary": "合計: {count} 件",
    },
    "zh": {
        "title": "你好世界",
        "description": "这是一个演示页面。",
        "btn_submit": "提交",
        "summary": "总计: {count} 项",
    },
    "ar": {
        "title": "مرحباً بالعالم",
        "description": "هذه صفحة تجريبية.",
        "btn_submit": "إرسال",
        "summary": "المجموع: {count} عنصر",
    },
    "he": {
        "title": "שלום עולם",
        "description": "זהו דף הדגמה.",
        "btn_submit": "שלח",
        "summary": "סה״כ: {count} פריטים",
    },
}

DEFAULT_LANGUAGE = "en"

def get_translations(language: str) -> dict:
    """Safe lookup — missing language falls back to English."""
    return TRANSLATIONS.get(language, TRANSLATIONS[DEFAULT_LANGUAGE])

Usage is dead simple:

def layout(language: str = "en", **_kwargs):
    t = get_translations(language)
    return html.Div([
        html.H2(t["title"]),
        html.P(t["description"]),
        html.Button(t["btn_submit"], id="submit-btn"),
    ])

Parameterized strings use str.format():

t = get_translations(language)
label = t["summary"].format(count=42)
# en: "Total: 42 items"
# ar: "المجموع: 42 عنصر"

Why per-page dicts instead of a global one? Because pages come and go. A monorepo of translations becomes a merge-conflict nightmare. Each page owns its strings. When you delete a page, you delete its translations too — no orphaned keys. Clean.


Trick 3: Multilingual Page Names — the title bar speaks your language

register_page(name=...) accepts a dictionary. That means page_registry carries every page name in every language, and your navigation layer just picks the right one at render time.

register_page(
    __name__,
    path="/economic-report",
    name={
        "en": "Economic Report",
        "fr": "Rapport Économique",
        "de": "Wirtschaftsbericht",
        "ar": "تقرير اقتصادي",
        "he": "דוח כלכלי",
        "ja": "経済レポート",
    },
)

The shared navigation code resolves it:

# navigation.py (or wherever your layout shell lives)
def get_page_name(path, language="en"):
    if not path:
        return ""
    for page in page_registry.values():
        pp = page.get("path", "")
        if pp == path:
            return _localized_name(page.get("name"), language) or page.get("relative_path", path)
    return path

def _localized_name(name, language="en"):
    if isinstance(name, dict):
        return name.get(language, name.get("en", str(name)))
    return name or ""

Wire it to a callback with Input("language", "data") and the page title in your header updates the moment the user switches languages. No page reload required.


Trick 4: RTL Layout — the page_wrapper that saves your sanity

You know what? Some of the world’s most widely spoken languages — Arabic (370+ million speakers), Hebrew, Persian, Urdu — are written right-to-left! Their scripts originated long before the printing press standardized Western left-to-right conventions, and they’ve remained RTL through centuries of written tradition. When these users open your dashboard, an LTR layout feels as jarring as reading English text aligned to the right margin. Every element — text, icons, tables, chart legends — sits in the wrong place.

The fix is straightforward: wrap every page in a utility that sets dir="rtl" and flips textAlign when the language demands it. Here’s the page_wrapper — a tiny function that keeps RTL handling in one place so you don’t scatter direction logic across fifty files.

# utils/i18n.py
from dash import html

RTL_LANGUAGES = {"ar", "he"}

def is_rtl(language: str) -> bool:
    return str(language).lower() in RTL_LANGUAGES

def get_direction(language: str) -> str:
    return "rtl" if is_rtl(language) else "ltr"

def get_text_align(language: str) -> str:
    return "right" if get_direction(language) == "rtl" else "left"

def page_wrapper(content, language: str = "en"):
    """Wrap page content with dir and text-align for RTL/LTR."""
    direction = get_direction(language)
    return html.Div(
        content,
        dir=direction,
        style={"direction": direction, "textAlign": get_text_align(language)},
    )

Every page ends with it:

def layout(language: str = "en", **_kwargs):
    t = get_translations(language)
    container = html.Div([
        html.H2(t["title"]),
        # ... all your content
    ])
    return page_wrapper(container, language)  # 👈 never skip this

What dir="rtl" gives you for free:

  • Text flows right-to-left
  • Flex and Grid item order reverses
  • Table column order reverses
  • Punctuation repositions correctly
Language group dir direction textAlign
en, fr, de, ja, ko ltr ltr left
ar, he rtl rtl right

:warning: Watch out: margin-left: 8px in your CSS does not automatically become margin-right under RTL. The browser doesn’t rewrite your styles. Use CSS logical properties (margin-inline-start, margin-inline-end) or component props that natively respect direction.


Trick 5: Browser Language Auto-Detection — meet users where they are

First-time visitors shouldn’t see English if their browser says fr-FR. A clientside callback reads navigator.language and maps it to a supported code:

// assets/js/callbacks.js
initLanguage: (pathname, langStorage) => {
    // If user picked a language before, honor it
    if (langStorage !== null && langStorage !== undefined) {
        return langStorage;
    }
    // Otherwise sniff the browser
    const browserLang = navigator.language || 'en';
    const langMap = {
        'fr': 'fr', 'fr-FR': 'fr',
        'de': 'de', 'de-DE': 'de',
        'ja': 'ja', 'ko': 'ko',
        'ar': 'ar', 'ar-SA': 'ar',
        'he': 'he', 'he-IL': 'he',
        'en': 'en', 'en-US': 'en',
    };
    return langMap[browserLang] || 'en';
},

The Python side:

from dash import ClientsideFunction, Input, Output, State, clientside_callback

clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="initLanguage"),
    Output("language", "data"),
    Input("url", "pathname"),
    State("lang-storage", "data"),
)

Logic flow: localStorage has a value? → use it. Otherwise? → navigator.language → mapped to a supported key → written into the central Store. Done.


Trick 6: The Language Picker — pattern-matching callbacks

A dropdown with a globe icon. Each option uses Dash’s pattern-matching ID system so a single callback handles every language:

html.Div([
    html.Button("🌐", id="lang-btn"),
    html.Div([
        html.Div("🇬🇧 English",   id={"type": "lang-option", "index": "en"}, n_clicks=0),
        html.Div("🇫🇷 Français",  id={"type": "lang-option", "index": "fr"}, n_clicks=0),
        html.Div("🇩🇪 Deutsch",   id={"type": "lang-option", "index": "de"}, n_clicks=0),
        html.Div("🇸🇦 العربية",   id={"type": "lang-option", "index": "ar"}, n_clicks=0),
        html.Div("🇮🇱 עברית",    id={"type": "lang-option", "index": "he"}, n_clicks=0),
    ], id="lang-dropdown"),
], id="language-menu")

The callback uses ALL to match every menu item at once:

from dash import ALL, Input, Output, callback, ctx, no_update

@callback(
    Output("language", "data", allow_duplicate=True),
    Input({"type": "lang-option", "index": ALL}, "n_clicks"),
    prevent_initial_call=True,
)
def update_language_from_menu(n_clicks):
    if not ctx.triggered_id or not any(n_clicks):
        return no_update
    # triggered_id is {"type": "lang-option", "index": "fr"} — grab the index
    return ctx.triggered_id["index"]

allow_duplicate=True is critical here. The language Store is written by initLanguage, saveLanguageChoice, and this callback. Without allow_duplicate, Dash will refuse to let multiple callbacks target the same output.


Trick 7: Persistence — so the user doesn’t have to re-select every visit

Local storage backed by dcc.Store with storage_type="local":

// assets/js/callbacks.js
saveLanguageChoice: (lang) => lang,  // 👈 that's it. the Store handles serialization.
clientside_callback(
    ClientsideFunction(namespace="clientside", function_name="saveLanguageChoice"),
    Output("lang-storage", "data"),
    Input("language", "data"),
    prevent_initial_call=True,
)

Complete lifecycle in three acts:

Act 1 — First visit
  initLanguage()
    → lang-storage is empty → navigator.language → map to supported code
    → writes "language" Store

Act 2 — User switches language
  update_language_from_menu()
    → updates "language" Store
    → saveLanguageChoice() syncs to lang-storage (localStorage)
    → all callbacks with Input("language", "data") re-fire

Act 3 — User refreshes the page
  initLanguage()
    → lang-storage has a value → restore it immediately
    → no flash of wrong language, no flicker

Trick 8: The Navigation Bar Needs Translation Too

“Home”, “User”, “Logout” — these don’t translate themselves. A shared dictionary and a single callback handle them:

# navigation.py (or wherever your layout shell lives)
NAV_TRANSLATIONS = {
    "en": {"home": "Home", "user": "User", "logout": "Logout", "content": "Content"},
    "fr": {"home": "Accueil", "user": "Utilisateur", "logout": "Déconnexion", "content": "Contenu"},
    "de": {"home": "Startseite", "user": "Benutzer", "logout": "Abmelden", "content": "Inhalt"},
    "ar": {"home": "الصفحة الرئيسية", "user": "المستخدم", "logout": "تسجيل خروج", "content": "المحتوى"},
    "he": {"home": "בית", "user": "משתמש", "logout": "התנתק", "content": "תוכן"},
}

@callback(
    Output(overview, "children"),
    Output(logout_btn, "children"),
    Output(drawer, "title"),
    Input("language", "data"),
)
def update_nav_labels(language):
    labels = NAV_TRANSLATIONS.get(language, NAV_TRANSLATIONS["en"])
    return labels["home"], labels["logout"], labels["content"]

If you have a clientside updateUser callback that also renders a username label, keep a parallel copy of the user-label translations in JavaScript. Otherwise you’ll get “Utilisateur” in the server-rendered nav bar and “User” in the clientside-rendered greeting. Nothing screams “unfinished” louder than inconsistent translations.


Complete Page Template — copy, paste, adapt

"""My page with full i18n support."""

from dash import register_page, html, dcc
from utils.i18n import page_wrapper

# ── 1. Register with multilingual names ──
register_page(
    __name__,
    path="/my-page",
    name={
        "en": "My Page",
        "fr": "Ma Page",
        "de": "Meine Seite",
        "ar": "صفحتي",
        "he": "הדף שלי",
    },
    is_public=False,
)

DEFAULT_LANGUAGE = "en"

# ── 2. Translation dictionary ──
TRANSLATIONS = {
    "en": {
        "title": "My Dashboard",
        "description": "Here is a chart.",
        "btn_refresh": "Refresh",
        "insight": "Revenue is up {pct}% quarter over quarter.",
    },
    "fr": {
        "title": "Mon Tableau de Bord",
        "description": "Voici un graphique.",
        "btn_refresh": "Actualiser",
        "insight": "Le chiffre d'affaires a augmenté de {pct} %.",
    },
    "de": {
        "title": "Mein Dashboard",
        "description": "Hier ist ein Diagramm.",
        "btn_refresh": "Aktualisieren",
        "insight": "Der Umsatz ist im Vergleich zum Vorquartal um {pct}% gestiegen.",
    },
    "ar": {
        "title": "لوحة القيادة",
        "description": "إليك رسمًا بيانيًا.",
        "btn_refresh": "تحديث",
        "insight": "ارتفعت الإيرادات بنسبة {pct}%.",
    },
}

def get_translations(language: str) -> dict:
    return TRANSLATIONS.get(language, TRANSLATIONS[DEFAULT_LANGUAGE])

# ── 3. Layout — language injected by routing_callback_inputs ──
def layout(language: str = DEFAULT_LANGUAGE, **_kwargs):
    t = get_translations(language)

    container = html.Div([
        html.H2(t["title"]),
        html.P(t["description"]),
        html.Button(t["btn_refresh"], id="refresh-btn"),
        html.Div(
            t["insight"].format(pct=12.5),
            style={"color": "green", "marginTop": "1rem"},
        ),
    ])

    # ── 4. Always wrap with page_wrapper ──
    return page_wrapper(container, language)

The Hall of Shame: Common Pitfalls

1. Forgetting **_kwargs

# ❌ Adding a new routing_callback_input breaks every page
def layout(language="en"):
    ...

# ✅ Future-proof
def layout(language="en", **_kwargs):
    ...

2. Hardcoded directional CSS

/* ❌ This margin does not flip under RTL */
.my-card { margin-left: 16px; }

/* ✅ CSS logical properties respect writing direction */
.my-card { margin-inline-start: 16px; }

3. Mixed language key conventions

# ❌ "en" in one page, "en-US" in another — they'll never match
TRANSLATIONS = {"en": {...}}     # page A
TRANSLATIONS = {"en-US": {...}}  # page B

# ✅ Pick one convention and stick with it everywhere
TRANSLATIONS = {"en": {...}, "de": {...}, "ja": {...}}

4. Untranslated chart labels

# ❌ English hardcoded into the figure
fig.update_layout(title="GDP vs Life Expectancy")

# ✅ Pull from your translation dict
t = get_translations(language)
fig.update_layout(title=t["chart_title"], xaxis_title=t["chart_x_axis"])

5. Chart doesn’t update on language switch

Your chart callback needs Input("language", "data") in its dependency list. Without it, the figure is built once and never rebuilt. The title stays in English while everything around it switches to Arabic.


RTL Quick Reference

from utils.i18n import is_rtl, get_direction, get_text_align

# Conditional layout based on direction
if is_rtl(language):
    chart_margin = dict(l=80, r=20)   # legend belongs on the left in RTL
else:
    chart_margin = dict(l=20, r=80)   # legend on the right in LTR

# Inline style dict
style = {"direction": get_direction(language), "textAlign": get_text_align(language)}

Bonus: Locale-Aware Number and Date Formatting

Translating strings is only half the battle. Numbers, dates, and currencies also need to match the user’s locale. Arabic-speaking users in Saudi Arabia expect Eastern Arabic numerals (٠١٢٣٤٥٦٧٨٩); users in Morocco often expect Western numerals. Hebrew speakers expect right-to-left dates. A chart showing $1,234.56 to a German user should read 1.234,56 €.

The browser’s Intl API handles this natively — zero dependencies, just like the rest of this architecture:

# Used in a clientside callback or inline <script>
const formatNumber = (value, language) =>
    new Intl.NumberFormat(language, { style: 'decimal' }).format(value);

const formatCurrency = (value, language, currency = 'USD') =>
    new Intl.NumberFormat(language, { style: 'currency', currency }).format(value);

const formatDate = (isoString, language) =>
    new Intl.DateTimeFormat(language, { dateStyle: 'long' }).format(new Date(isoString));
// Results for different locales:
formatNumber(1234567.89, 'en');  // "1,234,567.89"
formatNumber(1234567.89, 'de');  // "1.234.567,89"
formatNumber(1234567.89, 'ar');  // "١٬٢٣٤٬٥٦٧٫٨٩"

formatDate('2026-06-03', 'en');  // "June 3, 2026"
formatDate('2026-06-03', 'he');  // "3 ביוני 2026"
formatDate('2026-06-03', 'ja');  // "2026年6月3日"

Where to use this: KPI cards, data table cells, axis tick labels on charts, and anywhere raw numbers appear. Wire a clientside callback that listens to Input("language", "data"), reformats all numeric content, and updates the DOM. The Intl calls are fast enough to run on every language switch without noticeable latency.


Data Flow, Visualized

navigator.language
      │
      ▼
localStorage("lang-storage")
      │
      ▼
dcc.Store(id="language")  ←── language picker ←── user interaction
      │
      ├──→ routing_callback_inputs ──→ page.layout(language=...)
      │                                      │
      │                                      ├── get_translations(language)
      │                                      ├── render translated UI
      │                                      └── page_wrapper(content, language)
      │                                                 │
      ├──→ nav callbacks                                ├── dir="rtl" / "ltr"
      │                                                 └── textAlign
      └──→ clientside callbacks

demo


Parting Advice

  1. Don’t pipe your UI through Google Translate at runtime. “GDP per Capita” becomes “国内総生産 per キャピタ” and your Japanese users will weep. Hand-maintained dictionaries are the price of quality.

  2. RTL is not a CSS one-liner. Chart orientations, number formatting rules (Arabic uses different numerals in different regions), icon placement — all need review. Find a native speaker before you ship. Your future self will thank you.

  3. Name your keys like you’ll still understand them next year. "btn_submit" tells a story. "label_12" tells a mystery.

  4. Adding a new language is a four-step checklist:

    • Add entries to every TRANSLATIONS dict in every page
    • Add entries to NAV_TRANSLATIONS in your layout shell
    • Add a menu item to the language picker
    • If it’s RTL, add its code to RTL_LANGUAGES in your i18n utility module
  5. Once the framework is in place, adding new languages becomes an AI-friendly task. The pattern is so mechanical — take an existing language’s translation dict, copy it, replace the values — that you can hand a page file to any LLM and say “add German and Turkish translations to this TRANSLATIONS dict, following the exact same structure.” The model sees the dict shape, understands the keys from context, and fills in the translations. You review for accuracy, paste it in, and you’ve shipped a new language in minutes. The four steps above are all deterministic; none of them require creative decisions. That’s exactly the kind of work AI excels at, and exactly the kind of work you don’t want to do by hand at 11 PM.

That’s it. Four steps, zero external dependencies, and an architecture that scales from 2 languages to 20 without changing the pattern — and once it’s set up, you can offload most of the grunt work to an LLM.


Happy i18n-ing. Your Arabic users are going to love the RTL support they didn’t expect. :rocket:



Hope this helps you. XD

Keywords: Pages, i18n, internationalization, RTL, right-to-left, AI-friendly, Arabic, Hebrew, localization, locale formatting

Daily Tips - If I have a Bunch of Triggers

Wow - this is a great article! There are lots of useful tips here, and I also appreciate that you shared the overall approach and project organization.

Here is more info for people using Dash Mantine Components and/or Dash Ag Grid:

Dash Mantine Components
DMC has built-in RTL support. Wrap your app in dmc.DirectionProvider and Mantine components will automatically adapt to right-to-left layouts. The direction can be toggled dynamically in a callback.

app.layout = dmc.DirectionProvider(
    dmc.MantineProvider(
        [...]
    ),
    direction="rtl",
)

Live demo and docs: https://www.dash-mantine-components.com/rtl

Dash AG Grid
For DAG, set enableRtl=True in dashGridOptions to flip grid layout, column ordering, and scrollbars. You can also use the localeText dictionary to translate built-in UI elements such as menus, filters, and status messages:

dag.AgGrid(
    dashGridOptions={
        "enableRtl": True,
        "localeText": {
            "filterOoo": 'تصفية...',
            "equals": 'يساوي',
            "notEqual": 'لا يساوي',
              #others...
            
        },
    }
)

AG Grid localization docs: https://www.ag-grid.com/react-data-grid/localisation/