This app uses react-i18next for internationalization. All user-facing strings, including native menus, are translated from a single source of truth in JSON translation files.
- react-i18next: Industry-standard React i18n library with excellent TypeScript support
- JSON translation files: Simple, portable format stored in
/locales/ - JavaScript-based native menus: Menus are built from JavaScript (not Rust) to use the same translation system
- RTL support: CSS uses logical properties for automatic RTL layout
/locales/
├── en.json # English (default)
├── ar.json # Arabic (RTL example)
└── [lang].json # Additional languages
/src/i18n/
├── config.ts # i18next configuration
├── i18n.d.ts # TypeScript type definitions
├── language-init.ts # System locale detection
└── index.ts # Exports
Add your string to /locales/en.json:
{
"myFeature.title": "My Feature",
"myFeature.description": "This is my feature description",
"myFeature.button.save": "Save Changes"
}import { useTranslation } from 'react-i18next'
function MyComponent() {
const { t } = useTranslation()
return (
<div>
<h1>{t('myFeature.title')}</h1>
<p>{t('myFeature.description')}</p>
<button>{t('myFeature.button.save')}</button>
</div>
)
}Add the same keys to all other language files (e.g., /locales/ar.json).
Use dot notation to organize keys by feature/component:
| Pattern | Example | Use Case |
|---|---|---|
feature.element |
preferences.title |
Simple feature strings |
feature.section.element |
preferences.general.keyboardShortcuts |
Nested sections |
feature.action.verb |
commands.openPreferences.label |
Action labels |
common.word |
common.enabled |
Shared/reusable strings |
toast.type.key |
toast.success.preferencesSaved |
Toast notifications |
menu.item |
menu.quit |
Native menu items |
- Use camelCase for multi-word segments:
keyboardShortcuts, notkeyboard-shortcuts - Be specific:
preferences.appearance.colorTheme, nottheme - Group related strings: All preference strings under
preferences.* - Use consistent suffixes:
.label,.description,.placeholderfor form elements
Pass dynamic values using double curly braces:
{
"menu.about": "About {{appName}}",
"toast.error.windowCloseFailed": "Failed to close window: {{message}}"
}t('menu.about', { appName: 'My App' })
// Output: "About My App"
t('toast.error.windowCloseFailed', { message: 'Permission denied' })
// Output: "Failed to close window: Permission denied"i18next supports pluralization with _one, _other suffixes:
{
"items.count_one": "{{count}} item",
"items.count_other": "{{count}} items"
}t('items.count', { count: 1 }) // "1 item"
t('items.count', { count: 5 }) // "5 items"Copy /locales/en.json to /locales/[lang].json and translate all strings.
Update /src/i18n/config.ts:
import en from '../../locales/en.json'
import ar from '../../locales/ar.json'
import es from '../../locales/es.json' // NEW
const resources = {
en: { translation: en },
ar: { translation: ar },
es: { translation: es }, // NEW
}If the language is RTL, add it to the rtlLanguages array:
const rtlLanguages = ['ar', 'he', 'fa', 'ur'] // Add your RTL languageThe i18n config automatically updates document.documentElement.dir when the language changes:
// In /src/i18n/config.ts
i18n.on('languageChanged', lng => {
const dir = rtlLanguages.includes(lng) ? 'rtl' : 'ltr'
document.documentElement.dir = dir
document.documentElement.lang = lng
})Use CSS logical properties instead of physical properties for automatic RTL support:
| Physical (avoid) | Logical (use) |
|---|---|
left |
start or inset-inline-start |
right |
end or inset-inline-end |
margin-left |
margin-inline-start or ms-* |
margin-right |
margin-inline-end or me-* |
padding-left |
padding-inline-start or ps-* |
padding-right |
padding-inline-end or pe-* |
text-left |
text-start |
text-right |
text-end |
border-left |
border-s-* or border-inline-start |
border-right |
border-e-* or border-inline-end |
// ❌ BAD: Physical properties break in RTL
<div className="text-left pl-4 mr-2">
// ✅ GOOD: Logical properties work in both LTR and RTL
<div className="text-start ps-4 me-2">Native menus are built from JavaScript to use the same i18n system as React components.
See /src/lib/menu.ts for the menu builder implementation.
import i18n from '@/i18n/config'
export async function buildAppMenu(): Promise<Menu> {
const t = i18n.t.bind(i18n)
const myItem = await MenuItem.new({
id: 'my-action',
text: t('menu.myAction'),
action: handleMyAction,
})
// ... add to submenu
}Menus are automatically rebuilt when the language changes:
// In /src/lib/menu.ts
export function setupMenuLanguageListener(): void {
i18n.on('languageChanged', async () => {
await buildAppMenu()
})
}On app startup, the language is initialized based on:
- User's saved preference (if set in preferences)
- System locale (if we have translations for it)
- English (fallback)
See /src/i18n/language-init.ts for the implementation.
The language selector in Preferences > Appearance allows users to change the language:
import { availableLanguages } from '@/i18n/config'
import { useTranslation } from 'react-i18next'
function LanguageSelector() {
const { i18n } = useTranslation()
const handleChange = async (lang: string) => {
await i18n.changeLanguage(lang)
// Save to preferences...
}
return (
<Select value={i18n.language} onValueChange={handleChange}>
{availableLanguages.map(lang => (
<SelectItem key={lang} value={lang}>
{lang.toUpperCase()}
</SelectItem>
))}
</Select>
)
}The i18n.d.ts file provides type-safe translation keys:
// Type errors if key doesn't exist in en.json
t('nonexistent.key') // TypeScript error
// Autocomplete works for valid keys
t('preferences.title') // ✅ WorksFor non-React contexts (like menu building), import i18n directly:
import i18n from '@/i18n/config'
// Get the t function
const t = i18n.t.bind(i18n)
const text = t('menu.about', { appName: 'My App' })
// Or use i18n directly
const currentLanguage = i18n.language
await i18n.changeLanguage('ar')To test RTL layout:
- Open Preferences > Appearance
- Change language to Arabic (ar)
- Verify layout mirrors correctly
- Check all text alignment uses logical properties