Skip to content

docs: add i18n design doc#1122

Open
sampotts wants to merge 1 commit intomainfrom
docs/i18n
Open

docs: add i18n design doc#1122
sampotts wants to merge 1 commit intomainfrom
docs/i18n

Conversation

@sampotts
Copy link
Copy Markdown
Collaborator

@sampotts sampotts commented Mar 25, 2026

Summary

Add a design document for the i18n (internationalization) system, capturing the architecture decisions and rationale for how Video.js 10 will handle localization across its monorepo packages.

Changes

  • Architecture overview covering the i18n system design and package integration points
  • Decision log documenting tradeoffs and the reasoning behind chosen approaches
  • Index page tying the docs together

Testing

Documentation only — no code changes.


Note

Low Risk
Low risk: documentation-only additions with no runtime or build output changes.

Overview
Adds a draft internal i18n design doc set (internal/design/i18n/) covering the proposed translation architecture (core createTranslator + locale typing, React hook factory/provider pattern, HTML context/mixin/controller approach, and utils helpers like pluralize/formatDuration).

Includes a decision log capturing key tradeoffs (English-as-key, per-skin typed keys, lazy-loaded built-in packs, optional browser Translator API fallback, SSR locale/hydration guidance) and an index/quick-start reference tying the docs together.

Written by Cursor Bugbot for commit 669c88c. This will update automatically on new commits. Configure here.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Mar 30, 2026 5:01am

Request Review

@netlify
Copy link
Copy Markdown

netlify bot commented Mar 25, 2026

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit 669c88c
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/69ca03917a49fd00085fb99f
😎 Deploy Preview https://deploy-preview-1122--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 25, 2026

📦 Bundle Size Report

🎨 @videojs/html — no changes
Presets (7)
Entry Size
/video (default) 23.67 kB
/video (default + hls) 154.60 kB
/video (minimal) 23.55 kB
/video (minimal + hls) 154.37 kB
/audio (default) 21.80 kB
/audio (minimal) 21.86 kB
/background 6.80 kB
Media (6)
Entry Size
/media/background-video 1.03 kB
/media/container 1.59 kB
/media/dash-video 236.18 kB
/media/hls-video 132.19 kB
/media/mux-video 154.90 kB
/media/simple-hls-video 12.63 kB
Players (3)
Entry Size
/video/player 6.52 kB
/audio/player 6.51 kB
/background/player 6.50 kB
Skins (16)
Entry Type Size
/video/minimal-skin.css css 3.25 kB
/video/skin.css css 3.31 kB
/video/minimal-skin js 22.73 kB
/video/minimal-skin.tailwind js 23.20 kB
/video/skin js 22.88 kB
/video/skin.tailwind js 23.13 kB
/audio/minimal-skin.css css 2.37 kB
/audio/skin.css css 2.37 kB
/audio/minimal-skin js 21.05 kB
/audio/minimal-skin.tailwind js 21.38 kB
/audio/skin js 21.01 kB
/audio/skin.tailwind js 21.34 kB
/background/skin.css css 117 B
/background/skin js 1.13 kB
/base.css css 157 B
/shared.css css 86 B
UI Components (21)
Entry Size
/ui/alert-dialog 1.89 kB
/ui/alert-dialog-close 1.72 kB
/ui/alert-dialog-description 1.61 kB
/ui/alert-dialog-title 1.61 kB
/ui/buffering-indicator 1.70 kB
/ui/captions-button 1.87 kB
/ui/controls 1.69 kB
/ui/fullscreen-button 1.90 kB
/ui/mute-button 1.94 kB
/ui/pip-button 1.87 kB
/ui/play-button 1.89 kB
/ui/playback-rate-button 1.92 kB
/ui/popover 2.58 kB
/ui/poster 1.59 kB
/ui/seek-button 1.87 kB
/ui/slider 2.10 kB
/ui/thumbnail 1.94 kB
/ui/time 1.70 kB
/ui/time-slider 2.16 kB
/ui/tooltip 2.15 kB
/ui/volume-slider 2.30 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react — no changes
Presets (7)
Entry Size
/video (default) 19.29 kB
/video (default + hls) 150.42 kB
/video (minimal) 19.30 kB
/video (minimal + hls) 150.29 kB
/audio (default) 16.10 kB
/audio (minimal) 16.16 kB
/background 3.13 kB
Media (5)
Entry Size
/media/background-video 476 B
/media/dash-video 236.27 kB
/media/hls-video 132.17 kB
/media/mux-video 154.83 kB
/media/simple-hls-video 12.61 kB
Skins (14)
Entry Type Size
/video/minimal-skin.css css 3.25 kB
/video/skin.css css 3.31 kB
/video/minimal-skin js 19.22 kB
/video/minimal-skin.tailwind js 22.45 kB
/video/skin js 19.20 kB
/video/skin.tailwind js 22.44 kB
/audio/minimal-skin.css css 2.37 kB
/audio/skin.css css 2.37 kB
/audio/minimal-skin js 16.06 kB
/audio/minimal-skin.tailwind js 18.48 kB
/audio/skin js 16.00 kB
/audio/skin.tailwind js 18.40 kB
/background/skin.css css 90 B
/background/skin js 272 B
UI Components (18)
Entry Size
/ui/alert-dialog 2.28 kB
/ui/buffering-indicator 1.89 kB
/ui/captions-button 2.30 kB
/ui/controls 1.88 kB
/ui/fullscreen-button 2.30 kB
/ui/mute-button 2.29 kB
/ui/pip-button 2.31 kB
/ui/play-button 2.35 kB
/ui/playback-rate-button 2.30 kB
/ui/popover 2.90 kB
/ui/poster 1.77 kB
/ui/seek-button 2.33 kB
/ui/slider 3.16 kB
/ui/thumbnail 2.07 kB
/ui/time 1.93 kB
/ui/time-slider 2.73 kB
/ui/tooltip 2.74 kB
/ui/volume-slider 2.65 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core — no changes
Entries (7)
Entry Size
. 4.97 kB
/dom 8.74 kB
/dom/media/custom-media-element 1.81 kB
/dom/media/dash 235.71 kB
/dom/media/hls 131.72 kB
/dom/media/mux 154.40 kB
/dom/media/simple-hls 12.02 kB
🏷️ @videojs/element — no changes
Entries (2)
Entry Size
. 999 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Size
. 1.38 kB
/html 700 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (10)
Entry Size
/array 104 B
/dom 1.25 kB
/events 319 B
/function 261 B
/object 119 B
/predicate 265 B
/string 148 B
/style 190 B
/time 478 B
/number 158 B
📦 @videojs/spf — no changes
Entries (3)
Entry Size
. 40 B
/dom 10.22 kB
/playback-engine 10.12 kB

