storyflow-react-player
v1.0.3
Published
A modern, customizable, production-ready story viewer for React. WhatsApp/Instagram-style stories with progress bars, video & image support, gestures, deep linking, analytics, and a clean headless-friendly API.
Maintainers
Readme
storyflow
A modern, customizable, production-ready story viewer for React.
WhatsApp / Instagram-style stories with progress bars, image & video support, gesture navigation, deep linking, CTA buttons, and analytics hooks — all behind a clean, headless-friendly API.
<StoryPlayer stories={stories} />Why
Most story-viewer packages out there are outdated, ugly, poorly maintained, or hard to customize. storyflow is:
- Tiny & tree-shakeable — zero runtime dependencies, ESM + CJS, ships with types.
- Headless-friendly — every slot (header, footer, close button, loading, error) is replaceable via
renderXprops, every class name overridable viaclassNames. - Production-grade — fully typed, accessible, keyboard + gesture navigable, with analytics hooks for every lifecycle event.
- Real video support — uses the video's natural duration unless you override it; pause/resume keeps audio in sync.
- Built for mobile — pointer events, long-press to pause, swipe down to close, tap zones, autoplay-safe.
Install
npm install storyflow
# or
pnpm add storyflow
# or
yarn add storyflowPeer dependencies: react >= 17, react-dom >= 17.
Quick start
import { useState } from 'react';
import { StoryPlayer, type Story } from 'storyflow';
import 'storyflow/styles.css';
const stories: Story[] = [
{
id: 'welcome',
type: 'image',
url: 'https://picsum.photos/seed/1/1080/1920',
header: { title: 'Acme', subtitle: 'just now', avatar: '/logo.png' },
},
{
id: 'feature',
type: 'video',
url: 'https://example.com/promo.mp4',
cta: { label: 'Try it free', href: 'https://acme.dev/signup' },
},
];
export function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Open stories</button>
<StoryPlayer
stories={stories}
open={open}
onClose={() => setOpen(false)}
onAnalytics={(event) => analytics.track(`story.${event.type}`, event)}
/>
</>
);
}API
<StoryPlayer />
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| stories | Story[] | — | Ordered list of stories to play. |
| open | boolean | true | Controlled visibility. |
| initialIndex | number | 0 | Index to start playback from. |
| defaultDuration | number | 5000 | Per-slide duration (ms) for images. |
| loop | boolean | false | Wrap from last back to first. |
| autoPlay | boolean | true | Begin advancing immediately. |
| keyboard | boolean | true | Enable Esc / ← / → / Space. |
| gestures | boolean | true | Tap-zones, long-press pause, swipe-down close. |
| deepLink | boolean \| { paramName?, mode? } | false | Sync active story id to the URL. |
| tapZones | { prev?, next? } | {0.3, 0.7} | Width fractions for the left / right tap zones. |
| preload | number | 1 | How many upcoming slides to preload. |
| onSlideChange | (index, story) => void | — | |
| onComplete | () => void | — | Fires when the last slide finishes (and not looping). |
| onClose | () => void | — | |
| onCTAClick | (story, index) => void | — | |
| onAnalytics | (event) => void | — | Single sink for every lifecycle event. |
| classNames | StoryClassNames | — | Per-slot class overrides. |
| renderHeader / renderFooter / renderCloseButton / renderLoading / renderError | functions | — | Slot replacements. |
| videoProps / imageProps | HTML attrs | — | Spread onto every <video> / <img>. |
Story
interface Story {
id: string;
type?: 'image' | 'video'; // inferred from URL when omitted
url: string;
duration?: number; // ms; overrides natural video duration
poster?: string; // video poster image
cta?: {
label: string;
href?: string;
target?: '_blank' | '_self';
onClick?: (story: Story, index: number) => void;
};
header?: { title?: string; subtitle?: string; avatar?: string };
meta?: Record<string, unknown>;
}Analytics events
onAnalytics receives every lifecycle event in a normalized shape:
type StoryAnalyticsEventType =
| 'open' // viewer opened
| 'view' // a story became active (fires per-slide)
| 'complete' // a story's timer reached 100%
| 'skip-next' // user advanced manually
| 'skip-prev' // user went back manually
| 'pause' // playback paused (long-press, space, .pause())
| 'resume' // playback resumed
| 'cta-click' // CTA activated
| 'close'; // viewer closedWire it into Segment, GA4, Amplitude, PostHog, or your own pipeline — all the same way:
<StoryPlayer
stories={stories}
onAnalytics={(e) => analytics.track(`story.${e.type}`, { id: e.story.id, index: e.index })}
/>Deep linking
Pass deepLink to sync the active story id to the URL. On mount, ?story=<id> jumps the viewer to that story; on slide change, the URL updates without reloading; on close, the param is removed.
<StoryPlayer stories={stories} deepLink={{ paramName: 's', mode: 'replace' }} />Custom slots (headless mode)
Every visible piece of UI can be replaced. Pass classNames to override the built-in styling, or skip storyflow/styles.css entirely and bring your own:
<StoryPlayer
stories={stories}
classNames={{ root: 'my-story-root', cta: 'my-cta' }}
renderHeader={({ story, close }) => (
<header className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<img src={story.header?.avatar} className="h-8 w-8 rounded-full" alt="" />
<span className="font-semibold">{story.header?.title}</span>
</div>
<button onClick={close}>✕</button>
</header>
)}
renderFooter={({ story, next }) =>
story.cta && (
<footer className="p-4">
<a href={story.cta.href} onClick={next}>{story.cta.label}</a>
</footer>
)
}
/>Driving the player imperatively
The exported usePlayer and useAutoAdvance hooks give you the bare state machine if you want to build a fully custom UI:
import { usePlayer, useAutoAdvance } from 'storyflow';
function MyCustomPlayer({ stories }) {
const player = usePlayer({ stories });
const progress = useAutoAdvance({
duration: 5000,
paused: player.paused,
resetKey: player.story?.id ?? 0,
onComplete: player.next,
});
// … render whatever you want with `player.story`, `progress`, etc.
}Keyboard & gestures
| Input | Action |
| --- | --- |
| Esc | Close |
| ← | Previous slide |
| → | Next slide |
| Space | Toggle pause / resume |
| Tap left third | Previous slide |
| Tap right third | Next slide |
| Tap centre | Trigger CTA (if any) |
| Long press | Pause until release |
| Swipe down | Close |
Accessibility
- The root is
role="dialog"witharia-modal="true"and a configurablearia-label. - Each progress bar uses
role="progressbar"with a livearia-valuenow. - All controls are real
<button>elements witharia-label. - Respects
prefers-reduced-motion.
Browser support
Targets modern evergreen browsers (Chrome, Edge, Firefox, Safari, iOS Safari 13+).
Development
npm install
npm run typecheck # tsc --noEmit
npm test # vitest
npm run build # tsup (dual ESM + CJS + .d.ts + styles)