Internationalization
Tldraw's UI supports 49 languages out of the box, including right-to-left languages like Arabic, Hebrew, Farsi, and Urdu. The translation system loads language files on demand, detects the user's browser language, and lets you override any translation string or add custom ones.
Setting the locale
The user's locale is stored in user preferences. By default, tldraw detects the browser's language and selects the closest match from supported languages. The default main menu also includes a language submenu where users can change it.
To set the locale from your application, update the user's locale preference. The UI reads its language from editor.user.getLocale(), so this takes effect immediately:
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw
onMount={(editor) => {
// Change the locale to French
editor.user.updateUserPreferences({ locale: 'fr' })
// Get the current locale
const locale = editor.user.getLocale() // "fr"
}}
/>
</div>
)
}The locale value uses lowercase language codes: 'en', 'fr', 'de', 'ja', 'zh-cn', 'ar', and so on.
The Tldraw component also has a locale prop. Today it only sets the language for the outer translation provider (used by the loading screen); the editor UI itself keeps reading the user preference, so prefer updateUserPreferences to change the visible language.
Automatic detection
When no locale is set, tldraw uses getDefaultTranslationLocale to detect the user's preferred language from the browser:
import { getDefaultTranslationLocale } from 'tldraw'
// Returns 'fr', 'en', 'zh-cn', etc. based on browser settings
const locale = getDefaultTranslationLocale()For each entry in the browser's navigator.languages array, in order, it:
- Tries an exact match against supported languages
- Falls back to a language-only match (e.g.,
'fr-CA'→'fr') - Applies region defaults for Chinese (
'zh'→'zh-cn'), Portuguese ('pt'→'pt-br'), Korean ('ko'→'ko-kr'), and Hindi ('hi'→'hi-in')
The first entry that matches wins. If none match, it defaults to 'en'.
Using translations in components
The useTranslation hook returns a function for looking up translation strings. Unknown keys are returned as-is, so you can pass plain text or your own keys:
import { useTranslation } from 'tldraw'
function CopyButton() {
const msg = useTranslation()
return <button>{msg('action.copy')}</button>
}Pass a second argument to fill in a message's placeholders:
function SelectionStatus({ count }: { count: number }) {
const msg = useTranslation()
return <p>{msg('a11y.multiple-shapes', { num: count })}</p>
}Rendering a message as an element
F renders a message where it belongs in the markup, with its English alongside:
import { F } from 'tldraw'
function CopyButton() {
return (
<button>
<F defaultMessage="Copy" id="action.copy" />
</button>
)
}Where a string is needed instead — an aria-label, a title, a placeholder — pass the declared message to useTranslation. If the current locale has no translation for it yet, its defaultMessage is used:
import { defineMessages, useTranslation } from 'tldraw'
const messages = defineMessages({
close: { id: 'ui.close', defaultMessage: 'Close' },
})
function CloseButton() {
const msg = useTranslation()
return <button aria-label={msg(messages.close)} />
}Both resolve against the same translations, so an override reaches each of them.
Use useCurrentTranslation for the full translation object, including locale and text direction:
import { useCurrentTranslation } from 'tldraw'
function LocaleInfo() {
const translation = useCurrentTranslation()
return (
<div dir={translation.dir}>
<p>Locale: {translation.locale}</p>
<p>Label: {translation.label}</p>
</div>
)
}The TLUiTranslation object contains a locale code (e.g., 'fr'), a label in the native script (e.g., 'Français'), a messages record with all translation strings, and a dir indicating text direction ('ltr' or 'rtl').
Generated message ids
The SDK's own messages have hand-written ids, because an app overrides by id and an id has to survive a reworded English string. An app's own messages needn't: if you use @formatjs/unplugin with an idInterpolationPattern, your bundler derives each id from the message's own text, and you can leave it out.
import { defineMessages, F } from 'tldraw'
const messages = defineMessages({
// No id: the plugin injects one at build.
welcome: { defaultMessage: 'Welcome back' },
})
function Greeting() {
return <F {...messages.welcome} />
}Where no plugin ran either — a unit test, normally — the English stands in as the id. It matches nothing in any catalogue, so the message renders as that English rather than failing.
Overriding translations
Pass translation overrides through the overrides prop on Tldraw:
import { Tldraw } from 'tldraw'
function App() {
return (
<Tldraw
overrides={{
translations: {
en: {
'action.copy': 'Copy to clipboard',
'action.paste': 'Paste from clipboard',
},
fr: {
'action.copy': 'Copier dans le presse-papiers',
},
},
}}
/>
)
}Overrides are merged with the base translations for each language. English is the fallback: any key missing from the target language uses the English string. If you add your own keys (for example a custom tool label), provide at least an en override for them.
Your strings are ICU messages too, so they can pluralise and take placeholders. A { that isn't opening a placeholder has to be escaped as '{', or the message won't parse — a message that fails to parse renders as written rather than disappearing.
Message format
Messages are ICU MessageFormat, so a single message can say the right thing for any number:
{
"comments.replies": "{count, plural, one {# reply} other {# replies}}"
}msg('comments.replies', { count: 1 }) // "1 reply"
msg('comments.replies', { count: 4 }) // "4 replies"This matters beyond English. A language picks between its own plural categories, which is why one string per number doesn't travel: Arabic has six, Polish four, Russian three. ICU asks the locale which category a number falls into, so the translator writes each form their language needs and the right one is chosen at runtime.
select chooses between fixed options rather than numbers:
{
"my.app.greeting": "{gender, select, female {She} male {He} other {They}} shared a file"
}Numbers and dates are formatted for the locale as well:
{
"my.app.fileCount": "{count, number} files",
"my.app.lastEdited": "Edited {when, date, medium}"
}Use # inside a plural to print the number being matched. Outside a plural it's a literal #.
A message's defaultMessage is its English, and it's formatted as English. The translation from the catalog is what gets the user's locale and its plural categories, so a locale missing a string falls back to readable English rather than to English grammar applied to another language.
Translation keys
Translation keys follow a hierarchical naming convention. Common prefixes include action.* for user actions like copy and paste, tool.* for tool names, menu.* for menu labels, style-panel.* for style panel UI, and a11y.* for accessibility announcements.
The TLUiTranslationKey type provides autocomplete for all available keys:
import type { TLUiTranslationKey } from 'tldraw'
const key: TLUiTranslationKey = 'action.copy'Supported languages
Import LANGUAGES from tldraw for the complete list of supported languages:
import { LANGUAGES } from 'tldraw'
function LanguageSelector() {
return (
<select>
{LANGUAGES.map(({ locale, label }) => (
<option key={locale} value={locale}>
{label}
</option>
))}
</select>
)
}Each entry in LANGUAGES is a TLLanguage with a locale code and a label in that language's native script. Translation files are only loaded for locales in this list, so you can't add a new language by supplying a translation file alone.
The supported languages include: English, Spanish, French, German, Italian, Portuguese (Brazilian and European), Dutch, Russian, Polish, Czech, Danish, Finnish, Swedish, Hungarian, Norwegian, Romanian, Turkish, Ukrainian, Greek, Croatian, Slovenian, Arabic, Hebrew, Farsi, Urdu, Hindi, Tamil, Telugu, Malayalam, Kannada, Bengali, Gujarati, Nepali, Marathi, Punjabi, Thai, Khmer, Vietnamese, Indonesian, Malay, Filipino, Somali, Japanese, Korean, Simplified Chinese, Traditional Chinese (Taiwan), Catalan, and Galician.
Right-to-left support
Languages like Arabic, Hebrew, Farsi, and Urdu automatically set dir: 'rtl' in the translation object. The tldraw UI respects this direction. Layout and text alignment mirror automatically. The dir attribute is set on the editor's root container, and the built-in components use CSS logical properties (margin-inline-start, inset-inline-end, and so on) so they flip without per-component code.
When building custom UI components, use the useDirection hook to get the current text direction. It returns 'ltr' or 'rtl' from the active translation context, the same value as useCurrentTranslation().dir:
import { useDirection } from 'tldraw'
function CustomPanel() {
const dir = useDirection()
return <aside dir={dir}>{/* Panel content */}</aside>
}Use useCurrentTranslation instead when you also need the locale, label, or messages from the same render.
Building a language picker
Here's a language picker that updates the user's locale preference:
import { LANGUAGES, useEditor, useValue } from 'tldraw'
function LanguagePicker() {
const editor = useEditor()
const currentLocale = useValue('locale', () => editor.user.getLocale(), [editor])
return (
<select
value={currentLocale}
onChange={(e) => {
editor.user.updateUserPreferences({ locale: e.target.value })
}}
>
{LANGUAGES.map(({ locale, label }) => (
<option key={locale} value={locale}>
{label}
</option>
))}
</select>
)
}The language change applies immediately without a page reload. The preference persists to localStorage and synchronizes across browser tabs.
Translation loading
Translations load asynchronously when the locale changes. The system:
- Fetches the translation JSON file for the selected locale
- Merges it with English translations (ensuring all keys have values)
- Applies any custom overrides
- Updates the translation context
During loading, the UI uses the previous translations to avoid flicker. If loading fails, English remains as the fallback.
To use translations outside the full tldraw UI, wrap your components in TldrawUiTranslationProvider (inside an AssetUrlsProvider).
Related examples
- Custom translations and overrides - Override translation strings and use them in custom UI.