Skip to content

Languages

ScreenCI supports multiple language versions of a video or screenshot from a single script. You declare the languages once and ScreenCI records a separate pass per language, setting the browser locale automatically so a self-localizing app renders in the right language without extra work from you.

A plain video with no .languages(...) call records one round that stays language-agnostic (no [en] tag), pinned to the en-US browser locale.

Narration, values, overlays, and audio each accept the same per-language object form. The language set is inferred from the union of all feature keys, so adding a language to any one of them is enough to produce a version. TypeScript validates that every language covers the same cues, which catches drift early.

One language per plan

Multiple languages are a Business feature. On the Free and Starter plans, your organization renders a single narration language across all of its videos and screenshots:

  • An upload that declares more than one language is blocked.
  • Once your organization has rendered one language, an upload in a different language is blocked until you delete the videos and versions in the first language. This keeps the limit at one language in total, not one per video.
  • A per-cue language override (see Speak a cue in a different language) counts as a language too, so it cannot be used to slip a second spoken language past the limit.

Free and Starter narrate that one language with the built-in voice or your own self-recorded voice. See Voices and plans.

Upgrade to Business to render as many languages as you like. When an upload is blocked, screenci record prints the reason and a link back to this section.

Add languages

Add languages by keeping the same cue keys under each language code in video.narration(...):

video.narration({
en: { intro: 'Open the settings page.' },
fi: { intro: 'Avaa asetussivu.' },
})('Settings', async ({ page, narration }) => {
await narration.intro()
})

Use bare language keys such as en, fi, fr, and cmn. You can also add a default key as a shared fallback: any cue missing for a language falls back to the default value, for example video.narration({ default: { intro: 'Hi' }, fr: { intro: 'Salut' } }).

The same pattern applies to video.values(...), video.overlays(...), and video.audio(...): pass a language-major object and each language’s assets are realized in that language’s recording pass while the body drives the same controller name regardless of language:

video.overlays({
en: { badge: { path: 'assets/badge.en.png', x: 1382, y: 65, width: 384 } },
fi: { badge: { path: 'assets/badge.fi.png', x: 1382, y: 65, width: 384 } },
})('Landing', async ({ page, overlays }) => {
await page.goto('/')
await overlays.badge() // the active language's file in each pass
})

video.audio({ en: {...}, fi: {...} }) works the same way. A default key supplies a shared fallback for any language that omits a name:

video.overlays({
default: { badge: { path: 'assets/badge.png', x: 1382, y: 65, width: 384 } },
fi: { badge: { path: 'assets/badge.fi.png', x: 1382, y: 65, width: 384 } },
})('Landing', async ({ page, overlays }) => {
await overlays.badge() // shared badge for en, the Finnish one for fi
})

Per-language overlays, audio, and injected values need per-language capture (the default mode, below): they are baked into each language’s own recording pass. In shared capture mode one recording is reused for every language and only narration is overdubbed, so overlays, audio, and values are identical across languages there.

Localized recordings (per-language capture)

By default a localized video records a separate pass per language, setting the browser locale from the language and exposing the active language to the body. That is ideal when the UI itself differs per language: the app renders translated text, you navigate to a localized route, or you want the browser locale set.

import { video, voices } from 'screenci'
video.narration({
en: { intro: 'Open the settings page.' },
fi: { intro: 'Avaa asetussivu.' },
})('Tutorial', async ({ page, language, narration }) => {
// `language` is the language being recorded in this pass ('en' or 'fi').
// The browser locale is set automatically (en -> en-US, fi -> fi-FI), so a
// self-localizing app renders in the right language. You can also navigate
// per language.
await page.goto('/' + language)
await narration.intro()
})

Each declared language becomes its own recording pass, and the passes group into a single video with one language version each. The declared languages are the single source of truth: the narration map must cover exactly those languages, so a forgotten translation fails loudly instead of silently drifting.

Choosing the locale

Locales default from the language (fi -> fi-FI). Override per language with locales on video.languages(...) when you need a specific region. The object form of video.languages(...) takes the explicit languages set alongside its options:

