Skip to content
ilinxa/pro-ui

Story Viewer

alphav0.5.1

Full-screen story viewer — segmented progress, 3D cube transitions, finger-following swipe, tap zones, and an engagement overlay.

Category: MediaUpdated: 2026-08-19Created: 2026-05-03Author: ilinxa

Context

Use anywhere stories appear — Instagram-style modal viewer over a feed. Pairs with story-rail which fires onItemClick(item, index); host opens <StoryViewer isOpen stories={...} initialStoryIndex={index} onClose={...} /> in response. The viewer's onStoryViewed(storyId) callback is what hosts wire back into railRef.current.markViewed(storyId) to clear the unread ring — viewer is fully decoupled from the rail. Image and video items both supported (video composes media/video-player). v0.4 ships pure-CSS 3D cube transitions + pointer-driven swipe (no framer-motion peer dep); the cube engages only during the animation window and the front face sits at the perspective plane (no scale-jump). The engagement overlay (v0.2) composes engagement-bar v0.3.x; the comments panel (v0.3) and share panel (v0.3.1) are bottom-sheet slots typically host-wired to CommentThread and ShareMenu. Migration origin: kasder kas-social-front-v0 StoryViewer.tsx.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/story-viewer

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/story-viewer-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Demo source

demo.tsxtsx
"use client"; import { useMemo, useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { Button } from "@/components/ui/button";import { StoryViewer } from "./story-viewer";import {  STORY_VIEWER_DUMMY,  STORY_VIEWER_DUMMY_CURRENT_USER,  STORY_VIEWER_DUMMY_REACTION_KINDS,  STORY_VIEWER_DUMMY_VIEWERS,} from "./dummy-data";import { CommentThread } from "@/registry/components/data/comment-thread/comment-thread";import {  DUMMY_FLAT_COMMENTS,  generateOlderPage,} from "@/registry/components/data/comment-thread/dummy-data";import { ShareMenu } from "@/registry/components/data/engagement-bar/parts/share-menu";import { DUMMY_LIKE_USERS } from "@/registry/components/data/engagement-bar/dummy-data";import type {  Story,  StoryItem,  StoryViewerDelta,  Subscribe,  ViewerListItem,} from "./types"; function log(tag: string, payload: unknown) {  if (typeof console !== "undefined") {    console.log(`[demo:story-viewer:${tag}]`, payload);  }} const IMAGE_ONLY: Story[] = [STORY_VIEWER_DUMMY[0]];const VIDEO_ONLY: Story[] = [STORY_VIEWER_DUMMY[1]];const MIXED: Story[] = [STORY_VIEWER_DUMMY[2]];const ALL_STORIES: Story[] = STORY_VIEWER_DUMMY; function OpenButton({ label, onClick }: { label: string; onClick: () => void }) {  return (    <div className="flex items-center justify-center py-12">      <Button onClick={onClick}>{label}</Button>    </div>  );} function ImageOnlyTab() {  const [open, setOpen] = useState(false);  return (    <>      <OpenButton label="Open image-only story" onClick={() => setOpen(true)} />      <StoryViewer        stories={IMAGE_ONLY}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        onStoryViewed={(id) => log("story-viewed", id)}      />    </>  );} function VideoOnlyTab() {  const [open, setOpen] = useState(false);  return (    <>      <OpenButton label="Open video-only story" onClick={() => setOpen(true)} />      <StoryViewer        stories={VIDEO_ONLY}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        onItemViewed={(s, i, idx) => log("item-viewed", { s, i, idx })}      />    </>  );} function MixedTab() {  const [open, setOpen] = useState(false);  return (    <>      <OpenButton label="Open mixed (image + video)" onClick={() => setOpen(true)} />      <StoryViewer        stories={MIXED}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        onCursorChange={(s, i) => log("cursor", { s, i })}      />    </>  );} function MultiStoryTab() {  const [open, setOpen] = useState(false);  const [initialIdx, setInitialIdx] = useState(0);  const [disableCube, setDisableCube] = useState(false);  const [duration, setDuration] = useState(400);  return (    <div className="flex flex-col items-center gap-3 py-8">      <p className="px-6 text-center text-sm text-muted-foreground">        Open from a specific starting story to see story-to-story navigation —        v0.4 ships an Instagram-canonical 3D cube transition (auto-advance + ← →        arrows + tap-zone spillover at last item + keyboard arrows). On touch +        mouse, drag horizontally to swipe between stories (release commits past        30% width or 0.5 px/ms velocity; otherwise snaps back).      </p>      <div className="flex flex-wrap items-center justify-center gap-2">        {ALL_STORIES.map((s, idx) => (          <Button            key={s.id}            variant="outline"            size="sm"            onClick={() => {              setInitialIdx(idx);              setOpen(true);            }}          >            Open: {s.username}          </Button>        ))}      </div>      <div className="mt-2 flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground">        <label className="flex items-center gap-2">          <input            type="checkbox"            checked={disableCube}            onChange={(e) => setDisableCube(e.target.checked)}            className="size-4"          />          <code>disableStoryTransition</code>        </label>        <label className="flex items-center gap-2">          <span>            <code>storyTransitionDurationMs</code>: {duration}          </span>          <input            type="range"            min={100}            max={1000}            step={50}            value={duration}            onChange={(e) => setDuration(Number(e.target.value))}            className="w-32"          />        </label>      </div>      <StoryViewer        stories={ALL_STORIES}        initialStoryIndex={initialIdx}        isOpen={open}        onClose={() => setOpen(false)}        onStoryViewed={(id) => log("story-viewed", id)}        onAutoCloseAtEnd={() => log("auto-close-at-end", null)}        disableStoryTransition={disableCube}        storyTransitionDurationMs={duration}      />    </div>  );} function RealtimeTab() {  const [open, setOpen] = useState(false);  const subscribe = useMemo<Subscribe<StoryViewerDelta>>(    () => (handler) => {      const itemTimer = setInterval(() => {        const newItem: StoryItem = {          id: `live-item-${Date.now()}`,          type: "image",          src: "https://images.unsplash.com/photo-1454496522488-7a8e488e8606?w=900&h=1600&fit=crop",          duration: 5,        };        handler({          kind: "item-added",          storyId: ALL_STORIES[0].id,          item: newItem,          position: "end",        });      }, 8000);       const storyTimer = setInterval(() => {        const story: Story = {          id: `live-story-${Date.now()}`,          userId: `live-user-${Date.now()}`,          username: "live_friend",          createdAt: new Date().toISOString(),          hasUnread: true,          items: [            {              id: `live-story-${Date.now()}-item-1`,              type: "image",              src: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=900&h=1600&fit=crop",              duration: 5,            },          ],        };        handler({ kind: "story-added", story, position: "end" });      }, 15000);       return () => {        clearInterval(itemTimer);        clearInterval(storyTimer);      };    },    [],  );   return (    <>      <p className="px-4 py-2 text-center text-xs text-muted-foreground">        Synthetic feed: new item every 8s, new story every 15s. Open the viewer to see them appear live.      </p>      <OpenButton label="Open realtime viewer" onClick={() => setOpen(true)} />      <StoryViewer        stories={ALL_STORIES}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        subscribe={subscribe}        onSubscribeDelta={(d) => log("delta", d)}      />    </>  );} function CustomRenderItemTab() {  const [open, setOpen] = useState(false);   // Inject a "promo" item by spreading custom data into the viewer.  const customStories = useMemo<Story[]>(() => {    const promoItem: StoryItem = {      id: "promo-item-1",      type: "image",      src: "promo://placeholder",      duration: 6,    };    return [      {        ...ALL_STORIES[0],        items: [...ALL_STORIES[0].items, promoItem],      },    ];  }, []);   return (    <>      <OpenButton label="Open with custom 'promo' item" onClick={() => setOpen(true)} />      <StoryViewer        stories={customStories}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        renderItem={(item, ctx) => {          if (item.id === "promo-item-1") {            return (              <div className="flex h-full w-full items-center justify-center bg-linear-to-br from-accent via-warning to-destructive p-8 text-center">                <div>                  <p className="text-2xl font-bold text-primary-foreground">Sponsored</p>                  <p className="mt-2 text-sm text-primary-foreground/80">                    Custom item rendered via the renderItem slot. Item index {ctx.itemIndex + 1}.                  </p>                </div>              </div>            );          }          // For non-promo items, mimic the default by returning null + relying on          // the host wrapping pattern. In practice, hosts using renderItem own          // the full render — including image/video. Keep this minimal for demo.          if (item.type === "image") {            return <img src={item.src} alt="story" className="h-full w-full object-cover" />;          }          return (            <video              src={item.src}              autoPlay              muted              playsInline              className="h-full w-full object-cover"            />          );        }}      />    </>  );} // ─── v0.2.0 — viewer / owner / slots / link demos ────────────────────────── function ViewerModeTab() {  const [open, setOpen] = useState(false);  return (    <div className="flex flex-col items-center gap-3 py-8">      <p className="text-sm text-muted-foreground">        viewerMode=&quot;viewer&quot;: stacked engagement overlay + reply composer +        viewer-side kebab.      </p>      <OpenButton label="Open viewer-mode story" onClick={() => setOpen(true)} />      <StoryViewer        stories={ALL_STORIES}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        viewerMode="viewer"        currentUser={STORY_VIEWER_DUMMY_CURRENT_USER}        reactionKinds={STORY_VIEWER_DUMMY_REACTION_KINDS}        onLikeStory={(s, i, liked) => log("like", { s, i, liked })}        onReactStory={(s, i, kind) => log("react", { s, i, kind })}        onShareStory={(s, i) => log("share", { s, i })}        onAddReply={(s, i, content) => log("reply", { s, i, content })}        onReport={(s) => log("report", s)}        onBlockAuthor={(a) => log("block-author", a)}        onCopyLink={(s) => log("copy-link", s)}        onAuthorClick={(s) => log("author-click", s.username)}        renderCommentsPanel={(story, item) => (          <CommentThread            comments={DUMMY_FLAT_COMMENTS}            currentUser={{              id: STORY_VIEWER_DUMMY_CURRENT_USER.id,              name: STORY_VIEWER_DUMMY_CURRENT_USER.name,              avatar: STORY_VIEWER_DUMMY_CURRENT_USER.avatar,            }}            pageSize={5}            onAddComment={(content) => {              log("add-comment", { story: story.id, item: item.id, content });            }}            onLoadMore={async (page) => {              log("load-more", { story: story.id, item: item.id, page });              await new Promise((r) => setTimeout(r, 300));              return generateOlderPage(page);            }}            onLikeComment={(id, liked) => log("like-comment", { id, liked })}            className="px-4 py-3"          />        )}        renderSharePanel={(story, item, helpers) => (          <div className="px-4 py-3">            <ShareMenu              users={DUMMY_LIKE_USERS}              onShareTo={(user) => {                log("share-to", {                  story: story.id,                  item: item.id,                  to: user.username,                });                helpers.closeSharePanel();              }}              heading="Send to…"            />          </div>        )}      />    </div>  );} function OwnerModeTab() {  const [open, setOpen] = useState(false);  // Lazy-fetch handler — simulates a server round-trip after the eager  // count chip is tapped (Q-V5 hybrid: viewerCount eager, viewers lazy).  const onLoadViewers = useMemo(    () => async (storyId: string): Promise<ViewerListItem[]> => {      log("load-viewers", storyId);      await new Promise((r) => setTimeout(r, 350));      return STORY_VIEWER_DUMMY_VIEWERS;    },    [],  );  return (    <div className="flex flex-col items-center gap-3 py-8">      <p className="text-sm text-muted-foreground">        viewerMode=&quot;owner&quot;: view-count chip + lazy viewers list (350ms        simulated fetch). Owner-side kebab (save / delete / share-to-feed).      </p>      <OpenButton label="Open owner-mode story" onClick={() => setOpen(true)} />      <StoryViewer        stories={ALL_STORIES}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        viewerMode="owner"        onLoadViewers={onLoadViewers}        onSaveToHighlights={(s) => log("save-to-highlights", s)}        onDeleteStory={(s) => log("delete-story", s)}        onShareToFeed={(s) => log("share-to-feed", s)}        isSavedToHighlights={false}      />    </div>  );} function CustomSlotsTab() {  const [open, setOpen] = useState(false);  return (    <div className="flex flex-col items-center gap-3 py-8">      <p className="text-sm text-muted-foreground">        Full takeover via renderHeader + renderProgress + renderEngagementOverlay.        Slots receive helpers (cursor / pause / nav) so custom UI keeps full        control of the viewer.      </p>      <OpenButton label="Open custom-slots story" onClick={() => setOpen(true)} />      <StoryViewer        stories={ALL_STORIES}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        viewerMode="viewer"        currentUser={STORY_VIEWER_DUMMY_CURRENT_USER}        reactionKinds={STORY_VIEWER_DUMMY_REACTION_KINDS}        renderHeader={(story, _item, helpers) => (          <div className="absolute top-4 right-4 left-4 z-20 flex items-center justify-between rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm">            <p className="text-sm font-semibold text-white">@{story.username}</p>            <Button              size="sm"              variant="ghost"              className="text-white hover:bg-white/20 hover:text-white"              onClick={helpers.onClose}            >              Close            </Button>          </div>        )}        renderProgress={(items, idx, p) => (          <div className="absolute top-0 right-0 left-0 z-20 flex gap-0.5 p-2">            {items.map((it, i) => (              <div                key={it.id}                className="h-1 flex-1 overflow-hidden rounded-full bg-white/20"              >                <div                  className="h-full rounded-full bg-accent transition-[width] duration-100"                  style={{ width: `${i < idx ? 100 : i === idx ? p : 0}%` }}                />              </div>            ))}          </div>        )}        renderEngagementOverlay={(_story, _item, helpers) => (          <div className="absolute right-3 bottom-20 z-30 flex flex-col items-center gap-3">            <Button              size="icon"              variant="secondary"              onClick={() => helpers.setPaused(!helpers.isPaused)}            >              {helpers.isPaused ? "▶" : "❚❚"}            </Button>          </div>        )}      />    </div>  );} function LinkAndLongPressTab() {  const [open, setOpen] = useState(false);  // Use the story-1 fixture (the dummy ships an item with a link CTA).  return (    <div className="flex flex-col items-center gap-3 py-8">      <p className="px-6 text-center text-sm text-muted-foreground">        First item carries a link CTA (&quot;Shop now&quot; → example.com).        Hold-press anywhere on the viewer to pause; release to resume.      </p>      <OpenButton label="Open link + long-press story" onClick={() => setOpen(true)} />      <StoryViewer        stories={[STORY_VIEWER_DUMMY[0]]}        initialStoryIndex={0}        isOpen={open}        onClose={() => setOpen(false)}        viewerMode="viewer"        currentUser={STORY_VIEWER_DUMMY_CURRENT_USER}        reactionKinds={STORY_VIEWER_DUMMY_REACTION_KINDS}        onLinkClick={(s, i, url) => log("link-click", { s, i, url })}        longPressThresholdMs={250}      />    </div>  );} export default function StoryViewerDemo() {  return (    <Tabs defaultValue="image" className="w-full">      <SwipeTabsList>        <TabsTrigger value="image">Image only</TabsTrigger>        <TabsTrigger value="video">Video only</TabsTrigger>        <TabsTrigger value="mixed">Mixed</TabsTrigger>        <TabsTrigger value="multi">Cube + swipe</TabsTrigger>        <TabsTrigger value="realtime">Realtime</TabsTrigger>        <TabsTrigger value="custom">Custom renderItem</TabsTrigger>        <TabsTrigger value="viewer-mode">Viewer mode</TabsTrigger>        <TabsTrigger value="owner-mode">Owner mode</TabsTrigger>        <TabsTrigger value="custom-slots">Custom slots</TabsTrigger>        <TabsTrigger value="link-longpress">Link + long-press</TabsTrigger>      </SwipeTabsList>      <TabsContent value="image"><ImageOnlyTab /></TabsContent>      <TabsContent value="video"><VideoOnlyTab /></TabsContent>      <TabsContent value="mixed"><MixedTab /></TabsContent>      <TabsContent value="multi"><MultiStoryTab /></TabsContent>      <TabsContent value="realtime"><RealtimeTab /></TabsContent>      <TabsContent value="custom"><CustomRenderItemTab /></TabsContent>      <TabsContent value="viewer-mode"><ViewerModeTab /></TabsContent>      <TabsContent value="owner-mode"><OwnerModeTab /></TabsContent>      <TabsContent value="custom-slots"><CustomSlotsTab /></TabsContent>      <TabsContent value="link-longpress"><LinkAndLongPressTab /></TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for StoryViewer when you need an Instagram-style full-screen modal viewer for sequential stories. Pairs with StoryRail: rail fires onItemClick(item, index); host opens <StoryViewer isOpen ... />with the matching index. The viewer's onStoryViewed(storyId) feeds back into railRef.current.markViewed(storyId) so the unread ring clears.

Footgun: the `stories` prop is mount-only

Component captures stories as initial state on mount; subsequent prop reference changes are ignored. Realtime subscribemutates the internal store; for external pushes use the imperative handle's reset(next) or surgical dispatch(action). Cursor is ID-anchored (not index-based), so insertions / removals don't desync your position.

Cursor reset semantics

Cursor resets to (initialStoryIndex, 0) whenever the (initialStoryIndex, isOpen) pair changes — opening with a different index re-seeds; re-opening with the same index also goes back to item 0. Mid-view, in-component nav (tap zones / arrows / keyboard) is preserved across renders.

Minimal usage

import { StoryViewer } from "@/components/story-viewer";

<StoryViewer
  stories={stories}
  initialStoryIndex={activeStoryIndex}
  isOpen={open}
  onClose={() => setOpen(false)}
/>

Wired with story-rail (canonical)

const railRef = useRef<StoryRailHandle>(null);
const [activeIdx, setActiveIdx] = useState(-1);

<StoryRail
  ref={railRef}
  items={stories}
  onItemClick={(_item, index) => setActiveIdx(index)}
/>

{activeIdx >= 0 ? (
  <StoryViewer
    stories={stories}
    initialStoryIndex={activeIdx}
    isOpen
    onClose={() => setActiveIdx(-1)}
    onStoryViewed={(id) => railRef.current?.markViewed(id)}
  />
) : null}

Forward-only viewed semantics

onStoryViewed fires only on forward completion (last item OR forward navigation OR auto-close at end). Backward navigation does NOT mark stories viewed — matches Instagram.

Realtime via subscribe

import type {
  Subscribe,
  StoryViewerDelta,
} from "@/components/story-viewer";

const subscribe = useCallback<Subscribe<StoryViewerDelta>>(
  (handler) => channel.on("stories", handler),
  [channel],
);

<StoryViewer
  stories={stories}
  initialStoryIndex={0}
  isOpen={open}
  onClose={onClose}
  subscribe={subscribe}
  onSubscribeDelta={(d) => analytics.track("story-viewer-delta", d)}
/>

Imperative handle

const ref = useRef<StoryViewerHandle>(null);

ref.current?.goToStory(2);
ref.current?.goToItem(1);
ref.current?.setPaused(true);
ref.current?.dispatch({
  kind: "patch-story",
  storyId: "story-1",
  partial: { username: "newName" },
});
ref.current?.reset(updatedStories);

Custom item rendering

Pass renderItem for full takeover (Lottie items, polls, sponsored placements, etc.). Hosts using this MUST set item.duration explicitly for non-video items, since the video metadata fallback only applies to the default video branch. Hosts wanting to mix custom + default rendering should branch inside their renderItem and re-implement the image / video defaults themselves.

Role-aware mode (v0.2.0)

Pass viewerMode="viewer" to opt into the engagement overlay + DM composer + kebab. Pass viewerMode="owner" for owners (no engagement; owner overlay with view-count + viewers list instead). Per-action overrides go through permissions (e.g. { canReact: false }) or the universal canPerformAction(action, story, item) predicate, which wins over both. Resolution order: predicate → matrix → viewerMode-derived defaults.

Engagement overlay (v0.2.0)

<StoryViewer
  stories={stories}
  initialStoryIndex={0}
  isOpen={open}
  onClose={onClose}
  viewerMode="viewer"
  currentUser={{ id: "u1", name: "Hessam", avatar: "/me.png" }}
  reactionKinds={[
    { key: "love", icon: <Heart />, label: "Love", count: 0 },
    { key: "laugh", icon: <Laugh />, label: "Laugh", count: 0 },
  ]}
  onLikeStory={(storyId, itemId, nextLiked) => api.like(storyId, itemId, nextLiked)}
  onReactStory={(storyId, itemId, kind) => api.react(storyId, itemId, kind)}
  onShareStory={(storyId, itemId) => api.share(storyId, itemId)}
  onAddReply={(storyId, itemId, content) => api.dm(storyId, itemId, content)}
/>

Comments panel (v0.3.0)

Wire renderCommentsPanel to host the per-item comment thread (typically CommentThread). Tapping the comment icon opens a bottom-sheet (~62% viewer height) — the visual stack above scales to 55% and translates up; tap on the shrunk visual closes the panel. Always-mounted so the consumer's draft state survives open/close. The story timer auto-pauses while the panel is open. Set disableComments to fall back to the v0.2.x behavior (comment icon focuses the DM input).

<StoryViewer
  /* … */
  renderCommentsPanel={(story, item, helpers) => (
    <CommentThread
      comments={getCommentsFor(story.id, item.id)}
      onAddComment={(content) => api.addComment(story.id, item.id, content)}
      onLoadMore={() => api.loadMoreComments(story.id, item.id)}
    />
  )}
/>

Share panel (v0.3.1)

Same shape as renderCommentsPanel — wire renderSharePanel to a share UI (typically ShareMenu from @ilinxa/engagement-bar). Comments + share panels are mutually exclusive (opening one closes the other). disableSharePanel falls back to firing onShareStory directly (v0.2.x system-share behavior).

Story-to-story 3D cube + swipe (v0.4)

Story-to-story navigation (auto-advance + nav arrows + tap-zone spillover + keyboard + programmatic goToStory) animates an Instagram-canonical 3D cube with rotateY 0 → ∓90° over 400ms and Apple-spring easing. Item-to-item navigation within a story stays a hard cut. The cube is also finger-drivable: drag-left to advance, drag-right to return; release commits past 30% width or 0.5 px/ms velocity. Pass storyTransitionDurationMs to tune (default 400) or disableStoryTransition to revert to v0.3.x hard cuts.

Public types & helpers

The barrel exports every public type referenced by props + callbacks — Story / StoryItem / StoryItemLink / StoryViewerMode / StoryViewerPermissions / StoryEngagementReactionKind / StoryKebabMenuItem / ViewerListItem / StoryCurrentUser / StoryEngagementDelta + companions. Three internal hooks are also exported standalone for advanced consumers (custom viewers reusing the same reducer / progress timer / keyboard nav): useStoryViewerState, useStoryProgress, useStoryKeyboardNav. useCubeTransition and useLongPressPausestay internal — they're tightly coupled to this viewer's render shape.

Features

  • v0.5.1 — `reactors` / `onLoadReactors` are marked @notImplemented and dev-warn; both were declared and never read
  • Radix Dialog modal — focus trap + portal + Escape + backdrop click free
  • Mobile full-screen (h-dvh) / desktop centered portrait modal (md:h-175 md:w-100)
  • Segmented progress bars (one per item; CSS `transition-[width]` fill; ARIA progressbar)
  • Pause-preserving accumulator-based progress timer (fixes kasder's ~50ms drift per pause/resume)
  • Item duration resolution: explicit `item.duration` → video metadata → default fallback
  • Tap zones: left=prev item / middle=pause / right=next item (mobile + desktop)
  • Desktop nav arrows: ← → between stories (story-level navigation)
  • Keyboard nav: ArrowLeft/Right (item nav) + Space (pause) + Escape (close)
  • Header: avatar + username + relative time + pause/play + mute (video only) + close
  • video-player composed for video items (cross-folder via registryDependencies)
  • Subscribe<StoryViewerDelta> realtime contract: story-added / story-removed / item-added / item-removed / story-viewed
  • ID-anchored cursor (NOT index-based) — story/item insertions / removals don't desync the cursor
  • Always-uncontrolled state with `reset(next)` + `dispatch(action)` imperative escape hatches (matches story-rail / post-card / comment-thread)
  • Cursor reset on (initialStoryIndex, isOpen) pair change — re-opening with same initialStoryIndex still goes back to item 0
  • Forward-only `onStoryViewed` semantics (matches Instagram — backward navigation doesn't mark viewed)
  • Auto-close at end of last story with synchronous `onAutoCloseAtEnd` callback before `onClose`
  • renderItem slot for custom item types (Lottie, polls, sponsored, etc.)
  • useStoryProgress + useStoryKeyboardNav exported standalone for advanced consumers
  • i18n via 10-key labels object (defaults to English + native Intl.DateTimeFormat)
  • a11y: DialogTitle (sr-only) + per-button aria-labels + per-segment role=progressbar with aria-valuenow
  • v0.2.0 — Engagement overlay composing engagement-bar v0.3.x (variant=stacked) — like + reaction (host-supplied kinds) + comment + share (bookmark removed in v0.3.0; kebab moved to header in v0.3.5; column collapsed-by-default with heart toggle in v0.3.7)
  • v0.2.0 — DM composer (always-visible bottom 'Reply to @user…' input — Instagram-canonical Direct Message channel, NOT public comments). Composes comment-thread v0.2.1 CommentComposer with auto-pause-on-type. `onAddReply` callback name preserved for back-compat.
  • v0.2.0 — Role-aware mode (viewerMode='owner'|'viewer') + StoryViewerPermissions matrix + canPerformAction predicate (mirrors post-card v0.3.0 resolver)
  • v0.2.0 — Owner overlay: view-count chip (eager from story.viewerCount) + lazy viewers list panel (onLoadViewers slot; reuses LikersStrip)
  • v0.2.0 — Kebab as engagement-overlay item (moved to ViewerHeader's right cluster in v0.3.5)
  • Render slots: 9 total (v0.1 renderItem + v0.2 renderHeader/renderProgress/renderNavArrows/renderTapZones/renderEngagementOverlay/renderReplyComposer/renderOwnerOverlay + v0.3.0 renderCommentsPanel + v0.3.1 renderSharePanel)
  • Disable opt-outs: 12 flags (v0.2 disableTapZones/disableKeyboardNav/disableNavArrows/disableAutoClose/disableProgressBars/disableEngagement/disableReplyComposer/disableOwnerOverlay + v0.3.0 disableComments + v0.3.1 disableSharePanel + v0.4.0 disableStoryTransition + storyTransitionDurationMs tuning)
  • v0.2.0 — Imperative handle: 7→13 methods (added setMuted/triggerLike/triggerReaction/triggerReply/triggerShare/openKebab)
  • v0.2.0 — Polymorphic linkComponent + StoryItem.link CTA (redesigned as a top-anchored collapsible drawer in v0.3.8)
  • v0.2.0 — Long-press pause additive (Instagram-canonical mobile gesture; preserves v0.1 middle-tap-pause as desktop fallback; longPressThresholdMs prop tunable)
  • v0.2.0 — F-S1 hygiene: VideoPlayer import switched to specific-file path
  • v0.2.0 — Touch-target patch: header buttons 32×32 → 44×44 (WCAG 2.5.5 compliant)
  • v0.2.1 — F-cross-13 viewer-shell patch: drop `showCloseButton={false}` prop (not in consumer's Radix dialog) + suppress close button via `[&>button.absolute]:hidden` CSS (works on both backends)
  • v0.2.2 — Author tap-target additive: `onAuthorClick(story)` + polymorphic `authorComponent` (default `"button"` when handler set). Avatar + username strip becomes a real tap-target with hover/focus affordance; consumers can pass Next.js `<Link>` or `<a>` for href-based nav.
  • v0.3.0 — Bookmark action removed from engagement overlay (stories are ephemeral; viewers don't bookmark stories. Owner-side `Save to highlights` stays in the kebab).
  • v0.3.0 — Instagram-canonical comments panel: comment-icon tap opens a bottom-sheet (~62% viewer height) holding the host-supplied comments thread (typically `<CommentThread />` via `renderCommentsPanel`). Visual content above scales to 55% + translates up; tap anywhere on the shrunk visual closes the panel. Always-mounted (CommentThread draft state survives open/close). Story timer auto-pauses when panel open.
  • v0.3.0 — DM input semantic clarified: the always-visible bottom `<ReplyComposer>` is the Direct Message channel to the story author (Instagram-canonical 'Reply to @user…'), NOT public comments. Public comments live in the new panel. `onAddReply` callback name preserved for back-compat.
  • v0.3.0 — `disableComments?: boolean` opt-out — when set, comment-icon falls back to focusing the DM input (v0.2.x behavior).
  • v0.3.1 — Share panel: share-icon tap opens an Instagram-canonical bottom-sheet holding the host-supplied share targets (typically `<ShareMenu />` from `@ilinxa/engagement-bar`) via `renderSharePanel`. `disableSharePanel` opt-out falls back to v0.2.x onShareStory-only behavior. Comments + share panels are mutually exclusive (opening one closes the other).
  • v0.3.1 — `BottomSheet` part extracted (shared chrome for CommentsPanel + SharePanel). Drag-handle bar + heading row + scroll area + close button.
  • v0.3.1 — Scroll fix: panel content area uses `overflow-y-auto overscroll-contain` (was `overflow-hidden`) so CommentThread + ShareMenu scroll properly on mobile.
  • v0.3.1 — UI polish: backdrop dim (`bg-black/40`) behind shrunk visual when any panel is open; engagement icon sizes unified (kebab `h-5 → h-6` to match like/comment/share/reaction).
  • v0.3.1 — DM input clickability fix: explicit `pointer-events-auto` + `z-[31]` + `right-16` (leaves space for right-side engagement overlay) so the always-visible Direct Message input wins focus reliably.
  • v0.3.2 — DM composer + engagement overlay collision fix (user-flagged): when the composer is focused or has content, it expands to full width (`right-0`) AND the engagement overlay fades out (opacity-0 + pointer-events-none) so the Cancel + Send chrome no longer overlaps the right-edge icons. Lift via new `onActiveChange?: (active) => void` prop on ReplyComposer.
  • v0.3.3 — DM bar layout overhaul (user-flagged 'engagement pushes the bottom area to the left'): gradient strip is now full-width always (`right-0`); engagement column visually overlays it on the right. Cancel button removed entirely — Instagram-canonical story DM has no Cancel. Engagement column stays always visible (no longer fades when composer is active). `onActiveChange` prop kept on ReplyComposer for forward compat (e.g., future heart-toggle that reveals engagement on demand).
  • v0.3.4 — DM input full-width follow-up: removed leftover `pr-12` padding on the CommentComposer in v0.3.3 — the engagement column sits at `bottom-24` while the DM input lives at `bottom-0`, so they don't overlap vertically and the input can extend to the right edge.
  • v0.3.5 — Engagement column UX overhaul (user-flagged): kebab moved out of the engagement column into the ViewerHeader's right cluster (between mute and close). Engagement column now collapsed by default — only the heart toggle visible. Tap the heart → engagement icons (like / reaction / comment / share) reveal with a staggered bottom-to-top animation (delay-0/75/150/200ms). Tap the heart again or anywhere else → icons collapse back. Outside-pointer-down listener handles the dismiss. New EngagementOverlay props: `expanded` + `onToggle` + `containerRef`. ViewerHeader gains optional `onKebabClick` prop.
  • v0.3.6 — DM input height shrink (user-flagged: 'too high — match avatar height'). Two coordinated fixes: ReplyComposer's outer vertical padding `pt-8 pb-4` → `pt-3 pb-3`. CommentComposer's textarea overrides shadcn-baked `min-h-16` (64px) with `min-h-9` (36px) + `py-1.5 text-sm` via `[&_textarea]:` arbitrary-selector className passthrough. Avatar (h-8) and textarea (min-h-9) now visually align.
  • v0.3.7 — Heart toggle moved inline with the DM bar (user-flagged: 'put the heart in the same row with the direct input'). EngagementOverlay no longer renders the toggle; it only renders the engagement icons themselves and sits at `bottom-20` (just above the DM row). The toggle is now an absolute button at `right-3 bottom-3 z-32` rendered by story-viewer.tsx, aligned with the DM input avatar. ReplyComposer's outer gains `pr-16` so the input doesn't extend under the toggle. Outside-pointerdown listener checks both the engagement column ref AND the toggle ref so tapping the toggle doesn't trigger an immediate dismiss.
  • v0.3.8 — StoryItem.link CTA redesigned as a top-anchored collapsible drawer (user-flagged: bottom button collided with the DM bar). Default state: small rounded chip at `top-16 right-3` showing the host domain + link icon. Tap the chip → drawer slides down (origin-top-right scale+fade transition) showing the host preview + the CTA button + an X-close. Tap chip again or anywhere outside → collapses. Outside-pointer-down listener handles the dismiss. Matches Instagram-canonical link-sticker UX. Polymorphic `linkComponent` + `onLinkClick` semantics preserved.
  • v0.3.9 — Full-component review cleanup pass. (1) New label keys: `linkCloseLabel`, `engagementShowLabel`, `engagementHideLabel`, `replyAriaLabel` (function). (2) Removed hardcoded English aria-labels from heart toggle + link-drawer X (was wrongly using `commentsCloseLabel`) + DM textarea. (3) Stale JSDoc cleaned: kebab-panel, engagement-overlay, story-viewer.tsx scaling-wrapper inventory; meta.ts feature bullets reworded to reflect v0.3.x layout (DM composer vs reply composer naming; kebab in header; top-anchored link drawer). (4) Demo custom-slots tab engagement-overlay positioning `bottom-24 → bottom-20` to match v0.3.7. (5) `onActiveChange` on `ReplyComposer` marked `@deprecated` forward-compat. (6) Inline-copied `kind: "bookmark"` + `kind: "view-count"` arms documented as orphan-but-structurally-required.
  • v0.4.0 — Instagram-canonical 3D cube transition between stories (user-flagged: 'transition from story to other story must be more professional and more like Instagram'). Story-to-story navigation (auto-advance + next-tap-zone spillover at last item + nav arrows + keyboard arrows + programmatic `goToStory`) animates a `perspective-distant` cube swinging `rotateY 0 → ∓90deg` over 400ms with the Apple-spring easing `cubic-bezier(0.32, 0.72, 0, 1)`. The leaving story renders as a static ghost face (`parts/story-cube-face.tsx` — progress bars + header + image/video poster, no interactivity) on the front wall; the incoming story is pre-placed on the side wall and rotated into view. Detection runs during render (mid-render `setState` pattern) so the cube engages in the SAME React commit as the cursor change — no 1-frame flash. Item-to-item navigation within a single story stays a hard cut (matches Instagram). New opt-outs: `disableStoryTransition?: boolean` and `storyTransitionDurationMs?: number` (default 400). New hook `useCubeTransition` (internal). CSS uses Tailwind v4's `perspective-distant` + `@container` + `transform-3d` + `backface-hidden` plus `translateZ(50cqw)` inline so no JS width measurement is needed.
  • v0.4.1 — Finger-following swipe gesture + mobile-fullscreen hardening. (1) **Swipe**: pointer drag on the viewer body drives the cube angle in real-time (Δx → angle, 1:1 at half-width = 90°). Drag-left advances to the next story; drag-right returns to previous. On release: distance > 30% width OR velocity > 0.5 px/ms commits — else snap-back to current. During drag, prev + next ghost faces are mounted on the left/right walls so the user can swing either way. Boundary resistance (×0.25) at first/last story. Cube hook extended with `beginDrag` / `setDragAngle` / `releaseDrag` API; CSS transition disabled mid-drag (pointer is the driver) and re-enabled for release. Coexists with longPress pause (drag-intent cancels the long-press timer) and tap-zone clicks (a `swipeJustEnded` flag suppresses the click after a successful drag). (2) **Mobile full-screen fix**: shadcn `DialogContent` ships `sm:max-w-sm` (caps width at 384px on 640–767px viewports). Viewer-shell now explicitly clears the `sm:` cap with `sm:max-w-none sm:rounded-none sm:h-dvh sm:w-screen` so the modal stays truly full-screen across the entire `<md` range. Resolves the issue where on intermediate-mobile widths the modal floated as a 384px column with the docs-page features list bleeding through the `bg-black/10` overlay around it.
  • v0.4.2 — Cube-engagement scale-jump fix (user-flagged: 'scale gets bigger on swipe, must scale down for cubic effect'). Root cause: front face sat at `translateZ(50cqw)`, which CSS perspective magnifies ≈1.2× at rest (`perspective / (perspective − halfWidth)` ≈ `1200 / 1000`). The moment the cube engaged mid-swipe, the live story jumped from natural size 1.0× to 1.2×, then shrank during rotation — visually reads as 'big then shrinking', not a clean cube. Fix: prefix the rotator transform with `translateZ(-50cqw)` so the front face lands at world z=0 (the natural perspective plane) at idle. Now scale is 1.0× at engagement (no jump), shrinks DOWN to ≈0.857× as the face rotates to ∓90°, and the incoming face mirrors the curve (starts at 0.857×, grows to 1.0× as it arrives at front). Proper cube perspective behavior throughout. Tailwind v4 important-suffix (`h-dvh!` etc.) added to viewer-shell mobile sizing so shadcn's `sm:max-w-sm` no longer wins the cascade. Docs page (`src/app/components/[slug]/page.tsx`) gained `overflow-x-hidden sm:overflow-x-visible` + `wrap-break-word` on feature `<li>` so the long v0.4.x bullets wrap cleanly on mobile.
  • v0.4.3 — Full-component readiness review pass. Surfaced + closed five drift findings: (1) **🚫 BLOCKER** — `hooks/use-cube-transition.ts` and `parts/story-cube-face.tsx` were missing from `registry.json`, so consumer installs (`pnpm dlx shadcn add @ilinxa/story-viewer`) would have broken with missing-import TS errors. Both files added to the registry roster. (2) Stale `meta.context` + `registry.json.description` claiming 'framer-motion swipe-to-dismiss is the locked v0.2 adoption gate' and 'eighth and final ship in the social-posts-system arc' — both rewritten to reflect the actual v0.4 ship (pure-CSS cube + pointer-driven swipe; no framer-motion peer dep). (3) Feature bullets stale on counts — slots said 1→7, actual 9 (renderCommentsPanel + renderSharePanel were missing); disable opt-outs said 8, actual 12 (disableComments + disableSharePanel + disableStoryTransition + storyTransitionDurationMs were missing). (4) `index.ts` barrel was missing 13 public-API types referenced by props/handlers — `StoryViewerMode`, `StoryViewerPermissions`, `StoryPermissionAction`, `StoryEngagementDelta`, `StoryEngagementLocalAction`, `StoryEngagementAction`, `StoryEngagementActionAlign`, `StoryEngagementReactionKind`, `StoryEngagementBarLabels`, `ViewerListItem`, `StoryReactorProfile`, `StoryCurrentUser`, `StoryReplyComposerLabels`, `StoryKebabMenuItem`, `StoryViewerSlotHelpers`, `StoryItemLink`, `ResolvedStoryViewer01Labels` — all added. (5) `usage.tsx` documented only v0.1 (no engagement / comments / share / cube / swipe / role-aware) — refreshed with role-aware mode + engagement overlay + comments panel + share panel + cube/swipe + public-types sections. tsc / meta-deps / registry:build all clean post-review.
  • v0.4.4 — Docs + demo alignment pass. Surfaced + closed four follow-on drift findings: (1) **demo.tsx** — the 'Multi-story nav' tab silently exercised the v0.4 cube + swipe without calling them out. Renamed to 'Cube + swipe', updated the explainer to describe the gesture (drag-left → next, drag-right → prev, 30% / 0.5 px·ms commit thresholds), and added a `disableStoryTransition` checkbox + `storyTransitionDurationMs` slider (100–1000ms, step 50ms) so users can A/B the feature inline. (2) **guide.md '5 rules'** — Rule 5 still claimed 'framer-motion enters in v0.2 for swipe-to-dismiss (the locked motion-substrate adoption gate)'. Replaced with two rules: 'engagement/comments/share/cube are opt-in' and 'everything is pure CSS' (motion substrate stays deferred; v0.4's cube + swipe use Tailwind v4 3D utilities, not framer-motion). (3) **guide.md engagement overlay** — bullets still listed `bookmark` (removed v0.3.0) and 'kebab — 6th item' (moved to header in v0.3.5). Rewritten to reflect the v0.3.5+ collapsed-by-default column with heart toggle reveal + kebab in header right cluster. Slot count table extended to 9 (added renderCommentsPanel + renderSharePanel); opt-out table extended to 12 (added disableComments + disableSharePanel + disableStoryTransition + storyTransitionDurationMs). (4) **guide.md missing sections** — added new sections for v0.3.0 comments panel + DM-vs-comments semantic, v0.3.1 share panel + mutual-exclusion, v0.3.8 link-CTA drawer, v0.3.9 label keys, v0.4.0 cube geometry, v0.4.1 swipe + mobile-fullscreen sizing fix. 'What's NOT in v0.1' section retitled 'Still out of scope (as of v0.4)' with a 'now shipped' subsection clearing engagement/reply/kebab/swipe. Per-version planning docs (`description.md`/`description-v0.2.0.md`/`plan.md`/`plan-v0.2.0.md`) intentionally left frozen — they are historical records, not live docs.

Tags

story-viewerstoryviewermodaldialogmediasocial

Dependencies

shadcn primitives: dialog, avatar, button
npm peer deps: lucide-react@^1.11.0
internal: video-player, engagement-bar, comment-thread