ℹ️ How to interpret

All sizes are standalone totals (minified + brotli).

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current sizes.

@sampotts sampotts marked this pull request as draft March 25, 2026 09:22
@sampotts sampotts requested a review from mihar-22 March 25, 2026 20:45
@sampotts sampotts marked this pull request as ready for review March 25, 2026 20:46
Comment on lines +73 to +108
## Consumer Usage

### Static translations

```tsx
import { VideoSkin } from '@videojs/react/video';
import { esTranslations } from './locales/es';

<VideoSkin locale="es" translations={esTranslations} src={src} />
```

### Built-in translations

Video.js ships locale packs for common languages. Set `locale` and the matching pack loads automatically — no `translations` prop needed:

```tsx
// Built-in Spanish translations load lazily on mount
<VideoSkin locale="es" src={src} />
```

The pack is loaded via dynamic `import()` so it has zero impact on the default bundle. If no pack exists for the requested locale, the player falls back to English.

Consumer-provided `translations` always take priority over built-in packs:

```tsx
// Built-in "es" pack loads, then "Play" is overridden by the consumer value
<VideoSkin locale="es" translations={{ Play: 'Reproducir' }} src={src} />
```

For SSR or when you want zero flash-of-English on first render, pre-import the JSON and pass it directly:

```tsx
import es from '@videojs/core/i18n/locales/es.json';

<VideoSkin locale="es" translations={es} src={src} />
```
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a tiny bit of awkwardness, here, having two props that essentially describe the same thing. locale="es" translations={es}. I think I see how we got here. Like, we don't know if translations is a partial, so we need locale to fall back on. But. Just. Calling it out.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're for different uses but maybe the naming in the example is tripping you up.

  • locale (or lang as folks are pushing for) is used for Intl API calls to format numbers and duration, get plural rules etc.
  • translations is providing a dictionary of translation overrides.

Are you maybe suggesting we handle loading the JSON so they don't have to provide the translations prop here?

Copy link
Copy Markdown
Collaborator

@cjpillsbury cjpillsbury left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I threw out a few "blocking" comments, I trust the rest of the team to address/discuss sufficiently and I will be out for a long weekend and don't want to slow forward progress, so setting this review as "Comment" only.