video
.narration({
en: { intro: 'Welcome.' },
pt: { intro: 'Bem-vindo.' },
})
.languages({
languages: ['en', 'pt'],
locales: { en: 'en-GB', pt: 'pt-BR' },
})('Pricing', async ({ page, language }) => {
await page.goto('/' + language + '/pricing')
})

To skip setting the browser locale entirely, pass browserLocale: false to video.languages(...).

Shared capture mode

To capture once and overdub narration per language at render (instead of a pass per language), pass mode: 'shared' to video.languages(...). This is ideal when the visible UI is identical across languages. The body’s language fixture is then undefined:

video
.narration({
en: { intro: 'Welcome.' },
fi: { intro: 'Tervetuloa.' },
})
.languages({ mode: 'shared' })('Tour', async ({ page, narration }) => {
await page.goto('/')
await narration.intro()
})

Recording only some languages

To record (and render) a subset, pass --languages to screenci record:

Terminal window
screenci record --languages fi
screenci record --languages fi,en

Per-language videos record only the requested languages, so a run never produces more than you asked for. A shared-mode recording is a single capture and is not split by this filter.

The filter only restricts which languages are recorded and rendered this run, not which languages your video declares. Every recording still reports the full code-defined language set, so the app keeps showing the languages you did not render this time (rather than treating them as removed from code).

Localized screenshots

screenshot.values supports localized values (a still is silent, so it takes no narration). Each language produces its own localized still:

import { screenshot } from 'screenci'
screenshot.values({
en: { heading: 'Dashboard' },
fi: { heading: 'Hallinta' },
})('Dashboard hero', async ({ page, language, values, crop }) => {
await page.goto('/' + language + '/dashboard')
await page.getByTestId('heading').fill(values.heading)
await crop(page.getByTestId('revenue-card'), { padding: 0.06 })
})

Variants with each

video.each([...]) (and screenshot.each([...])) produce a separate video per variant, for cases like viewport or theme. Each variant has its own identity and history. It chains with the per-feature methods:

video
.each([
{ key: 'mobile', recordOptions: { aspectRatio: '9:16' } },
{ key: 'desktop', recordOptions: { aspectRatio: '16:9' } },
])
.narration({
en: { intro: 'Welcome.' },
fi: { intro: 'Tervetuloa.' },
})('Landing', async ({ page, language, narration }) => {
await page.goto('/' + language)
await narration.intro()
})

This records Landing mobile and Landing desktop as separate videos, each with en and fi language versions.

Run modifiers

A localized video builder supports the usual run modifiers, chained before the call: .only(...), .skip, .fixme, and .fail. The in-body conditional video.skip(condition, reason) still exists separately for skipping mid-test.

Managing languages from Editor

Every declared language set is web-owned: the recorded set is the union of the web app’s selection, the code seed, and any language keys used by per-language features. Call video.languages() with no argument to hand the whole set to the web app.

import { video } from 'screenci'
video.narration(['intro']).languages()(
'Product tour',
async ({ page, narration }) => {
await narration.intro()
await page.goto('/dashboard')
}
)

With no argument, nothing is seeded, so rendering is held until the web app selects a language set. To start from an initial set the web app can still change, seed it: video.languages(['en', 'fi']) records en and fi plus whatever the web app adds. To seed the capture options too, pass a config object, for example video.languages({ languages: ['en', 'fi'], mode: 'shared' }).

The web app can edit the language set but not mode, locales, or browserLocale yet, so set those to their final values in code up front: they are seeded once and used for every render until web editing of them ships.

The Languages section on the Editor page lists the current languages and lets you add or remove them. Adding a language opens a short guided setup: fill in that language’s narration (a checklist tracks what is still missing), then render. The render reuses the existing capture with the new narration, so you do not have to re-record just to get a narrated version in another language.

On-screen text values for a newly added language start as a read-only copy of an existing language (English if present, otherwise the first alphabetically) because text is captured while the video records, not at render time. To localize that text, edit the values and re-record the language version once it exists. The re-record reuses the same Editor narration, overlays, and audio configuration, and runs from the web when the project is connected to GitHub.

Adding languages from the web works with any video.languages(...) declaration, seeded or not: a code seed (a plain array or config object, as shown in the sections above) only sets the starting point, and the recorded set is the union of the web selection, the code seed, and per-feature language keys. For a single language you do not want in the permanent set, use a one-off language (below). See Editor for the full Editor guide.

