React video library comparison
React video library comparison: measured figures
Measured 2026-10-03 on Node v22.23.2, Vite 8.3.1, esbuild
0.28.2, from tests/compare's pinned installs. React, ReactDOM
and the JSX runtime are marked external for every library alike and excluded
from every figure below. "Gzipped (Vite)" and "Gzipped (esbuild)" are each the
sum of each reachable chunk's own gzip size from that bundler's own build, not
one gzip of their concatenation -- see scripts/compare-libraries.mjs's
header for why, and its "What is measured" entry in docs/comparison/method.md
for what the two bundlers agreeing, or not, is evidence of. "Delta" is
esbuild's figure relative to Vite's, signed and rounded to one decimal.
"Not counted" is the chunks the Vite build produced but this fixture's fixed
inputs cannot reach, gzipped the same way -- see
docs/comparison/method.md for what each library's excluded chunks are.
| Library | Version | Composition measured | Gzipped (Vite) | Gzipped (esbuild) | Delta | Not counted |
|---|---|---|---|---|---|---|
| Playdeck (no parts) | 1.3.0 | core + native provider, no control parts | 22.41 KB | 23.31 KB | +4.0% | 10 chunks, 317.08 KB |
| Playdeck | 1.3.0 | core + primitives + native provider | 23.24 KB | 24.08 KB | +3.6% | 10 chunks, 317.05 KB |
| Playdeck (play-only) | 1.3.0 | core + primitives + native provider + one control (PlayButton) | 24.39 KB | 25.37 KB | +4.0% | 10 chunks, 317.08 KB |
| Playdeck (control bar) | 1.3.0 | core + primitives + native provider + control bar (5 of Media Chrome's 7 controls) | 28.02 KB | 29.00 KB | +3.5% | 11 chunks, 319.30 KB |
| react-player | 3.4.0 | default export, controls, html5 fallback player |
2.97 KB | 2.40 KB | -19.2% | 13 chunks, 727.65 KB |
| Vidstack | 1.15.6 | MediaPlayer + MediaProvider + DefaultVideoLayout | 89.67 KB | 91.83 KB | +2.4% | 13 chunks, 22.02 KB |
| Media Chrome | 4.19.2 | MediaController + a 7-button control bar | 41.82 KB | 43.70 KB | +4.5% | 0 |
| Video.js | 8.24.1 | videojs() with controls: true, hand-wrapped |
199.52 KB | 205.90 KB | +3.2% | 0 |
| Video.js 10 (rc) | 10.0.0-rc.4 | VideoPlayer + VideoSkin + Video (@videojs/react/video preset) |
67.14 KB | 73.46 KB | +9.4% | 0 |
Regenerate with pnpm compare:libraries -- run pnpm build first; a
stale dist/ changes Playdeck's rows and nothing else. The date above
records when this file was last regenerated; pnpm compare:libraries:check
does not police how old it is, only whether the figures, versions and
compositions below still match a fresh run. Re-run the command above to
bring the date current.
React video library comparison: features
This table is sourced and checked, not measured: unlike
docs/comparison/results.md, which puts every library through the same
bundler and reads a number back, nothing here comes out of a build. Every cell
is a claim tests/compare/features.mjs makes in writing, anchored to
something this script re-verifies against the installed package on every run
-- an export, a line in a shipped file, a type declaration, a package.json
field, a token found in none of the files a glob matches across the package,
or importing the package in plain Node with no DOM globals. A claim whose
anchor no longer holds fails pnpm compare:features:check rather than
sitting here stale.
A cell reads yes where the library ships a ready-made UI part for that
axis, partial where it exposes the axis in its own API or state with no UI
part to drive it, no where it does neither, plugin where a documented
plugin outside the package is the answer, and n/a where the axis cannot
apply to that library at all. A status never encodes which of a library's
providers can do the thing -- a YouTube iframe cannot enter picture-in-picture
under any of these libraries -- so that limit is written in the footnote
instead, for every column alike. docs/comparison/method.md's "Features"
section has the full rule.
Measured 2026-10-03 against tests/compare's pinned installs:
Playdeck 1.3.0, react-player 3.4.0, Vidstack 1.15.6, Media Chrome 4.19.2, Video.js 8.24.1, Video.js 10 (rc) 10.0.0-rc.4.
| Axis | Playdeck | react-player | Vidstack | Media Chrome | Video.js | Video.js 10 (rc) |
|---|---|---|---|---|---|---|
| Captions / text tracks | yes1 | partial2 | yes3 | yes4 | yes5 | yes6 |
| Quality selection | yes7 | no8 | yes9 | yes10 | partial11 | yes12 |
| Playback rate | yes13 | plugin14 | yes15 | yes16 | yes17 | yes18 |
| Picture-in-picture | yes19 | partial20 | yes21 | yes22 | yes23 | yes24 |
| Fullscreen | yes25 | plugin26 | yes27 | yes28 | yes29 | yes30 |
| AirPlay | yes31 | no32 | yes33 | yes34 | plugin35 | yes36 |
| Chromecast / Google Cast | yes37 | no38 | yes39 | yes40 | plugin41 | yes42 |
| Keyboard operation | yes43 | plugin44 | yes45 | yes46 | yes47 | yes48 |
| Screen-reader labelling | yes49 | partial50 | yes51 | yes52 | yes53 | yes54 |
| DRM / EME | no55 | no56 | no57 | no58 | plugin59 | yes60 |
| HLS | yes61 | yes62 | yes63 | plugin64 | yes65 | yes66 |
| DASH | no67 | yes68 | yes69 | plugin70 | yes71 | plugin72 |
| Live streaming | partial73 | no74 | yes75 | yes76 | yes77 | yes78 |
| YouTube | yes79 | yes80 | yes81 | plugin82 | plugin83 | plugin84 |
| Vimeo | yes85 | yes86 | yes87 | plugin88 | plugin89 | plugin90 |
| Wistia | yes91 | yes92 | no93 | plugin94 | no95 | plugin96 |
| Other hosted providers (named) | partial97 | yes98 | no99 | plugin100 | no101 | plugin102 |
| Audio tracks | yes103 | no104 | yes105 | yes106 | yes107 | yes108 |
| Chapters | yes109 | no110 | yes111 | partial112 | yes113 | yes114 |
| Thumbnails / preview on seek | yes115 | no116 | yes117 | yes118 | plugin119 | yes120 |
| Playlists | no121 | no122 | no123 | no124 | plugin125 | no126 |
| Ads / IMA | no127 | no128 | no129 | no130 | plugin131 | no132 |
| Analytics hooks | no133 | no134 | no135 | no136 | plugin137 | plugin138 |
| Plugin system | partial139 | yes140 | no141 | no142 | yes143 | no144 |
| Shipped skin / theme | yes145 | no146 | yes147 | no148 | yes149 | yes150 |
| Headless, independently composable parts | yes151 | no152 | yes153 | yes154 | no155 | yes156 |
| Requires an external stylesheet for usable controls | no157 | no158 | yes159 | no160 | yes161 | yes162 |
| Lazy / deferred provider loading | yes163 | yes164 | yes165 | n/a166 | no167 | no168 |
| React version supported | yes169 | yes170 | yes171 | partial172 | no173 | yes174 |
| Imports on a server (no DOM globals) | yes175 | yes176 | yes177 | yes178 | yes179 | yes180 |
| TypeScript types shipped | yes181 | yes182 | yes183 | yes184 | yes185 | yes186 |
| ESM/CJS (dual build) | partial187 | partial188 | partial189 | yes190 | yes191 | partial192 |
Regenerate with pnpm compare:features. pnpm compare:features:check fails
if a fresh check of every anchor in tests/compare/features.mjs would
produce a different table than the one above; it does not police how old the
date on this file is on its own.
- Captions / text tracks — Playdeck: yes. mechanical check:
@playdeck/reactexportsCaptionsButton. Source: packages/react/README.md ↩ - Captions / text tracks — react-player: partial. Captions render only through a native
<track>child and the browser's own control UI; react-player draws no captions button itself. mechanical check:react-player'sREADME.mdincludeskind="subtitles". Source: react-player 3.4.0, node_modules/react-player/README.md (installed package) ↩ - Captions / text tracks — Vidstack: yes. mechanical check:
@vidstack/reactexportsCaptionButton. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Captions / text tracks — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaCaptionsButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Captions / text tracks — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesregisterComponent('CaptionsButton'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Captions / text tracks — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsCaptionsButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Quality selection — Playdeck: yes. mechanical check:
@playdeck/reactexportsQualityMenu. Source: packages/react/README.md ↩ - Quality selection — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsQuality. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Quality selection — Vidstack: yes. mechanical check:
@vidstack/react'sprod/player/vidstack-default-components.jsincludesDefaultQualityMenu. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/player/vidstack-default-components.js (installed package) ↩ - Quality selection — Media Chrome: yes. mechanical check:
media-chrome/react/menuexportsMediaRenditionMenu. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Quality selection — Video.js: partial. The
qualityLevels()API andQualityLevelListship in core with no default UI button. A documented UI plugin exists (videojs-http-source-selector, npmrepositorygithub.com/jfujita/videojs-http-source-selector, third-party); the status reads the core API rather than that plugin. mechanical check:video.js'sdist/video.es.jsincludesvideojs.registerPlugin('qualityLevels'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Quality selection — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsQualityRadioGroup. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Playback rate — Playdeck: yes. mechanical check:
@playdeck/reactexportsPlaybackRateMenu. Source: packages/react/README.md ↩ - Playback rate — react-player: plugin. A
playbackRateprop sets the rate (dist/types.d.ts); no playback-rate control ships in react-player's own code, and its README's "Custom player controls" section composes<MediaPlaybackRateButton>for one. Provider limit, not a status: the same README says the prop is "Only supported by YouTube, Wistia, and file paths". Pluginmedia-chrome: npmrepositorygithub.com/muxinc/media-chrome, third-party. mechanical check: no file ofreact-playermatching**/*.jsor**/*.d.tscontainsPlaybackRateButton. Source: react-player 3.4.0, node_modules/react-player/README.md (installed package), its "Custom player controls" section, which composes Media Chrome parts around a<ReactPlayer slot="media">↩ - Playback rate — Vidstack: yes. mechanical check:
@vidstack/react'sprod/player/vidstack-default-components.jsincludesDefaultSpeedMenu. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/player/vidstack-default-components.js (installed package) ↩ - Playback rate — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaPlaybackRateButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Playback rate — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesregisterComponent('PlaybackRateMenuButton'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Playback rate — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsPlaybackRateButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Picture-in-picture — Playdeck: yes. Provider limit, not a status: Playdeck's YouTube adapter reports this capability as provider-unavailable (
packages/provider-youtube/src/adapter-values.ts), as an embedded player would under any library here. mechanical check:@playdeck/reactexportsPipButton. Source: packages/react/README.md ↩ - Picture-in-picture — react-player: partial. A
pipprop enters and leaves picture-in-picture; no picture-in-picture control of react-player's own ships. Provider limit, not a status: its own README says it is "Only available when playing file URLs in certain browsers". mechanical check:react-player'sdist/types.d.tsincludespip?: boolean;. Source: react-player 3.4.0, node_modules/react-player/dist/types.d.ts (installed package) ↩ - Picture-in-picture — Vidstack: yes. Provider limit, not a status: Vidstack publishes this as provider-dependent player state, which an embedded provider leaves unset. mechanical check:
@vidstack/reactexportsPIPButton. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Picture-in-picture — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaPipButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Picture-in-picture — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesregisterComponent('PictureInPictureToggle'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Picture-in-picture — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsPiPButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Fullscreen — Playdeck: yes. mechanical check:
@playdeck/reactexportsFullscreenButton. Source: packages/react/README.md ↩ - Fullscreen — react-player: plugin. No fullscreen prop, method or button in its own shipped code; its README's "Custom player controls" section composes
<MediaFullscreenButton>for one. Without that, a fullscreen button appears only when the native<video controls>or an iframe provider (YouTube, Vimeo) supplies its own. Pluginmedia-chrome: npmrepositorygithub.com/muxinc/media-chrome, third-party. mechanical check: no file ofreact-playermatching**/*.jsor**/*.d.tscontainsfullscreen. Source: react-player 3.4.0, node_modules/react-player/README.md (installed package), its "Custom player controls" section, which composes Media Chrome parts around a<ReactPlayer slot="media">↩ - Fullscreen — Vidstack: yes. mechanical check:
@vidstack/reactexportsFullscreenButton. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Fullscreen — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaFullscreenButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Fullscreen — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesregisterComponent('FullscreenToggle'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Fullscreen — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsFullscreenButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - AirPlay — Playdeck: yes. Provider limit, not a status: Playdeck's YouTube adapter reports this capability as provider-unavailable (
packages/provider-youtube/src/adapter-values.ts), as an embedded player would under any library here. mechanical check:@playdeck/reactexportsAirPlayButton. Source: packages/react/README.md ↩ - AirPlay — react-player: no. Searched for
irplay, which catchesairplay,AirplayandAirPlayalike. ShipsdisableRemotePlayback(opts out of the browser's own remote-playback picker) but no AirPlay-specific API of its own. mechanical check: no file ofreact-playermatching**/*.jsor**/*.d.tscontainsirplay. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - AirPlay — Vidstack: yes. mechanical check:
@vidstack/reactexportsAirPlayButton. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - AirPlay — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaAirplayButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - AirPlay — Video.js: plugin. Searched for
AirPlayrather than the broaderirplayused in the react-player column: this bundle's only lower-caseairplaystring is inside the wordFairplay, a DRM key system, not an AirPlay control. No AirPlay button in core. Pluginvideojs-airplay: npmrepositorygithub.com/jgubman/videojs-airplay, third-party. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsAirPlay. Source: registry.npmjs.org/videojs-airplay ↩ - AirPlay — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsAirPlayButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Chromecast / Google Cast — Playdeck: yes. Reached through the standards-based Remote Playback API (the media element's
remoteobject,capabilities.remotePlayback/showRemotePlaybackPicker()), explicitly not the Cast SDK: no sender script, no receiver page. That SDK route remains unshipped and is possible later as an external provider. mechanical check:@playdeck/reactexportsRemotePlaybackButton. Source: packages/react/README.md ↩ - Chromecast / Google Cast — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsCast. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Chromecast / Google Cast — Vidstack: yes. mechanical check:
@vidstack/reactexportsGoogleCastButton. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Chromecast / Google Cast — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaCastButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Chromecast / Google Cast — Video.js: plugin. Searched for
CastButtonrather than the broaderCastused in the Playdeck column: this bundle's onlyChromecaststring isIS_CHROMECAST_RECEIVER, a receiver-context flag, which is not a sender button. Pluginvideojs-chromecast: npmrepositorygithub.com/benjipott/video.js-chromecast, third-party. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsCastButton. Source: registry.npmjs.org/videojs-chromecast ↩ - Chromecast / Google Cast — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsCastButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Keyboard operation — Playdeck: yes. mechanical check:
@playdeck/react'sdist/index.jsincludesArrowLeft. Source: packages/react/README.md ("Controls is a focusable region that owns the media keyboard shortcuts") ↩ - Keyboard operation — react-player: plugin. No media keyboard handling in its own shipped code (
dist/Preview.jsbindsonKeyDownfor thelight-mode preview button alone); its README's "Custom player controls" section composes<MediaController>, which owns the media hotkeys (hotkeys,nohotkeysandkeydownin media-chrome'sdist/media-controller.js). Without it, keyboard operation comes from the native<video controls>or an iframe provider's own player. Pluginmedia-chrome: npmrepositorygithub.com/muxinc/media-chrome, third-party. mechanical check: no file ofreact-playermatching**/*.jsor**/*.d.tscontainskeyboard. Source: react-player 3.4.0, node_modules/react-player/README.md (installed package), its "Custom player controls" section, which composes Media Chrome parts around a<ReactPlayer slot="media">↩ - Keyboard operation — Vidstack: yes. mechanical check:
@vidstack/reactexportsMEDIA_KEY_SHORTCUTS. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Keyboard operation — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaKeyboardShortcutsDialog. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Keyboard operation — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludes'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Keyboard operation — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsHotkey. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Screen-reader labelling — Playdeck: yes. mechanical check:
@playdeck/react'sdist/index.jsincludesaria-label. Source: packages/react/README.md ↩ - Screen-reader labelling — react-player: partial. Only the
light-mode preview button carries an authoredpreviewAriaLabel; the native control set otherwise supplies its own accessible names. Notplugin, unlike the fullscreen, playback-rate and keyboard rows: react-player authors one label of its own, so the answer is not only a plugin. mechanical check:react-player'sdist/types.d.tsincludespreviewAriaLabel?: string;. Source: react-player 3.4.0, node_modules/react-player/dist/types.d.ts (installed package) ↩ - Screen-reader labelling — Vidstack: yes. mechanical check:
@vidstack/react'sprod/player/vidstack-default-components.jsincludesaria-label. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/player/vidstack-default-components.js (installed package) ↩ - Screen-reader labelling — Media Chrome: yes. mechanical check:
media-chrome'sdist/media-play-button.jsincludesaria-label. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Screen-reader labelling — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludescontrolText_. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Screen-reader labelling — Video.js 10 (rc): yes. mechanical check:
@videojs/react'sdist/default/player/container.jsincludesaria-label. Source: @videojs/react 10.0.0-rc.4, every.jsand.d.tsfile in node_modules/@videojs/react and in the five@videojs/*packages it depends on (installed packages) ↩ - DRM / EME — Playdeck: no. mechanical check: no file of
@playdeck/coreand@playdeck/reactmatching**/*.jsor**/*.d.tscontainsrequestMediaKeySystemAccess. Source: packages/core and packages/react, every.jsand.d.tsfile each ships underdist/afterpnpm build↩ - DRM / EME — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsrequestMediaKeySystemAccess. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - DRM / EME — Vidstack: no. mechanical check: no file of
@vidstack/reactmatching**/*.jsor**/*.d.tscontainsrequestMediaKeySystemAccess. Source: @vidstack/react 1.15.6, every.jsand.d.tsfile in node_modules/@vidstack/react (installed package) ↩ - DRM / EME — Media Chrome: no. mechanical check: no file of
media-chromematching**/*.jsor**/*.d.tscontainsrequestMediaKeySystemAccess. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - DRM / EME — Video.js: plugin. Core has no EME call of its own. Plugin
videojs-contrib-eme: its published npm manifest declares norepositoryfield, so no owner is recorded here. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsrequestMediaKeySystemAccess. Source: registry.npmjs.org/videojs-contrib-eme ↩ - DRM / EME — Video.js 10 (rc): yes. A
source.drmmap of EME key-system ids onShakaVideoandHlsjsVideo; the key-system constants (KeySystems) are re-exported by@videojs/reactfrom@videojs/media, whosedist/default/core/drm.jsimplements them. The playback engine behind it (shaka-player, hls.js) is the consumer's own install. mechanical check:@videojs/react'sdocs/reference/components/shaka-video.mdincludessource.drm. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (reference/components/shaka-video.md, "Protected content") ↩ - HLS — Playdeck: yes. mechanical check:
@playdeck/core'sdist/types.d.tsincludesHlsSource. Source: packages/core/dist/types.d.ts (PlayerSource); packages/provider-hls ↩ - HLS — react-player: yes. mechanical check:
react-player'sdist/players.jsincludescanPlay: canPlay.hls. Source: react-player 3.4.0, node_modules/react-player/dist/players.js (lazyhls-video-element) ↩ - HLS — Vidstack: yes. mechanical check:
@vidstack/reactexportsHLSProviderLoader. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - HLS — Media Chrome: plugin. No provider or engine module of its own; the documented compatible element is
<hls-video>. Pluginhls-video-element: npmrepositorygithub.com/muxinc/media-elements, org-published. mechanical check: no file ofmedia-chromematching**/*.jsor**/*.d.tscontainshls. Source: media-chrome.org/docs/en/media-element ↩ - HLS — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesvideojs-http-streaming. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - HLS — Video.js 10 (rc): yes. mechanical check:
@videojs/react/media/hls-videoexportsHlsVideo. Source: @videojs/react 10.0.0-rc.4, every.jsand.d.tsfile in node_modules/@videojs/react and in the five@videojs/*packages it depends on (installed packages) ↩ - DASH — Playdeck: no. Searched for
createDashProviderrather than the bare word:@playdeck/react/browserbundles React and ReactDOM, whose own SVG attribute table carriesstroke-dasharray/stroke-dashoffset, so the bare word no longer holds vacuously true the way it does for every other library here. Every existing provider exports acreate<Name>Providerfactory (createHlsProvider,createNativeProvider,createVimeoProvider,createWistiaProvider,createYouTubeProvider); a DASH provider would be the same shape.PlayerSourceis a closed union ofstring | VideoFileSource | HlsSource | YouTubeSource | VimeoSource | WistiaSource(packages/core/dist/types.d.ts);.out-of-scope/dash.mdrecords the decision. mechanical check: no file of@playdeck/coreand@playdeck/reactmatching**/*.jsor**/*.d.tscontainscreateDashProvider. Source: packages/core and packages/react, every.jsand.d.tsfile each ships underdist/afterpnpm build↩ - DASH — react-player: yes. mechanical check:
react-player'sdist/players.jsincludescanPlay: canPlay.dash. Source: react-player 3.4.0, node_modules/react-player/dist/players.js (lazydash-video-element) ↩ - DASH — Vidstack: yes. mechanical check:
@vidstack/reactexportsDASHProviderLoader. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - DASH — Media Chrome: plugin. Searched for
DashVideorather than the bare word: the package's onlydashstrings are its owndashedToCamelhelper. No provider or engine module of its own; the documented compatible element is<dash-video>. Plugindash-video-element: npmrepositorygithub.com/muxinc/media-elements, org-published. mechanical check: no file ofmedia-chromematching**/*.jsor**/*.d.tscontainsDashVideo. Source: media-chrome.org/docs/en/media-element ↩ - DASH — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesmpd-parser. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - DASH — Video.js 10 (rc): plugin. Changed since
10.0.0-beta.32:DashVideois still exported from@videojs/react/media/dash-video, but that adapter now imports@videojs/dash-video, a package@videojs/react's ownpeerDependenciesMetamarks optional and this pinned install does not add, so importing the subpath throws rather than exporting the component. Plugin@videojs/dash-video: npmrepositorygithub.com/videojs/v10, org-published. mechanical check:@videojs/react'sdocs/reference/components/dash-video.mdincludespnpm add @videojs/dash-video. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (reference/components/dash-video.md, "Import") ↩ - Live streaming — Playdeck: partial.
LiveIndicatorrenders a non-interactive live/DVR-edge badge (data-stateat-edge/behind-edge), derived fromPlayerState.live; it ships no seek-to-live-edge control of its own (tracked separately). mechanical check:@playdeck/reactexportsLiveIndicator. Source: packages/react/README.md ↩ - Live streaming — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsisLive. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Live streaming — Vidstack: yes. mechanical check:
@vidstack/reactexportsLiveButton. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Live streaming — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaLiveButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Live streaming — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesseekToLive. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Live streaming — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsLiveButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - YouTube — Playdeck: yes. mechanical check:
@playdeck/core'sdist/types.d.tsincludesYouTubeSource. Source: packages/core/dist/types.d.ts (PlayerSource); packages/provider-youtube ↩ - YouTube — react-player: yes. mechanical check:
react-player'sdist/players.jsincludescanPlay: canPlay.youtube. Source: react-player 3.4.0, node_modules/react-player/dist/players.js (lazyyoutube-video-element) ↩ - YouTube — Vidstack: yes. mechanical check:
@vidstack/reactexportsYouTubeProviderLoader. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - YouTube — Media Chrome: plugin. No provider module of its own; the documented compatible element is
<youtube-video>. Pluginyoutube-video-element: npmrepositorygithub.com/muxinc/media-elements, org-published. mechanical check: no file ofmedia-chromematching**/*.jsor**/*.d.tscontainsyoutube. Source: media-chrome.org/docs/en/media-element ↩ - YouTube — Video.js: plugin. No YouTube tech in core. Plugin
videojs-youtube: npmrepositorygithub.com/videojs/videojs-youtube, org-published. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsyoutube. Source: registry.npmjs.org/videojs-youtube ↩ - YouTube — Video.js 10 (rc): plugin. Changed since
10.0.0-beta.32:YouTubeVideois still exported from@videojs/react/media/youtube-video, but that adapter now imports@videojs/youtube-video, a package@videojs/react's ownpeerDependenciesMetamarks optional and this pinned install does not add, so importing the subpath throws rather than exporting the component. Plugin@videojs/youtube-video: npmrepositorygithub.com/videojs/v10, org-published. mechanical check:@videojs/react'sdocs/reference/components/youtube-video.mdincludespnpm add @videojs/youtube-video. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (reference/components/youtube-video.md, "Import") ↩ - Vimeo — Playdeck: yes. mechanical check:
@playdeck/core'sdist/types.d.tsincludesVimeoSource. Source: packages/core/dist/types.d.ts (PlayerSource); packages/provider-vimeo ↩ - Vimeo — react-player: yes. mechanical check:
react-player'sdist/players.jsincludescanPlay: canPlay.vimeo. Source: react-player 3.4.0, node_modules/react-player/dist/players.js (lazyvimeo-video-element) ↩ - Vimeo — Vidstack: yes. mechanical check:
@vidstack/reactexportsVimeoProviderLoader. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Vimeo — Media Chrome: plugin. No provider module of its own; the documented compatible element is
<vimeo-video>. Pluginvimeo-video-element: npmrepositorygithub.com/muxinc/media-elements, org-published. mechanical check: no file ofmedia-chromematching**/*.jsor**/*.d.tscontainsvimeo. Source: media-chrome.org/docs/en/media-element ↩ - Vimeo — Video.js: plugin. No Vimeo tech in core. Plugin
videojs-vimeo: npmrepositorygithub.com/eXon/videojs-vimeo, third-party. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsvimeo. Source: registry.npmjs.org/videojs-vimeo ↩ - Vimeo — Video.js 10 (rc): plugin. Changed since
10.0.0-beta.32:VimeoVideois still exported from@videojs/react/media/vimeo-video, but that adapter now imports@videojs/vimeo-video, a package@videojs/react's ownpeerDependenciesMetamarks optional and this pinned install does not add, so importing the subpath throws rather than exporting the component. Plugin@videojs/vimeo-video: npmrepositorygithub.com/videojs/v10, org-published. mechanical check:@videojs/react'sdocs/reference/components/vimeo-video.mdincludespnpm add @videojs/vimeo-video. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (reference/components/vimeo-video.md, "Import") ↩ - Wistia — Playdeck: yes. mechanical check:
@playdeck/core'sdist/types.d.tsincludesWistiaSource. Source: packages/core/dist/types.d.ts (PlayerSource); packages/provider-wistia ↩ - Wistia — react-player: yes. mechanical check:
react-player'sdist/players.jsincludescanPlay: canPlay.wistia. Source: react-player 3.4.0, node_modules/react-player/dist/players.js ↩ - Wistia — Vidstack: no. Searched for
istia, which catches bothwistiaandWistia; the same token is used in the Video.js column. mechanical check: no file of@vidstack/reactmatching**/*.jsor**/*.d.tscontainsistia. Source: @vidstack/react 1.15.6, every.jsand.d.tsfile in node_modules/@vidstack/react (installed package) ↩ - Wistia — Media Chrome: plugin. No provider module of its own; the documented compatible element is
<wistia-video>. Pluginwistia-video-element: npmrepositorygithub.com/muxinc/media-elements, org-published. mechanical check: no file ofmedia-chromematching**/*.jsor**/*.d.tscontainswistia. Source: media-chrome.org/docs/en/media-element ↩ - Wistia — Video.js: no. mechanical check: no file of
video.jsmatching**/*.jsor**/*.d.tscontainsistia. Source: video.js 8.24.0, every.jsand.d.tsfile in node_modules/video.js (installed package) ↩ - Wistia — Video.js 10 (rc): plugin. New since
10.0.0-beta.32, which shipped no Wistia adapter at all (every file of@videojs/reactand the five engine packages it then depended on was searched and none containedistia).10.0.0-rc.4addsWistiaVideoat@videojs/react/media/wistia-video, whose adapter imports the optional peer@videojs/wistia-video, not installed by this pinned fixture. Plugin@videojs/wistia-video: npmrepositorygithub.com/videojs/v10, org-published. mechanical check:@videojs/react'sdocs/reference/components/wistia-video.mdincludespnpm add @videojs/wistia-video. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (reference/components/wistia-video.md, "Import") ↩ - Other hosted providers (named) — Playdeck: partial. No named hosted platform ships in the box, but
Player.Root'sprovidersprop lets a consumer register one of their own: adetect/loadpair keyed by the source-kind name, tried once the five built-in kinds fail to detect a URL. Shipping an actual Twitch/Mux/etc. adapter through it is left to the consumer or a separate package. mechanical check:@playdeck/react'sdist/root.d.tsincludesreadonly providers?: P;. Source: packages/react/dist/root.d.ts (RootProps.providers) ↩ - Other hosted providers (named) — react-player: yes. Mux, Twitch, TikTok and Spotify each have their own
Configkey and a lazy-loaded provider. mechanical check:react-player'sdist/types.d.tsincludesmux?: Record<string, unknown>;. Source: react-player 3.4.0, node_modules/react-player/dist/types.d.ts (installed package) ↩ - Other hosted providers (named) — Vidstack: no. Providers beyond HLS/DASH/YouTube/Vimeo/audio/video are not hosted platforms (e.g. a Remotion render provider). mechanical check: no file of
@vidstack/reactmatching**/*.jsor**/*.d.tscontainsTwitch. Source: @vidstack/react 1.15.6, every.jsand.d.tsfile in node_modules/@vidstack/react (installed package) ↩ - Other hosted providers (named) — Media Chrome: plugin. The docs page lists 11 compatible elements. Beyond the HLS, DASH, YouTube, Vimeo and Wistia rows above, they are
<cloudflare-video>,<jwplayer-video>,<mux-video>,<shaka-video>,<spotify-audio>and<videojs-video>. Plugincloudflare-video-element: npmrepositorygithub.com/muxinc/media-elements, org-published. mechanical check: no file ofmedia-chromematching**/*.jsor**/*.d.tscontainscloudflare. Source: media-chrome.org/docs/en/media-element ↩ - Other hosted providers (named) — Video.js: no. mechanical check: no file of
video.jsmatching**/*.jsor**/*.d.tscontainsTwitch. Source: video.js 8.24.0, every.jsand.d.tsfile in node_modules/video.js (installed package) ↩ - Other hosted providers (named) — Video.js 10 (rc): plugin. Twitch, TikTok, Spotify, Cloudflare Stream and Mux each still ship as their own media component under the
@videojs/react/media/*subpath, but changed since10.0.0-beta.32: each adapter now imports its own optional peer package (@videojs/twitch-videohere), which@videojs/react's ownpeerDependenciesMetamarks optional and this pinned install does not add, so importing the subpath throws rather than exporting the component. Plugin@videojs/twitch-video: npmrepositorygithub.com/videojs/v10, org-published. mechanical check:@videojs/react'sdocs/reference/components/twitch-video.mdincludespnpm add @videojs/twitch-video. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (reference/components/twitch-video.md, "Import") ↩ - Audio tracks — Playdeck: yes. Provider limit, not a status: the YouTube, Vimeo and Wistia embeds report
selectAudioTrackas provider-unavailable (packages/provider-youtube, packages/provider-vimeo, packages/provider-wistia), as an embedded player would under any library here. mechanical check:@playdeck/reactexportsAudioTrackMenu. Source: packages/react/README.md ↩ - Audio tracks — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsAudioTrack. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Audio tracks — Vidstack: yes. mechanical check:
@vidstack/react'sprod/player/vidstack-default-components.jsincludesDefaultAudioTracksMenu. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/player/vidstack-default-components.js (installed package) ↩ - Audio tracks — Media Chrome: yes. mechanical check:
media-chrome/react/menuexportsMediaAudioTrackMenu. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Audio tracks — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesregisterComponent('AudioTrackButton'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Audio tracks — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsAudioTrackRadioGroup. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Chapters — Playdeck: yes. mechanical check:
@playdeck/reactexportsChaptersMenu. Source: packages/react/README.md ↩ - Chapters — react-player: no. Searched for
hapter, which catches bothchapterandChapter. mechanical check: no file ofreact-playermatching**/*.jsor**/*.d.tscontainshapter. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Chapters — Vidstack: yes. mechanical check:
@vidstack/react'sprod/player/vidstack-default-components.jsincludesDefaultChaptersMenu. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/player/vidstack-default-components.js (installed package) ↩ - Chapters — Media Chrome: partial. Shows the current chapter title while scrubbing; ships no chapters navigation menu. mechanical check:
media-chrome/reactexportsMediaPreviewChapterDisplay. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Chapters — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesregisterComponent('ChaptersButton'. Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Chapters — Video.js 10 (rc): yes. mechanical check:
@videojs/react'sdist/dev/index.d.tsincludesTimeSliderChapters. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Thumbnails / preview on seek — Playdeck: yes. mechanical check:
@playdeck/react'sdist/thumbnails.jsincludes"data-playdeck-part": "thumbnail". Source: packages/react/README.md ↩ - Thumbnails / preview on seek — react-player: no. The
lightprop is a static startup poster fetched through oEmbed (thumbnail_urlindist/Preview.js), not a hover/scrub seek preview. mechanical check: no file ofreact-playermatching**/*.jsor**/*.d.tscontainsthumbnails. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Thumbnails / preview on seek — Vidstack: yes. mechanical check:
@vidstack/reactexportsThumbnail. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Thumbnails / preview on seek — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaPreviewThumbnail. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Thumbnails / preview on seek — Video.js: plugin. No seek-preview thumbnail support in core. Plugin
videojs-sprite-thumbnails: npmrepositorygithub.com/phloxic/videojs-sprite-thumbnails, third-party. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsthumbnails. Source: registry.npmjs.org/videojs-sprite-thumbnails ↩ - Thumbnails / preview on seek — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsThumbnail. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Playlists — Playdeck: no.
Roottakes onesource, not a list. mechanical check: no file of@playdeck/coreand@playdeck/reactmatching**/*.jsor**/*.d.tscontainsPlaylist. Source: packages/core and packages/react, every.jsand.d.tsfile each ships underdist/afterpnpm build↩ - Playlists — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsPlaylist. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Playlists — Vidstack: no. The only
Playliststring in the package is thePlaylistIconart inicons.d.ts; no playlist component or state ships. mechanical check: no file of@vidstack/reactmatching**/*.jsor**/*.d.tscontainsPlaylistInstance. Source: @vidstack/react 1.15.6, every.jsand.d.tsfile in node_modules/@vidstack/react (installed package) ↩ - Playlists — Media Chrome: no. mechanical check: no file of
media-chromematching**/*.jsor**/*.d.tscontainsPlaylist. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - Playlists — Video.js: plugin. No playlist component in core (the
Playliststrings incore.es.jsare HLS media-playlist parsing). Pluginvideojs-playlist: npmrepositorygithub.com/brightcove/videojs-playlist, third-party. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsregisterComponent('Playlist. Source: registry.npmjs.org/videojs-playlist ↩ - Playlists — Video.js 10 (rc): no. Searched for
PlaylistProps, the name a Playlist component would carry in a package that gives every component anXPropstype: the bare word appears in@videojs/mediafor Google Cast queues and HLS media-playlist parsing. Its own migration guide lists playlists among the "genuinely missing features" that "need real work". mechanical check: no file of@videojs/reactand@videojs/coreand@videojs/mediaand@videojs/spfand@videojs/storeand@videojs/utilsmatching**/*.jsor**/*.d.tscontainsPlaylistProps. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (guides/migrate-from-video-js-8.md, "Plugins") ↩ - Ads / IMA — Playdeck: no. mechanical check: no file of
@playdeck/coreand@playdeck/reactmatching**/*.jsor**/*.d.tscontainsAdBreak. Source: packages/core and packages/react, every.jsand.d.tsfile each ships underdist/afterpnpm build↩ - Ads / IMA — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsAdBreak. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Ads / IMA — Vidstack: no. mechanical check: no file of
@vidstack/reactmatching**/*.jsor**/*.d.tscontainsAdBreak. Source: @vidstack/react 1.15.6, every.jsand.d.tsfile in node_modules/@vidstack/react (installed package) ↩ - Ads / IMA — Media Chrome: no. mechanical check: no file of
media-chromematching**/*.jsor**/*.d.tscontainsAdBreak. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - Ads / IMA — Video.js: plugin. No ad support in core. The ad-timeline framework is usually paired with Google's
videojs-ima(npmrepositorygithub.com/googleads/videojs-ima, third-party), which is not what this cell is anchored on. Pluginvideojs-contrib-ads: npmrepositorygithub.com/videojs/videojs-contrib-ads, org-published. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsAdBreak. Source: registry.npmjs.org/videojs-contrib-ads ↩ - Ads / IMA — Video.js 10 (rc): no. Its own migration guide says "If your player depends on an ads plugin, there's no v10 answer today". mechanical check: no file of
@videojs/reactand@videojs/coreand@videojs/mediaand@videojs/spfand@videojs/storeand@videojs/utilsmatching**/*.jsor**/*.d.tscontainsAdBreak. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (guides/migrate-from-video-js-8.md, "Plugins") ↩ - Analytics hooks — Playdeck: no. mechanical check: no file of
@playdeck/coreand@playdeck/reactmatching**/*.jsor**/*.d.tscontainsAnalytics. Source: packages/core and packages/react, every.jsand.d.tsfile each ships underdist/afterpnpm build↩ - Analytics hooks — react-player: no. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsAnalytics. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Analytics hooks — Vidstack: no. The only
Analyticsstring in the package documents a YouTube embed parameter (types/vidstack-instances.d.ts); no analytics component ships. mechanical check: no file of@vidstack/reactmatching**/*.jsor**/*.d.tscontainsAnalyticsInstance. Source: @vidstack/react 1.15.6, every.jsand.d.tsfile in node_modules/@vidstack/react (installed package) ↩ - Analytics hooks — Media Chrome: no. mechanical check: no file of
media-chromematching**/*.jsor**/*.d.tscontainsAnalytics. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - Analytics hooks — Video.js: plugin. No analytics reporting in core;
videojs-muxis the Mux Data SDK for Video.js. Pluginvideojs-mux: its published npm manifest declares norepositoryfield, so no owner is recorded here. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsAnalytics. Source: registry.npmjs.org/videojs-mux ↩ - Analytics hooks — Video.js 10 (rc): plugin. A
MuxDatacomponent ships in the package, and no other analytics vendor has a component here, but changed since10.0.0-beta.32: the component moved from@videojs/react/media/mux-datato@videojs/react/extensions/mux-data, and its adapter now imports the optional peer@videojs/mux-data, which@videojs/react's ownpeerDependenciesMetamarks optional and this pinned install does not add. Plugin@videojs/mux-data: npmrepositorygithub.com/videojs/v10, org-published. mechanical check:@videojs/react'sdocs/reference/components/mux-data.mdincludespnpm add @videojs/react @videojs/mux-data. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (reference/components/mux-data.md, "Import") ↩ - Plugin system — Playdeck: partial. Extensibility is still mostly React composition (compose primitives, pass props/render props), not a registry a plugin calls into -- with one seam:
Player.Root'sprovidersprop lets a consumer register adetect/loadpair for a source kind this package does not ship a loader for. No hook over commands or events exists yet. mechanical check:@playdeck/react'sdist/root.d.tsincludesreadonly providers?: P;. Source: packages/react/dist/root.d.ts (RootProps.providers) ↩ - Plugin system — react-player: yes.
ReactPlayer.addCustomPlayerandremoveCustomPlayersare assigned in the shipped code and typed indist/index.d.ts; they register and drop a custom player implementation. mechanical check:react-player'sdist/ReactPlayer.jsincludesReactPlayer.addCustomPlayer =. Source: react-player 3.4.0, node_modules/react-player/dist/ReactPlayer.js and README.md (installed package), theaddCustomPlayer/removeCustomPlayerslines ↩ - Plugin system — Vidstack: no. mechanical check: no file of
@vidstack/reactmatching**/*.jsor**/*.d.tscontainsregisterPlugin. Source: @vidstack/react 1.15.6, every.jsand.d.tsfile in node_modules/@vidstack/react (installed package) ↩ - Plugin system — Media Chrome: no. Extensibility is authoring another custom element, not a plugin registry. mechanical check: no file of
media-chromematching**/*.jsor**/*.d.tscontainsregisterPlugin. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - Plugin system — Video.js: yes. mechanical check:
video.js'sdist/video.es.jsincludesstatic registerPlugin(name, plugin). Source: video.js 8.24.0, node_modules/video.js/dist/video.es.js (installed package) ↩ - Plugin system — Video.js 10 (rc): no. Its own migration guide opens that section "v10 has no plugin system"; extension is composition, an ejected skin, or a swapped media component. mechanical check: no file of
@videojs/reactand@videojs/coreand@videojs/mediaand@videojs/spfand@videojs/storeand@videojs/utilsmatching**/*.jsor**/*.d.tscontainsregisterPlugin. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (guides/migrate-from-video-js-8.md, "Plugins") ↩ - Shipped skin / theme — Playdeck: yes.
theme.cssanddocked.cssare exports entries the primitives never import themselves (see "requires an external stylesheet" below). mechanical check:@playdeck/react'spackage.jsonincludes"./theme.css": "./theme.css". Source: packages/react/README.md ↩ - Shipped skin / theme — react-player: no. Ships no CSS file at all; visible controls are always the native
<video>chrome or an iframe provider's own UI. mechanical check:react-playerships no file matching**/*.css. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Shipped skin / theme — Vidstack: yes. mechanical check:
@vidstack/react/player/layouts/defaultexportsDefaultVideoLayout. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/player/layouts/default (installed package) ↩ - Shipped skin / theme — Media Chrome: no. No stylesheet and no
dist/themesdirectory ship.MediaThemeElementis a theming engine for a consumer's own template, not a pre-built skin. mechanical check:media-chromeships no file matching**/*.cssordist/themes/**/*. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - Shipped skin / theme — Video.js: yes. mechanical check:
video.js'spackage.jsondeclaresstyle. Source: video.js 8.24.0, node_modules/video.js/package.json (installed package) ↩ - Shipped skin / theme — Video.js 10 (rc): yes. The
@videojs/react/videopreset shipsskin.cssandminimal-skin.cssbeside itsVideoSkincomponent. mechanical check:@videojs/react'spackage.jsonincludes"./video/*.css". Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/package.json (installed package) ↩ - Headless, independently composable parts — Playdeck: yes. mechanical check:
@playdeck/reactexportsPlayButton. Source: packages/react/README.md ↩ - Headless, independently composable parts — react-player: no. One configured component; controls are either the native chrome or an iframe provider's own UI, not independently importable parts. mechanical check: no file of
react-playermatching**/*.jsor**/*.d.tscontainsPlayButton. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Headless, independently composable parts — Vidstack: yes. mechanical check:
@vidstack/reactexportsPlayButton. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Headless, independently composable parts — Media Chrome: yes. mechanical check:
media-chrome/reactexportsMediaPlayButton. Source: media-chrome 4.19.2, node_modules/media-chrome/README.md (installed package) ↩ - Headless, independently composable parts — Video.js: no. Searched for
jsx-runtimerather than a part name: the bundle does containbigPlayButton, but as an internal component id, and the question this axis asks is whether React parts are importable at all. This pinned package ships no React integration, so it has none; its own components are reachable imperatively (player.controlBar.getChild(...)). The videojs GitHub org publishes a separate React library,@videojs/react, which is the Video.js 10 (rc) column. mechanical check: no file ofvideo.jsmatching**/*.jsor**/*.d.tscontainsjsx-runtime. Source: video.js 8.24.0, every.jsand.d.tsfile in node_modules/video.js (installed package) ↩ - Headless, independently composable parts — Video.js 10 (rc): yes. mechanical check:
@videojs/reactexportsPlayButton. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/dist/dev/index.d.ts (installed package) ↩ - Requires an external stylesheet for usable controls — Playdeck: no. Searched for
.css"-- a quoted import specifier, the shape a real self-import would take in this build's output -- rather than the bare extension:@playdeck/react/browserbundles ReactDOM, whose own style-property helpers readelement.cssFloatandelement.cssText, both of which contain the bare substring. No shipped JavaScript imports a stylesheet; an unstyled composition still renders and operates. mechanical check: no file of@playdeck/coreand@playdeck/reactmatching**/*.jscontains.css". Source: packages/core and packages/react, every.jsand.d.tsfile each ships underdist/afterpnpm build↩ - Requires an external stylesheet for usable controls — react-player: no. mechanical check: no file of
react-playermatching**/*.jscontains.css. Source: react-player 3.4.0, every.jsand.d.tsfile in node_modules/react-player (installed package) ↩ - Requires an external stylesheet for usable controls — Vidstack: yes. mechanical check:
@vidstack/react'splayer/styles/default/theme.cssincludesPlayer. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/player/styles/default/theme.css (installed package) ↩ - Requires an external stylesheet for usable controls — Media Chrome: no. Each custom element ships its own Shadow DOM styles. mechanical check:
media-chrome'sdist/media-chrome-button.jsincludesattachShadow. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - Requires an external stylesheet for usable controls — Video.js: yes. mechanical check:
video.js'spackage.jsondeclaresstyle. Source: video.js 8.24.0, node_modules/video.js/package.json (installed package) ↩ - Requires an external stylesheet for usable controls — Video.js 10 (rc): yes. Changed since
10.0.0-beta.32: the skin's own type declaration (dist/dev/presets/video/skin.d.ts) no longer names the stylesheet in its doc comment, so this cell now cites the guide's ownimport '@videojs/react/video/skin.css';example instead. The shipped stylesheet itself (dist/default/presets/video/skin.css) is unchanged. mechanical check:@videojs/react'sdocs/guides/skins.mdincludesimport '@videojs/react/video/skin.css';. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/docs (the package ships its own documentation) (guides/skins.md, "Packaged skin") ↩ - Lazy / deferred provider loading — Playdeck: yes. mechanical check:
@playdeck/react'sdist/index.jsincludesimport(. Source: packages/react/README.md ("Provider packages are pulled in as dependencies but loaded lazily") ↩ - Lazy / deferred provider loading — react-player: yes. mechanical check:
react-player'sdist/players.jsincludeslazy(. Source: react-player 3.4.0, node_modules/react-player/README.md (installed package) ↩ - Lazy / deferred provider loading — Vidstack: yes. mechanical check:
@vidstack/react'sprod/vidstack.jsincludesimport(. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/prod/vidstack.js (installed package, the file a bare import resolves to) ↩ - Lazy / deferred provider loading — Media Chrome: n/a. The axis cannot apply: media-chrome ships no provider or engine module of its own to defer -- its controller wraps whatever media element a consumer slots in. mechanical check: no file of
media-chromematching**/*.jsor**/*.d.tscontainshls. Source: media-chrome 4.19.2, every.jsand.d.tsfile in node_modules/media-chrome (installed package) ↩ - Lazy / deferred provider loading — Video.js: no. The HLS/DASH engine (videojs-http-streaming, mpd-parser, m3u8-parser) is a static import with no dynamic boundary. mechanical check: no file of
video.jsmatching**/*.jscontainsimport(. Source: video.js 8.24.0, every.jsand.d.tsfile in node_modules/video.js (installed package) ↩ - Lazy / deferred provider loading — Video.js 10 (rc): no. Searched across the two of the six packages that ship the providers and playback engines, because that is what this axis asks about. Each media component is its own
@videojs/react/media/*subpath a consumer imports statically, so a page pays only for the one it names, and none is deferred. Dynamicimport()does appear elsewhere in the six:@videojs/core'sdist/*/core/i18n/load-locale.jsdefers 53 translation packs, which are locales rather than providers. mechanical check: no file of@videojs/reactand@videojs/mediamatching**/*.jscontainsimport(. Source: @videojs/react 10.0.0-rc.4, every.jsfile in node_modules/@videojs/react and node_modules/@videojs/media (installed packages) ↩ - React version supported — Playdeck: yes.
>=19 <20-- React 19 only. mechanical check:@playdeck/react'spackage.jsondeclarespeerDependencies.react. Source: packages/react/package.json ↩ - React version supported — react-player: yes.
^17.0.2 || ^18 || ^19. mechanical check:react-player'spackage.jsondeclarespeerDependencies.react. Source: react-player 3.4.0, node_modules/react-player/package.json ↩ - React version supported — Vidstack: yes.
^18.0.0 || ^19.0.0. mechanical check:@vidstack/react'spackage.jsondeclarespeerDependencies.react. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/package.json (installed package) ↩ - React version supported — Media Chrome: partial. Ships a React wrapper (generated at build time via
ce-la-react, a runtime dependency) but declares no peer range for it; itspackage.jsondevDependenciespinreact19.2.2. mechanical check:media-chrome'spackage.jsonhas nopeerDependencies.react. Source: media-chrome 4.19.2, node_modules/media-chrome/package.json ↩ - React version supported — Video.js: no. This pinned package ships no React integration, so it declares no React range. The videojs GitHub org publishes a separate React library,
@videojs/react, which is the Video.js 10 (rc) column and declares^18.0.0 || ^19.0.0. mechanical check:video.js'spackage.jsonhas nopeerDependencies. Source: video.js 8.24.0, node_modules/video.js/package.json (installed package) ↩ - React version supported — Video.js 10 (rc): yes.
^18.0.0 || ^19.0.0. mechanical check:@videojs/react'spackage.jsondeclarespeerDependencies.react. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/package.json (installed package) ↩ - Imports on a server (no DOM globals) — Playdeck: yes. mechanical check: importing
@playdeck/reactin plain Node, with no DOM globals, succeeds. Source: packages/react, the built@playdeck/reactthis generator imports ↩ - Imports on a server (no DOM globals) — react-player: yes. mechanical check: importing
react-playerin plain Node, with no DOM globals, succeeds. Source: react-player 3.4.0, node_modules/react-player (installed package) ↩ - Imports on a server (no DOM globals) — Vidstack: yes. Also ships a dedicated
server/workerbuild condition (server/vidstack.js) alongside the'use client'entry. mechanical check: importing@vidstack/reactin plain Node, with no DOM globals, succeeds. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react (installed package) ↩ - Imports on a server (no DOM globals) — Media Chrome: yes. Renders as inert custom-element markup during SSR (guarded by
isServer) without a'use client'boundary; behaviour attaches on hydration. mechanical check: importingmedia-chromein plain Node, with no DOM globals, succeeds. Source: media-chrome 4.19.2, node_modules/media-chrome (installed package) ↩ - Imports on a server (no DOM globals) — Video.js: yes. The package Node loads for
import 'video.js'is its CJS build (main,dist/video.cjs.js); it loads with nowindowordocumentpresent. It ships no React integration, so it carries no'use client'boundary either way. mechanical check: importingvideo.jsin plain Node, with no DOM globals, succeeds. Source: video.js 8.24.0, node_modules/video.js (installed package) ↩ - Imports on a server (no DOM globals) — Video.js 10 (rc): yes. mechanical check: importing
@videojs/reactin plain Node, with no DOM globals, succeeds. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react (installed package) ↩ - TypeScript types shipped — Playdeck: yes. mechanical check:
@playdeck/react'spackage.jsonincludes"types": "./dist/index.d.ts". Source: packages/react/package.json ↩ - TypeScript types shipped — react-player: yes. mechanical check:
react-player'spackage.jsonincludes"types": "./dist/index.d.ts". Source: react-player 3.4.0, node_modules/react-player/package.json ↩ - TypeScript types shipped — Vidstack: yes. mechanical check:
@vidstack/react'spackage.jsondeclarestypes. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/package.json (installed package) ↩ - TypeScript types shipped — Media Chrome: yes. mechanical check:
media-chrome'spackage.jsonincludes"types": "./dist/react/index.d.ts". Source: media-chrome 4.19.2, node_modules/media-chrome/package.json ↩ - TypeScript types shipped — Video.js: yes. mechanical check:
video.js'spackage.jsondeclarestypes. Source: video.js 8.24.0, node_modules/video.js/package.json (installed package) ↩ - TypeScript types shipped — Video.js 10 (rc): yes. mechanical check:
@videojs/react'spackage.jsondeclarestypes. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/package.json (installed package) ↩ - ESM/CJS (dual build) — Playdeck: partial. ESM-only (
"type": "module"); therequirecondition resolves to a stub that throws by name. mechanical check:@playdeck/react'sesm-only.cjsincludesESM only. Source: packages/react/esm-only.cjs ↩ - ESM/CJS (dual build) — react-player: partial. ESM-only (
"type": "module", norequireexport condition). mechanical check: no file ofreact-playermatchingpackage.jsoncontains"require":. Source: react-player 3.4.0, node_modules/react-player/package.json ↩ - ESM/CJS (dual build) — Vidstack: partial. ESM-only (
"type": "module", norequireexport condition). mechanical check: no file of@vidstack/reactmatchingpackage.jsoncontains"require":. Source: @vidstack/react 1.15.6, node_modules/@vidstack/react/package.json (installed package) ↩ - ESM/CJS (dual build) — Media Chrome: yes. mechanical check:
media-chrome'spackage.jsonincludes"require":. Source: media-chrome 4.19.2, node_modules/media-chrome/package.json ↩ - ESM/CJS (dual build) — Video.js: yes.
main(CJS,dist/video.cjs.js) andmodule(ESM,dist/video.es.js) are both published. mechanical check:video.js'spackage.jsondeclaresmain. Source: video.js 8.24.0, node_modules/video.js/package.json (installed package) ↩ - ESM/CJS (dual build) — Video.js 10 (rc): partial. ESM-only (
"type": "module", norequireexport condition). mechanical check: no file of@videojs/reactmatchingpackage.jsoncontains"require":. Source: @videojs/react 10.0.0-rc.4, node_modules/@videojs/react/package.json (installed package) ↩
Comparing Playdeck against other React video libraries: method
This is a snapshot, and a page: every document in this directory is rendered
whole at apps/site's /guides/comparison/ route, because that build reads
docs/comparison/ at build time. test:site-links and test:site-analytics
walk the built site rather than docs/ directly, and the comparison route is
part of what they walk now. Publishing it there is a maintainer ruling
recorded on issue #637, amending the #543 ruling below for this one route: the
standing rule for / is "no claim about any other library, named or implied",
and this work exists to produce data and a method for that decision, not to
pre-empt it.
Everything a reader would need to dispute a figure lives in this repository:
the fixture that composes each library (tests/compare/), the harness that
bundles and measures it (scripts/compare-libraries.mjs, with its pure logic
covered by scripts/compare-libraries.test.mjs), and the numbers themselves
(docs/comparison/results.md, regenerated by pnpm compare:libraries and
checked by pnpm compare:libraries:check). Re-run it, read the entry file for
any row that looks wrong, or read the harness's own header comment for how
each of the two figures is built up from its own bundler's module graph --
Rollup's, via Vite, for one; esbuild's own for the other.
Who this is for
The reader this page is written for is a product team embedding video in a React application — a course platform, a catalogue, a marketing site, or documentation — choosing what to build the player on.
A consumer imports and composes exactly the parts they use: Player.Root,
Player.Viewport and Player.Media for the core and its native provider, then
whichever control parts the product needs on top — an ActivationButton, or a
Controls wrapper holding some combination of PlayButton, MuteButton,
VolumeSlider, SeekSlider, Time and FullscreenButton. results.md's
bundle tables measure four such compositions — Playdeck (no parts),
Playdeck, Playdeck (play-only) and Playdeck (control bar) — each
described row by row in "Equivalent composition per library" below; the table
carries what each one costs.
Most performant is a claim about this repository's own rules, not a
comparative one — nothing this page measures is a runtime number; "What is
not measured, and why" below says exactly that about playback performance.
What backs the word instead is a set of gates this repository fails its own
build over: the committed per-row ceiling on the four Playdeck compositions
("Date and how to re-run" below), which fails pnpm compare:libraries and
pnpm compare:libraries:check the moment a row's gzipped size passes it; the
per-row forbidden-module matrix (forbiddenPartsExcept in
scripts/compare-libraries.mjs), which fails the same two commands the
moment any of the four Playdeck rows' reachable chunks touch a control part
it does not render, or one of the four named non-native provider adapters
the check lists (youtube, wistia, hls, vimeo); and
tests/bundle/native-only/test.mjs, run by pnpm test:bundle in a real
Chromium, which fails if any provider chunk is requested before a consumer
clicks the activation button. None of the three measures an alternative;
all three measure Playdeck against a number or a rule this repository
committed to and can fail its own build on.
Three things Playdeck deliberately does not do, restated here because a reader comparing libraries meets the gap before they would find the reason. Two of them — DASH and DRM — are each already decided and recorded elsewhere in this repository; the third, ads and playlists, has not shipped, with nothing beyond that absence recorded anywhere:
DASH stays out of scope. .out-of-scope/dash.md is the record: no browser
plays DASH natively the way Safari and iOS already play HLS from a plain
<video src>, so a DASH provider costs a full adaptive-bitrate engine on every
platform, with no platform where it costs only an adapter the way HLS does.
The decision is dash.md's, and #447 — filed under its "Prior requests"
heading — is what prompted it.
DRM is out of scope, and nothing here builds toward it: Playdeck never
calls requestMediaKeySystemAccess, and no provider it ships exposes a
key-system option for a consumer to configure. @playdeck/provider-hls's full
hls.js build carries hls.js's own EME support; its README names EME among what
the light build compiles out, which is the only place this adapter's own
docs mention it — there is no option anywhere in createHlsProvider that
turns it on. Where DRM plays at all here, it plays inside a provider's own
iframe under that provider's own rules — docs/third-party-requests.md
records YouTube's allow list carrying encrypted-media, and Vimeo's
withholding it as Playdeck writes the frame, though the Vimeo SDK's own
updateDRMEmbeds routine appends the grant back and reloads the frame if it
reports a DRM-initialisation failure — which is a fact about those frames,
not a capability Playdeck built or configures.
Ads and playlists are out of scope. Both belong to the broadcast shape of a
player — a single surface that owns an ad break or a queue across items — and
neither has shipped in this repository. Extension today is React composition,
not a plugin registry (docs/comparison/features.md's "Plugin system" row).
What is measured
Gzipped bytes for one fixed composition per library: a player that plays one
MP4 URL (https://example.com/video.mp4, never fetched — nothing here loads a
real network resource) with that library's own idea of default controls.
React, ReactDOM and the JSX runtime are marked external for every library
alike and excluded from every figure, because every one of them needs a
consumer to already have them and none of them would ship them — charging one
library for a dependency they all share would not be a fact about that
library.
The number is a sum of gzip sizes, one per JavaScript chunk a browser is
guaranteed to fetch for that fixed composition, not one gzip of everything the
library ships. scripts/compare-libraries.mjs's header explains the full
mechanics; in short, each entry is put through an ordinary vite build
(write: false, in memory) so a library's own code-splitting survives as
separate Rollup chunks, and only the chunks reachable without resolving a
dynamic import() this fixture's fixed inputs cannot reach are counted. CSS is
not imported by any entry and is not counted for any library — see "Where each
alternative measures smaller, or wins on something else" below for what that
costs each one differently.
results.md's "Not counted" column is the other half of that same build:
every chunk that entry's vite build emitted that reachableChunks did not
count, gzipped and summed the same way the "Gzipped" column is. What it holds
differs by row. For Playdeck it is the other four provider adapters and
hls.js, each loaded only for a source that actually needs it. For
react-player it is its other, lazily-loaded provider packages. For Vidstack
it is the split-off UI and caption modules the "Equivalent composition per
library" section below names individually. For Media Chrome, Video.js and
Video.js 10 (rc) it is nothing — each emits a single chunk, with no split
to report. The column
exists so "Gzipped" cannot hide code-splitting in either direction: a library
that defers a lot would otherwise look smaller than what it actually ships,
and a library that splits nothing gets no credit for that either without a
number sitting next to it. It is measured from the same build the "Gzipped"
figure comes from, not read off a library's documentation.
Cross-checked with a second bundler
Every row in results.md is bundled twice: once with Vite
(bundleEntryVite) and once, independently, with esbuild
(bundleEntryEsbuild), from the same entry file, the same externals, and the
same reachableChunks rule -- scripts/compare-libraries.mjs's "the esbuild
cross-check" header section has the exact esbuild options. results.md
prints both as "Gzipped (Vite)" and "Gzipped (esbuild)". This is not
redundant with picking one bundler and trusting it: a figure that depends on
which of this harness's own tools produced it would be a fact about the
harness, not about the library, and the whole point of naming a bundler in
the first place is that a reader can pick a different one and check. Two
bundlers agreeing to within a few percent is what shows a figure is close to
that ceiling; a bigger gap needs a name for what caused it, not a shrug.
A correction to a premise it would be easy to bring to this comparison: this
repository's pinned vite (8.1.5) does not bundle or minify through esbuild.
Its own package.json depends on rolldown — this is the merged,
Rolldown-based line of Vite — so its default minifier is Rolldown's own
(built on oxc-minify), a different implementation from esbuild's. esbuild
0.28.1 is also one of Vite's own dependencies, but as a transform fallback,
not as what actually bundles or minifies these entries. The two paths
measured here are therefore independent on bundling, tree-shaking, chunk
splitting and minification alike -- not, as it would be easy to assume, two
runs of the same minifier under two different bundlers.
Every row's actual delta is results.md's own "Delta" column -- generated
by scripts/compare-libraries.mjs's delta function from the same two
figures the "Gzipped" columns print, rather than typed here, so it can never
disagree with them. What follows explains the shape of that column, not
its numbers: read the numbers from the table, not from this paragraph.
Media Chrome and Video.js have no dynamic import() anywhere in their
reachable code (confirmed from both builds' own graphs: neither entry has a
dynamicImports edge at all), so nothing about code-splitting or reachability
can explain their gaps. What is left is exactly the two minifiers producing
different bytes for the same input, which is expected of two different
implementations and is the kind of disagreement this cross-check is supposed
to surface as normal rather than hide: a small, one-directional gap, on a row
with nothing else that could move it, is minifier noise, not a finding.
Video.js 10 (rc) is not one of those two, and carries the table's
second-largest gap, so it was traced rather than filed under the same
heading. Its graph does contain dynamic imports: @videojs/core's
dist/default/core/i18n/load-locale.js holds a loaders map of 53
() => import("../../i18n/locales/…") entries, one per translation pack, and
esbuild's own metafile lists that module among the build's inputs. What
happens to them differs by bundler. This fixture registers no locale, so the
module that would reach them is dropped from the output either way: Vite emits
one chunk, which contains neither load-locale nor any import( at all,
while esbuild still emits the 53 packs (plus two re-export shims) as separate
outputs that its entry chunk does not import. reachableChunks counts the
entry's own closure, so neither figure is charged for them, and results.md's
"Not counted" column — which reports the Vite build — has no such chunk to
report. What is left between the two figures is minification: the two entry
chunks were compared module by module and hold the same set of source
modules.
Playdeck, Playdeck (control bar), and Vidstack carry the same kind of
minifier noise plus one more, structural difference: their entry chunks
contain a dynamic import(), and Vite (through Rolldown) wraps every dynamic
import in its own module-preload machinery by default -- a __vite__mapDeps
table plus a small runtime that injects a <link rel="modulepreload"> for
each target's own static dependencies before resolving the import. esbuild's
plain import() output carries no such wrapper. Confirmed directly rather
than inferred, by isolating that machinery in Vite's own output for
react-player's entry (the smallest row in the table): it accounts for most of
the
gap its Delta cell reports. Every other row's entry is large enough that the
same fixed cost reads as a few percent rather than the dominant term.
react-player is therefore the one row where esbuild's figure is smaller than Vite's, and by a wide margin proportionally the largest gap in the table (see its Delta cell), for a reason that has nothing to do with react-player's own code: its reachable bundle is small enough that Vite's own preload-helper overhead, which every row with a dynamic import carries, is no longer a rounding error next to it. Nothing here suggests either bundler is wrong -- both are reporting what a page built with that bundler would actually download, preload helper included for Vite's -- so the gap is reported as what it is rather than smoothed into agreement.
What is not measured, and why
Playback performance. Time-to-first-frame and similar are dominated by the network and the source provider, not by which library requested the play — a number that noisy would be worse than none. The maintainer ruling on #543 rules this out explicitly.
DRM, casting, analytics, and other capabilities no fixture here exercises. Measuring whether a library merely has a capability by reading its own documentation would be exactly the "reading of a competitor's README" the issue's acceptance criteria forbid. The two axes the Agent Brief on #543 names as worth trying — capability coverage and provider reach — were tried and dropped; see the next two sections for why.
Capability coverage — which of the capabilities Playdeck's Availability
and PlayerCapabilities model (CONTEXT.md's "Availability" entry) each other
library exposes at all, and whether it reports why when a capability is
unavailable. This is not mechanically derivable from an installed package in
any way this harness could reproduce and re-check on every run. Playdeck's
answer is a closed union with a reason attached
(available / unknown with a reason / unavailable with a reason); the
alternatives each expose their own state through their own, differently-shaped
API, and there is no common shape a script can query without a person first
reading each library's types and deciding what counts as an equivalent
answer. That reading is exactly the kind of "impression" the issue's
acceptance criteria rule out for a figure that claims to be measured, so no
cell for this axis appears in results.md.
The same axis was later tried on the features table (#638) and dropped there
too, for the same reason plus a sharper one — see this document's "Features"
section below. In short: the check that had been standing in for it searched
each other library for Playdeck's own identifier, Availability, which
answers a question about naming rather than about behaviour, and the wider
search the features generator now runs shows the naming does not hold either
(media-chrome declares an AvailabilityStates of its own in
dist/constants.js,
and @videojs/react's own documentation describes a three-state
available / unavailable / unsupported model). CONTEXT.md carries
Playdeck's own claim about this; neither table makes one about anybody
else's.
Provider reach — which sources each library drives natively. Also not
mechanically derivable in a way this harness can keep honest across runs.
Where a library keeps its own provider list as data this repository could in
principle read — react-player's players.js is a literal array of
{ key, canPlay, player } entries, and @videojs/react publishes one media
component per provider under its own media/* export subpath — the others do
not: Media Chrome ships no provider or engine module at all, and its
controller wraps whatever media element the consumer slots in; video.js 8's
reach is whatever videojs-http-streaming and its own tech registry decide to
claim for a MIME type at runtime; and Vidstack's is scattered across
per-provider loader classes with no single enumerable list. A parser that
reached into one library's internal, unstable module shape to produce a
count would be bespoke, unverified by any test that would catch it silently
going stale, and would read as a measurement while actually being a guess
about a private surface. No cell for this axis appears in results.md
either. The features table answers the same ground a different way, one named
provider per row (HLS, DASH, YouTube, Vimeo, Wistia, other hosted providers),
each cell anchored to something in the installed package rather than to a
count nobody can re-derive.
The original issue body also proposed testing the loading="interaction"
claim — what loads before a click — against the other libraries by the same
method. The Agent Brief that supersedes the issue body on #543 narrows the
required axes to gzipped bytes, capability coverage and provider reach, and
does not carry that probe forward; it would need a real browser
(tests/bundle/native-only/test.mjs is what does this for Playdeck alone,
with Playwright) rather than the static bundle analysis this harness does, and
is left as a possible follow-up rather than attempted here.
Which libraries, and why
The issue names six candidates: Vidstack, Media Chrome / Mux Player, react-player, Plyr, Video.js with a React wrapper, and Shaka Player. Five libraries are measured, one of them the second, separately-published line of the Video.js name.
In:
Each entry says what was checked rather than how the library is generally spoken of. "Most popular" and "most established" are not facts this repository can re-derive on a later run, so they are not used.
- react-player — a general-purpose React video component whose own
dist/players.jscarries ten provider entries (hls,dash,mux,spotify,tiktok,twitch,vimeo,wistia,youtubeand thehtmlfallback), the widest provider list of anything measured here and the reason its "other hosted providers" cell is the onlyyeson that axis: Video.js 10 (rc)'s equivalent providers moved to separate, optional peer packages in the rc line and its cell ispluginrather thanyes(see "Features" below). - Vidstack — a React-first, composable player that also ships a configured
default skin:
@vidstack/react/player/layouts/defaultexportsDefaultVideoLayout, which is what this comparison's Vidstack entry renders. That combination — composable parts plus a ready skin — is the closest structural match here to "Playdeck plus a themed layout". - Media Chrome — a set of custom elements with a React wrapper. It ships no provider or engine module; its controller wraps whatever media element the consumer slots into it (its own docs list eleven separate elements a consumer can slot in), which is the candidate closest to Playdeck's own compose-it-yourself design.
- Video.js — the oldest package measured here: npm records
time.createdforvideo.jsas 2013-08-21. The pinned stable line,video.js8.24.0, ships no React integration of its own, which is itself a data point: what a React reader gets from that package is a class-based library and a hand-writtenuseEffect. That is a fact about the pinned package and not about the project, which is the next entry. - Video.js 10 (rc) —
@videojs/react10.0.0-rc.4, published by the videojs GitHub org (its ownrepositoryfield is github.com/videojs/v10, directorypackages/react; first published 2025-10-25). It is not a React wrapper aroundvideo.js8.24.0: it is the Video.js 10 rewrite, depending on its own@videojs/core,@videojs/media,@videojs/spf,@videojs/storeand@videojs/utilsengine packages and not onvideo.jsat all, and its own README now marks it release candidate ("Close to stable. Adoption in real projects encouraged."). It is measured as its own row and its own column, under the same rules as every other one, because it is a React video library a reader can install today — and both tables label it "Video.js 10 (rc)" so no figure of its is ever read asvideo.js8.24.0's, or the reverse.
Out:
- Shaka Player — a playback engine (DASH/HLS/DRM), not a React component
library. What was checked, rather than assumed: the
shaka-playerpackage publishes noexportsmap and itsmainandtypesfields point atdist/shaka-player.compiled.jsand.d.ts, so it has no React entry point to install; and@shaka-project/reactis not a published package (npm returns 404). A third-party wrapper does exist on npm (shaka-player-react), so this is not a claim that no React binding exists anywhere — only that the project publishes none. It occupies the same role hls.js already does for Playdeck — a provider dependency an adapter could wrap — not a peer to measure a "default controls" composition against. Comparing an application-level component library to a playback engine would not be like with like. Media Chrome's own docs list<shaka-video>among the elements its controller can wrap, which is the same relationship. - Plyr — ships no React integration either, which is the reason
video.jswas chosen to represent that category. Plyr andvideo.js8 would both be measured the same way here (the base library plus a hand-writtenuseEffectwrapper this harness, not the library, authors), so a second entry in that shape would not add a materially different choice for a React reader deciding between component libraries. It is dropped for that reason, not because installing or bundling it failed. - Mux Player — built on Media Chrome, which is measured directly:
@mux/mux-player's owndependenciesaremedia-chrome,player.style,@mux/mux-videoand@mux/playback-core, so measuring it would measure Media Chrome plus a configured skin and Mux's own playback core. Two of those four dependencies are Mux's own, so a plain MP4 URL from an arbitrary host is not the composition the package is assembled for, and its row would not be measuring the same thing the other rows measure.
Every library measured installed and bundled without needing to be dropped for
a technical reason; the "install or bundle failed" escape hatch in the Agent
Brief was not needed for any of them, @videojs/react included.
Vidstack's version
Checked 2026-09-05: @vidstack/react's latest npm dist-tag resolved to
0.6.15, which has no DefaultVideoLayout export — the import this fixture
needs. Its next dist-tag resolved to 1.15.6, which is what
vidstack.io's documentation installs. This measures
1.15.6 for that reason, and records the tag mismatch here so nobody reads
results.md's version column against npm view @vidstack/react version and
concludes the pin is stale.
Equivalent composition per library
The judgement call the Agent Brief asks to be written down: what "the same thing" means across six libraries with six different ideas of what a video player is.
Playdeck is measured four times, as four rows, because one composition cannot be the fair comparison for react-player (which draws no control surface of its own), for the "only what you use loads" claim (which needs a number for one control, not zero or five), and for Media Chrome, Vidstack and Video.js (which each draw a full control bar). The issue's own warning about comparing a headless core against a library that "bundles their own UI or a full playback engine" applies just as much to comparing the wrong Playdeck row against the wrong alternative: the one-button row against a seven-button bar, or the control-bar row against a composition with no control bar at all, would each overstate a gap that is really a difference in what was built, not in how much either library costs for the same thing.
Playdeck (no parts) (tests/compare/entries/playdeck-no-parts.tsx) is
Player.Root, Player.Viewport and Player.Media alone — core plus the
native provider, and no control part of any kind, not even the one-button
Player.ActivationButton the row below adds. This row is the fair
comparison against react-player's row, and is the more honest one of the two
Playdeck rows that could be read against it: react-player renders no UI of its
own for a plain MP4 either (see the react-player paragraph below), so a row
that also renders none is the one that isolates the library cost from the "did
it draw a button" difference — the plain Playdeck row one paragraph down
draws one.
Playdeck (tests/compare/entries/playdeck.tsx) is the composition
README.md's byte table already calls "MP4 or WebM": Player.Root with
loading="interaction", Player.Viewport, Player.Media, and one
Player.ActivationButton holding a Player.PlayIcon. It is reused verbatim
from tests/bundle/native-only/src/main.tsx rather than redrawn for this
harness, so this document cannot describe Playdeck's own minimal player any
more generously than the fixture that gates its bundle budget already does.
This is one button, not a control bar — Playdeck is headless by design and
ships no built-in seek bar, volume control or time display; a consumer adds
exactly the parts they use. Its row in results.md is smaller than
README.md's own byte table figure for the same row, because that figure
deliberately does not tree-shake (it measures the whole @playdeck/react
package, to bound any selection a consumer might make) while this harness
bundles this fixture's actual five imports through a real bundler. The two
numbers are not interchangeable, and this harness's approach is the one doing
here what it does for every other library: measuring what this specific,
named set of imports costs, not an upper bound over every possible selection.
This row sits between the no-parts row above it and the play-only row
below it rather than being the fair comparison against any other library's
row on its own: it draws one part more than react-player's no-UI composition
(an ActivationButton, not a PlayButton, so it is not the play-only row's
composition either), and one control fewer than the five-of-seven control bar
further down.
Playdeck (play-only) (tests/compare/entries/playdeck-play-only.tsx) is
the same fixture as the no-parts row, plus one Player.Controls wrapping one
Player.PlayButton with no icon children — the number behind the "only what
you use loads" claim, isolated to the smallest control a consumer could add.
This row is not the fair comparison against any other library's row: no
other library measured here ships a bare, unstyled play button as its own
default composition, so it exists to make one specific claim checkable
against a figure rather than to sit beside a competitor. Read it against the
no-parts row above it instead — the difference between the two rows in
results.md is what Player.Controls plus one Player.PlayButton cost on
their own, with the native provider and everything above them held fixed.
Playdeck (control bar) (tests/compare/entries/playdeck-control-bar.tsx)
is the same fixture plus a Player.Controls holding the parts that
correspond to Media Chrome's seven-button bar: PlayButton (swapping
PlayIcon/PauseIcon), MuteButton (swapping MutedIcon/VolumeHighIcon),
VolumeSlider, SeekSlider, Time (current and duration), and
FullscreenButton (swapping FullscreenEnterIcon/FullscreenExitIcon) —
every part and every prop shape copied from
apps/storybook/stories/reference/reference-player.tsx's own control bar
rather than invented for this fixture. This row carries five of Media
Chrome's seven controls, not seven: Playdeck ships no
seek-backward or seek-forward button part. SeekBackwardIcon and
SeekForwardIcon exist (packages/react/src/icons.tsx, re-exported from
packages/react/src/index.tsx) with no SeekBackwardButton or
SeekForwardButton beside them to hold one — confirmed by reading
packages/react/src/index.tsx and packages/react/src/transport-controls.tsx
rather than assumed. Time is included for the same reason the reference
composition never pairs a seek slider without one, though it answers nothing
on Media Chrome's own bar (built from MediaTimeRange alone, with no
MediaTimeDisplay) and is not part of the five-of-seven count. This row is
the fair comparison against Media Chrome, Vidstack, Video.js and Video.js 10
(rc), each of
which draws a full control bar rather than one button; see "Where each
alternative measures smaller, or wins on something else" below for the two
controls Media Chrome ships that this row does not.
react-player (entries/react-player.tsx) renders <ReactPlayer src="…mp4" controls />. For a plain file, react-player's own players.js
resolves to its html player — a thin wrapper around a native <video> — the
only provider in that array that is not React.lazy-loaded, so controls
here means the same thing it means on a bare <video> element: the browser's
own native control set, not any UI react-player draws itself. Its row in
results.md is smaller than Playdeck's no-parts row too, and the honest
reason is not that react-player is a smaller library; both rows draw no
custom UI, so the remaining gap is core-plus-native-provider weight, not a
button either row draws.
Vidstack (entries/vidstack.tsx) is <MediaPlayer src="…mp4"> wrapping
<MediaProvider /> and <DefaultVideoLayout icons={defaultLayoutIcons} /> —
Vidstack's own documented answer to "give me default controls", and one of the
two full, off-the-shelf skins measured here (the other is Video.js 10 (rc)'s
VideoSkin): a play button, seek bar, volume, fullscreen, and
settings/captions/chapters menus, all drawn by the library. results.md's "Not counted" column names the further
chunks the same build produced beyond that row's own figure. That count and
size are what the harness measures; what follows is a reading of those
chunks' moduleIds and code, taken on the measurement date and not
re-verified by pnpm compare:libraries:check — only the count and the total
size in that column are. Most are the caption/text-track pipeline the
media-captions dependency brings in (VTT/SRT/SSA parsing and an error
dialog), gated on a text track this fixture never adds; several are Google
Cast support; one is an AirPlay button; one is thumbnail-preview support,
gated on a source that publishes preview thumbnails, which this fixture's
plain MP4 does not; one is the chapters/quality/keyboard-shortcuts overlay;
and a remaining chunk this reading did not identify. None of them is
reachable without a text track, a casting device, AirPlay, alternate quality
or chapter tracks, or thumbnail previews — none of which this fixture's
fixed MP4 provides — so none are counted in that row's own figure.
Media Chrome (entries/media-chrome.tsx) has no single "default"
composition to reuse — like Playdeck, it publishes a set of parts rather than
a configured player — so this uses the control bar its own README teaches: a
<MediaController> wrapping a native <video> on the slot="media"
convention, with a <MediaControlBar> of seven buttons (play, seek back, seek
forward, mute, volume, time range, fullscreen). One measured, not asserted,
structural fact about this row: media-chrome's
React wrapper re-exports every custom element it has from one module
(media-chrome/react/index.js does import * as Modules from '../index.js'
and defines a React component for each), so importing any one control pulls
in the whole registry — a menu, a dialog, a live-stream button, none of which
this fixture's seven imports use — rather than only the seven this entry
names. That is Media Chrome's own module boundary, not a fixture choice.
Video.js (entries/video-js.tsx) is the video.js 8.24.0 package, which
ships no React integration, so this hand-writes the standard idiom: a
<video> ref, and videojs(videoRef.current, { controls: true, sources: […] }) called once after mount. controls: true
is what turns on video.js's own default skin — the big play button and
control bar drawn by its main bundle, with no separate package to opt into.
This row's own figure is close to gzipping video.js's own published
dist/video.min.js directly, which is the check that this row is measuring
the library and not this harness's four-line wrapper around it. The reason
the figure is the largest in the table: import videojs from 'video.js'
statically
pulls in videojs-http-streaming, mux.js, mpd-parser and m3u8-parser —
video.js's own HLS/DASH engine — with no dynamic import() boundary around any
of it, so it loads whether or not the page ever plays anything but the plain
MP4 this fixture asks for. That is measured from the installed package's own
dist/video.es.js, not asserted: the file has no code-splitting to preserve,
so nothing about it depends on this harness's own reachability logic.
Video.js 10 (rc) (entries/videojs-react.tsx) is @videojs/react
10.0.0-rc.4, a different library from the row above it — see "Which
libraries, and why". Its composition is the one the package's own bundled
documentation installs, read from
node_modules/@videojs/react/docs/guides/installation.md rather than from a
docs site: <VideoPlayer><VideoSkin><Video src playsInline /></VideoSkin></VideoPlayer>,
imported from the @videojs/react/video preset subpath. VideoSkin is that
preset's packaged default skin, which is this library's own answer to "default
controls", so this row belongs beside Vidstack's and Media Chrome's rather
than beside react-player's. That guide also imports
@videojs/react/video/skin.css; this entry drops it for the same reason no
other entry here imports a stylesheet, and the bullet below records what
excluding CSS costs this row. Its build emits one chunk with no dynamic
import(), so nothing about its figure depends on this harness's
reachability logic either.
Where each alternative measures smaller, or wins on something else
The issue's fairness rule is not satisfied by a bytes table alone, and reading
one number off results.md without this paragraph would misread it:
- react-player's row is smaller than Playdeck's no-parts row here (see
results.md), and both draw no custom UI — native browser controls on a bare<video>for react-player, no controls of any kind for Playdeck's no-parts row — so the remaining gap is not one either library spends on a button. A reader who wants zero custom UI gets it from react-player for less code than Playdeck's core plus native provider. - Media Chrome ships two controls Playdeck's control-bar row does not:
a seek-backward and a seek-forward button. Playdeck has no
SeekBackwardButtonorSeekForwardButtonpart — only the icons meant to fill one, with nothing built to hold them — so its row carries five of Media Chrome's seven controls, and a reader who wants those two exact buttons gets them from Media Chrome, not from Playdeck, without writing one first. See "Equivalent composition per library" above for how that fifth and sixth control were confirmed absent rather than assumed. - Vidstack's, Video.js's and Video.js 10 (rc)'s rows all ship more than Playdeck's control-bar row does at the same five-of-seven baseline — Vidstack's row and Video.js 10 (rc)'s row each draw a full skin with menus this fixture's control bar does not attempt, and Video.js's row adds an entire HLS/DASH engine none of the others needed for this fixture's plain MP4. Media Chrome's row is the closest like-for-like match at this composition: a control bar with two more buttons and no menus, no bundled streaming engine.
- Video.js, Vidstack's
DefaultVideoLayoutand Video.js 10 (rc)'sVideoSkinall need a stylesheet import to render correctly — a control bar with no CSS is unstyled, not merely plain. Neither this harness nor Playdeck's ownREADME.mdbyte table counts a stylesheet in its headline figure, so this comparison is consistent with itself, but the exclusion is not symmetric in what it costs each row. It costs those three rows something; it costs the other three nothing, and that is the table's own "requires an external stylesheet" row rather than a claim made here: Playdeck, react-player and Media Chrome all readnoon it, react-player because it ships no CSS file at all and Media Chrome because each of its custom elements carries its own Shadow DOM styles. For Playdeck the anchored fact is narrower than "unstyled works": no JavaScript@playdeck/reactships imports a stylesheet, andtests/bundle/native-only— whose fixture imports no CSS — is driven in a real Chromium by its owntest.mjs, which clicks the activation button and asserts the provider chunk is requested only after that click. So an unstyled composition renders and its controls respond; whether it looks finished is a judgement this document does not make. Excluding CSS understates what a working page actually downloads for the three rows above in a way it does not for the other three. - Media Chrome's per-component React exports are not independently
tree-shakeable from the rest of its custom-element registry — a real
reader who wants only a play button and a time display pays for the whole
set regardless, which this row's own figure in
results.mdalready reflects but which Playdeck's own per-primitive imports do not have as a constraint. - "Does this library distinguish 'this capability will never work' from
'not known yet', with a reason?" could not be given an anchored answer for
every library, so it is on neither table — not the byte table, which
cannot reach it at all, and not the features table, which tried and dropped
it (see "Dropped axes" below).
CONTEXT.mdrecords what Playdeck claims for its ownAvailabilityunion. This document makes no claim, in either direction, about what any other library here does or does not do on that ground.
Features
docs/comparison/features.md (issue #638) extends this comparison onto ground
results.md cannot cover: what each library can do, not what it weighs.
Features cannot come out of a bundler the way bytes can, so this table needs a
rule of its own to keep the property that makes the byte table publishable —
that every figure in it is something a reader can re-derive rather than
something they have to take on trust.
The rule: every cell is a sourced claim with a mechanical anchor, never an
impression. tests/compare/features.mjs encodes the table as data — one
axis at a time, one entry per library, each an anchor
scripts/compare-features.mjs re-evaluates against the actually-installed
package on every run. A "yes", "partial" or "plugin" cell names something the
generator can check for itself: a named export the installed package still
has, a line still present in a shipped file or its type declarations, or a
package.json field still set the way the cell says it is. pnpm compare:features fails outright, naming every axis and library at once, if a
single anchor no longer holds: that is what keeps a "yes" from surviving the
export it named being renamed or removed in a later pin.
What each status means
The vocabulary is defined once, here and in scripts/compare-features.mjs's
own header, because a status that means one thing in one column and something
else in the next is the quietest way for a comparison to become an argument:
Most axes are about a control a viewer can see and press — captions, quality, playback rate, picture-in-picture, fullscreen, AirPlay, Chromecast, keyboard operation, screen-reader labelling, live streaming, audio tracks, chapters, thumbnails. For those:
yes— the library ships a ready-made UI part for that axis.partial— the library exposes the axis in its own API or state, but ships no UI part to drive it, or its UI part covers only some of the axis.no— neither, and an absence the generator checked.plugin— the answer is a documented plugin outside the package.n/a— the axis cannot apply to that library at all, with a footnote saying why. Distinct fromno, which is a library that could have shipped the thing and did not: Media Chrome's "lazy provider loading" cell isn/abecause it ships no provider to defer, not because it defers badly.
The rest of the axes are not about a control at all — DRM, HLS, DASH, each
named provider, other hosted providers, a plugin system, lazy provider
loading, importing on a server, TypeScript types, ESM/CJS, analytics, a
shipped skin, a required stylesheet, headless parts, the React version. There
is no UI part to look for, so "ships a UI part" would mark every column no
and say nothing. For those:
yes— the capability is in the package's own shipped code or its declared exports, and the anchor names where.partial— it is there with a documented limit, named in the note (arequirecondition that resolves to a throwing stub, a React wrapper with no declared peer range).pluginandnomean what they mean above.
The two lists are written out because a vocabulary that shifts silently between rows is the same failure as a status that shifts silently between columns.
A status never encodes which of a library's providers can do the thing.
It describes the library's own API and UI for its own file or native
playback. A YouTube iframe cannot enter picture-in-picture under any library
in this table — Playdeck's own YouTube adapter marks pictureInPicture and
airPlay provider-unavailable in
packages/provider-youtube/src/adapter-values.ts — so that limit is written
into the footnote, in every column that has it, rather than pushing one
library's cell down and leaving another's alone. Before this rule was written
down, react-player's picture-in-picture and playback-rate cells were the only
two in the table demoted for a provider caveat, which is exactly the
asymmetry the rule exists to remove.
What a "no" has to be searched over
An absence is only worth as much as the ground it was searched over, and that
ground was not the same in every column. Ten Media Chrome cells and several
Vidstack ones searched a barrel index.d.ts — a file that is a list of import
names and nothing else — for tokens that could not have appeared in it
whatever the library did, while video.js's no cells were searched against
its whole bundle. The absent-in-tree anchor removes that difference: it
walks every .js and .d.ts file the package ships and holds only if the
token is in none of them, so a no costs the same evidence in every column,
Playdeck's included. It searches every package a consumer of that column
installs, not only the one the column is named after: Playdeck is
packages/core plus packages/react, since a consumer installs one and gets
the other, and Video.js 10 (rc) is @videojs/react plus the five
@videojs/* engine packages its own dependencies pin at the same version.
Anything narrower would let one column's no cost less evidence than
another's. One cell narrows deliberately and says so in its footnote: "lazy
provider loading" for Video.js 10 (rc) searches the two of those six that
actually ship providers, because that is the question the axis asks — the
dynamic imports in the other four are @videojs/core's translation packs.
absent-in-tree also refuses to hold vacuously in the other direction: a glob
matching no file at all throws rather than reporting an absence nobody
searched for.
Running that wider search moved real ground, which is the point of running
it: react-player does bind onKeyDown (for its light-mode preview button
alone) and does fetch a thumbnail_url (an oEmbed poster, not a seek
preview); Vidstack ships a PlaylistIcon and documents a YouTube analytics
parameter; video.js's Playlist strings are HLS media-playlist parsing and
its only lower-case airplay is inside Fairplay; media-chrome's only dash
is its own dashedToCamel; and @videojs/media's Playlist strings are
Google Cast queues and HLS parsing.
Where the obvious token for an axis collides with an unrelated identifier
like those, the anchor narrows to a token that does not, and the footnote
names the collision — the token every cell was actually searched for is
printed in its own footnote, so nothing about the narrowing is hidden.
Two anchors that ask a property rather than read a string
SSR was the axis where a text search was standing in for a question it
could not answer: Media Chrome scored yes for shipping an isServer helper
while video.js scored no for not shipping a "use client" string, which is
two different questions wearing one label. The imports-in-node anchor asks
the property directly and identically for every column — import the package in
a plain Node process, which has no window and no document, and record that
it loaded or the error it threw. Every library here imports cleanly, video.js
included, so the axis is now named for what it checks ("imports on a server
(no DOM globals)") and reads yes across the row. A flat row is an honest
answer; the axis is kept rather than dropped for the same reason the analytics
axis was kept when it was flat.
Plugin provenance is recorded as data, not as an adjective. A plugin
cell carries the plugin's name, the GitHub owner its own published npm
manifest points at, and one of two words derived from that owner: a plugin
whose repository owner is the same GitHub org that publishes the library is
org-published, and anything else is third-party, with the owner printed
beside the word so a reader can disagree with the word and still have the
fact. This replaced "official" and "community", which were neither checkable
nor consistently applied — videojs-youtube's repository is
github.com/videojs/videojs-youtube, the library's own org, and it had been
labelled a community plugin. The rule applies outside the Video.js columns
too: react-player's own README teaches a Media Chrome composition for custom
controls, so its fullscreen, playback-rate and keyboard cells are plugin,
naming media-chrome (npm repository github.com/muxinc/media-chrome,
third-party — react-player is published by a different owner). Two of the plugins named here
(videojs-contrib-eme, videojs-mux) publish no repository field at all,
and their footnotes say exactly that rather than guessing who stands behind
them. A "plugin" cell still only names a plugin that is itself documented —
its own README, its own docs page, or the library's own docs naming it — never
a guess at what probably exists.
Playdeck's own cells follow the identical rule and carry no adjective the
other columns do not. A no for Playdeck reads exactly like a no for any
other library — a checked absence, not a confession — and a yes is anchored
the same way theirs are: against @playdeck/react and @playdeck/core's own
exports, type declarations and READMEs, never asserted from what the rest of
this repository already knows to be true.
How the axis list was built
The axis list is the union of what each compared library advertises about itself, not Playdeck's feature list with the others read against it — the second shape would make the table an argument for Playdeck before a reader reached row one, which is exactly the "reading of a competitor's README" this document's "What is not measured" section above already rules out for the byte comparison, and would be worse here: a features table built from one library's marketing copy is not a comparison, it is that copy with extra columns.
So each library's own documentation was read for what it claims about itself,
and every axis that surfaced in at least one of them was tried: react-player
3.4.0's own README.md and its shipped dist/*.d.ts (the props table, the
"Supported media" list, the Config interface naming Mux, Twitch, TikTok and
Spotify as lazily-loaded providers); vidstack.io and
@vidstack/react 1.15.6's own index.d.ts (over a thousand exports, read for
every primitive, provider loader and event the main entry actually carries);
media-chrome 4.19.2's own README.md and
its "compatible media elements" docs page,
which is where the DASH, HLS, YouTube, Vimeo, Wistia, Cloudflare, JW Player,
Mux and Shaka Player rows for that column come from — media-chrome ships none
of them itself, and says so; video.js 8.24.0's own shipped
dist/video.es.js and dist/types/video.d.ts (every registerComponent call
is a built-in part, read directly rather than assumed from the docs site,
which changes less often than the installed code); @videojs/react
10.0.0-rc.4's own bundled documentation, which the package ships in
node_modules/@videojs/react/docs beside its dist/dev/index.d.ts, so every
claim about that column is read from the installed package rather than from a
docs site; and, for Playdeck, CONTEXT.md, packages/react/README.md and the
installed @playdeck/react and @playdeck/core packages, read under the
identical rule.
The result is deliberately larger than any one library's own feature list: it
is why quality selection, playback rate, chapters and audio tracks all have
rows even though tests/compare/entries/playdeck.tsx's composition uses none
of them, and why Chromecast, ads and a plugin system appear even though
Playdeck has none of the three.
Dropped axes
The rule that governs this: drop an axis only where an honest, anchored answer cannot be written for every library. One axis is dropped under it.
- Capability honesty — whether a library distinguishes "this capability
will never work" from "its answer is not known yet", with a reason
attached. Every cell for it in the alternatives' columns had been anchored
on the absence of Playdeck's own identifier,
Availability, from that library's types. That is self-referential: it tests whether another project chose Playdeck's word, not whether it models the distinction — and this document's own "What is not measured" section had already ruled the concept not mechanically derivable, so the byte comparison and the features comparison were answering the same question two different ways. The widerabsent-in-treesearch then showed the naming test failing on its own terms: media-chrome declares anAvailabilityStatesof its own indist/constants.js, and@videojs/react's own documentation describes a three-stateavailable/unavailable/unsupportedmodel. The axis is on neither table.CONTEXT.mdis where Playdeck's own claim about itsAvailabilityunion lives; this document makes no statement about what any other library here does or does not do on that ground.
Two axes came close enough to be worth recording here rather than silently deciding them:
- Analytics hooks is close to flat, and was kept rather than dropped when
it was flat: a checked answer that does not discriminate is still an honest
answer, and dropping an axis merely because it did not discriminate would be
a second, quieter way to make the table an argument. It has since stopped
being flat:
videojs-muxis a documented Mux Data plugin for video.js, so that cell isplugin, and@videojs/reactships aMuxDatacomponent behind its own optional peer package (@videojs/mux-data, org-published and not part of this fixture's pinned install), so that cell isplugintoo.npm viewfinds no equivalent published plugin for Media Chrome or Vidstack, which is why those two stayno. What the axis is not asking is whether a library emits playback events a consumer could wire to any analytics tool — every library here, Playdeck included, has those, and an axis they all satisfy by definition would be unfalsifiable. - React version supported is not a yes/no feature at all, and is recorded
as
yesfor every library that declares any range, with the actual range carried in the cell's note rather than encoded in the status — the differentiating fact (Playdeck:>=19 <20; react-player:^17.0.2 || ^18 || ^19;@vidstack/react:^18.0.0 || ^19.0.0;@videojs/react:^18.0.0 || ^19.0.0; media-chrome: no declared range at all;video.js8.24.0: no React integration to declare one for) is in the footnote, not flattened into a status the vocabulary cannot hold.
Where each alternative has something Playdeck does not
Read directly off the table. Quality selection and playback rate are
full, ready-made UI primitives in Vidstack, Media Chrome and Video.js 10
(rc) (and, for playback rate, video.js 8 as well), where Playdeck ships
only the command and capability behind a consumer's own SettingsMenu
composition. Audio track selection exists in Vidstack, Media Chrome and
both Video.js lines and is absent from Playdeck entirely, and so is a
thumbnail preview on seek (Vidstack, Media Chrome and Video.js 10 (rc)
ship one; video.js 8 has a documented plugin). Chapters get a full
navigation UI in Vidstack and both Video.js lines; Playdeck publishes the same
Chapter collection on player state but ships no primitive to browse it.
Live streaming gets a dedicated UI (a "LIVE" button or badge) in Vidstack,
Media Chrome and both Video.js lines; Playdeck models the state, lets existing
controls adapt to it, and stops there. Chromecast works in Vidstack, Media
Chrome and Video.js 10 (rc), and through a documented plugin in video.js
8; Playdeck and react-player have no casting beyond AirPlay. DRM ships in Video.js 10
(rc) (a source.drm map of key-system ids on its Shaka and hls.js media
components) and is a documented plugin away in video.js 8, with no
equivalent path in Playdeck at all. Playlists and ads are each a
documented video.js 8 plugin away and reach nothing else here, Playdeck
included — Video.js 10 (rc)'s own migration guide lists both among the
features its rewrite does not yet answer. DASH plays in react-player and
Vidstack outright, and through a documented compatible element in Media
Chrome; video.js 8 bundles it, and Video.js 10 (rc) reaches it only through
a separate, optional peer package (@videojs/dash-video) this fixture does
not install, so that cell reads plugin rather than yes — a change from
the beta line, which bundled its media adapters directly. Playdeck's refusal
is deliberate and already on record in .out-of-scope/dash.md. Analytics
is a documented plugin away in both Video.js lines — videojs-mux for
video.js 8, @videojs/mux-data (again a peer package, not bundled) for
Video.js 10 (rc) — and reaches nothing else here. And other hosted
providers — Mux, Twitch, TikTok, Spotify, Cloudflare Stream — reach
react-player outright through its Config interface, and Video.js 10 (rc)
through the same kind of separate, optional @videojs/*-video peer package as
DASH (each still resolves through the library's own media/* subpath, but
importing it now throws unless the matching peer is installed); Playdeck's
closed PlayerSource union carries none of them.
Where Playdeck has something no alternative does
Nowhere, on this table. No axis reads yes for Playdeck and something other
than yes for every other column, and that is worth stating plainly rather
than leaving a reader to work out from the absence of a paragraph. The axis
that used to be claimed here — capability honesty — is dropped, for the
reasons under "Dropped axes" above; CONTEXT.md is where Playdeck's own claim
about it lives.
What the table does separate is smaller and mutual. AirPlay and Chromecast
are no for react-player, which draws no UI of its own for a plain file and
inherits whatever the native <video> element happens to expose; its
fullscreen, playback-rate and keyboard cells read plugin rather than
no because its own README's "Custom player controls" section composes Media
Chrome parts for exactly those three. Wistia plays outright in Playdeck
and react-player, is absent from Vidstack and video.js 8 entirely, and
reaches Media Chrome and Video.js 10 (rc) each only through a separately
published custom element or peer package (wistia-video-element,
@videojs/wistia-video — the latter new in the rc line; the beta line had no
Wistia adapter at all). Headless,
independently composable parts — primitives a consumer imports and arranges
individually rather than a single configured component — are yes for
Playdeck, Vidstack, Media Chrome and Video.js 10 (rc) alike, so this is not
a Playdeck-only property; it is one video.js 8, which publishes no React
parts at all, is on the other side of.
Date and how to re-run
docs/comparison/results.md carries its own measurement date, and the Node,
Vite and esbuild versions it was built with, on its own header line. Re-run
the whole thing with:
pnpm install
pnpm build
pnpm compare:librariespnpm build first: the Playdeck entry imports @playdeck/react, whose
package.json exports field points at its own dist/, gitignored and not
rebuilt by pnpm install alone — the same reason CI's docs:bytes:check runs
inside the build job rather than beside docs:check in static. A stale
dist/ changes Playdeck's four rows and nothing else, silently, which is
what makes it worth checking first rather than after --check fails.
pnpm compare:libraries:check fails if a fresh run would produce different
figures, versions or composition labels than the ones checked in; it ignores
the date and Node-version tokens on their own, so it never fails purely
because the calendar moved or because CI's Node differs from a local
checkout's. The date on results.md's header records when the file was last
regenerated, not a promise about how current it still is — re-run the command
above whenever a pinned version changes, a library is added, or a figure is
doubted.
Each of the four Playdeck rows also carries a committed ceiling — a
ceilingKb field beside that row's own entry in scripts/compare-libraries.mjs's
libraries array, not a number in this document's prose, so a ceiling change
is a diff to the harness's own data rather than a side effect of editing this
file. pnpm compare:libraries and pnpm compare:libraries:check both fail,
naming the row and its measured and committed figures, the moment a row's
Vite gzip size passes its ceiling — whether that run is regenerating
results.md or only checking it, so a growth past the ceiling can never be
committed by regenerating the doc and then re-committing the moved ceiling
along with it. Each ceiling started at the row's own figure on the day #649
added it, rounded up to the next 0.25 KB, and stays there until someone
raises it on purpose: edit the ceilingKb value in libraries, in the same
commit as whatever grew the row, and say in that commit what grew and why —
the same "deliberate, reviewable edit with a stated reason" #649 asked this
mechanism to produce, rather than a ceiling that silently tracks whatever the
row happens to measure. Shrinking a composition back under its existing
ceiling needs no such edit; only raising the ceiling itself does.
Each of the four rows also carries a second, independent gate: its reachable
Vite chunks must not reach a control part the row's own fixture does not
render, or any provider other than native — see
scripts/compare-libraries.mjs's GUARDABLE_PART_NAMES and
forbiddenPartsExcept for the full guardable list and how each row's own
forbidden set is built from it. That check fails naming the specific module
reached and, where one exists, the import chain that reaches it, and is
unrelated to the ceiling above: a composition can breach either, both or
neither.