Files
v10/internal/design/i18n.md
T
2026-05-18 10:02:55 +10:00

47 KiB

status, date
status date
draft 2026-03-25

Internationalization (i18n)

Opaque-key translation system with a global registry and ES module locale files for CDN, typed hooks for React.

Problem

All aria-label, aria-valuetext, tooltip labels, and time unit strings in Video.js 10 are hardcoded English. PlayButtonCore.getLabel() returns 'play'. formatDuration returns '2 minutes, 30 seconds'. There is no mechanism to supply translated strings without overriding each component's label prop individually, and no single language switch for skins.

Requirements:

  • Single entry point — one API replaces all English defaults regardless of layer (HTML, React, CDN)
  • Stable keys — renaming an English label must not require updating every locale file
  • Typed keys — TypeScript autocomplete for all keys
  • Decoupled — i18n providers are not embedded in skins; any skin works with any provider
  • Works without skins — standalone VJS components (buttons, sliders, time elements used outside of <video-skin>) can consume a provider directly
  • CDN-compatible — ES module locale files self-register with no bundler and no global namespace pollution
  • No side-effect locale imports — bundlers don't accidentally include all languages
  • Framework-agnostic core — the registry and translator live in @videojs/core/i18n, re-exported to consumers as @videojs/html/i18n and @videojs/react/i18n
  • Locale-aware — native Intl APIs handle duration, number, and plural formatting

API

Registering translations

The single entry point for both HTML and React consumers:

// HTML
import { registerI18n } from '@videojs/html/i18n';
import es from '@videojs/html/i18n/locales/es';

// React
import { registerI18n } from '@videojs/react';
import es from '@videojs/react/i18n/locales/es';

registerI18n('es', es);

registerI18n merges (does not replace) — multiple calls for the same language are additive. English defaults (en.ts) are pre-registered as the base layer on module import.

HTML

<media-i18n-provider> reads from the nearest ancestor lang attribute, matching the inheritance model of the native HTML lang attribute. Setting lang on <html> — or any ancestor — is enough:

<html lang="es">
  <body>
    <video-player>
      <media-i18n-provider>
        <video-skin>
          <video src="..." playsinline></video>
        </video-skin>
      </media-i18n-provider>
    </video-player>
  </body>
</html>

An explicit lang attribute on the provider overrides the inherited value — useful when a single page hosts players in different languages:

<html lang="en">
  <media-i18n-provider lang="es">
    <video-player>
      <video-skin>
        <video src="..." playsinline></video>
      </video-skin>
    </video-player>
  </media-i18n-provider>
</html>

The element reads from the registry for whichever lang is active. No changes to <video-skin> or any other skin element.

CDN

Locale files are self-registering ES modules. They import registerI18n from the same CDN module URL, sharing the same registry instance — no global namespace pollution:

<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js"></script>

<html lang="es">
  <video-player>
    <media-i18n-provider>
      <video-skin>
        <video src="..." playsinline></video>
      </video-skin>
    </media-i18n-provider>
  </video-player>
</html>
// es.js — self-registering, no window touch
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });

Custom registration

// Override individual keys
registerI18n('es', { play: 'Comenzar' });

// Or override a built-in pack entirely
import myEs from './my-es';
registerI18n('es', myEs);

React

I18nProvider is a standalone component. Consumers add it explicitly inside the player's Provider — the skin itself has no i18n awareness. When locale is omitted, it reads from the nearest ancestor lang attribute (typically <html lang="…">):

import { I18nProvider } from '@videojs/react';
import { VideoSkin, Video } from '@videojs/react/video';

// locale inherited from <html lang="es">
<Provider>
  <I18nProvider>
    <VideoSkin>
      <Video src="..." playsInline />
    </VideoSkin>
  </I18nProvider>
</Provider>

Pass locale explicitly to override inheritance, or translations to bypass the registry entirely:

import es from '@videojs/react/i18n/locales/es';

<Provider>
  <I18nProvider locale="es" translations={es}>
    <VideoSkin>
      <Video src="..." playsInline />
    </VideoSkin>
  </I18nProvider>
</Provider>

SSR / no flash-of-English — pass translations directly on first render:

// Server component
const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`);

<Provider>
  <I18nProvider locale={locale} translations={translations}>
    <VideoSkin>
      <Video src="..." playsInline />
    </VideoSkin>
  </I18nProvider>
</Provider>

Dynamic switching

Just flip locale — the provider lazy-loads the built-in pack for the new locale:

const [locale, setLocale] = useState('en');

<Provider>
  <I18nProvider locale={locale}>
    <VideoSkin>
      <Video src="..." playsInline />
    </VideoSkin>
  </I18nProvider>
</Provider>

Or for zero-flash switching, pre-import the locale and pass translations directly:

const [{ locale, translations }, setLocale] = useState({ locale: 'en', translations: undefined });

async function switchTo(next: string) {
  const { default: translations } = await import(`@videojs/react/i18n/locales/${next}`);
  setLocale({ locale: next, translations });
}

With next-intl

import { getLocale } from 'next-intl/server';

const locale = await getLocale();
const { default: translations } = await import(`@videojs/react/i18n/locales/${locale}`);

<Provider>
  <I18nProvider locale={locale} translations={translations}>
    <VideoSkin>
      <Video src="..." playsInline />
    </VideoSkin>
  </I18nProvider>
</Provider>

Translation keys

Keys are opaque camelCase identifiers. The English string is the value in en.ts — not the key. All keys are optional; missing keys fall back to the English default.

Key English default Params Used by
play 'Play' PlayButtonCore
pause 'Pause' PlayButtonCore
replay 'Replay' PlayButtonCore
mute 'Mute' MuteButtonCore
unmute 'Unmute' MuteButtonCore
seek 'Seek' TimeSliderCore (aria-label)
volume 'Volume' VolumeSliderCore (aria-label)
muted 'muted' VolumeSliderCore (aria-valuetext suffix)
enterFullscreen 'Enter fullscreen' FullscreenButtonCore
exitFullscreen 'Exit fullscreen' FullscreenButtonCore
enableCaptions 'Enable captions' CaptionsButtonCore
disableCaptions 'Disable captions' CaptionsButtonCore
enterPictureInPicture 'Enter picture-in-picture' PipButtonCore
exitPictureInPicture 'Exit picture-in-picture' PipButtonCore
currentTime 'Current time' TimeCore
duration 'Duration' TimeCore
remaining 'Remaining' TimeCore
seekForward 'Seek forward {seconds} seconds' {seconds} SeekButtonCore
seekBackward 'Seek backward {seconds} seconds' {seconds} SeekButtonCore
playbackRate 'Playback rate {rate}' {rate} PlaybackRateButtonCore
timePosition '{current} of {duration}' {current}, {duration} TimeSliderCore (aria-valuetext)
remaining 'remaining' formatDuration (negative time suffix)

timePosition params are already-formatted time phrases from Intl.DurationFormat, not raw numbers.

Intl.DurationFormat handles all duration unit labels; Intl.NumberFormat handles percent formatting. Only muted and remaining are translation keys because Intl has no concept of those suffixes.

Architecture

Layers

@videojs/core/i18n
  Translations · Translator · createTranslator
  registerI18n · getI18nTranslations · onI18nRegistryChange
  Registry (singleton Map) · en.ts (base layer, always present)
        │
        ├── @videojs/react/i18n
        │     createI18n() → { I18nContext, I18nProvider, useTranslator, useLocale }
        │     Consumer wraps skin: <I18nProvider locale="es" translations={…}>
        │
        └── @videojs/html/i18n
              createI18n() → { context, I18nController, ProviderMixin, TextMixin }
              <media-i18n-provider lang="es"> = ProviderMixin(ReactiveElement)
              <media-text key="play"> = TextMixin(ReactiveElement)
              MediaButtonElement uses I18nController → t(core.getLabel(state))

@videojs/utils/i18n   pluralize(count, forms, locale?)
@videojs/utils/time   formatDuration(seconds, options?)

Core types

// @videojs/core/i18n/types.ts

export type BuiltInLocale =
  | 'ar' | 'de' | 'es' | 'fr' | 'it' | 'ja'
  | 'ko' | 'nl' | 'pl' | 'pt' | 'ru' | 'tr' | 'zh';

/** Any BCP 47 tag; named built-in locales autocomplete in editors. */
export type Locale = BuiltInLocale | (string & {});

/** Helper — string type that must contain a given literal substring. */
type Contains<Needle extends string> = `${string}${Needle}${string}`;

/** Per-key parameter contract — maps each key to the exact params it requires (or `never`). */
export interface TranslationParams {
  play: never;
  pause: never;
  replay: never;
  mute: never;
  unmute: never;
  seek: never;
  volume: never;
  muted: never;
  enterFullscreen: never;
  exitFullscreen: never;
  enableCaptions: never;
  disableCaptions: never;
  enterPictureInPicture: never;
  exitPictureInPicture: never;
  currentTime: never;
  duration: never;
  remaining: never;
  seekForward: { seconds: string | number };
  seekBackward: { seconds: string | number };
  playbackRate: { rate: string | number };
  timePosition: { current: string | number; duration: string | number };
}

/** All player translation keys. All keys are optional — missing keys fall back to English.
 *  Value types enforce the `{param}` placeholders required by `TranslationParams`. */
export interface Translations {
  play?: string;
  pause?: string;
  replay?: string;
  mute?: string;
  unmute?: string;
  seek?: string;
  volume?: string;
  muted?: string;
  enterFullscreen?: string;
  exitFullscreen?: string;
  enableCaptions?: string;
  disableCaptions?: string;
  enterPictureInPicture?: string;
  exitPictureInPicture?: string;
  currentTime?: string;
  duration?: string;
  remaining?: string;
  seekForward?: Contains<'{seconds}'>;
  seekBackward?: Contains<'{seconds}'>;
  playbackRate?: Contains<'{rate}'>;
  timePosition?: Contains<'{current}'> & Contains<'{duration}'>;
}

type SimpleKeys = { [K in keyof TranslationParams]: TranslationParams[K] extends never ? K : never }[keyof TranslationParams];
type ParamKeys = Exclude<keyof TranslationParams, SimpleKeys>;

/** Callable translator. Keys with params require the exact params object; keys without params take no second argument. */
export type Translator = {
  (key: SimpleKeys): string;
  <K extends ParamKeys>(key: K, params: TranslationParams[K]): string;
  readonly locale?: Locale;
};

The Contains<Needle> helper makes locale files compile-error when they forget a {param} placeholder. Translator's overloaded signature catches two classes of mistake at call sites:

t('play');                              // ✓ no params
t('play', { foo: 1 });                  // ✗ TS error — no params accepted
t('seekForward');                       // ✗ TS error — missing { seconds }
t('seekForward', { seconds: 10 });      // ✓
t('seekForward', { second: 10 });       // ✗ TS error — typo, wrong key

Registry

// @videojs/core/i18n/registry.ts
const registry = new Map<string, Partial<Translations>>();
const subscribers = new Set<() => void>();

// en.ts is the base layer — pre-registered at module init
import en from './locales/en';
registry.set('en', en);

export function registerI18n(locale: string, translations: Partial<Translations>): void {
  const existing = registry.get(locale) ?? {};
  registry.set(locale, { ...existing, ...translations });
  subscribers.forEach(fn => fn());
}

export function getI18nTranslations(locale: string): Partial<Translations> {
  const en = registry.get('en') ?? {};
  // Walk up the BCP 47 subtag chain: zh-Hant-HK → zh-Hant → zh → en
  const parts = locale.split('-');
  for (let i = parts.length; i > 0; i--) {
    const tag = parts.slice(0, i).join('-');
    const pack = registry.get(tag);
    if (pack) return { ...en, ...pack };
  }
  return { ...en };
}

export function onI18nRegistryChange(callback: () => void): () => void {
  subscribers.add(callback);
  return () => subscribers.delete(callback);
}

getI18nTranslations always returns at least the English defaults (from en.ts). No key is ever missing — the per-key fallback is registry[locale][key] → registry[parent-subtag][key] → … → en[key] → key. See the BCP 47 fallback section for the full subtag-truncation rules.

createTranslator

export function createTranslator(
  translations: Partial<Translations> = {},
  locale?: Locale
): Translator {
  // Overload signatures are type-only; implementation uses the loose callable form.
  function t(key: keyof Translations, params?: Record<string, string | number>): string {
    let value = (translations[key] ?? key) as string;
    if (params) {
      for (const [k, v] of Object.entries(params)) {
        value = value.replace(`{${k}}`, String(v));
      }
    }
    return value;
  }
  t.locale = locale;
  return t as Translator;
}

Locale files

Locale files are TypeScript source files. They use satisfies Partial<Translations> so typos in key names are caught at build time — a misspelled key is a compile error, not a silent runtime miss:

// locales/en.ts
import type { Translations } from '../types';

export default {
  play: 'Play',
  pause: 'Pause',
  replay: 'Replay',
  mute: 'Mute',
  unmute: 'Unmute',
  seek: 'Seek',
  volume: 'Volume',
  muted: 'muted',
  enterFullscreen: 'Enter fullscreen',
  exitFullscreen: 'Exit fullscreen',
  enableCaptions: 'Enable captions',
  disableCaptions: 'Disable captions',
  enterPictureInPicture: 'Enter picture-in-picture',
  exitPictureInPicture: 'Exit picture-in-picture',
  currentTime: 'Current time',
  duration: 'Duration',
  remaining: 'Remaining',
  seekForward: 'Seek forward {seconds} seconds',
  seekBackward: 'Seek backward {seconds} seconds',
  playbackRate: 'Playback rate {rate}',
  timePosition: '{current} of {duration}',
} satisfies Partial<Translations>;
// locales/es.ts — partial locale, falls back to en.ts for missing keys
import type { Translations } from '../types';

export default {
  // Play controls
  play: 'Reproducir',
  pause: 'Pausa',
  replay: 'Repetir',
  // Mute
  mute: 'Silenciar',
  unmute: 'Activar sonido',
  // …
} satisfies Partial<Translations>;

The satisfies constraint checks key names and value types without widening the type — keyof typeof es remains the narrow set of keys present, not keyof Translations. Comments above groups of keys are useful context for translators.

For CDN, locale files are self-registering ES modules. The build emits a separate CDN entry point alongside the bundler-importable output:

// locales/es.cdn.ts — CDN entry, self-registers
import { registerI18n } from '../registry';
import translations from './es';

registerI18n('es', translations);
// Built output: es.js (CDN)
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });

React: createI18n

A factory that closes over a React context so useTranslator() returns Translator with no explicit type annotation at call sites. Called once in @videojs/react/i18n; the result is exported directly.

When locale is omitted, I18nProvider reads document.documentElement.lang and subscribes to changes via MutationObserver:

export function createI18n() {
  const I18nContext = createContext<Translator | null>(null);

  function I18nProvider({ locale: explicitLocale, translations, children }: {
    locale?: Locale;
    translations?: Partial<Translations>;
    children: ReactNode;
  }) {
    // Subscribe to ambient DOM lang attribute when no explicit prop is given
    const ambientLocale = useSyncExternalStore(
      subscribeToDocumentLang,
      () => document.documentElement.lang || undefined,
      () => undefined, // SSR: no ambient locale
    );
    const locale = explicitLocale ?? ambientLocale;

    // Lazy-load built-in pack when locale is set
    const [builtIn, setBuiltIn] = useState<Partial<Translations>>({});
    useEffect(() => {
      if (!locale) { setBuiltIn({}); return; }
      const tags = [...new Set([locale, locale.split('-')[0]])];
      const load = (i = 0): Promise<void> =>
        import(`@videojs/core/i18n/locales/${tags[i]}`)
          .then(m => { registerI18n(tags[i], m.default); setBuiltIn(m.default); })
          .catch(() => i + 1 < tags.length ? load(i + 1) : setBuiltIn({}));
      load();
    }, [locale]);

    // Browser Translation API — background fallback, pre-installed model only.
    // Enumerates the full key set from en.ts (no consumer input needed).
    const [browserTranslated, setBrowserTranslated] = useState<Partial<Translations>>({});
    useEffect(() => {
      if (!locale) { setBrowserTranslated({}); return; }
      let cancelled = false;
      getBrowserTranslations(locale).then(result => {
        if (!cancelled) setBrowserTranslated(result);
      });
      return () => { cancelled = true; };
    }, [locale]);

    // Priority: browser API < registry/built-in < consumer translations
    const translator = useMemo(
      () => createTranslator(
        { ...browserTranslated, ...getI18nTranslations(locale ?? 'en'), ...builtIn, ...translations },
        locale
      ),
      [browserTranslated, builtIn, translations, locale]
    );

    return <I18nContext.Provider value={translator}>{children}</I18nContext.Provider>;
  }

  function useTranslator(): Translator {
    return useContext(I18nContext) ?? createTranslator(getI18nTranslations('en'));
  }

  function useLocale(): Locale | undefined {
    return useTranslator().locale;
  }

  return { I18nContext, I18nProvider, useTranslator, useLocale };
}

/** Shared MutationObserver subscription for document.documentElement.lang changes. */
function subscribeToDocumentLang(onChange: () => void): () => void {
  const observer = new MutationObserver(onChange);
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['lang'] });
  return () => observer.disconnect();
}

HTML: createI18n factory

Parallel to React's createI18n, createI18n bundles the context, controller, provider element, and text element into one call. @videojs/html/i18n invokes it once and re-exports the result. Consumer-facing API is a single side-effect import that registers <media-i18n-provider> / <media-text>:

// @videojs/html/i18n/create-i18n.ts

export function createI18n() {
  const context = createContext<Translator>(Symbol('@videojs/i18n'));

  /** Controller for any element that consumes translated strings (e.g. button labels). */
  class I18nController implements ReactiveController { /* subscribes to context */ }

  /** Mixin: adds lang attribute + DOM lang resolution + ContextProvider for i18nContext. */
  const ProviderMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
    class extends base { /* see "Provider mixin" below */ };

  /** Mixin: adds key attribute + subscribes to i18nContext + writes textContent. */
  const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
    class extends base { /* see "Text mixin" below */ };

  return { context, I18nController, ProviderMixin, TextMixin };
}

Mirrors createPlayer — the factory returns building blocks (context, controller, mixins). Define files compose them onto concrete bases and register the custom elements:

// @videojs/html/src/define/media-i18n-provider.ts
import { createI18n } from '@videojs/html/i18n';

const { ProviderMixin } = createI18n();
export class MediaI18nProviderElement extends ProviderMixin(ReactiveElement) {}
safeDefine('media-i18n-provider', MediaI18nProviderElement);
// @videojs/html/src/define/media-text.ts
import { createI18n } from '@videojs/html/i18n';

const { TextMixin } = createI18n();
export class MediaTextElement extends TextMixin(ReactiveElement) {}
safeDefine('media-text', MediaTextElement);

HTML: Provider mixin

Reads from the registry, provides via i18nContext. Resolves lang from the element's own attribute if set, otherwise from the nearest ancestor's lang attribute (mirroring native HTML inheritance). Subscribes to both registry changes and DOM lang attribute mutations so live elements re-render when either changes:

const ProviderMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
  class extends base {
    static override properties = { ...base.properties, lang: { type: String, reflect: true } };

    /** Explicit override. When unset, resolves from DOM ancestor chain. */
    lang: Locale | undefined = undefined;

    readonly #provider = new ContextProvider(this, {
      context,
      initialValue: createTranslator(getI18nTranslations('en')),
    });

    #unsubscribeRegistry: (() => void) | null = null;
    #langObserver: MutationObserver | null = null;

    /** Resolve effective locale: explicit lang attribute → nearest ancestor[lang] → documentElement.lang → undefined. */
    get #effectiveLocale(): Locale | undefined {
      if (this.lang) return this.lang;
      const ancestor = this.parentElement?.closest('[lang]');
      return (ancestor?.getAttribute('lang') || document.documentElement.lang) || undefined;
    }

    override connectedCallback(): void {
      super.connectedCallback();
      this.#unsubscribeRegistry = onI18nRegistryChange(() => this.#refresh());
      // Observe document-wide lang attribute changes so ambient updates re-render descendants.
      this.#langObserver = new MutationObserver(() => this.#refresh());
      this.#langObserver.observe(document.documentElement, {
        subtree: true, attributes: true, attributeFilter: ['lang'],
      });
      this.#refresh();
    }

    override disconnectedCallback(): void {
      super.disconnectedCallback();
      this.#unsubscribeRegistry?.();
      this.#langObserver?.disconnect();
      this.#unsubscribeRegistry = this.#langObserver = null;
    }

    protected override updated(changed: PropertyValues): void {
      super.updated(changed);
      if (changed.has('lang')) this.#refresh();
    }

    async #refresh(): Promise<void> {
      const locale = this.#effectiveLocale;
      if (!locale) { this.#updateProvider(undefined); return; }

      if (!hasRegisteredI18n(locale)) {
        const tags = [...new Set([locale, locale.split('-')[0]])];
        for (const tag of tags) {
          try {
            const { default: data } = await import(`@videojs/core/i18n/locales/${tag}`);
            registerI18n(tag, data);
            break;
          } catch { /* try next tag */ }
        }
      }

      // Browser Translation API — background, pre-installed model only
      if (!hasRegisteredI18n(locale) && TRANSLATION_KEYS.length) {
        getBrowserTranslations(locale, TRANSLATION_KEYS).then(result => {
          if (Object.keys(result).length) registerI18n(locale, result);
        });
      }

      this.#updateProvider(locale);
    }

    #updateProvider(locale: Locale | undefined): void {
      this.#provider.setValue(createTranslator(getI18nTranslations(locale ?? 'en'), locale));
    }
  };

HTML: Text mixin

Renders a translated string for a key attribute. Subscribes to i18nContext:

const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
  class extends base {
    static override properties = { ...base.properties, key: { type: String } };

    key: string = '';
    readonly #i18n = new I18nController(this);

    protected override update(changed: PropertyValues): void {
      super.update(changed);
      this.textContent = this.#i18n.value(this.key);
    }
  };

<media-text> is for any dynamic translated content that isn't already wired to a component state (free-floating tooltip copy, dialog bodies, custom labels in ejected skins). Built-in button elements already auto-forward their aria-label from the registry via I18nController — no <media-text> wrapping required for those.

<!-- Typical use: free-floating translated text in a skin template -->
<media-tooltip id="help">
  <media-text key="seek"></media-text>
</media-tooltip>

HTML: I18nController and MediaButtonElement

I18nController is returned from createI18n() alongside the mixins, closing over the same context symbol so it reads the value set by any ProviderMixin-backed element. Added to MediaButtonElement and MediaUIElement for auto-forwarded labels:

// Returned from createI18n() — closes over the factory's context
class I18nController implements ReactiveController {
  readonly #consumer: ContextConsumer<typeof context, ReactiveElement>;

  constructor(host: ReactiveElement) {
    this.#consumer = new ContextConsumer(host, { context, subscribe: true });
    host.addController(this);
  }

  get value(): Translator {
    return this.#consumer.value ?? createTranslator(getI18nTranslations('en'));
  }

  hostConnected(): void {}
  hostDisconnected(): void {}
}
// MediaButtonElement.update()
readonly #i18n = new I18nController(this);

protected override update(changed: PropertyValues): void {
  super.update(changed);
  const media = this.mediaState.value;
  if (!media) return;

  this.core.setMedia(media);
  const state = this.core.getState();
  const t = this.#i18n.value;

  const key = this.core.getLabel(state);       // opaque key, e.g. 'play'
  const params = this.core.getLabelParams?.(state);
  this.setAttribute('aria-label', t(key, params));

  applyElementProps(this, this.core.getAttrs(state));
  applyStateDataAttrs(this, state, this.stateAttrMap);
}

When no <media-i18n-provider> ancestor is present, I18nController.value falls back to a translator seeded from the English registry — English strings are rendered by default.

Locale resolution

The provider resolves its active locale from the first source that yields a value:

1. Explicit locale prop (React) / lang attribute (HTML) on the provider
2. Nearest ancestor element with a [lang] attribute (HTML only; document.documentElement.lang in React)
3. undefined → English defaults

<media-i18n-provider> walks the DOM with this.parentElement?.closest('[lang]') and falls back to document.documentElement.lang. Both the explicit attribute and ambient sources are tracked — a MutationObserver watches document.documentElement's subtree for lang attribute changes, so any update to <html lang> (or any intermediate wrapper's lang) re-renders descendants automatically.

React's I18nProvider uses useSyncExternalStore over document.documentElement.lang for the ambient value. SSR returns undefined from the server snapshot; on hydration the client reads the rendered <html lang> attribute, keeping server and client in sync.

This matches the inheritance semantics of the native HTML lang attribute — set it once at the page level and every player picks it up without extra wiring.

BCP 47 fallback

Locale values are BCP 47 language tags. The registry lookup in getI18nTranslations(locale) applies a left-truncation fallback — progressively dropping the rightmost subtag until a registered entry is found:

es-419-u-nu-latn  →  es-419  →  es  →  en
zh-Hant-HK        →  zh-Hant →  zh  →  en
pt-BR             →  pt-BR   →  pt  →  en
en-GB-scotland    →  en-GB-scotland → en-GB → en
sr-Latn           →  sr-Latn →  sr  →  en

The algorithm treats each hyphen as a subtag boundary and truncates from the right. Script subtags (Hant, Latn), region subtags (419, HK, GB), and extension subtags (u-nu-latn) are all handled uniformly — no special cases.

What it does not do. Sibling fallback is not implemented: a request for es-AR will not fall back to es-MX even if only es-MX is registered. The chain is strictly up the parent hierarchy. Consumers who need sibling negotiation should register the base language (es) or the specific variants they want to cover.

<media-i18n-provider> applies the same fallback when lazy-loading built-in packs: it tries pt-BR.ts, then pt.ts, before giving up. Only full subtag matches succeed — en-GB-scotland requires a literal en-GB-scotland.ts file; otherwise it falls through to en-GB and finally en.

The Browser Translation API path uses the native Intl.Locale minimization where available for cross-referencing user preferences against the registered pack list.

Browser Translation API

The Translator API (WICG draft, Chrome 138+ origin trial) provides on-device text translation. Video.js uses it as a background fallback for locales with no registered pack.

Only activates when Translator.availability() returns 'available' — model already present, no network cost. 'downloadable' / 'downloading' / 'unavailable' are silently skipped.

Since keys are opaque, the browser API translates the English values from en.ts, then maps the results back to keys:

const en = getI18nTranslations('en');
const englishValues = keys.map(k => en[k] ?? k);
const translated = await Promise.all(englishValues.map(v => translator.translate(v)));
return Object.fromEntries(keys.map((k, i) => [k, translated[i]]));

Results are cached by locale at module level — repeated mounts of the same skin do not re-trigger translation.

Priority merge:

English defaults    (en.ts — always present)
  ↑
Browser API         (auto-translated, background, pre-installed only)
  ↑
Registry pack       (registerI18n / built-in locale)
  ↑
Consumer prop       (translations — always wins)

Intl API integration

Intl.DurationFormat — drives formatDuration. Handles unit labels, pluralization, and locale-specific ordering automatically (baseline: Chrome 122+, Firefox 127+, Safari 18+). The remaining suffix still uses translate?.('remaining') since Intl.DurationFormat has no concept of remaining time.

Intl.NumberFormat with style: 'percent' — formats volume values. No translation key needed.

Intl.PluralRules — powers the pluralize utility for skin authors who need locale-correct plurals in custom components:

import { pluralize } from '@videojs/utils/i18n';

const label = `${count} ${pluralize(count, { one: t('minute'), other: t('minutes') }, locale)}`;

SSR & hydration safety

Intl APIs produce locale-specific output. If the active locale differs between server and client, hydration mismatches occur. The fix: always derive the locale from the same source on both sides (URL routing, cookie, or Accept-Language header passed explicitly).

HTTP request
  → read locale from cookie / URL / Accept-Language
  → set <html lang={locale}> on the server-rendered document
  → load translations
  → server render:  <Provider><I18nProvider translations={t}>…   (locale inherited from <html lang>)
  → client hydrate: <Provider><I18nProvider translations={t}>…   ← identical = no mismatch

Passing locale explicitly to I18nProvider also works but is only required when overriding the document locale for a subtree. The ambient-locale pattern makes single-locale pages zero-config.

CDN build strategy

The CDN bundle is an ES module. Locale files are separate self-registering ES modules — they import registerI18n from the same CDN URL, sharing the singleton registry instance with no global namespace touch:

<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js"></script>
// https://cdn.jsdelivr.net/npm/@videojs/html/cdn/locales/es.js
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
registerI18n('es', { play: 'Reproducir', pause: 'Pausa', /* … */ });

<media-i18n-provider lang="es"> reads from the registry regardless of how data arrived — same element, same attribute, whether the translations came from an inline registerI18n call or a CDN locale module.

Decisions

Opaque camelCase keys, not English strings

Decision. Keys are camelCase identifiers (play, seekForward). The English string is the value in en.ts.

Alternatives considered.

  • English as keyt('Play') falls back to 'Play' automatically. Self-documenting. Renaming 'Play' to 'Start' in English breaks all other locale files. Established by VJS v7/8 and Media Chrome.
  • Numeric keys — stable, compact, completely opaque. No useful fallback.
  • Namespaced pathst('controls.play'). Common in i18next; adds indirection.

Rationale. Stable keys decouple English UX copy from locale file versioning. Adding a new language or updating an English label are independent operations. The English string is still the default — it lives in en.ts, which is always pre-registered as the base layer. Consistent with i18next, FormatJS, iOS NSLocalizedString, and Android strings.xml.

Global registry, not lazy-loading on skin element

Decision. A singleton Map in @videojs/core/i18n. registerI18n(locale, translations) is the imperative entry point. <media-i18n-provider> reads from it.

Alternatives considered.

  • JSON attribute on skin element<video-skin translations='…'>. Couples translations to the skin element; JSON attributes are fragile and lose type safety.
  • Side-effect locale importsimport '@videojs/core/i18n/locales/es' auto-registers. Media Chrome uses this pattern (import 'media-chrome/lang/es.js'). Simple for CDN, but bundlers may include unwanted locales, and there's no explicit registration call for overrides.
  • Lazy-load only inside provider — each provider independently loads its pack. Multiple players for the same locale each trigger a separate network request.

Rationale. Module-level singleton means each locale is loaded once per page regardless of how many players are mounted. For CDN, locale files are self-registering ES modules — they import registerI18n from the same module URL, sharing the registry instance with no global namespace touch. For bundlers, consumers call registerI18n explicitly after importing the JSON. <media-i18n-provider> can still lazy-load built-in packs as a convenience fallback (triggered only when the locale is not already registered).

Standalone providers — skins are i18n-unaware

Decision. Neither HTML skins nor React skins own the i18n provider. Consumers add <media-i18n-provider> or <I18nProvider> explicitly and place them wherever they need.

Alternatives considered.

  • I18nMixin on the skin element — skin element owns translations and lang attributes. Simpler consumer API but couples skins to i18n. Adding lang to <video-skin> creates an attribute collision with the native HTML lang attribute.
  • locale/translations props on <VideoSkin> — skin internally wraps itself with provider. Zero friction, but skin must know about i18n; harder to share a provider across multiple skins.

Rationale. Skins are presentation layers. i18n is a cross-cutting concern. Decoupling follows the same principle as <Provider> for the store — skins consume context, providers supply it, and neither owns the other. Consumers can wrap multiple skins in one provider, or give each its own. The HTML lang attribute conflict disappears since <media-i18n-provider lang="es"> is a distinct element.

Provider inherits locale from the DOM

Decision. When no explicit locale prop (React) / lang attribute (HTML) is set on the provider, it resolves the locale from the nearest ancestor [lang] attribute (HTML) or document.documentElement.lang (React). A MutationObserver watches for changes so updates to <html lang> propagate automatically.

Alternatives considered.

  • Explicit locale always required on the provider — previous revision behavior. Ergonomic friction: every app must pass the locale into the provider even when <html lang> already declares the page language.
  • Skin reads DOM lang directly, no provider — would eliminate the provider for simple cases, but the provider is needed anyway for context distribution, registry subscription, and inline translations.
  • Read navigator.language — reflects the user's browser preference, not the page's declared language. Can be wrong (e.g., a Spanish-language page viewed by a French user).

Rationale. The HTML lang attribute is the standard mechanism for declaring content language — search engines, screen readers, and spell-checkers already read it. Making the i18n provider honor the same attribute means zero extra config for the common case: set <html lang="es"> once and every player on the page uses Spanish. The provider remains explicit and required; only its locale input becomes optional. React uses locale as the prop name (aligning with Intl and the rest of the TypeScript surface) while HTML uses the native lang attribute.

createI18n() factory, not direct exports

Decision. A factory that closes over a React context. Returns { I18nContext, I18nProvider, useTranslator, useLocale }. Called once in @videojs/react/i18n and re-exported.

Alternatives considered.

  • Direct module-level exportsI18nProvider and useTranslator exported without a factory. Works but commits to a single context instance, preventing independent provider trees.
  • useTranslator() with call-site generic — verbose; every component needs an explicit type argument.

Rationale. The factory pattern allows consumers to create independent provider trees if needed (e.g., two players with different languages and fully isolated contexts). It also keeps the provider and hooks as a cohesive unit — impossible to import useTranslator without the matching context.

<media-text> for template strings

Decision. A <media-text key="play"> element renders translated text inside shadow DOM templates.

Alternatives considered.

  • <media-tooltip label="play"> self-translating — tooltip knows its translation key. Couples tooltip semantics to i18n; translation key becomes a tooltip API concern.
  • Skin re-renders full template on locale change — not viable; shadow DOM templates are static HTML cloned once on element creation.

Rationale. <media-text> is the minimal reactive primitive for translated text inside static shadow DOM. It subscribes to i18nContext independently and updates only textContent — no parent re-render needed. The key attribute is the only i18n contract in ejected skin templates, making them easy to audit and override.

{param} interpolation, not ICU message format

Decision. Simple {key} replacement, as in t('seekForward', { seconds: 10 }).

Rationale. The player has a small, known set of interpolated strings; none require plural rules at the interpolation site. ICU format requires a runtime library (~20 KB). {key} replacement adds zero runtime weight and is established in VJS v8/9.

Native Intl APIs for locale-aware formatting

Decision. Intl.DurationFormat for time phrases, Intl.NumberFormat with style: 'percent' for volume, and Intl.PluralRules via pluralize for plural selection.

Rationale. Intl.DurationFormat became baseline in 2024 and handles unit labels, pluralization, and locale-specific ordering — zero translation keys. Intl.NumberFormat handles percent symbols and digit forms. Only remaining and muted need translation keys because Intl has no concept of those suffixes.

Browser Translation API — pre-installed model only

Decision. Only activate when Translator.availability() returns 'available'. Skip silently for all other states.

Rationale. 'downloadable' triggers a ~100 MB background download without consumer awareness. 'available' means the model is already on-device — zero cost, free bonus. The feature is not reliable enough to be the primary translation mechanism; built-in locale packs are.

Prior Art

System Key type Distribution Registry?
VJS v7/8 English string Built-in JSON + consumer No — set via videojs.addLanguage
i18next Opaque string Consumer JSON Yes — i18next.addResourceBundle
FormatJS / react-intl Opaque string Consumer JSON No — prop-based
GNU gettext English string .po files No
Android strings.xml Opaque XML ID Resource files No
iOS NSLocalizedString Opaque string .strings files No
Media Chrome English string Side-effect import "media-chrome/lang/es.js" Yes — auto-registered on import; lang attribute on <media-controller>

VJS v8's addLanguage is the closest precedent. This design replaces English-as-key (VJS v7/8) with opaque keys (consistent with i18next/Android/iOS) while keeping the imperative registration API.

Edge Cases

Multiple players, different languages. Each <media-i18n-provider> or <I18nProvider> scopes its locale independently. The registry holds all registered locales simultaneously — providers read from it without interfering with each other. An explicit locale prop (React) or lang attribute (HTML) on the provider overrides any ambient <html lang>.

registerI18n called after element mounts. <media-i18n-provider> subscribes to onI18nRegistryChange. Calling registerI18n after mount triggers a re-render of all descendant elements.

<html lang> changed at runtime. Both HTML and React providers observe document.documentElement via MutationObserver. Dynamic locale switching works without remounting — update document.documentElement.lang and all providers without an explicit override re-render.

SSR flash-of-English. React I18nProvider starts with builtIn: {} — first render is English unless translations is passed. Import the locale module directly and pass as translations for zero flash (see SSR section above).

en.ts bundle footprint. en.ts is imported as a module-level side effect of @videojs/core/i18n. Consumers who never use i18n should not import this module — they will not pay the cost if they use only @videojs/core/ui or similar.

Tooltip keys in ejected skins. <media-text key="play"> inside <media-tooltip> is unusual markup compared to hardcoded text. This is an acceptable tradeoff — the key attribute makes the translation contract explicit and the element is thin enough to not be confusing.

Descoped

  • Right-to-left layout — RTL support (mirroring, bidirectional text) is a separate concern from string translation and is not covered here.
  • Plural forms in translation strings — e.g., {count, plural, one {# result} other {# results}}. ICU format is not supported. Skin authors who need locale-correct plurals use the pluralize utility from @videojs/utils/i18n.
  • Locale negotiation — determining which locale to use from Accept-Language, cookies, or user preference is left to the consumer. Video.js accepts an explicit locale value.
  • Caption auto-selection by locale — matching a textTrack against the active locale and toggling it on is a separate concern (application vs. user vs. stream-default language preferences all overlap here). Can be revisited as its own feature.
  • Translation memory / CAT tools — locale files are TypeScript source; a compile step to JSON for CAT tooling compatibility is not provided.
  • Translator perf optimizationscreateTranslator re-parses {param} templates on every call via String.prototype.replace. Future work, not required for v1:
    • Run-time — parse each template once on first use, cache tokens keyed by source string, reuse thereafter (similar to i18next's format cache).
    • Build-time — AOT-compile locale entries into functions (seekForward: ({ seconds }) => \Seek forward ${seconds} seconds`); inline static keys with dead-code elimination for the active locale (similar tobabel-plugin-formatjs/lingui`).
  • Custom translator implementations — swapping createTranslator for a consumer-supplied translator (e.g., to delegate to i18next or FormatJS). The current Translator type is a callable, so this is feasible, but no public extension point is wired up.

File Structure

packages/
├── core/src/i18n/
│   ├── types.ts            ← BuiltInLocale, Locale, Translations, Translator
│   ├── translator.ts       ← createTranslator
│   ├── registry.ts         ← registerI18n, getI18nTranslations, onI18nRegistryChange
│   ├── index.ts            ← re-exports
│   └── locales/
│       ├── en.ts           ← English defaults (pre-registered at module init)
│       ├── ar.ts … zh.ts   ← built-in locale packs
│       └── *.cdn.ts        ← CDN self-registering entry points (one per locale)
│
├── react/src/i18n/
│   ├── create-i18n.tsx         ← createI18n
│   ├── browser-translation.ts
│   ├── locales/                ← re-exports of core/locales/*.ts
│   └── index.ts                ← public entry: registerI18n, I18nProvider, useTranslator, useLocale, Translations, Translator, Locale
│
└── html/src/i18n/
    ├── create-i18n.ts          ← createI18n → { context, I18nController, ProviderMixin, TextMixin }
    ├── browser-translation.ts
    ├── locales/                ← re-exports of core/locales/*.ts
    ├── define/
    │   ├── media-i18n-provider.ts   ← ProviderMixin(ReactiveElement) + customElements.define
    │   └── media-text.ts            ← TextMixin(ReactiveElement) + customElements.define
    └── index.ts                ← public entry: registerI18n, Translations, Translator, Locale (side-effect registers elements)

packages/utils/src/i18n/
└── pluralize.ts

packages/utils/src/time/
└── format.ts               ← formatDuration, TimeFormatOptions, TimeTranslate

Modified files

File Change
packages/core/src/core/ui/seek-button/seek-button-core.ts getLabel returns 'seekForward'/'seekBackward'; add getLabelParams
packages/core/src/core/ui/playback-rate-button/playback-rate-button-core.ts Same
packages/utils/src/time/format.ts Add TimeTranslate, TimeFormatOptions, optional translate param
packages/html/src/ui/media-button-element.ts Add I18nController; apply t() in update()
packages/html/src/define/video/skin.ts Replace hardcoded tooltip strings with <media-text key="…"> children
packages/react/src/i18n/index.ts Export { I18nProvider, useTranslator, useLocale } from createI18n()