Arabic, English and right to left
Read when: you add a language, a translated string, a value inside Arabic text, a directional icon, or format a number or date. Owns: the two language settings, choosing and storing the language, typed dictionaries shared by page and server, logical classes, mixed-direction text, inputs, mirrored icons, numbers and dates, Arabic type.
TL;DR
- Two settings, both needed:
<UApp :locale>for components,htmlAttrs: { lang, dir }for the page. - Write layout once with logical classes (
ms-* pe-* start-* text-start border-s rounded-e-*). - LTR values inside Arabic text go in
<bdi dir="ltr">; text a user typed getsdir="auto". - Inputs follow the page direction. Never
dir="ltr"on a plain input. - Pointing icons mirror in RTL; object icons never do.
- Latin digits everywhere:
Intllocalear-u-ca-gregory-nu-latnfor Arabic dates,en-USfor numbers.
Two settings, both needed
<!-- app/app.vue -->
<script setup lang="ts">
import { ar, en } from '@nuxt/ui/locale'
useHead({ htmlAttrs: { lang: () => locale.value, dir: () => (isRtl.value ? 'rtl' : 'ltr') } })
</script>
<template>
<UApp :locale="locale === 'ar' ? ar : en" :toaster="{ position: 'bottom-center' }">
<NuxtLayout><NuxtPage /></NuxtLayout>
</UApp>
</template>
- Each Nuxt UI locale carries
dir;UAppuses it for component behaviour (menus, arrow keys in tabs, pickers). It does not set<html dir>(checked 2026-11-03, ui.nuxt.com/docs/getting-started/integrations/i18n/nuxt). <html dir>drives logical CSS, alignment and thertl:variant. Also set the default innuxt.config.tsapp.head.htmlAttrsso the first paint is right before the app boots.- With
@nuxtjs/i18ninstalled,UAppfollows its current locale on its own (Nuxt UI 4.12+, checked 2026-11-03);useLocaleHead()then fills<html lang dir>.
Choosing the language
A dashboard (ssr: false): a module-level ref, saved in localStorage, with a default from settings.
// app/utils/locale.ts
const LOCALE_KEY = 'my-app:locale'
function storedLocale(): Locale | null {
try {
const value = localStorage.getItem(LOCALE_KEY)
return isLocale(value) ? value : null
}
catch {
return null
}
}
export const locale = ref<Locale>(storedLocale() ?? 'ar')
export const isRtl = computed(() => locale.value === 'ar')
/** The language set in the app's settings, used until this browser picks one. */
export function useDefaultLocale(fallback: Locale): void {
if (!storedLocale()) locale.value = fallback
}
export function setLocale(next: Locale): void {
locale.value = next
try { localStorage.setItem(LOCALE_KEY, next) }
catch { /* storage blocked: the default returns next visit */ }
}
export const intlLocale = () => (locale.value === 'ar' ? 'ar-u-ca-gregory-nu-latn' : 'en-US')
Reading storage at module load only works client-side. With SSR, read it in onMounted or use a cookie.
A public site: one URL per language so both are indexed: / and /ar/, guides at /docs/x/ and
/ar/docs/x/. The language comes from the route; the switch is a link, not a toggle.
// app/composables/useLang.ts
export function useLang() {
const route = useRoute()
const lang = computed<Lang>(() => (route.path.startsWith('/ar') ? 'ar' : 'en'))
return {
lang,
text: computed(() => content[lang.value]),
isRtl: computed(() => lang.value === 'ar'),
otherPath: computed(() => (lang.value === 'ar'
? route.path.replace(/^\/ar/, '') || '/'
: `/ar${route.path === '/' ? '/' : route.path}`)),
}
}
pages/index.vue and pages/ar/index.vue both render <LandingPage />. A site with many translated routes is
a better fit for @nuxtjs/i18n.
Dictionaries shared by page and server
No i18n module for a dashboard: two typed files in shared/ and one helper used by both sides, so server
errors arrive already translated.
// shared/utils/i18n.ts
import type ar from '../locales/ar'
export const LOCALES = ['ar', 'en'] as const
export type Locale = (typeof LOCALES)[number]
export type Params = Record<string, string | number>
type Leaf = string | ((params: Params) => string)
type Shape<T> = { [K in keyof T]: T[K] extends Leaf ? Leaf : Shape<T[K]> }
type Paths<T, P extends string = ''> = {
[K in keyof T & string]: T[K] extends Leaf ? `${P}${K}` : Paths<T[K], `${P}${K}.`>
}[keyof T & string]
export type Dictionary = Shape<typeof ar>
export type MessageKey = Paths<typeof ar>
// Each side hands in the data: importing the dictionaries here breaks Nitro's resolver.
let dictionaries: Partial<Record<Locale, Dictionary>> = {}
let source: () => Locale = () => 'ar'
export function setupI18n(options: { dictionaries: Record<Locale, Dictionary>, locale: () => Locale }): void {
dictionaries = options.dictionaries
source = options.locale
}
export const isLocale = (value: unknown): value is Locale => LOCALES.includes(value as Locale)
export function translate(locale: Locale, key: string, params: Params = {}): string | undefined {
let node: unknown = dictionaries[locale]
for (const part of key.split('.')) node = (node as Record<string, unknown> | undefined)?.[part]
if (typeof node === 'function') return node(params)
return typeof node === 'string'
? node.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
: undefined
}
export const t = (key: MessageKey, params?: Params): string => translate(source(), key, params) ?? key
/** For keys built at run time from outside data: `fallback` when the dictionary has no entry. */
export const tOr = (key: string, fallback: string, params?: Params): string => translate(source(), key, params) ?? fallback
// shared/locales/ar.ts: the shape every other language must match
import type { Params } from '../utils/i18n'
export default {
common: { save: 'حفظ', cancel: 'إلغاء', continue: 'متابعة', copied: 'تم النسخ', copyFailed: 'تعذّر النسخ' },
invoices: {
add: 'إضافة فاتورة',
count: ({ n }: Params) => (Number(n) === 1 ? 'فاتورة واحدة' : Number(n) === 2 ? 'فاتورتان' : `${n} فواتير`),
},
}
// shared/locales/en.ts
import type { Dictionary } from '../utils/i18n'
const en: Dictionary = {
common: { save: 'Save', cancel: 'Cancel', continue: 'Continue', copied: 'Copied', copyFailed: 'Could not copy' },
invoices: { add: 'Add invoice', count: ({ n }) => (Number(n) === 1 ? 'One invoice' : `${n} invoices`) },
}
export default en
// app/plugins/i18n.ts
import ar from '#shared/locales/ar'
import en from '#shared/locales/en'
export default defineNuxtPlugin(() => setupI18n({ dictionaries: { ar, en }, locale: () => locale.value }))
// server/utils/locale.ts: the request's X-Locale header, held for the request
import { AsyncLocalStorage } from 'node:async_hooks'
import type { H3Event } from 'h3'
import ar from '#shared/locales/ar'
import en from '#shared/locales/en'
const requestLocale = new AsyncLocalStorage<Locale>()
export const localeOf = (event: H3Event): Locale => {
const header = getHeader(event, 'x-locale')
return isLocale(header) ? header : 'ar'
}
export const withLocale = <T>(event: H3Event, run: () => T): T => requestLocale.run(localeOf(event), run)
setupI18n({ dictionaries: { ar, en }, locale: () => requestLocale.getStore() ?? 'ar' })
en: Dictionarymakestypecheckfail when English misses a key Arabic has.t('invoices.count', { n }): values as{name}in strings; wording that depends on a count is a function. Arabic has six plural categories;new Intl.PluralRules('ar').select(n)returns the one to use.- Keys from outside data use
tOr(`status.${code}`, code)so an unknown code still shows. - After a language switch, refetch data whose text comes from the server (
watch(locale, refresh)).
Layout once, both directions
| Instead of | Write |
|---|---|
ml-2 / mr-2, pl-4 / pr-4 |
ms-2 / me-2, ps-4 / pe-4 |
left-0 / right-0 |
start-0 / end-0 |
text-left / text-right |
text-start / text-end |
border-l / border-r |
border-s / border-e |
rounded-l-lg / rounded-r-lg |
rounded-s-lg / rounded-e-lg |
space-x-2 on a flex row |
gap-2 |
- Flex and grid order follow
dir; never reverse them by hand. rtl:/ltr:variants cover the rare exception. Centring withleft-1/2 -translate-x-1/2is symmetric.- Never branch on direction in script for something CSS can state.
Mixed-direction text
A run in another direction must be isolated, or neutral characters (digits, slashes, brackets, punctuation) jump ends. This is a correctness bug, not style.
- LTR values inside Arabic (phone numbers, IDs, codes, paths, versions, emails, URLs, money):
<bdi dir="ltr">{{ value }}</bdi>, ordir="ltr"on the inline element. - Inside a translated string, wrap the value in the isolates U+2066 … U+2069:
export const ltr = (text: string) => `${text}`. Add them only where a flip shows; they are invisible in source. - Text a user typed or that may be either language:
dir="auto"(textareas, previews, excerpts). A text input showing such content can also take:ui="{ base: '[unicode-bidi:plaintext]' }". - Code and commands:
<pre dir="ltr" class="text-start">.
Inputs
- Inputs follow the page direction: right aligned in Arabic, left in English. Latin typed into an RTL input reads fine.
dir="ltr"on a plain input drags the caret and the placeholder to the wrong edge. Don't.- An input that holds Latin (a host, a JSON body) may take
dir="auto"once it has a value; an empty box has nothing to read and falls back to LTR:const autoDir = (v: unknown) => (v === null || v === undefined || v === '' ? undefined : 'auto'). - An affix with meaning (a
.example.testsuffix, a dial code) makes the whole group LTR. A leading icon is not an affix.
Icons that point somewhere
- Mirror: chevrons, arrows, back and forward, undo and redo, reply, send, external link.
- Never: clocks, phones, checkmarks, play, logos, anything with a digit or letter.
- Lucide icons from
@nuxt/iconrender as<span class="iconify i-lucide:name">; the flip list lives in../assets/main.css. One-off:class="rtl:rotate-180"(symmetric glyphs) orrtl:-scale-x-100(asymmetric). - Use the left chevron for "previous", the right for "next", and let CSS flip both.
- A typed arrow (
→) in copy cannot flip; use an icon.
Numbers, dates and money
// app/utils/format.ts
export const count = (value?: number) => new Intl.NumberFormat('en-US').format(value ?? 0)
export function money(value: number, currency = 'USD'): string {
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(value) }
catch { return `${value.toFixed(2)} ${currency}` }
}
export const compactMoney = (value: number, currency = 'USD') =>
new Intl.NumberFormat('en-US', { style: 'currency', currency, notation: 'compact', maximumFractionDigits: 1 }).format(value)
export const shortDay = (iso: string) =>
new Intl.DateTimeFormat(intlLocale(), { day: 'numeric', month: 'short' }).format(new Date(`${iso}T00:00:00`))
export const timeOfDay = (date: Date | number) =>
new Intl.DateTimeFormat(intlLocale(), { hour: 'numeric', minute: '2-digit' }).format(date)
- Parse a bare date as local midnight (
${iso}T00:00:00), or it shifts a day west of UTC. - Server-rendered dates use a fixed
timeZone('UTC') so server and browser print the same text. new Intl.DisplayNames(intlLocale(), { type: 'region' })names countries in the page language.- Numeric chart axes take
dir="ltr"; a time axis reading left to right is normal in Arabic too.
Arabic type
- Line height about 1.6 or more for Arabic paragraphs (
text-sm/7,text-lg/8). - No
tracking-*and nouppercaseon Arabic: spacing breaks joined letters, and Arabic has no case. A label that is uppercase orfont-monoin English drops both when translated. - Arabic reads larger at the same size; don't shrink it to match English line length.