01OVERVIEW
Loupe is a skin for a Video.js v10 player in React. Alongside the usual controls (play, seek, volume, speed, captions, fullscreen) it adds a contact sheet: all sampled thumbnails supplied by the storyboard, as a filmstrip under the video and as a full sheet you can open beneath it. Click or tap a frame to jump there, drag across frames with a mouse to scrub, move through them with the keyboard, and save the sheet as a one page PDF with timestamp links configured for the destination page. Frame jumps use an exact seek just inside the selected tile.
Save picks exports only the frames you mark. Use the tile checkbox, X on the focused frame, or SELECT on touch screens. Marks remain when you close the sheet. SAVE PICKS creates a linked PDF in time order: landscape uses one per row for 1–2 picks, two for 3–8, and three for 9 or more. Vertical uses up to three per row for 1–3 picks, four for 4–8, and five for more than eight. Images fit the printable area, with extra pages only when needed. With nothing marked, SAVE SHEET still saves every frame on one page.
Click any frame in the saved PDF and the video opens at that moment. The link is the page address with a t value in seconds, and the page passes it to the player as startTime.
The contact sheet shows three complete rows at rest. Mouse wheels and trackpads move in whole rows and pass scrolling to the page at the first or last row. On touch screens, a vertical swipe scrolls the sheet, settles on a whole row and passes scrolling to the page at either end; horizontal sheet paging is not used. A tap jumps to its frame, or marks it with SELECT on; dragging across frames scrubs only with a mouse. Touch scrubbing remains on the seek bar and filmstrip, not sheet tiles. It follows the playing frame; manual scrolling pauses following for five seconds. Frame number and time overlay the playing frame, the hovered frame and the focused frame while focus is in the sheet, not every tile. On touch, the current and focused frames show their number and time.
02REQUIREMENTS
- PLAYER
- Video.js v10. Built and tested against @videojs/react and @videojs/mux-video 10.3.0.
- FRAMEWORK
- React 19 with TypeScript and Tailwind v4, matching the demo project.
- SOURCE
- A Mux video, identified by its playback ID, which comes with a storyboard.
03STORYBOARD
Loupe does not extract frames itself. It builds the sheet from the storyboard the video host already makes: one sprite image of evenly spaced frames, plus a JSON file saying where each frame sits.
The storyboard URL is fixed to Mux: https://image.mux.com/<playbackId>/storyboard.json. Playback goes through Mux's player element. An MP4 URL on its own carries no tile data, so there is no sheet. Using another host means changing four things: the source configuration, the poster URL, the storyboard fetch and normalisation, and the caption setup. The sprite must allow cross-origin canvas readback, or the PDF export cannot draw it.
This page's own storyboard, for reference: https://image.mux.com/gBOjab6Gxtqhq5m1OwwgpXBiY2HHp8JcgiogD7I01FEA/storyboard.json
If the storyboard fails to load, the video still plays with ordinary controls, but no filmstrip or sheet appears. The skin expects this shape:
{
"url": "https://image.mux.com/<playbackId>/storyboard.jpg",
"tile_width": 284,
"tile_height": 160,
"duration": 74.933,
"tiles": [
{ "start": 0, "x": 0, "y": 0 },
{ "start": 1.529, "x": 284, "y": 0 }
]
}This sample is trimmed: real responses contain one entry per tile, 50 for this video.
The skin reads url, tile_width, tile_height, duration and each tile's start (seconds), x and y (pixel offsets into the sprite).
04INSTALL
Loupe is not a published package. There is nothing to npm install by name; it is source you copy into a React project that uses the @/ import alias for src/. The files it needs:
- src/lib/utils.ts
- Button class-name helper; copy its clsx and tailwind-merge dependencies.
- public/displacement-map.png, public/displacement-map-panel.png, public/loupe-logo.png
- Preserve these root URLs or update GlassRefraction and sheet-export. The PDF renderer requires the wordmark.
- src/components/ProofSheetPlayer.tsx
- the player and skin (default export)
- src/components/PlayerExtras.tsx
- volume, speed and caption controls
- src/components/PlayerIcons.tsx
- the drawn icon set
- src/components/KeyboardHelp.tsx
- measured docked keyboard help or page dialog
- src/components/SheetExpansion.tsx
- contact sheet open and close
- src/components/SheetSeekPreview.tsx
- seek preview image
- src/components/SaveSheetButton.tsx
- SAVE SHEET PDF
- src/components/GlassRefraction.tsx
- SVG filter for the glass frame
- src/components/ui/button.tsx
- the "player" button variant
- src/hooks/use-caption-cues.ts, use-local-captions.ts, use-menu-surface.ts, use-player-width.ts, use-sheet-chrome.ts, use-sheet-scroll.ts, use-sheet-seek.ts, use-sheet-transition.ts, use-start-time.ts, use-video-tap.ts
- Native captions, exclusive popups, border-box width, chrome, three-row scrolling, seeking, expansion and touch playback. All listed files live in src/hooks/.
- src/lib/player-captions.ts, player-keyboard.ts, player-layout.ts, player-menu.ts, player-theme.ts, sheet-export.ts, sheet-interactions.ts, sheet-pdf.ts, sheet-picks.ts, sheet-picks-pdf.ts, sheet-scroll.ts, sheet-seek.ts, playback-controls.ts, video-tap.ts, volume-panel.ts, export-config.ts
- All listed files live in src/lib/. Includes single/multiple-track CC, popup geometry, marks, full-sheet and picks PDFs, count-based pagination and whole-row following.
- src/styles.css
- every .ps-* rule and the theme variables
Then add its dependencies:
npm install @videojs/react@10.3.0 @videojs/mux-video@10.3.0 @radix-ui/react-dialog@^1.1.15 @radix-ui/react-slot@^1.2.4 class-variance-authority@^0.7.1 clsx@^2.1.1 tailwind-merge@^3.5.0 jspdf@^4.2.1bun add @videojs/react@10.3.0 @videojs/mux-video@10.3.0 @radix-ui/react-dialog@^1.1.15 @radix-ui/react-slot@^1.2.4 class-variance-authority@^0.7.1 clsx@^2.1.1 tailwind-merge@^3.5.0 jspdf@^4.2.1pnpm add @videojs/react@10.3.0 @videojs/mux-video@10.3.0 @radix-ui/react-dialog@^1.1.15 @radix-ui/react-slot@^1.2.4 class-variance-authority@^0.7.1 clsx@^2.1.1 tailwind-merge@^3.5.0 jspdf@^4.2.1The demo's playback IDs live in src/lib/demo-sources.ts. ProofSheetPlayer has no fallback video: pass your own playbackId.
React, React DOM, TypeScript and Tailwind v4 must already be configured. Include every copied TSX file in Tailwind scanning. Copy all player, glass, interaction, reduced-motion and forced-colors rules in order, including inherited tokens. Controls retain state-aware accessible names for screen readers. Player text uses one monospace face through --font-mono (JetBrains Mono in the demo). The PDF renderer also uses JetBrains Mono; load that face before exporting. Copy the SVG filters once per page and their two displacement maps.
Configure DEMO_BASE_URL and export destination paths for your deployment. Frame links append t=<seconds>; the destination must select the exported video and parse t into startTime. Preserve video identity in the URL if one path can show different sources. The demo parser accepts only finite nonnegative t values.
05USAGE
ProofSheetPlayer is the default export. This minimal TanStack example uses the same browser-only boundary as the demo; The playback ID below is the same Europa Clipper source used by the Home hero.
import { lazy, Suspense } from "react";
import { ClientOnly } from "@tanstack/react-router";
import { GlassRefraction } from "@/components/GlassRefraction";
// SSR: lazy loading alone does not disable server rendering.
const ProofSheetPlayer = lazy(() => import("@/components/ProofSheetPlayer"));
function Demo() {
return (
<>
<GlassRefraction /> {/* once per page */}
<ClientOnly fallback={null}>
<Suspense fallback={null}>
<ProofSheetPlayer
playbackId="gBOjab6Gxtqhq5m1OwwgpXBiY2HHp8JcgiogD7I01FEA"
title="Europa Clipper: NASA's Mission to Jupiter's Ocean Moon"
aspectRatio={16 / 9}
muted
/>
</Suspense>
</ClientOnly>
</>
);
}The demo also wraps it in an error boundary that offers a page reload if the player's code fails to download (PlayerLoadBoundary.tsx). That is optional.
ClientOnly is the TanStack Router boundary used in this project. In another SSR framework, use its browser-only equivalent. In a client-rendered React application the boundary is unnecessary. GlassRefraction mounts once per page; the demo mounts it in the root. Demo players start muted, without autoplay or looping, while portable defaults are all false.
06OPTIONS
Thirteen props, all optional in the TypeScript signature. A nonempty playbackId is needed to render a player.
| PROP | TYPE | DEFAULT | WHAT IT DOES |
|---|---|---|---|
playbackId | string | none | Mux playback ID. Without one the player renders nothing and logs a console error naming the missing prop. Changing it remounts the player. |
title | string | the playback ID | Trimmed title for full-sheet and picks PDF headers, metadata and filenames; a blank title falls back to the playback ID. Filenames use a normalized title slug, up to 50 characters, ending -contact-sheet.pdf or -contact-sheet-picks.pdf. Picks headers and metadata add · Picks. |
aspectRatio | number | none | Width / height to reserve before anything loads. The storyboard tile ratio, snapped to a standard ratio within 1%, and then the video's own metadata confirm it. Without it the player stays hidden until a shape is known. |
startTime | number | none | Initial seconds, applied after a finite positive duration is available. Values above duration are capped; supply a finite nonnegative value. Changing the prop reapplies the seek. |
captionsUrl | string | none | English WebVTT override; filters source captions/subtitles. Unset retains source tracks. Changing this value remounts the player. |
theme | "light" | "dark" | detected | Forces the theme. When unset, the theme is read from the page behind the player (see Theming). |
frame | "glass" | "none" | "none" | Optional flat refractive perimeter at every screen size. Below 640px browser viewport width the band is 10px; otherwise large, intermediate and narrow players use exactly 28px, 20px or 16px bands. The height cap and space remaining for the band determine the tier. Both modes have lift shadows and inset edge lighting, omitted in forced colors. |
muted | boolean | false | Start muted. |
loop | boolean | false | Loop playback. |
autoPlay | boolean | false | Start playing on load. Browsers generally only allow this when muted. |
initialSheetOpen | boolean | false | Initial expansion state only, not a controlled open-state prop. |
posterTime | number | 6 | Seconds into the video for the poster frame, taken from Mux's thumbnail service. |
onFrameCount | (count: number) => void | none | Effect callback with loaded storyboard tile count, or 0 while unavailable/loading. Runs when the sheet or callback identity changes; not a one-shot event. |
07THEMING
Light or dark chosen from the parent background. Every colour is a variable you can override. Pass theme to choose one explicitly. Otherwise, after mounting, the skin walks up from the player to the nearest ancestor with a fully opaque background colour, and picks light when that colour's relative luminance is above 0.5, dark otherwise. If no opaque background is found it uses dark. Each player decides for itself, so two players on one page can differ.
The variables are declared on .ps-player and .ps-player[data-theme="light"], so setting them on a parent element has no effect. To change them, edit those rules or override them with a selector that targets .ps-player.
| VARIABLE | DARK | LIGHT | USED FOR |
|---|---|---|---|
--bg | #08090A | #F7F8F9 | Base surface |
--panel | #101214 | #FFFFFF | Player background (via --sheet) |
--chrome-surface | #101214 | #F2F4F6 | Control bar |
--sheet-header | #101214 | #F2F4F6 | Contact sheet header |
--sheet-body | #08090A | #FFFFFF | Contact sheet behind the tiles |
--line | #1E2227 | #E2E6EA | Hairlines |
--text | #E8EBED | #0D0F11 | Text and icons |
--dim | #828B94 | #5C6670 | Secondary text |
--accent | #FFFFFF | #0D0F11 | Active/current-frame and pressed-control accents. Seek progress uses --seek-played. |
--seek-track | rgba(255,255,255,0.15) | rgba(13,15,17,0.12) | Seek track |
--seek-buffered | rgba(255,255,255,0.28) | rgba(13,15,17,0.22) | Buffered part of the seek track |
--seek-played | #E8EBED | #0D0F11 | Played part of the seek track and the handle |
--sheet | #101214 | #FFFFFF | Player background; alias of --panel |
--focus-light | #FFFFFF | #FFFFFF | Light stop of the two-tone focus outline |
--focus-dark | #000000 | #000000 | Dark stop of the two-tone focus outline |
The glass frame around the player has its own --glass-* variables on .ps-glass-frame, with light overrides on .ps-glass-frame[data-theme="light"].
Framed and frameless players share two black lift shadows: 28px down with 60px blur, and 6px down with 12px blur. Dark/photo use 60% and 40% opacity; light uses 18% and 12%. The inset top edge is white at 28%, fading at the ends, with other dark edges at 8%; light edges are black at 10%. These do not change player size or position, and disappear in forced colors.
Automatic background detection runs after mount and when theme changes, not continuously when ancestor classes/backgrounds change. Pass an explicit reactive theme when the host switches themes.
08KEYBOARD
These groups share their shortcut data with the player's KEYS panel and the home page. Shortcuts work while focus is inside the player.
PLAYER
- SpacePlay or pause
- ← →Seek 5s
- ↑ ↓Volume
- MMute
- FFullscreen
- CCaptions
- SContact sheet
IN THE CONTACT SHEET
- ← → ↑ ↓Move between frames
- EnterJump to frame
- XMark frame
- EscClose
Click a frame or press S to move into the contact sheet. Esc, or a click on the video, moves back out.
Additional supported shortcuts: K play/pause; J/L seek -10/+10 seconds; 0–9 seek 0–90%; Home/End start/end; ? toggle keyboard help. S toggles the sheet. X marks the focused frame. Esc closes the active help panel, menu or sheet. Editable fields and surfaces with their own keyboard handling exclude normal player shortcuts; Alt/Ctrl/Meta combinations are ignored. Space on a button activates that button.
The KEYS button is hidden only on touch-primary devices matching hover: none and pointer: coarse, not by player width. The ? shortcut remains available with a keyboard.
09ACCESSIBILITY
- SHEET
- The frame grid is one tab stop with aria-activedescendant; sheet header controls remain separate tab stops. Clicking or tapping a frame moves focus into the sheet on that frame, without a pointer ring. Pressing S opens the sheet and focuses the currently playing frame. While focus is in the sheet, the focused frame shows its number and time. Arrow keys move the highlight between frames and reveal hidden rows without seeking. Enter or Space seeks to the focused frame and leaves the sheet open. X marks or unmarks the focused frame and updates the announced selected count. Esc closes the sheet; clicking the video or control bar moves focus out and restores player shortcuts. Each tile exposes selected state and an accessible frame number/time; preview/playback frames are marked current.
- TARGETS
- Measured on this demo at player widths 1000, 640, 380 and 280px: control-bar buttons are 44px tall, 44px wide at 1000px and 32px wide in narrow players. Speed and multi-track caption lists have 40px rows; chip rows below 640px player width are 44px tall with flexible widths, not guaranteed 44px wide. Home sheet tiles measured 118.5×66.75, 120×67.59, 86×48.44 and 61×44px respectively; their size depends on ratio and columns, not a fixed square. Seek interaction is 44px tall around a 3px track and 10px square handle.
- FOCUS
- The player container never shows a focus ring. Rings appear only on individual controls and frames, for keyboard use. Clicking or tapping a frame does not draw a ring; pressing a key restores its keyboard focus indication. Automatically opened sheets do not take focus on page load. Keyboard opening focuses the grid; menus return focus to their button when closed. Option/help menus trap Tab; volume allows Tab to pass through.
- VOLUME
- Changing the volume or muting from the keyboard shows the volume slider for 1.2 seconds and announces the new level or muted status.
- CAPTIONS
- White text in both themes on black at 60% with 12px backdrop blur, or 80% without blur support, with a fine light hairline. With controls visible the box sits 12px above their measured height; at rest it sits 8% from the video bottom. Captions hide while a popup is open. CC is disabled with no tracks, a pressed toggle with one, and a menu with several; C toggles and restores the last chosen track.
- MOTION
- With reduced motion requested, the sheet, chrome, seek preview and button transitions are switched off.
- Reduced transparency
- Reduced transparency replaces the glass frame, controls and popups with solid theme surfaces, uses opaque black captions, frame labels and a white-on-black centre play button, and preserves sizing, sheet-edge masks and lift shadows.
- Increased contrast
- Increased contrast also makes surfaces opaque, sets main and secondary text to pure white or black, strengthens rules, uses 3px keyboard outlines, restores full tile brightness and adds 2px marked-frame and active-corner outlines, while forced colors keeps priority.
- COLORS
- In forced colors mode, glass, blur and shadows are dropped for system colours, and focus and the current frame use the system highlight.
- RTL
- In right-to-left pages, left and right arrows swap for seeking and sheet navigation, and pointer seeking follows reading direction.
- CONTRAST
- Recalculated from the live opaque control-bar colors: main text is 15.68:1 dark and 17.42:1 light. The --dim tokens are #828B94 and #5C6670, measuring 5.43:1 and 5.31:1 against their bars and sheet headers. Dim text on the sheet body measures 5.76:1 dark and 5.85:1 light. All four meet 4.5:1. Within the control panel, .ps-dim is overridden to main text. These figures do not establish contrast over translucent menus, arbitrary footage or independently themed page labels.
10LICENSE
Apache License 2.0. Copyright 2026 Milena Webb. Full text: apache.org/licenses/LICENSE-2.0.txt, also in the LICENSE file in the source.