One-off languages

You can also add a single language from the web as a one-off language without touching the saved language set. It renders and serves like any other language version, but it is not part of the saved set or your code, so re-recording in CI never updates it automatically. This mirrors an Editor one-off version, but at the language level.

On the video’s page, use the Add a one-off language picker in the Language versions section (it also works for screenshots). Picking a language opens that language’s page with the same guided setup as an Editor-managed language: fill in its narration (auto-translated from an existing language, with a checklist for what is still missing), then render. The render reuses the existing capture with the new narration, so you do not have to re-record.

A one-off language is marked with a purple One-off badge in the language list and a banner on its page, both noting that it is not in your code and will not auto-update. Because it is not declared in code, a CI re-record of the video leaves it untouched: it is only ever re-rendered when you explicitly render it again from its page.

To make a one-off language permanent (so CI keeps it up to date), either list it in the video.languages([...]) seed in code or add it to the language set from the Editor page.

Available languages

The language-major forms (video.narration(...), video.values(...)) and video.languages(...) accept the supported language keys below.

For the built-in voices, narration coverage depends on the voice’s modelType (see Narration). The consistent model (the default) and the expressive model cover different language sets: most languages work with either, some are available only with the expressive model, and Cantonese (yue) is available only with the consistent model. Narrating a language with a model that does not cover it fails at record time with a message telling you which modelType to use.

The per-model split applies only to the built-in voices. Your own ElevenLabs voices (including a voice you record and clone from a sample) are multilingual and cover every key in either table, so any language below works with them regardless of modelType.

(values and other non-narration features also work for every key regardless of model, since they carry no synthesized speech.)

One built-in-voice exception is worth calling out: Russian (ru) currently supports the built-in names Ava, Daniel, Emma, Leo, Lily, Max, Miles, and Nora. When a shared default also needs to cover Russian, use one of those names; the built-in fallback voice is Ava.

Available with any model

These narrate with both the consistent (default) and expressive models:

LanguageKey
Arabicar
Bengalibn
Bulgarianbg
Croatianhr
Czechcs
Danishda
Dutchnl
Englishen
Estonianet
Finnishfi
Frenchfr
Germande
Greekel
Gujaratigu
Hebrewhe
Hindihi
Hungarianhu
Indonesianid
Italianit
Japaneseja
Kannadakn
Koreanko
Latvianlv
Lithuanianlt
Malayalamml
Mandarincmn
Marathimr
Norwegian Bokmalnb
Polishpl
Portuguesept
Punjabipa
Romanianro
Russianru
Serbiansr
Slovaksk
Sloveniansl
Spanishes
Swahilisw
Swedishsv
Tamilta
Telugute
Thaith
Turkishtr
Ukrainianuk
Urduur
Vietnamesevi

Expressive model only

With the built-in voices these narrate only with the expressive model, which is their only built-in voice, so it is selected automatically on every plan: you do not need to set modelType, and Free and Starter can narrate one of these languages just like any other. (Choosing the expressive model as a tone upgrade for a language that also has a consistent voice is the part that requires the Business tier, along with style prompts.) They also work with your own ElevenLabs or sample-cloned voice, and for non-narration features (values, locale selection) they behave like any other key.

LanguageKey
Afrikaansaf
Albaniansq
Amharicam
Armenianhy
Azerbaijaniaz
Basqueeu
Belarusianbe
Burmesemy
Catalanca
Cebuanoceb
Filipinofil
Galiciangl
Georgianka
Haitian Creoleht
Icelandicis
Javanesejv
Konkanikok
Laolo
Latinla
Luxembourgishlb
Macedonianmk
Maithilimai
Malagasymg
Malayms
Mongolianmn
Nepaline
Norwegian Nynorsknn
Odiaor
Pashtops
Persianfa
Sindhisd
Sinhalasi

Consistent model only

Cantonese narrates only with the consistent (default) model. Selecting modelType: 'expressive' for it fails at record time. It also works with your own ElevenLabs or sample-cloned voice.

LanguageKey
Cantoneseyue