```tsx
import { VideoSkin } from '@videojs/react/video';

<VideoSkin
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(blocking): Have there been any discussions on the tradeoffs of including the provider in the skin vs. elsewhere, e.g.:

  • bundled with our MediaProvider, which can still be two distinct providers "under the hood"
  • a standalone provider that folks can opt in to use as they see fit
  • something else?
    Concerned about this breaking our broad pattern of not adding too much "configuration" to a Skin. (BYO Skins, Skins without labels if they want, etc. etc.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is another aspect I went back and forth on. Happy to discuss next week (I'll setup a call) but I figured the skins may own the translations it requires. That said given our eject stance, we could just have "core" translations that cover all of our skins requirements and ejected folks can opt to use our i18n or (more likely) their own implementation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I'm not keen on is:

<AProvider>
  <BProvider>
    <CProvider>
      <DProvider>
        {children}
      </DProvider>
    </CProvider>
  </BProvider>
</AProvider>

😆

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, per usual, I'm more concerned about "fundamentals" and architectural assumptions. I get not wanting ☝️ (although that has become the "accepted" norm in most of React land, which is what I'm assuming is top of mind with your example). I do think we want to keep i18n (and likely other categories of state) as separate providers from the core media state, it's easy enough to define a "Provider" as a thin wrapper around multiple Providers if that's the preferred balance between devex vs. composition


## One `Translations` type, not two

**Decision.** There is a single `Translations` interface that carries both the index signature (`[key: string]: string | undefined`) and all the named player keys. Skins extend it directly with their own keys. There is no separate structural base type.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): I'm wondering if we should make this (in the TypeScript sense), an extendable set of well-defined string literals instead of simply string? I suspect @mihar-22 could help you with that.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're not going to both with skin-specific translations, this would be miles easier for sure. I'm all for strongly typed where possible.


- **Separate base interface** (e.g. `BaseTranslations` with only the index signature, `Translations extends BaseTranslations` with the named keys). An extra type and export whose only job is to donate an index signature.

**Rationale.** A base interface that exists solely to donate an index signature is needless indirection — the index signature belongs on the type that consumers actually work with. The HTML `i18nContext` previously typed to a structural base (`Translator<BaseTranslations>`) now uses `Translator<Translations>` directly, which is equally assignable from any `Translator<SkinTranslations>` since `SkinTranslations extends Translations`.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): My concern here is that there will be no "hints" for folks on whether a particular "key" is valid. This can easily result in dev toil/maintenance difficulties in both using the API and defining a JSON object for a new lang.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The component builders should still get those hints right. A base would only be handy for folks creating a new language dict but you can just copy the english dict for example and start changing values.


## Native `Intl` APIs for locale-aware formatting

**Decision.** Use `Intl.DurationFormat` for time phrases, `Intl.NumberFormat` with `style: 'percent'` for volume values, and `Intl.PluralRules` (via `pluralize`) for plural selection — with translation-key fallbacks for environments that lack `Intl.DurationFormat`.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. 👌

- **Pass `Translator` into each Core class** — via constructor or `setProps({ t })`. Core is the single source of truth for all string production.
- **Add `translate?` param to `getLabel` and `getAttrs`** — Core calls `translate?.('Play') ?? 'Play'` internally.

**Rationale.** Core is runtime-agnostic and framework-independent. Threading a context-derived translator into Core would create implicit coupling to whichever context system the caller uses (React context, DOM context, or a test double). The UI layer already holds the translator and is the natural place to apply it — it is the same layer that decides which attributes to set on the element. Core producing the key and the UI layer calling `t(key)` keeps the layers clean.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): I know we've gone down the path of expecting folks to pass in all labels etc, but I think this will create a lot of noise/burden on the user for the "standard use case". I still think these should be treated more like our built in state management, and they can be directly related to identical logic as state changes (e.g. showing both "Play" and the play icon in paused state; showing "Pause" and the pause icon in paused state). We weave that context through core already and have already solved those hard problems. That said, I believe this can be a future effort and there are no inherent "left turns instead of rights", so I don't think this should be a blocker.


## Separate `i18nContext` for HTML, not extending `playerContext`

**Decision.** A new `i18nContext` is created alongside `playerContext`, `mediaContext`, and `containerContext`.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. This is a different context with different responsibilities. However (non-blocking), we may want well-defined hooks that "know about" both contexts. This relates to my earlier comment about state, above, and was related to our earlier conversations on props hooks, react-aria/adobe spectrum points of inspiration, and the like. e.g. something like usePlayButtonProps(state, props) having reference to the I18N context. Like above, this is non-blocking, since nothing you're proposing precludes or "code away from" this in a future effort.


## `I18nMixin` on the skin element, not a separate provider element

**Decision.** `VideoSkinElement.translations` triggers re-provision to all descendants via `I18nMixin`.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): This I think pretty strongly is a wrong turn, for a few reasons.

  • It creates a hard coupling of skin to i18n, meaning you need to use a skin to use i18n
  • It creates a hard coupling of i18n to a UI component, meaning you need to use a particular UI component to use (data/state) i18n
  • It's a break in our general "compositional" ethos
  • It creates an unnecessary disparity between React vs. HTML "for the wrong reasons". While we're okay with divergences, those should be primarily motivated by the idioms of the platform/framework/etc.
  • It creates an unnecessary disparity between React vs. HTML that will complicate any future possible compiler work. While this shouldn't be a primary motivator for decisions (i.e. our general stance has been that we should make the "right" decisions and deal with any compiler consequences as needed), it's worth surfacing if there are already other concerns on the decision.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, can change this. As above, perhaps we don't need to allow our own skins to have their own translations anyway as it just complicates things needlessly. Something to also discuss.


## Built-in locale packs, lazy-loaded

**Decision.** Ship JSON translation files for common languages in `@videojs/core/i18n/locales/`. Load them dynamically when `locale` changes; merge with consumer `translations` (consumer always wins).
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): Deeply assuming this and only this does have ripple effects that I think are worth talking through, like how locales are written, stored, made available, build tools, https://caniuse.com/mdn-javascript_statements_import_import_attributes_type_json support, etc. etc. I'm interested in others' thoughts here. This also abuts my (out of band) question re: async translate() and optional fallbacks on e.g. https://developer.mozilla.org/en-US/docs/Web/API/Translator if/when available in the future.


### How it works

The store's `textTrackList` already captures `language` (mapped from the DOM `TextTrack.language` / `srclang` attribute) for every track. When `locale` is set, a new store feature compares it against the track list and activates the best match.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue(blocking): I think any textTrackList should be out of scope for this effort. @heff + @luwes (and also @gkatsev) and I spent a lot of time talking through the difference between (1) "Application" language settings and preferences versus (2) "user" language settings and preferences versus (3) "default stream" language details. What you're working on primarily is (1) and is typically defined by app designers/developers (even if they expose a UI for "locale selection". (2) is generally deferred to things like navigation.language and the like, with the possibility of localStorage or similar for a dynamic "user preferences" model. (3) is typically defined in a way (per spec) that provides a "default" (e.g. subtitle, audio, etc.) language for a src, but also "assuming no detectable user preferences". E.g. from the HLS/Pantos/RFC8216 spec:

   DEFAULT

  The value is an enumerated-string; valid strings are YES and NO.
  If the value is YES, then the client SHOULD play this Rendition of
  the content in the absence of information from the user indicating
  a different choice.  This attribute is OPTIONAL.  Its absence
  indicates an implicit value of NO.

I'd just omit this section/effort altogether for now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also thanks @mister-ben for calling this out on Discord! 👯

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. This can come later.


## English strings as translation keys

**Decision.** Translation keys are the English strings themselves. `t('Play')` not `t(TranslationKey.PLAY)`.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We came to the same conclusion in media-chrome. Makes a lot of sense and should also help gzip.
muxinc/media-chrome#1071 (comment)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought(non-blocking): Yeah this is pretty different from how it typically works in other (non React-y) envs and I expressed similar sentiments when this has come up in the past, but not a hill I need to die on 😅.

Examples where these are well-defined keys:

(there are more, and there are reasons to not conflate presentation with data, esp if we don't want to assume the english phrasing will be "set in stone", as that has p big ripple effects, esp on 3rd party BYO language keys, but I'm happy to let this be y'allz decision in the end)


```tsx
// Consumer API — just props on the skin
<VideoSkin locale="es" translations={{ Play: 'Reproducir', 'Skip Intro': 'Saltar Intro' }} />
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we consider the lang prop / attr here?
https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/lang

