@page-speed/media-immersive
TikTok / Reels / Shorts-style vertical video feed for React, portalled with CSS isolation so it renders correctly inside arbitrary customer sites. Built on @page-speed/img and @page-speed/video.
- Portal-isolated fullscreen viewer —
Portal + all: revert-layerso consumer CSS cannot leak in. - Pointer-driven vertical pager — transform + spring, not CSS scroll-snap. Works around iOS Safari address-bar snap bugs.
- Native-feeling swipe — pointer events, rubber-band at ends, velocity + distance thresholds.
- HLS with mp4 fallback — delegates to
@page-speed/video, which decides the optimal source at runtime. - iOS-safe body scroll lock with
position:fixedrestore, not justoverflow:hidden. - Zero-defaults action rail. No coupling to Toastability / any specific consumer.
- Tree-shakable per-subpath exports — pull in only what you use.
- SSR-safe — no browser API access during server render.
- Full keyboard support —
↑↓←→,Space,M,Esc. prefers-reduced-motion— animations disabled when the user requests it.
Install
pnpm add @page-speed/media-immersive @page-speed/img @page-speed/video react react-dom
@page-speed/img and @page-speed/video are runtime dependencies. React ≥17 is a peer dependency.
The 60-second version
import { ImmersiveFeed } from "@page-speed/media-immersive";
const items = [
{
id: "intro",
poster: "https://cdn.example.com/reel-1.jpg",
src: "https://cdn.example.com/reel-1.mp4",
badge: "INTRO",
kind: "Welcome",
title: "Meet Encapsa — your site, built by AI",
caption: "A 90-second tour of everything we just researched, wrote and designed for you.",
durationMs: 92000,
},
// ...
];
export function Page() {
return (
<ImmersiveFeed
items={items}
onIndexChange={(i, item) => analytics.track("reel_view", { id: item.id })}
/>
);
}
This renders a horizontal thumbnail carousel where the user is, and portals a fullscreen viewer to document.body when a card is tapped.
Three composition patterns
1. Carousel + viewer (default)
<ImmersiveFeed items={items} variant="carousel" />
2. Controlled — trigger from anywhere on the page
import { useRef } from "react";
import { ImmersiveFeed, ThumbnailCard } from "@page-speed/media-immersive";
import type { ImmersiveFeedHandle } from "@page-speed/media-immersive";
function Page() {
const feedRef = useRef<ImmersiveFeedHandle>(null);
return (
<>
{/* Trigger UI can live anywhere */}
<ThumbnailCard
item={heroItem}
size="hero"
onOpen={(id) => feedRef.current?.open(id)}
/>
{/* Viewer is portalled from here — nothing rendered inline */}
<ImmersiveFeed ref={feedRef} items={items} variant="controlled" />
</>
);
}
3. Fully custom composition
import {
ImmersiveFeedProvider,
ThumbnailStrip,
ThumbnailCard,
ImmersiveViewer,
useImmersiveFeed,
} from "@page-speed/media-immersive";
function MyMenu() {
return (
<ImmersiveFeedProvider items={menuVideos}>
<div className="menu-grid">
{menuVideos.map((v) => (
<MyMenuTile key={v.id} item={v} />
))}
</div>
<ImmersiveViewer brandName="Carlos O'Brien's" />
</ImmersiveFeedProvider>
);
}
function MyMenuTile({ item }) {
const { open } = useImmersiveFeed();
return (
<ThumbnailCard item={item} onOpen={open} size="hero" />
);
}
The MediaItem shape
Every consumer works with a MediaItem[]. Only three fields are required.
interface MediaItem {
id: string; // stable id
poster: string; // thumbnail URL
title: string; // shown on card overlay + fullscreen caption
// At least one video source (any/all — @page-speed/video picks):
src?: string; // progressive mp4 or transform URL
masterPlaylistUrl?: string; // pre-computed HLS master
fallbackSrc?: string; // progressive mp4 fallback
// Optional display metadata
badge?: string; // "INTRO", "PRODUCT TOUR", ...
kind?: string; // "Demo", "Testimonial", ...
caption?: string; // long caption (fullscreen only)
durationMs?: number; // pre-computed, or read from video metadata
durationLabel?: string; // pre-formatted, else derived from durationMs
// Free-form consumer metadata (analytics, order ids, lesson refs, ...)
meta?: Record<string, unknown>;
}
Actions rail (the right-side buttons)
There are no default actions. You must pass your own. Different consumers want very different action sets — a restaurant menu wants Order/Directions/Save; a charter school wants Bookmark/Ask/Notes; a marketing site wants Like/Comment/Share.
const actions = [
{
id: "like",
icon: ({ active }) => (active ? <HeartFilled /> : <HeartOutline />),
label: ({ active }) => (active ? "Liked" : "Like"),
active: (item) => likedIds.has(item.id),
onPress: (item) => toggleLike(item.id),
},
{
id: "share",
icon: <ShareIcon />,
label: "Share",
onPress: async (item) => {
if (navigator.share) await navigator.share({ url: item.meta?.shareUrl });
},
},
];
<ImmersiveFeed items={items} actions={actions} />
Leave actions unset (or []) and the rail renders nothing; the caption expands to full width.
Theming
Theme is applied via CSS custom properties on the portal root. Every field is optional.
<ImmersiveFeed
items={items}
theme={{
accent: "#f39e1e",
brandBg: "#182b4a",
brandFg: "#ffffff",
viewerBg: "#05070d",
chromeBg: "rgba(255,255,255,0.16)",
chromeFg: "#ffffff",
fontFamily: "Inter, system-ui, sans-serif",
}}
/>
If you already use @page-speed/skins, pass token values directly here — no dependency required.
Autoplay policy
Modern browsers refuse unmuted autoplay without a prior user gesture. Because of this, initiallyMuted defaults to true. When the browser refuses playback anyway (some iOS Safari edge cases), the library fires onAutoplayBlocked(item) so you can render a tap-to-play affordance. The built-in mute toggle acts as a user gesture, so the first tap enables sound for the whole session.
<ImmersiveFeed
items={items}
initiallyMuted={true}
onAutoplayBlocked={(item) => showTapToPlayHint(item.id)}
/>
Keyboard controls
| Key | Action |
|---|---|
↑ / ← |
Previous video |
↓ / → |
Next video |
Space |
Play / pause active video |
M |
Toggle mute (whole feed) |
Esc |
Close viewer |
Handlers use the same useKeyboardShortcuts shape as @page-speed/lightbox, exported as a hook so you can extend them:
import { useKeyboardShortcuts } from "@page-speed/media-immersive/hooks";
useKeyboardShortcuts({
L: () => toggleLike(currentItem.id),
}, viewerIsOpen);
CSS isolation model (important for embedded use cases)
<ImmersiveViewer> portals its DOM into document.body and wraps it in data-psmi-scope="root" with all: revert-layer + a scoped stylesheet. Every internal class is prefixed psmi-*. Every layout-critical property is applied inline, so if the injected stylesheet is blocked or fails, the viewer still positions correctly — only decorative polish disappears.
What this protects against:
- Consumer
overflow:hidden/transform:scaleon ancestors (portal escapes them). - Consumer's
body/global resets bleeding into our scope (all: revert-layer). - z-index stacking issues (root sits at
z-index: 2147483000).
What this does not protect against:
- Consumer CSS using
!importanttargeting body descendants unconditionally. If you're integrating into a site with hostile CSS, wrap the whole app in a container element and target styles by that container, or reach out about a Shadow DOM adapter.
Tree-shaking
Each subpath in the exports map compiles to a single file (not a barrel):
import { ImmersiveFeed } from "@page-speed/media-immersive";
import { ThumbnailCard } from "@page-speed/media-immersive/thumbnails/card";
import { useKeyboardShortcuts } from "@page-speed/media-immersive/hooks";
import { ImmersivePortal } from "@page-speed/media-immersive/portal";
Bundlers that respect "sideEffects": false drop unused entry points entirely.
Full prop reference
See the type definitions — every prop has JSDoc:
ImmersiveFeedPropsImmersiveFeedProviderPropsImmersiveViewerPropsThumbnailCardPropsThumbnailStripPropsMediaItem,ImmersiveAction,ImmersiveTheme
Contributing
pnpm install— installpnpm build— tsc → ESM + CJS pairspnpm test— vitest (happy-dom)pnpm typecheck— no-emit type check
See AGENTS.md for AI-agent contribution guidance, architectural invariants, and the reasoning behind the pointer-based pager and CSS isolation choices.
License
BSD-3-Clause. Copyright OpenSite AI.