The widget picks a language for each visitor automatically and can be pinned to one explicitly. It ships copy for the 16 most widely spoken languages in Europe, all inside the single bundle — no extra request, so switching is instant and the widget never renders in the wrong language while a file loads.
Ordered by number of speakers in Europe. The code is the BCP-47 primary subtag; the label is what the in-panel picker shows (each language in its own name).
| # | Code | Language | Picker label |
|---|---|---|---|
| 1 | en |
English | English |
| 2 | ru |
Russian | Русский |
| 3 | de |
German | Deutsch |
| 4 | fr |
French | Français |
| 5 | it |
Italian | Italiano |
| 6 | es |
Spanish | Español |
| 7 | pl |
Polish | Polski |
| 8 | uk |
Ukrainian | Українська |
| 9 | ro |
Romanian | Română |
| 10 | nl |
Dutch | Nederlands |
| 11 | tr |
Turkish | Türkçe |
| 12 | pt |
Portuguese | Português |
| 13 | el |
Greek | Ελληνικά |
| 14 | cs |
Czech | Čeština |
| 15 | sv |
Swedish | Svenska |
| 16 | hu |
Hungarian | Magyar |
en is both the first entry and the fallback: anything unrecognised lands there
rather than showing raw message keys.
First match wins:
data-lang, the lang boot option, or
window.hotlineSettings.lang.branding.defaultLanguage, set per widget instance in
the dashboard.navigator.language.Region subtags are normalised to the base language, so fr-CA → fr,
pt-BR → pt, de-AT → de. Case and _/- separators do not matter
(pt_BR, SV, and el all resolve).
A visitor can still change it themselves with the picker in the panel header; that choice re-renders the UI immediately and does not persist.
Per embed (highest precedence) — useful when the page already knows its audience:
<script
src="https://cdn.hotline.red/hotline.js"
data-public-key="pk_live_..."
data-lang="fr"
type="module"
></script>
Per widget instance — set branding.defaultLanguage in the dashboard's
config editor. Applies everywhere that instance is embedded, unless a page
overrides it as above.
Leave both unset to follow each visitor's own browser, which is the default and usually what you want.
Everything is checked by the type system or a spec, so the compiler and the suite will tell you if you miss a step.
Add the code to the shared enum — packages/contracts/src/enums.ts,
LocaleSchema. This is the single source of truth; the widget's tables are
typed as Record<Locale, …>, so the build fails until step 3 is done.
Add the picker label — apps/widget/src/i18n/index.ts, LOCALES (in
speaker order) and LOCALE_LABELS. Use the endonym — the language's own
name, Français not French — since visitors scan the list for their own.
Add the message table — same file, MESSAGES. Copy the en block and
translate every key. Notes that matter in practice:
… on progress strings (sending, capturing,
connecting, listening) is what marks them as transient, and a spec
asserts it survives translation.poweredBy is followed by the brand name, so it must read as a phrase that
precedes one.you / agent / system label who spoke in a transcript; filing /
lookup / tool are short status labels.Update the spec's expected set — apps/widget/src/i18n/index.test.ts
lists the supported codes, and language.spec.ts asserts the count. Both
should name the new language.
Run the checks.
pnpm --filter @hotline/widget test && pnpm --filter @hotline/widget typecheck
The specs verify that no language is missing a key (a gap would reach a
visitor as a raw key like msgPlaceholder), that a language is not wholesale
English left untranslated, and that progress strings keep their ellipsis.
Check the size cost. Each language adds roughly 500 bytes gzipped to a bundle every embedding site downloads:
pnpm --filter @hotline/widget build
gzip -c apps/widget/dist/hotline.js | wc -c
Optionally add the language to docs/i18n.md (this table) and AGENTS.md.
All 16 tables are inline. The alternative — fetching a locale file on demand — would mean a network round trip before the panel could render, and a visible flash of the wrong language on a slow connection, in exchange for bytes that compress extremely well.
The measured cost of going from 6 to 16 languages was +5.1 KB gzipped (19.9 → 25.0 KB). We also measured packing the tables into positional arrays to strip the repeated key names: it saved only 522 bytes gzipped, because gzip already collapses that repetition, and it would have made every table position-sensitive and error-prone to edit. The readable form was kept deliberately.
apps/backend/src/email/email.templates.ts keeps its own SUPPORTED_LOCALES
and falls back to English for anything else. This is intentional: shipping
widget copy is cheap and self-contained, but an outbound email nobody has
proofread is not. Widening the widget's languages does not widen the email
templates — do that separately and deliberately.