We used this in Media Chrome, it can go on every html tag and gives the browsers extra context.

Or is there a reason not to meddle these two?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related above:
#1122 (comment)

Comment on lines +53 to +54
* Any BCP 47 locale tag. Known built-in locales autocomplete in editors;
* any other tag (e.g. 'pt-BR', 'zh-TW', 'en-AU') is accepted without a cast.
Copy link
Copy Markdown

@mister-ben mister-ben Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BCP 47 tags don't necessarily match the pattern of the examples given. zh-Hant-TW, es-419, yor, sr-Latn, en-GB-scotland. Will these be accepted too?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's the plan. I can add some more examples in here.

---

## Parametric cores return the template key, not the resolved string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question(non-blocking): This feels counter-intuitive and makes a hard requirement for those not using our i18n infrastructure. In other words, we've just lost the value add for the "simple case" and replaced it with a dependency on the "more advanced case". I won't dig my heels in on this, but it feels like some of the complications here are because we're deciding to use the presentation template string as both "key" and (unresolved) value, some of the complications here are because we've moved away from having i18n considerations more deeply integrated into the components (a la react-spectrum inspiration crud we were exploring/aiming toward last year). This also creates a steeper curve as folks start trying to use VJS "more closely to the metal" (which I think has been a theme of some of our decisions in 2026). Food for thought.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants