defuss-shadcn / Guides / translation
Translation
defuss-i18n translates the markup you already have. Each region of a component keeps one <template> per language next to it; switching the language morphs the region into that template through the same df$ the components run on, so focus, typed input and open dialogs survive. Numbers, dates and plurals come from the browser's Intl. No string tables, no build step - every Application Scaffold and every Website template uses it.
On this page (8)
§Load it
The library ships a browser build that imports nothing. Use dist/core.js next to defuss-shadcn: it takes the df$ you hand it (createDomI18n(df$)) and leaves the global alone - dist/all.min.js would install itself as df$.i18n, and in this system only core.js extends df$. With a bundler, install the package and import the same names.
<script type="module"> import { createDomI18n, createI18n } from 'https://cdn.jsdelivr.net/npm/defuss-i18n@0.2.0/dist/core.js'; const locale = createI18n({ locale: 'en', fallback: ['en'] }); const { bind, mount } = createDomI18n(df$); // the components' own runtime does the DOM work mount(document, locale); // every [data-i18n-component] on the page locale.setLocale('de'); // synchronous: German when this returns</script>// bun add defuss-i18nimport { createDomI18n, createI18n } from 'defuss-i18n/core';const locale = createI18n({ locale: 'en' });const { bind } = createDomI18n(df$);§The markup is the catalog
A component root carries data-i18n-component. Inside it, data-i18n-target="name" marks a region and <template data-i18n-for="name" data-i18n-locale="de"> holds that region in one language - beside the region, never inside it. The live markup is the default language, so the page reads right before any script runs. Attributes outside a region get variants: data-i18n-aria-label-de, data-i18n-placeholder-de, data-i18n-title-de (16 attributes in all). The example card below switches between English, German and Arabic - Arabic also turns the card right to left, which the components follow through their logical properties.
§A workshop card in three languages
One component root, three regions, one attribute variant. The date, the price and the seat count are values formatted for the language; the count picks the plural - Arabic has six forms. Reserve counts down.
| Markup / call | What it does |
|---|---|
data-i18n-component | The root: everything inside belongs to this component (a nested root starts another one). bind() writes lang and dir on it |
data-i18n-target="title" | A region: on a switch its content becomes the matching template. Keep regions to text and simple inline markup |
<template data-i18n-for data-i18n-locale> | One language of a region. Inert: the browser renders nothing of it and runs nothing in it |
data-i18n-aria-label-de="Sprache" | An attribute variant: the attribute takes the value of the current language |
data-i18n-value="date" | A slot for a value, inserted as plain text - never parsed as HTML |
data-i18n-count="seats" + data-i18n-plural | The value seats (a number) picks the template for Intl.PluralRules' category; other is required per language |
bind(root, locale, { getState, values }) | Binds one root; values() runs on every render with the state getState() reads |
binding.refresh() | Renders again after the state changed - same language |
locale.setLocale(tag) | Switches every bound root, synchronously |
§Numbers, dates and plurals
The controller formats in its current language: formatNumber and formatDate are Intl.NumberFormat and Intl.DateTimeFormat, plural(n) is Intl.PluralRules. Pass them as values and the region shows them; a count stays a number (it chooses the plural) and its formatted twin is a second value. The Admin Dashboard does exactly this for its order and customer counts - 9,842 in English, 9.842 in German - and for the bulk bar, whose "1 order selected" / "1,284 orders selected" is a plural over the data grid's selection.
let selected = 0;const texts = bind(app, locale, { getState: () => selected, values: (n) => ({ selected: n, selectedLabel: locale.formatNumber(n) }),});grid.store.subscribe(({ config }) => { selected = config.selected.length; texts.refresh(); // the plural and the number follow});§Translating shipped components
A component's script owns its markup's state - a tab's aria-selected, a link's aria-current, a dialog's open. A region is replaced by its template on every switch, so it must not hold that state. The scaffolds follow four rules:
- Regions are the text, not the component. Wrap a sidebar link's label, a tab's label or a dialog title in a target -
<a class="sidebar-link"><i data-lucide="users"></i><span data-i18n-target="nav-customers">Customers</span></a>- never the link, the tab list or the dialog. - Labels the user never sees are attribute variants.
aria-label,placeholder,titleandaltgetdata-i18n-{attribute}-{language}; they need no unique name, so a label repeated in every chat of the Messenger is one pattern. - A stamped
<template>gets no target. A row or page the script clones would copy the target's name, and names are unique per component; attribute variants are fine there - they are re-read on each render. - Text a script writes is the script's. defuss-i18n translates markup, not strings in code: a toast or a generated row stays in the language the script wrote it in. When a script needs a translated string, keep it in the markup as a hidden target and read its text at the moment the script needs it.
Catalogs grow with the app, so each scaffold keeps its templates in one block at the end of its root - one line per region and language, like a translation file. The library's validator finds what a catalog misses; the e2e tests run it over every scaffold:
import { validateI18n } from 'https://cdn.jsdelivr.net/npm/defuss-i18n@0.2.0/dist/core.js';for (const issue of validateI18n(app, { locales: ['en', 'de'] })) console.warn(issue.code, issue.message, issue.element); // e.g. MISSING_LOCALE: Target nav-orders lacks de§Choosing the language
Start in the visitor's language: resolveLocale matches navigator.languages against the languages you ship, walking parent tags (de-CH finds de) without guessing siblings. A Select (or a Settings row, as in the Messenger and the Desktop) switches; subscribe keeps every switch in step and is the place to keep the choice - a df$.store.persisted store - or to set <html lang dir> when the app is the whole page.
import { createI18n, resolveLocale } from 'https://cdn.jsdelivr.net/npm/defuss-i18n@0.2.0/dist/core.js';const kept = df$.store.persisted('app:language', '');const preferred = navigator.languages.map((tag) => resolveLocale(['en', 'de'], tag, [])).find(Boolean);const locale = createI18n({ locale: kept.value || preferred || 'en' });const select = df$('[data-app-locale]');select.val(locale.locale).on('change', () => locale.setLocale(select.val()));locale.subscribe(({ locale: tag, direction }) => { kept.set(tag); select.val(tag); document.documentElement.lang = tag; document.documentElement.dir = direction;});§Loading languages on demand
Ship the default language in the page and fetch the others when they are picked. loadLocale(tag, loader, commit) lets only the newest request commit: the loader fetches and parses without touching the page, the synchronous commit appends the templates and calls rescan(), then the language is published. Append parsed nodes, not markup strings - a template written through df$().html() loses its content.
const loaded = new Set(['en']);async function switchTo(tag) { if (loaded.has(tag)) return locale.setLocale(tag); const result = await locale.loadLocale( tag, async (canonical, signal) => { const html = await (await fetch(`/i18n/${canonical}/app.html`, { signal })).text(); return new DOMParser().parseFromString(`<body>${html}`, 'text/html').body.children; }, (templates) => { app.append(...templates); // beside the regions texts.rescan(); // adopt them before the language is published loaded.add(tag); }, ); return result.status; // 'superseded' when another language was picked meanwhile}§Where it is used
Every Application Scaffold and every Website template binds its root once and switches between English and German; the routes of each are in Client-side Routing. The templates show the values: the Shop formats every price as a currency (€88.90, 88,90 €), the Comments say how long ago with Intl.RelativeTimeFormat (2 hours ago, vor 2 Stunden), the News date its masthead with formatDate, the Search counts its results.
| Scaffold | The switch | What it translates |
|---|---|---|
| Admin Dashboard | Top bar | Navigation, views, palette; counts formatted, the selection a plural |
| Messenger | Settings › App language | Rail, list, panels, info column, every contact label |
| Issue Tracker | Sidebar foot | Navigation, view heads, palette, create dialog |
| Notes & Docs | Sidebar foot | Sidebar, workspace menu, palette, trash |
| Status Page | Header | Header, section heads, legend, history, console tabs |
| Desktop | Settings › Language | Start menu, quick settings, context menus, Settings, window controls |
| Document Editor | Beside Share | Document list, menus, toolbar, dialogs |
Comments, ideas or improvements? Edit this page's source on GitHub