Files
v10/internal/design/i18n.md
T

50 KiB

status, date
status date
implemented 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> 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>
        <video-skin>
          <video src="..." playsinline></video>
        </video-skin>
      </media-i18n>
    </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 lang="es">
    <video-player>
      <video-skin>
        <video src="..." playsinline></video>
      </video-skin>
    </video-player>
  </media-i18n>
</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>
      <video-skin>
        <video src="..." playsinline></video>
      </video-skin>
    </media-i18n>
  </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

Changing locale loads the shipped pack automatically via loadLocale (or pass translations / call registerI18n for zero-flash SSR):

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

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

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
seekForward 'Seek forward {seconds} seconds' {seconds} SeekButtonCore
seekBackward 'Seek backward {seconds} seconds' {seconds} SeekButtonCore
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
playingLive 'Playing live' LiveButtonCore
seekToLiveEdge 'Seek to live edge' LiveButtonCore
liveBadge 'Live' Live badge / time display
startCasting 'Start casting' CastButtonCore
stopCasting 'Stop casting' CastButtonCore
connectingCast 'Connecting' CastButtonCore
seek 'Seek' TimeSliderCore (aria-label)
volume 'Volume' VolumeSliderCore (aria-label)
timeCurrent 'Current time' TimeCore
timeDuration 'Duration' TimeCore
timeRemaining 'Remaining' TimeCore
remainingTimeSuffix 'remaining' formatDuration (negative time suffix)
playbackRateAria 'Playback rate {rate}' {rate} PlaybackRateButtonCore, playback-rate menu
timeSliderValueTextRange '{current} of {duration}' {current}, {duration} TimeSliderCore (aria-valuetext)
volumeSliderValueTextMuted '{percent}, muted' {percent} VolumeSliderCore (aria-valuetext when muted)
indicatorMuted 'Muted' Input feedback (status / announcer)
indicatorVolume 'Volume' Input feedback (status indicator label)
indicatorVolumeWithValue 'Volume {value}' {value} Input feedback (status announcer)
indicatorCaptionsOn 'Captions on' Input feedback
indicatorCaptionsOff 'Captions off' Input feedback
indicatorPaused 'Paused' Input feedback
indicatorPlaying 'Playing' Input feedback
indicatorFullscreen 'Enter fullscreen' Input feedback
indicatorExitFullscreen 'Exit fullscreen' Input feedback
indicatorPictureInPicture 'Picture in picture' Input feedback
indicatorExitPictureInPicture 'Exit picture in picture' Input feedback
mediaErrorAborted 'You aborted the media playback' Error dialog
mediaErrorNetwork 'A network error caused…' Error dialog
mediaErrorDecode 'A media error caused playback…' Error dialog
mediaErrorSrcNotSupported 'An unsupported error occurred…' Error dialog
mediaErrorEncrypted 'The media is encrypted…' Error dialog
mediaErrorCustom '' Error dialog (custom errors use literal message)
errorDialogTitle 'Something went wrong.' Error dialog
errorDialogDismiss 'OK' Error dialog
mediaErrorFallback 'An error occurred. Please try again.' Error dialog

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

Intl.DurationFormat handles duration unit labels; Intl.NumberFormat handles percent formatting. Only remainingTimeSuffix and volumeSliderValueTextMuted need translation keys for suffixes Intl cannot express.

Full key list and param contracts: packages/core/src/core/i18n/types.ts (TranslationParams).

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 lang="es"> = ProviderMixin(ReactiveElement)
              <media-text>Play</media-text> = TextMixin(ReactiveElement)
              MediaButtonElement uses I18nController → t(core.getLabel(state))

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

Core types

Authoritative definitions: packages/core/src/core/i18n/types.ts and locales.ts.

  • Locale(typeof LOCALES)[number] | (string & {}) for any BCP 47 tag.
  • TranslationParams — per-key param contract (never = no params).
  • Translations — optional overlay; parametric values must contain required {placeholder} substrings (Contains<> helper).
  • Translator — typed (key, params?) => string from createTranslator.

