npm.io
0.4.0 • Published yesterday

@page-speed/media-immersive

Licence
BSD-3-Clause
Version
0.4.0
Deps
2
Size
243 kB
Vulns
0
Weekly
0

@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 viewerPortal + all: revert-layer so 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:fixed restore, not just overflow: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

<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:scale on 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 !important targeting 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:

Contributing

  • pnpm install — install
  • pnpm build — tsc → ESM + CJS pairs
  • pnpm 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.

Keywords