Build a shorts feed with React

Build a vertical, TikTok-style shorts experience with the FastPix web player in a React 19 app, including scroll snapping, autoplay, custom controls, and audio and subtitle switching.

This guide shows you how to build a vertical, shorts-style video feed with the FastPix web player in a React 19 app. You’ll use the <fastpix-player> web component to handle streaming while React controls feed behavior such as scroll snapping, autoplay, mute state, and custom UI.

The guide is based on the open-source react-shorts-app demo. Use it as a working reference implementation: you can copy the patterns directly into your own project.

Before you begin

Make sure you have the following:

  • Node.js 18 or later and npm 9 or later.
  • A React 19 app. The demo uses Vite.
  • One or more vertical (9:16) FastPix assets, each with a playback ID. Encode your assets for portrait viewing, because the demo assumes a shorts layout only.

This guide uses the FastPix web player as a custom element (<fastpix-player>). To learn how to install the player through npm or a CDN, see Install the FastPix web player.

How the demo divides responsibilities

The demo follows one core principle: FastPix handles playback, and your React app owns the feed and the UI. Keeping this boundary clear lets you restyle or rearrange the interface without touching any streaming logic.

FastPix handles the following at the player level:

  • HLS and DASH playback, manifest fetching, buffering, and error handling.
  • Track selection for audio and captions, DRM, thumbnails, and ad integrations.
  • Seek-bar logic, including hover thumbnails and click-to-seek.

Your React app handles the following at the application level:

  • The shorts feed and rendering one item per entry.
  • Which short is active, the global mute state, and whether the user has interacted with the page.
  • When to call the player’s play(), pause(), mute(), and unmute() methods.
  • All visible UI: play and pause buttons, the mute toggle, the custom progress bar, like and share controls, and the scroll rail.
  • Navigation, including scroll snapping and keyboard shortcuts.

Set up the demo

Clone the repository from GitHub:

$git clone https://github.com/FastPix/fastpix-web-player-react-shorts-demo.git
$cd fastpix-web-player-react-shorts-demo
$npm install
$npm run dev

If you’re working inside the larger fastpix-web-player monorepo instead of the standalone repo, the demo lives at react-app-publish/fastpix-web-player-react-shorts-demo. Either layout starts the demo the same way.

Open the local URL that Vite prints (for example, http://localhost:5173) in your browser.

To create a production build, run the following command. It runs tsc -b and vite build, producing a static build in dist/:

$npm run build

Choose your player bundle

The demo imports the FastPix player from a relative path inside the monorepo (../../../dist/player.js). That keeps the demo aligned with your local FastPix build, including any in-progress track APIs and attributes such as hide-native-subtitles.

In your own project, swap that import for the published npm package:

1// Demo (monorepo path):
2import "../../../dist/player.js";
3
4// Your project (npm):
5import "@fastpix/fp-player";

Pin a version of @fastpix/fp-player that supports the APIs you rely on in this guide — multi-track audio and subtitle methods, the fastpixsubtitlecue event, and the hide-native-subtitles attribute.

Define your shorts feed

The feed is a JSON array. Each entry maps a FastPix playback ID to display metadata. The format is universal, so you can hard-code the array or fetch it from your own backend as long as you keep these keys:

1[
2 {
3 "id": "YOUR_PLAYBACK_ID_VERTICAL_VIDEO_1",
4 "creator": "CREATOR_NAME_1",
5 "title": "TITLE_OF_VERTICAL_VIDEO_1",
6 "likes": "12.4K",
7 "comments": "342",
8 "shares": "891"
9 },
10 {
11 "id": "YOUR_PLAYBACK_ID_VERTICAL_VIDEO_2",
12 "creator": "CREATOR_NAME_2",
13 "title": "TITLE_OF_VERTICAL_VIDEO_2",
14 "likes": "8.7K",
15 "comments": "201",
16 "shares": "543"
17 }
18]

In the demo, this array is the SHORTS_FEED constant in src/shorts/types.ts. Replace every YOUR_PLAYBACK_ID_VERTICAL_VIDEO_* value with one of your own FastPix playback IDs, and replace the CREATOR_NAME_* and TITLE_OF_VERTICAL_VIDEO_* placeholders with your real metadata. The code depends only on these keys.

Each entry’s id is passed to a player instance through the ShortItem component:

1<ShortItem playbackId={short.id} metadata={short} ... />

Mount the player

The demo creates each player with document.createElement("fastpix-player") inside a useEffect hook rather than rendering it as JSX. This gives you deterministic control over when each player is created and destroyed, which matters in a feed where you don’t want too many player instances alive at once.

1useEffect(() => {
2 const container = containerRef.current;
3 if (!container || playerRef.current) return;
4
5 const el = document.createElement("fastpix-player") as FastPixPlayerElement;
6 el.setAttribute("playback-id", playbackId);
7 el.setAttribute("autoplay-shorts", "");
8 el.setAttribute("muted", "");
9 el.setAttribute("loop", "");
10 el.setAttribute("disable-keyboard-controls", "");
11 el.setAttribute("preload", preload);
12 el.setAttribute("hide-native-subtitles", "");
13 el.style.width = "100%";
14 el.style.height = "100%";
15 el.style.objectFit = "cover";
16
17 container.appendChild(el);
18 playerRef.current = el;
19 registerPlayer(itemIndex, el);
20
21 return () => {
22 registerPlayer(itemIndex, null);
23 try {
24 if (typeof el.destroy === "function") el.destroy();
25 } catch {}
26 if (container.contains(el)) container.removeChild(el);
27 playerRef.current = null;
28 };
29}, [playbackId, itemIndex, registerPlayer]);

The following table describes the attributes the demo sets on each player.

AttributePurpose
playback-idThe vertical asset to play. Maps one-to-one with the feed JSON id.
autoplay-shortsTunes autoplay behavior for instant playback start.
mutedStarts the player muted, which is required for autoplay in most browsers.
loopLoops the video continuously.
disable-keyboard-controlsLets the app, not the player, own keyboard shortcuts.
preloadSets a per-item preload hint: auto, metadata, or none.
hide-native-subtitlesHides the player’s built-in subtitle text so you can render your own caption overlay.

A FastPix React component with idiomatic props and hooks is in progress. When it’s available, you’ll be able to replace the createElement approach with a <FastPixPlayer /> component. Until then, the custom-element approach keeps the integration explicit and predictable.

Access the underlying video element

FastPix exposes the native HTMLVideoElement as a video property on the custom element. The demo types the element as follows:

1interface FastPixPlayerElement extends HTMLElement {
2 video?: HTMLVideoElement;
3 play?: () => Promise<void> | void;
4 pause?: () => void;
5 mute?: () => void;
6 unmute?: () => void;
7 destroy?: () => void;
8}

After the element mounts, you can read playerRef.current.video to listen for native media events, drive a custom progress bar, or set per-item preload behavior. The following example syncs an isPlaying state flag with the video’s play and pause events:

1useEffect(() => {
2 const player = playerRef.current;
3 const vid = player?.video;
4 if (!vid) return;
5
6 const onPlay = () => setIsPlaying(true);
7 const onPause = () => setIsPlaying(false);
8 setIsPlaying(!vid.paused);
9
10 vid.addEventListener("play", onPlay);
11 vid.addEventListener("pause", onPause);
12
13 return () => {
14 vid.removeEventListener("play", onPlay);
15 vid.removeEventListener("pause", onPause);
16 };
17}, [playbackId]);

Control playback across the feed

The app tracks all player instances by index and uses a single helper, playAt, to drive playback. This helper pauses and mutes every other short so only one plays at a time.

First, register each player as it mounts:

1const playerRefsByIndex = useRef<Record<number, FastPixPlayerElement | null>>({});
2
3const registerPlayer = useCallback(
4 (index: number, player: FastPixPlayerElement | null) => {
5 if (player) playerRefsByIndex.current[index] = player;
6 else delete playerRefsByIndex.current[index];
7 },
8 [],
9);

Then centralize playback control in playAt:

1const playAt = useCallback(
2 (index: number, options: { resetTime?: boolean } = {}) => {
3 const { resetTime = true } = options;
4
5 // Pause and mute all other players.
6 Object.entries(playerRefsByIndex.current).forEach(([i, p]) => {
7 const playerEl = p;
8 if (!playerEl) return;
9 if (Number(i) !== index) {
10 playerEl.mute?.();
11 playerEl.pause?.();
12 }
13 });
14
15 const player = playerRefsByIndex.current[index];
16 if (!player) return;
17
18 // Optionally reset time when snapping to a new short.
19 if (resetTime && player.video) player.video.currentTime = 0;
20
21 // Before any gesture, always play muted. After interaction, honor app mute state.
22 if (!hasUserInteractedRef.current) {
23 player.mute?.();
24 } else {
25 isMutedRef.current ? player.mute?.() : player.unmute?.();
26 }
27
28 const playResult = player.play?.();
29 if ((playResult as Promise<void> | undefined)?.catch) {
30 (playResult as Promise<void>).catch(() => {
31 requestAnimationFrame(() => {
32 if (activeIndexRef.current === index) {
33 player.play?.()?.catch?.(() => {});
34 }
35 });
36 });
37 }
38 },
39 [],
40);

The app keeps a single mute flag for the entire feed. The handleMuteToggle handler applies it to the active short, and playAt reapplies it whenever the active short changes:

1const handleMuteToggle = useCallback(() => {
2 const player = playerRefsByIndex.current[activeIndexRef.current];
3 if (!player) return;
4
5 if (isMuted) {
6 hasUserInteractedRef.current = true;
7 player.unmute?.();
8 setIsMuted(false);
9 } else {
10 player.mute?.();
11 setIsMuted(true);
12 }
13}, [isMuted]);

Optimize feed performance

To avoid mounting every player at once, the demo applies three techniques:

  • Windowing. App.tsx renders only a window of items around the active index, for example the current item plus or minus two. Each item is absolutely positioned at top: i * 100vh, so scroll position still matches the item index.
  • Strict preloading. The active short uses preload="auto", its neighbors use metadata, and the rest of the window uses none.
  • Deduplicated play calls. playAt skips re-running play logic for the same index by comparing against a lastPlayIndexRef value. This prevents play and pause flicker when scroll handlers fire multiple times.

For quick checks during development, the demo logs [perf] ShortsApp first render and [perf] First short started playing as milliseconds since module load. Compare these between npm run dev and npm run build && npm run preview.

Build custom controls

The demo replaces most of the built-in player chrome with React components.

For per-short play and pause, read the underlying video element directly:

1const vid = playerRef.current?.video;
2if (vid) vid.paused ? vid.play() : vid.pause();

For fullscreen, request fullscreen on the scroll container rather than an individual player. This keeps scroll snapping and overlays working the same way in fullscreen and windowed modes:

1const el = scrollRef.current;
2(el.requestFullscreen || (el as any).webkitRequestFullscreen)?.call(el);

Like, follow, and share controls are handled entirely in React state. They don’t require any FastPix API. The share handler builds a URL for the current playback ID and uses navigator.share() when available, falling back to navigator.clipboard.writeText().

Use a custom seek bar

The demo replaces the built-in player chrome with a shorts-style overlay for two reasons:

  • Shorts UX. A vertical feed needs a custom top bar (play, mute, fullscreen), a right rail (likes, share, follow), and a slim bottom progress stripe that hugs the card’s rounded corners. The default desktop controls don’t fit any of those positions.
  • Branding and layout control. Hiding the default chrome lets the app own spacing, border radius, and overlay placement consistently across every card in the feed.

The important thing to understand is that you’re hiding visuals only — the seek-bar logic stays active under the hood. Hover thumbnails, timestamp previews, and click-to-seek all keep working through the hidden bar. You then render your own progress stripe on top.

In src/index.css:

1fastpix-player {
2 --middle-controls-mobile: none;
3 --mobile-play-button-initialized: none;
4 --bottom-right-controls-mobile: none;
5 --bottom-right-controls: none;
6 --left-controls-bottom: none;
7 --left-controls-bottom-mobile: none;
8 --seekbar-bottom: 0px;
9 /* Hide the progress bar visually but keep it for hover and seek. */
10 --progress-bar-invisible: 1;
11 --play-button-initialized: none;
12}

Setting --progress-bar-invisible: 1 hides the visual bar but keeps FastPix’s seek behavior active. You then render your own progress stripe on top, driven by video.currentTime:

1const [progress, setProgress] = useState(0);
2
3useEffect(() => {
4 const player = playerRef.current;
5 const vid = player?.video;
6 if (!vid) return;
7
8 const paint = () => {
9 const duration = vid.duration;
10 if (duration > 0 && isFinite(duration)) {
11 setProgress((vid.currentTime / duration) * 100);
12 } else {
13 setProgress(0);
14 }
15 };
16
17 let rafId: number | null = null;
18 const loop = () => {
19 paint();
20 rafId = requestAnimationFrame(loop);
21 };
22 const startRAF = () => {
23 if (rafId == null) rafId = requestAnimationFrame(loop);
24 };
25 const stopRAF = () => {
26 if (rafId != null) {
27 cancelAnimationFrame(rafId);
28 rafId = null;
29 }
30 paint();
31 };
32
33 paint();
34 vid.addEventListener("play", startRAF);
35 vid.addEventListener("playing", startRAF);
36 vid.addEventListener("pause", stopRAF);
37 vid.addEventListener("ended", stopRAF);
38 vid.addEventListener("seeking", paint);
39 vid.addEventListener("seeked", paint);
40
41 return () => {
42 if (rafId != null) cancelAnimationFrame(rafId);
43 vid.removeEventListener("play", startRAF);
44 vid.removeEventListener("playing", startRAF);
45 vid.removeEventListener("pause", stopRAF);
46 vid.removeEventListener("ended", stopRAF);
47 vid.removeEventListener("seeking", paint);
48 vid.removeEventListener("seeked", paint);
49 };
50}, []);

The custom bar is read-only. It reflects FastPix’s playback state, but all seek and thumbnail behavior stays inside the player.

Own keyboard navigation

By default, the player captures keys such as space and the arrow keys. In a shorts feed, that can interfere with navigation, so the demo sets disable-keyboard-controls on each player and implements its own handlers:

1const handleKeyDown = useCallback(
2 (e: KeyboardEvent) => {
3 if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
4 e.preventDefault();
5 wheelSnapTo(activeIndexRef.current + (e.key === "ArrowDown" ? 1 : -1));
6 },
7 [wheelSnapTo],
8);
9
10useEffect(() => {
11 window.addEventListener("keydown", handleKeyDown);
12 return () => window.removeEventListener("keydown", handleKeyDown);
13}, [handleKeyDown]);

With this setup, the arrow keys move to the next or previous short, and the player doesn’t change volume or seek in response. You can extend the same pattern for other keys, such as L to like or M to mute.

Add audio and subtitle switching

The demo wires multi-track audio and subtitle switching on top of the web component. Set the following optional attributes when you create the player:

AttributePurpose
hide-native-subtitlesHides the player’s internal subtitle text. The fastpixsubtitlecue event still fires, so your React overlay can render captions.
disable-hidden-captionsStarts subtitles in the off state on load.
default-audio-trackSets the initial audio track by label, for example French. The label must match a track from the HLS manifest.
default-subtitle-trackSets the initial subtitle track by label. Applies only when subtitles are allowed to show.

After the manifest and text tracks are ready, you can call these methods on the custom element:

MethodDescription
getAudioTracks()Returns a snapshot of audio tracks, deduplicated by label.
setAudioTrack(label)Switches audio by label. Use a string, not a numeric ID.
getSubtitleTracks()Returns a snapshot of subtitle and caption tracks.
setSubtitleTrack(label | null)Enables a track by label, or pass null to turn captions off.
disableSubtitles()Turns captions off, the same as choosing Off in the built-in menu.

Each track object includes id, label, optional language, isDefault, and isCurrent. Prefer label for switching tracks, and use language to persist a user’s preference across assets.

The player emits these events:

EventWhen it firesTypical use
fastpixtracksreadyAfter the HLS manifest is parsed, and again when subtitle tracks attach.Populate menus from event.detail.audioTracks and subtitleTracks.
fastpixaudiochangeWhen audio changes.Refresh the UI highlight.
fastpixsubtitlechangeWhen subtitles change.Refresh the UI highlight.
fastpixsubtitlecueWhen the active cue text updates.Drive a custom React caption overlay.

To keep off-screen players from flooding state and the console, the demo listens for fastpixsubtitlecue only on the active short. App.tsx passes isActive={i === activeIndex} into each ShortItem:

1useEffect(() => {
2 const player = playerRef.current as any;
3 if (!player || !isActive) return;
4
5 const onCue = (e: Event) => {
6 const d = (e as CustomEvent).detail || {};
7 setSubtitleText(d.text ?? "");
8 };
9
10 player.addEventListener("fastpixsubtitlecue", onCue);
11 return () => player.removeEventListener("fastpixsubtitlecue", onCue);
12}, [playbackId, isActive]);

Surface track switching in the UI

The demo exposes audio and subtitle switching through two small surfaces — a kebab menu in the card’s top bar and a centered caption pill near the bottom of the card.

Three-dots (⋯) menu in the top bar. The menu is conditionally rendered. It only appears when the current short exposes more than one audio track or at least one subtitle track, so you don’t show a menu with a single inert option. When open, the menu lists the available Audio tracks and Subtitles tracks (including an Off row for captions), and selecting an item calls setAudioTrack(label) or setSubtitleTrack(label | null) on the player. Refresh the selected highlight using the fastpixaudiochange and fastpixsubtitlechange events.

Drive the menu’s visibility from the fastpixtracksready event:

1useEffect(() => {
2 const player = playerRef.current as any;
3 if (!player) return;
4
5 const onTracksReady = (e: Event) => {
6 const detail = (e as CustomEvent).detail || {};
7 setAudioTracks(detail.audioTracks ?? []);
8 setSubtitleTracks(detail.subtitleTracks ?? []);
9 };
10
11 player.addEventListener("fastpixtracksready", onTracksReady);
12 return () => player.removeEventListener("fastpixtracksready", onTracksReady);
13}, [playbackId]);
14
15const showMenu = audioTracks.length > 1 || subtitleTracks.length >= 1;

Centered caption pill near the bottom. Because the demo sets hide-native-subtitles, the player’s internal subtitle text doesn’t render — but fastpixsubtitlecue still fires for every active cue. Render the cue text yourself in a small pill positioned just above the custom progress bar:

1{subtitleText && (
2 <div
3 style={{
4 position: "absolute",
5 bottom: 24,
6 left: "50%",
7 transform: "translateX(-50%)",
8 maxWidth: "80%",
9 padding: "6px 12px",
10 borderRadius: 999,
11 background: "rgba(0, 0, 0, 0.7)",
12 color: "#fff",
13 fontSize: 14,
14 textAlign: "center",
15 pointerEvents: "none",
16 }}
17 >
18 {subtitleText}
19 </div>
20)}

This pattern keeps the captions layer fully under your control: you choose the typography, position, and background, while the player handles cue timing, language switching, and Off behavior.

Reference files

When you adapt the demo on GitHub, these files are the most useful starting points:

  • src/App.tsx — the feed container, player refs, and behavior such as playAt, handleMuteToggle, and the wheel and keyboard handlers.
  • src/shorts/ShortItem.tsx — per-card player mounting and card UI.
  • src/shorts/types.ts — the universal JSON format for your feed.
  • src/index.css — how to hide built-in controls and restyle the player for a shorts layout.

Next steps