The Contains<Needle> helper makes locale files compile-error when they forget a {param} placeholder. Translator's 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 overlays, then browser fallback when no pack exists.
    useEffect(() => {
      const seq = ++lazySeqRef.current;
      const locale = resolvedLocale;
      void (async () => {
        const { merged, loadedTags } = await mergeLocaleOverlays(locale, loadLocale, localeLookupChain);
        if (seq !== lazySeqRef.current) return;
        if (shouldAttemptBrowserTranslation(locale, loadedTags, merged)) {
          const browser = await getBrowserTranslations(locale);
          if (Object.keys(browser).length) registerI18n(locale, browser);
        }
        if (seq !== lazySeqRef.current) return;
        setLazyLayer(merged);
      })();
    }, [resolvedLocale]);

    // Priority: registry (incl. browser registerI18n) < lazy built-in < consumer translations
    const translations = useMemo(
      () => ({ ...getI18nTranslations(resolvedLocale), ...lazyLayer, ...translationsProp }),
      [resolvedLocale, lazyLayer, translationsProp, registryEpoch]
    );
    const translator = useMemo(() => createTranslator(translations, resolvedLocale), [translations, resolvedLocale]);

    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> / <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: captures source text + 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.ts
import { createI18n } from '@videojs/html/i18n';

const { ProviderMixin } = createI18n();
export class MediaI18nProviderElement extends ProviderMixin(ReactiveElement) {}
safeDefine('media-i18n', 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 #resetLazyAndLoad(): Promise<void> {
      const localeSnapshot = resolveProviderLocale(this);
      this.#lazyResetStartedForLocale = localeSnapshot;
      this.#lazySeq += 1;
      const seq = this.#lazySeq;
      this.#lazyLayer = {};
      void (async () => {
        const { merged, loadedTags } = await mergeLocaleOverlays(localeSnapshot, loadLocale, localeLookupChain);
        if (seq !== this.#lazySeq) return;
        if (shouldAttemptBrowserTranslation(localeSnapshot, loadedTags, merged)) {
          const browser = await getBrowserTranslations(localeSnapshot);
          if (Object.keys(browser).length) registerI18n(localeSnapshot, browser);
        }
        if (seq !== this.#lazySeq) return;
        this.#lazyLayer = merged;
        this.requestUpdate();
      })();
    }

    #publish(): void {
      const locale = resolveProviderLocale(this);
      const translations = { ...getI18nTranslations(locale), ...this.#lazyLayer };
      this.#i18nProvider.setValue({ translator: createTranslator(translations, locale), locale });
    }
  };

HTML: Text mixin

Renders a translated string for its initial text content. Subscribes to i18nContext:

const TextMixin = <Base extends Constructor<ReactiveElement>>(base: Base) =>
  class extends base {
    readonly #i18n = new I18nController(this);
    #text: string | undefined;

    override connectedCallback(): void {
      this.#text ??= this.textContent?.trim() ?? '';
      super.connectedCallback();
    }

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

HTML: Built-in skin tooltips (trigger sync)

Built-in skins use empty <media-tooltip> elements linked to controls via commandfor. Tooltip text is not authored in the skin template — TooltipElement copies the trigger button's translated label at runtime (getLabel() / getResolvedLabel() via I18nController), keeping tooltip copy and aria-label in sync.

<!-- packages/html/src/define/video/skin.ts -->
<media-play-button commandfor="play-tooltip"></media-play-button>
<media-tooltip id="play-tooltip" side="top" class="media-surface media-tooltip"></media-tooltip>

Changing text inside <media-tooltip> in a built-in skin has no effect. To customize tooltip and aria-label, set label on the control (e.g. <media-play-button label="play">) or override the component's core label key.

HTML: <media-text> (ejected / custom skins only)

<media-text> renders translated copy for static shadow DOM strings not owned by a component's getLabel() — standalone help text, dialog bodies, or tooltips in ejected skins without a linked trigger. Built-in button labels and built-in tooltips do not use <media-text>.

<!-- Ejected skin: free-floating translated copy with no trigger button -->
<media-tooltip id="help">
  <media-text>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> 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> 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> 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+) provides on-device text translation. Video.js uses it as a background fallback for locales with no registered or lazy-loaded pack. Implementation lives in @videojs/core/i18n (getBrowserTranslations, shouldAttemptBrowserTranslation).

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

Sandbox exception. The dev sandbox may call getBrowserTranslations with downloadIfNeeded: true for browser-only locale tags (Chrome Translator API languages without a shipped pack). This is for demo coverage only — not production behavior.

Providers call shouldAttemptBrowserTranslation(locale, loadedTags, merged) after mergeLocaleOverlays — skip when locale is English, when lazy built-ins loaded, or when any non-en tag in localeLookupChain(locale) is already registered (covers es-MXes).

Since keys are opaque, the browser API translates the English values from en.ts, then maps the results back to keys. Results are registered via registerI18n (triggering onI18nRegistryChange) and cached per target language at module level.

Priority merge:

English defaults    (en.ts — always present)
  ↑
Browser API         (registerI18n after async translate; pre-installed model only)
  ↑
Registry pack       (registerI18n / CDN locale modules)
  ↑
Lazy built-in       (`loadLocale` overlay from `@videojs/core/i18n`)
  ↑
Consumer prop       (React translations — always wins)

loadLocale

loadLocale(tag) in @videojs/core/i18n lazy-imports shipped locale packs by exact BCP 47 tag. It skips tags already present in the registry (including en) so explicit registerI18n overrides are preserved. Default createI18n() providers call it via mergeLocaleOverlays when the locale changes — <html lang="es"> or <I18nProvider locale="es"> loads Spanish without a prior registerI18n call. Override with createI18n({ loadLocale }) in tests or custom apps.

Explicit registerI18n, CDN locale modules, and React translations still merge on top and remain the preferred path for SSR (zero flash) and CDN.

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-time suffix uses remainingTimeSuffix via formatOptions.translate 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 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> 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 may call registerI18n explicitly or rely on default provider loadLocale lazy imports. Pass React translations for SSR zero-flash.

Standalone providers — skins are i18n-unaware

Decision. Neither HTML skins nor React skins own the i18n provider. Consumers add <media-i18n> 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 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.

Built-in tooltips: trigger sync, not <media-text>

Decision. Shipped video/audio/live skins use empty <media-tooltip> + commandfor; TooltipElement syncs translated text from the linked control's label.

Alternatives considered.

  • <media-text>Play</media-text> inside each tooltip — duplicates the control's translation key; two sources of truth for the same string.
  • <media-tooltip label="play"> self-translating — couples tooltip semantics to i18n; translation key becomes a tooltip API concern.
  • Hardcoded English in skin templates — original v9 pattern; breaks locale switching.

Rationale. Control cores already expose opaque label keys; MediaButtonElement and TooltipElement both resolve through I18nController. One key → one translated string for aria-label and tooltip.

<media-text> for ejected skin template strings

Decision. A <media-text>…</media-text> element renders translated text inside shadow DOM templates where no component owns the phrase.

Alternatives considered.

  • 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 ad-hoc translated copy in custom/ejected skins. It subscribes to i18nContext independently and updates only textContent — no parent re-render needed.

{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 remainingTimeSuffix and volumeSliderValueTextMuted 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> 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> 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.

When to use <media-text>. Prefer control label + trigger-synced tooltips for anything tied to a button. Use <media-text> only for standalone copy in ejected skins (help text, custom dialog bodies) where no component exposes getLabel().

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 — a consumer-supplied translate(key) → string override (e.g. to delegate to i18next or FormatJS). Deferred follow-up; use registerI18n or React translations prop today.

File Structure

packages/
├── core/src/core/i18n/
│   ├── types.ts            ← Locale, Translations, Translator
│   ├── translator.ts       ← createTranslator
│   ├── browser-translation.ts ← getBrowserTranslations, shouldAttemptBrowserTranslation
│   ├── load-locale.ts        ← loadLocale (codegen lazy import map)
│   ├── 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 (default export each)
│       ├── all.ts          ← aggregated map `{ all, localeTags }` (generated; loads every pack)
│       └── *.cdn.ts        ← CDN self-registering entry points (one per locale)
│
├── react/src/i18n/
│   ├── create-i18n.tsx         ← createI18n
│   ├── locales/                ← generated 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 }
    ├── locales/                ← generated re-exports of core/locales/*.ts
    ├── define/
    │   ├── media-i18n.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

Adding a locale: author packages/core/src/core/i18n/locales/{tag}.ts, append the tag to locales.ts, then run pnpm -F @videojs/core generate:locales (also runs on @videojs/core prebuild). That regenerates locales/all.ts and html/react re-export stubs.

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/ui/tooltip/tooltip-element.ts Sync tooltip text from trigger getResolvedLabel()
packages/core/src/core/i18n/browser-translation.ts Browser Translation API fallback + shouldAttemptBrowserTranslation
packages/react/src/i18n/index.ts Export { I18nProvider, useTranslator, useLocale } from createI18n()