Stories Player

An Instagram-style stories player overlay for React using @reelkit/react-stories-player.

View live demo →

Features

Nested Navigation
Tap to advance stories, swipe to switch groups
Video Stories
Autoplay with sound toggle
Auto-Advance
Configurable timer per story
3D Transitions
Cube, flip, fade, zoom, slide
Progress Bar
Canvas-based segmented progress
Image & Video
Handles both media types
Virtualized
Only 3 slides in DOM
Double-Tap Like
Heart animation on double-tap
Desktop Nav
Chevron buttons on desktop
Story Rings
Instagram-style avatar rings
Generic Types
Extend StoryItem with custom data
Render Props
Customize every UI element
URL State
Shareable ?story=group.story links
Viewed State
Seen rings and resume survive reloads

Installation

bash
npm i @reelkit/react-stories-player @reelkit/react lucide-react

Don't forget to import the styles:

typescript
import '@reelkit/react-stories-player/styles.css';
Icons

The default header uses lucide-react for icons. If you prefer a different icon library, use renderHeader and renderNavigation to provide your own.

Quick Start

The StoriesOverlay component renders a full-screen stories player. Pair it with StoriesRingList for Instagram-style entry points. Pass an array of StoriesGroup objects and control visibility with isOpen.

tsx
import { useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: {
      id: 'user-1',
      name: 'Alice',
      avatar: '/cdn/samples/avatars/avatar-06.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's1-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-001.jpg',
      },
      {
        id: 's1-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-002.jpg',
      },
      {
        id: 's1-3',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-003.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-2',
      name: 'Bob',
      avatar: '/cdn/samples/avatars/avatar-07.jpg',
    },
    stories: [
      {
        id: 's2-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-004.jpg',
      },
      {
        id: 's2-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-005.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-3',
      name: 'Charlie',
      avatar: '/cdn/samples/avatars/avatar-08.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's3-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-006.jpg',
      },
    ],
  },
];

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedGroup, setSelectedGroup] = useState(0);
  // What was seen: rings, resume and recording from one controller, kept in
  // localStorage across reloads.
  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groups,
    }),
  );

  const openStories = (groupIndex: number) => {
    setSelectedGroup(groupIndex);
    setIsOpen(true);
  };

  return (
    <div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
      <StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />

      <StoriesOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        groups={groups}
        initialGroupIndex={selectedGroup}
        viewed={viewed}
      />
    </div>
  );
}

Live Demo

StoriesPlayer.tsx
Alice
Alice
Bob
Bob
Charlie
Charlie

Click a story ring to open the player. Tap left/right sides to navigate, swipe to switch users.

URL State

View live demo →

StoriesUrlOverlay is a separate component whose open state lives in the address bar. Both axes ride one parameter — ?story=<group>.<story> — so the playing story has a link that can be shared, bookmarked, and closed with the back button. Build a controller with useOverlayUrlState and urlIndexTwoAxisKey, then hand it over as controller.

Built-in keys

Stories are two-axis, so spread a two-axis key into the controller: urlIndexTwoAxisKey (group and story by position) or urlStableIdTwoAxisKey (the group by a stable id) — both re-exported from @reelkit/react. See the URL State guide and Core API.

tsx
import {
  StoriesUrlOverlay,
  useOverlayUrlState,
  urlIndexTwoAxisKey,
} from '@reelkit/react-stories-player';
import { Link } from 'react-router-dom';

const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
  }),
});

// Opening a user is a link — the overlay reads the URL and opens itself.
{groups.map((g, i) => (
  <Link key={g.author.id} to={`?story=${i}.0`}>{g.author.name}</Link>
))}

<StoriesUrlOverlay controller={stories} groups={groups} />
  • Opening pushes one history entry. Swiping stories and switching users both replace it, so N navigations add no entries and one back step always closes the player. Back closes; it does not step stories.
  • Inner navigation is carried. The story index is not frozen at group granularity — advancing within a user's stories updates ?story=2.n so a deep link lands on the exact story.
  • Back closes only when the player was opened from within the app — the link pushed an entry. A shared link opened directly in a fresh tab has no history behind it, so browser-back leaves the site; the ✕ button or Escape removes the parameter in place and stays.
  • A parameter naming no group or story — a stale bookmark, a hand-edited value, a story past a group's end — is dropped from the URL rather than opening a neighbor.

Routed app — pass an adapter. Writing history.pushState behind a router leaves its location stale, and its next navigation drops the parameter:

tsx
import { useReactRouterUrlAdapter } from '@reelkit/react/react-router-url-adapter';

const adapter = useReactRouterUrlAdapter();
const stories = useOverlayUrlState({
  param: 'story',
  adapter,
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
  }),
});

<StoriesUrlOverlay controller={stories} groups={groups} />

Stable links. The group is positional by default, so a bookmarked ?story=2.0 opens a different user once the feed is reordered. Address the group by a stable id instead — outerCodec spells the id into the URL, outerLocator finds where it sits. Pass innerCodec with innerLocate and innerIdentify to do the same for the story within its group; leave them out and the story half stays a plain local index.

tsx
const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
    // ?story=user_42.3
    outerCodec: { decode: (raw) => raw, encode: (id) => id },
    outerLocator: {
      locate: (id) => groups.findIndex((g) => g.author.id === id),
      identify: (index) => groups[index].author.id,
    },
    // Optional: ?story=user_42.story_7 — the story by id too
    innerCodec: { decode: (raw) => raw, encode: (id) => id },
    innerLocate: (outer, id) => {
      const index = groups[outer].stories.findIndex((s) => s.id === id);
      return index === -1 ? null : index;
    },
    innerIdentify: (outer, index) => groups[outer].stories[index].id,
  }),
});

Infinite feeds. Paging is a outerLocator concern, independent of the codec. locate is synchronous, so it answers only for groups already loaded — a shared link to group 400 of a feed that has loaded 20 comes up empty. locateAsync is the fallback, called only when locate misses; the story is re-bounded against whichever group it settles on.

Same locateAsync, outer axis

This is the same locateAsync pager the single-axis keys take — on a two-axis key it rides the outerLocator you pass, so the group axis pages while the story stays a local index within the resolved group.

tsx
const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
    outerLocator: {
      locate: (index) => (index < groups.length ? index : null),
      identify: (index) => index,
      locateAsync: async (index) => {
        const loaded = await loadUntilGroup(index); // page up to it
        if (!loaded) return null; // exhausted — link names no group
        setGroups(loaded); // commit — the overlay renders from this state
        return index;
      },
    },
  }),
});
  • While locateAsync is pending the player stays closed and the parameter is left alone, so the deep link survives the fetch. A null or a rejection drops the parameter.
  • An answer arriving after the URL moved on, after a close, or after unmount is discarded — a slow fetch cannot open a story nobody asked for.
  • Full useOverlayUrlState options live in the React API reference, and the walkthrough is in the React guide.

Remembering What Was Seen

Rings show a gradient until a group has been watched to the end, and a group reopens on the first story the viewer has not seen. Both come from one createStoriesViewedStateController, created once where the feed lives and handed to the ring list and the player as viewed: they read the store, follow it and record what is shown by themselves. A stored entry names the author and the story by id, so it survives the feed being reordered, and the same controller works in Vue and Angular.

tsx
import { useRef, useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';

function Feed({ groups }: { groups: StoriesGroup[] }) {
  const [open, setOpen] = useState(false);
  const [group, setGroup] = useState(0);

  // The controller reads the groups through a getter every time it needs
  // them, so keep the getter current: a ref for a plain prop, or a signal.
  const groupsRef = useRef(groups);
  groupsRef.current = groups;

  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groupsRef.current,
    }),
  );

  return (
    <>
      {/* The rings follow the controller by themselves. */}
      <StoriesRingList
        groups={groups}
        viewed={viewed}
        onSelect={(index) => {
          setGroup(index);
          setOpen(true);
        }}
      />

      {/* The player resumes, records and draws its card rings from it. */}
      <StoriesOverlay
        isOpen={open}
        onClose={() => setOpen(false)}
        groups={groups}
        initialGroupIndex={group}
        viewed={viewed}
      />
    </>
  );
}
  • The opening story counts. The story already on screen is reported viewed on mount, so opening a one-story group and closing marks it watched.
  • Swiping onward resumes too. resumeStoryIndex is consulted for every group reached for the first time this session, the one the player opens on included, unless initialStoryIndex names a story outright; a group already swiped through reopens where it was left.
  • A link still wins. With StoriesUrlOverlay, the parameter decides where the player opens, whatever has been stored. Everywhere else the resume callback decides.
  • A count is a position, not a tally. An entry names the furthest story reached, so adding a story to a watched group lights its ring again, and removing one from the middle shortens the count. The same self-healing a shared link gets.
  • Storage is pluggable. Pass storage: createSessionStorageAdapter() to forget on tab close, or your own StorageAdapter. Two open tabs stay in step through the browser's storage event.
  • A player mounted only when it opens reads too late. The player reads the store in an effect that runs while it is still closed. With {open && <StoriesOverlay … />} there is no such moment, so call useAttachViewedState(viewed) in the component that owns the controller; it attaches on mount and lets go on unmount. The options, and the lower-level pieces this is built from, are on the Stories Core page.

Set desktopLayout="carousel" to lay the player out like the Instagram web viewer: the active story stays in the center at its usual size, and up to two neighbouring groups sit on each side as smaller, dimmed cards. Clicking a card opens that group, and the cards slide across while the group changes. Pass the same viewed controller the ring list takes, and a watched group's card gets the muted ring. The cards follow it by themselves, so only they repaint when a story is marked seen and the player does not render again.

tsx
// The same controller the ring list takes: the cards draw the same rings and
// repaint by themselves when a story is marked seen.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  initialGroupIndex={group}
  desktopLayout="carousel"
  viewed={viewed}
/>;
  • Phones keep the plain player. Up to 768px wide the cards are not rendered and swipes use groupTransition, as with the default 'single' layout. Resizing across that width switches live.
  • Each card previews where the group opens: the story it was left on this session, else what resumeStoryIndex returns. It shows the video poster or the image. A story with neither, like a text story on a gradient, is drawn by your renderSlide at the player's size and scaled down; a video without a poster shows the card background. That preview calls renderSlide with isActive: false and is only a picture: nothing in it can be focused or clicked. Keep whatever a slide does when it mounts, such as playback or analytics, behind isActive.
  • The timer waits for the slide. The story does not advance, and onStoryViewed does not fire for it, until the cards have finished moving.
  • Every way of changing group slides: a card click, StoriesApi.goToGroup, nextGroup and prevGroup, and the arrow keys past the end of a group. A touch swipe moves the player itself, so it gets no slide on top.

Progress Bar and Header per Group

By default one progress bar and one header sit above the player and switch to the new group once it has changed. Set chromePlacement="group" to give every group its own, drawn inside its slide, so both turn with the group the way they do on Instagram.

tsx
// Every group carries its own progress bar and header, so both turn with the
// group instead of switching above the player once it has changed.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  chromePlacement="group"
/>;
  • A neighbouring group shows where it stands. While the player turns to it, its bar and header show the story it will open on, with nothing of it played yet.
  • The group being left holds still. Its bar keeps the progress it had until the turn ends, so a group played to the end stays full rather than emptying mid-turn.
  • Custom renderers are called for every group on screen. renderProgressBar and renderHeader get groupIndex and isActive; for a neighbouring group isActive is false and the progress signals hold still.
  • A custom header sits in the swipe area. A tap on it moves between stories unless it lands on a button, a link, or an element with role="button", so make every control in it one of those.
  • The desktop carousel is unaffected mid-slide. The player is hidden while the cards slide, so the choice shows once the group is open.

API Reference

StoriesOverlayProps

StoriesOverlayProps<T>

PropTypeDefaultDescription
isOpenbooleanrequiredControls overlay visibility. When true, body scroll is locked.
groupsStoriesGroup<T>[]requiredArray of story groups to display
onClose() => voidrequiredCallback to close the overlay
ariaLabelstring'Stories player'Accessible label for the dialog region; announced by screen readers when the overlay opens
initialGroupIndexnumber0Zero-based index of the initially visible group
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), else 0Zero-based index of the initially visible story within the group. Naming one wins over anything remembered; leave it out and the opening group resumes like every other.
resumeStoryIndex(groupIndex: number) => number—Which story a group opens on the first time it is reached, so a viewer continues where they left off. Only consulted for a group not yet visited during this open.
groupTransitionTransitionTransformFncubeTransitionTransition effect for the outer (group) slider
defaultImageDurationnumber5000Default auto-advance duration for image stories in milliseconds
tapZoneSplitnumber0.3Tap zone split ratio (0–1). Left portion triggers prev, right triggers next.
hideUIOnPausebooleantrueHide the progress bar and header while paused by a long press
enableKeyboardbooleantrueEnable keyboard navigation (left/right arrows, Escape)
innerTransitionDurationnumber200Duration of the inner (story) transition animation in milliseconds
minSegmentWidthnumber8Minimum segment width in pixels for the progress bar
apiRefMutableRefObject<StoriesApi | null>-Ref to access the imperative StoriesApi
desktopLayout'single' | 'carousel''single'Desktop layout. 'carousel' shows neighbouring groups as preview cards beside the active story and slides between groups. Phones always show the story alone. The type is exported as DesktopLayout.
chromePlacement'overlay' | 'group''overlay'Where the progress bar and header live. 'overlay' draws one copy above the player; 'group' draws one inside every group slide, so both turn with the group. The type is exported as ChromePlacement.
viewedStoriesViewedStateController-What the viewer has seen, from createStoriesViewedStateController(). Groups resume on their first unseen story, every story shown is recorded, and carousel cards mute the ring of a group watched to the end. Hand the same controller to StoriesRingList.
renderHeader(props: HeaderRenderProps<T>) => ReactNode-Custom header renderer. Receives author, story, pause/mute state. With chromePlacement="group" it sits in the swipe area; see Progress Bar and Header per Group.
renderFooter(props: FooterRenderProps<T>) => ReactNode-Custom footer renderer. Receives author and story info.
renderSlide(props: SlideRenderProps<T>) => ReactNode-Custom slide renderer, replacing the default image/video slides.
renderNavigation(props: NavigationRenderProps) => ReactNode-Custom desktop navigation. Replaces default prev/next chevron buttons.
renderProgressBar(props: ProgressBarRenderProps<T>) => ReactNode-Custom progress bar. Replaces default canvas progress bar.
renderLoading(props: LoadingRenderProps<T>) => ReactNode-Custom loading UI renderer. When not provided, shows default header spinner.
renderError(props: ErrorRenderProps<T>) => ReactNode-Custom error UI renderer. When not provided, shows default error icon overlay.
renderGroupPreview(props: GroupPreviewRenderProps<T>) => ReactNode-Custom desktop carousel card. Replaces the default card content.

StoriesUrlOverlayProps

StoriesUrlOverlayProps<T>

Takes every StoriesOverlay prop except the open-state trio — isOpen, initialGroupIndex, initialStoryIndex — supplied from the controller instead.

PropTypeDefaultDescription
controllerUrlStateController<TwoAxisPosition>requiredController from useOverlayUrlState spread with urlIndexTwoAxisKey. Its position — a { outer, inner } object — decides whether the player is open and where it opens; the overlay writes back on every navigation and on close.

Callbacks

PropTypeDescription
onClose() => voidCalled when the player closes. Required on StoriesOverlay (you own the open state, so you must handle closing); optional on StoriesUrlOverlay, where the URL drives closing — pass it only to react after close.
onStoryChange(groupIndex: number, storyIndex: number) => voidFired when the active story changes
onGroupChange(groupIndex: number) => voidFired when the active group changes
onStoryViewed(groupIndex: number, storyIndex: number) => voidFired when a story becomes visible
onStoryComplete(groupIndex: number, storyIndex: number) => voidFired when a story's timer completes
onDoubleTap(groupIndex: number, storyIndex: number) => voidFired on a double-tap gesture
onPause() => voidFired when the player is paused
onResume() => voidFired when the player is resumed

Transitions

The groupTransition prop controls the 3D transition effect when swiping between user groups. Import transition functions from @reelkit/react:

tsx
import {
  cubeTransition,   // default — 3D cube rotation
  flipTransition,   // card flip
  fadeTransition,   // crossfade
  zoomTransition,   // zoom in/out
  slideTransition,  // horizontal slide
} from '@reelkit/react';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  groupTransition={flipTransition}
/>

Content Loading Lifecycle

Each story slide communicates its loading state via callbacks provided through SlideRenderProps:

CallbackWhen
onReadyContent is ready (image loaded, video playing). The progress timer starts.
onWaitingContent stalls (video buffering mid-playback). The spinner shows and timer pauses.
onErrorContent failed to load. The error overlay is shown.
onDurationReadyReport the actual media duration (e.g. from video metadata) to restart the timer with the correct duration.
onEndedSignal that the media has ended (e.g. video finished). Advances to the next story.
Preloader caching

The built-in ImageStorySlide and VideoStorySlide components preload the next story in the background. When a user navigates to a preloaded story, the content appears instantly without a loading spinner.

Render Props

Every UI element can be replaced via render props. Each receives typed props with all necessary state and callbacks.

renderHeader

Replace the default header (author info, pause/mute buttons, close button):

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderHeader={({ author, story, isPaused, isMuted, isVideo, onToggleSound, onTogglePause, onClose }) => (
    <div style={{
      position: 'absolute',
      top: 0,
      left: 0,
      right: 0,
      padding: 16,
      display: 'flex',
      alignItems: 'center',
      gap: 8,
      zIndex: 10,
    }}>
      <img src={author.avatar} style={{ width: 32, height: 32, borderRadius: '50%' }} />
      <span style={{ color: '#fff', fontWeight: 600 }}>{author.name}</span>
      {isVideo && <button onClick={onToggleSound}>{isMuted ? 'Unmute' : 'Mute'}</button>}
      <button onClick={onClose} style={{ marginLeft: 'auto' }}>Close</button>
    </div>
  )}
/>

renderFooter

Add a footer below the story content:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderFooter={({ author, story, storyIndex }) => (
    <div style={{
      position: 'absolute',
      bottom: 0,
      left: 0,
      right: 0,
      padding: 16,
      background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
      color: '#fff',
      zIndex: 10,
    }}>
      <span>{author.name} — Story {storyIndex + 1}</span>
    </div>
  )}
/>

renderSlide

Fully replace the default image/video slides. Use ImageStorySlide and VideoStorySlide sub-components for built-in media handling:

tsx
import {
  StoriesOverlay,
  ImageStorySlide,
  VideoStorySlide,
} from '@reelkit/react-stories-player';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderSlide={({ story, index, groupIndex, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }) => {
    const [w, h] = size;

    if (story.mediaType === 'video') {
      return (
        <div style={{ width: w, height: h, background: '#000' }}>
          <VideoStorySlide
            src={story.src}
            poster={story.poster}
            groupIndex={groupIndex}
            storyIndex={index}
            activeGroupIndex={activeGroupIndex}
            activeStoryIndex={activeStoryIndex}
            onDurationReady={onDurationReady}
            onPlaying={onReady}
            onWaiting={onWaiting}
            onEnded={onEnded}
            onError={onError}
          />
        </div>
      );
    }

    return (
      <div style={{ width: w, height: h, background: '#000' }}>
        <ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />
      </div>
    );
  }}
/>

renderNavigation

Replace the default desktop chevron buttons:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderNavigation={({ onPrevStory, onNextStory, onPrevGroup, onNextGroup }) => (
    <div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, display: 'flex', justifyContent: 'center', gap: 8, zIndex: 10 }}>
      <button onClick={onPrevGroup}>Prev Group</button>
      <button onClick={onPrevStory}>Prev</button>
      <button onClick={onNextStory}>Next</button>
      <button onClick={onNextGroup}>Next Group</button>
    </div>
  )}
/>

renderProgressBar

Replace the default canvas progress bar with a custom implementation. The progress signal emits values from 0 to 1:

tsx
import { Observe } from '@reelkit/react';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderProgressBar={({ totalStories, activeIndex, progress }) => (
    <div style={{ display: 'flex', gap: 4, padding: '8px 16px' }}>
      {Array.from({ length: totalStories }, (_, i) => (
        <div key={i} style={{ flex: 1, height: 2, background: 'rgba(255,255,255,0.3)', borderRadius: 1, overflow: 'hidden' }}>
          <Observe signals={[activeIndex, progress]}>
            {() => {
              const fill = i < activeIndex.value ? 1 : i === activeIndex.value ? progress.value : 0;
              return <div style={{ width: `${fill * 100}%`, height: '100%', background: '#fff' }} />;
            }}
          </Observe>
        </div>
      ))}
    </div>
  )}
/>

renderLoading

Custom loading indicator while content is being fetched:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderLoading={({ story, storyIndex, groupIndex }) => (
    <div style={{
      position: 'absolute',
      inset: 0,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      color: '#fff',
    }}>
      <span>Loading story {storyIndex + 1}...</span>
    </div>
  )}
/>

renderError

Custom error overlay when content fails to load:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderError={({ story, storyIndex, groupIndex }) => (
    <div style={{
      position: 'absolute',
      inset: 0,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      flexDirection: 'column',
      color: '#fff',
      background: 'rgba(0,0,0,0.8)',
    }}>
      <span style={{ fontSize: 48 }}>!</span>
      <span>Failed to load story</span>
    </div>
  )}
/>

renderGroupPreview

Replace the content of a desktop carousel card. The player still positions, scales and slides the card; call onOpen to open the group:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  desktopLayout="carousel"
  renderGroupPreview={({ group, story, onOpen }) => (
    <button
      type="button"
      className="my-story-card"
      aria-label={`Open stories by ${group.author.name}`}
      onClick={onOpen}
    >
      {story?.poster && <img src={story.poster} alt="" />}
      <span>{group.author.name}</span>
    </button>
  )}
/>

StoriesApi

Use the apiRef prop for imperative control:

tsx
import { useRef } from 'react';
import { StoriesOverlay, type StoriesApi } from '@reelkit/react-stories-player';

function App() {
  const apiRef = useRef<StoriesApi | null>(null);

  return (
    <>
      <button onClick={() => apiRef.current?.nextStory()}>Next Story</button>
      <button onClick={() => apiRef.current?.nextGroup()}>Next Group</button>
      <button onClick={() => apiRef.current?.pause()}>Pause</button>

      <StoriesOverlay
        isOpen={isOpen}
        onClose={handleClose}
        groups={groups}
        apiRef={apiRef}
      />
    </>
  );
}

Methods

MethodTypeDescription
nextStory()() => voidAdvance to the next story within the current group
prevStory()() => voidGo to the previous story within the current group
nextGroup()() => voidSwitch to the next user group
prevGroup()() => voidSwitch to the previous user group
goToGroup(index)(index: number) => voidJump to a specific group by index
pause()() => voidPause auto-advance and the progress timer
resume()() => voidResume auto-advance and the progress timer

Double-Tap & Likes

A built-in heart animation plays on double-tap, giving instant visual feedback. The onDoubleTap callback fires with the group and story index so you can persist the like in your own state (API call, local storage, etc.). The player does not manage like state internally.

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={() => setIsOpen(false)}
  groups={groups}
  onDoubleTap={(groupIndex, storyIndex) => {
    // Built-in heart animation plays automatically.
    // Handle the like in your own state:
    const story = groups[groupIndex].stories[storyIndex];
    toggleLike(story.id);
  }}
/>

Customizing the Heart Animation

Tweak the animation speed via the --rk-stories-heart-duration token (see Theming). For color, size, or hiding the heart entirely, target the .rk-stories-heart class directly. The HeartAnimation component is also exported for standalone use.

css
/* Speed up the pop via the token */
:root {
  --rk-stories-heart-duration: 1s;
}

/* Restyle color and size via the class */
.rk-stories-heart {
  color: #ff3b5c;
  font-size: 80px;
}

/* Or hide the built-in heart entirely */
.rk-stories-heart {
  display: none;
}

The built-in heart animation cannot be replaced via a render prop yet. You can restyle it with CSS or hide it with display: none and handle your own animation in the onDoubleTap callback. If you need a renderDoubleTap render prop, let us know via GitHub Issues.

Sub-Components

Reusable building blocks exported for composition in custom render props. Each one's props type is exported beside it — CanvasProgressBarProps, StoryHeaderProps, ImageStorySlideProps, VideoStorySlideProps, StoriesRingProps, StoriesRingListProps and HeartAnimationProps — for a component that wraps one:

CanvasProgressBar

High-performance canvas-based segmented progress bar. Renders segments for each story and animates the active segment fill via requestAnimationFrame. Supports a sliding window for groups with many stories. Pass live={false} for a bar that only needs to show where a group stands: it draws when its signals change and after a resize, with no animation loop.

tsx
import { CanvasProgressBar } from '@reelkit/react-stories-player';

<CanvasProgressBar
  totalStories={group.stories.length}
  activeIndex={activeIndexSignal}
  progress={progressSignal}
  minSegmentWidth={8}
  gap={2}
  barHeight={2}
  // A bar that is not live draws only when its signals change, with no
  // animation loop: enough for a group that is not playing.
  live={isActiveGroup}
/>

StoryHeader

Default header with author avatar, name, verified badge, relative timestamp, pause/play toggle, mute/unmute toggle, loading spinner, and close button. Used automatically when renderHeader is not provided.

tsx
import { StoryHeader } from '@reelkit/react-stories-player';

<StoryHeader
  author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg', verified: true }}
  createdAt={new Date(Date.now() - 3600_000)}
  onClose={handleClose}
  isPaused={false}
  onTogglePause={togglePause}
  isMuted={true}
  onToggleSound={toggleSound}
  isVideo={true}
  isLoading={false}
/>

ImageStorySlide

Full-bleed image slide with object-fit: cover. Reports load/error via callbacks for lifecycle tracking.

tsx
import { ImageStorySlide } from '@reelkit/react-stories-player';

<ImageStorySlide
  src="/photo.jpg"
  aspectRatio={9 / 16}
  onLoad={() => console.log('loaded')}
  onError={() => console.log('failed')}
/>

VideoStorySlide

Video slide using a shared <video> element for iOS sound continuity. Handles autoplay, poster frames, sound sync, and reports duration and playback lifecycle events.

tsx
import { VideoStorySlide } from '@reelkit/react-stories-player';

<VideoStorySlide
  src="/clip.mp4"
  poster="/clip-poster.jpg"
  groupIndex={0}
  storyIndex={2}
  activeGroupIndex={activeGroupSignal}
  activeStoryIndex={activeStorySignal}
  onDurationReady={(ms) => console.log('duration:', ms)}
  onPlaying={() => console.log('playing')}
  onWaiting={() => console.log('buffering')}
  onEnded={() => console.log('ended')}
  onError={() => console.log('error')}
/>

StoriesRing

Circular avatar with an Instagram-style gradient ring. Two states: a group with stories left to watch gets the rotating gradient, a fully watched one gets a flat muted ring.

tsx
import { StoriesRing } from '@reelkit/react-stories-player';

<StoriesRing
  author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg' }}
  totalStories={5}
  viewedCount={2}
  onClick={() => openStories(0)}
/>

StoriesRingList

Horizontal scrollable row of StoriesRing components with author names. One ring per group.

tsx
import { StoriesRingList } from '@reelkit/react-stories-player';

// `viewed` is the same createStoriesViewedStateController the player takes; the
// rings follow it by themselves.
<StoriesRingList
  groups={groups}
  viewed={viewed}
  onSelect={(groupIndex) => openStories(groupIndex)}
/>;

HeartAnimation

Animated heart overlay triggered on double-tap. Scales up and fades out over 800ms. Customize via CSS (see Double-Tap & Likes section).

tsx
import { HeartAnimation } from '@reelkit/react-stories-player';

<HeartAnimation onComplete={() => console.log('animation done')} />

Re-exports

Everything a consumer needs comes from this package, so a direct @reelkit/core import is never necessary.

FromRe-exported
@reelkit/reactSoundProvider, useSoundState, useOverlayUrlState, createViewedStateController, twoAxisViewedTracking, createLocalStorageAdapter, createSessionStorageAdapter, createMemoryStorageAdapter, urlIndexTwoAxisKey, urlStableIdTwoAxisKey, base64UrlCodec, and the types UrlAdapter, UrlCodec, UrlLocator, UrlKey, UrlStateController, TwoAxisPosition, TwoAxisIdentity, UrlIndexTwoAxisKeyOptions, OverlayUrlStateOptions, ViewedStateController, ViewedStateOptions, StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController, StoriesViewedStateController, StoriesViewedStateControllerConfig, and the content types StoryItem, AuthorInfo, StoriesGroup, MediaType

SoundProvider and useSoundState carry the sound state the player shares with its slides. A custom slide that plays its own media reads the mute toggle through useSoundState. Inside the player a provider is already in place; a VideoStorySlide or custom slide rendered anywhere else needs a SoundProvider above it, or useSoundState throws.

Types

StoryItem

typescript
interface StoryItem {
  id: string;
  mediaType: 'image' | 'video';
  src: string;
  poster?: string;
  duration?: number;       // ms, images default to 5000, videos use natural duration
  createdAt?: string | Date;
  aspectRatio?: number;    // width / height
}

AuthorInfo

typescript
interface AuthorInfo {
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

StoriesGroup<T>

typescript
interface StoriesGroup<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  stories: T[];
}

HeaderRenderProps<T>

typescript
interface HeaderRenderProps<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
  isPaused: boolean;
  isMuted: boolean;
  isVideo: boolean;
  groupIndex: number;
  isActive: boolean; // false for a neighbouring group's header
  onToggleSound: () => void;
  onTogglePause: () => void;
  onClose: () => void;
}

FooterRenderProps<T>

typescript
interface FooterRenderProps<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
}

SlideRenderProps<T>

typescript
interface SlideRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  index: number;
  groupIndex: number;
  isActive: boolean;
  size: [number, number];
  activeGroupIndex: Signal<number>;
  activeStoryIndex: Signal<number>;
  onDurationReady: (durationMs: number) => void;
  onReady: () => void;
  onWaiting: () => void;
  onError: () => void;
  onEnded: () => void;
}
typescript
interface NavigationRenderProps {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

ProgressBarRenderProps<T>

typescript
interface ProgressBarRenderProps<T extends StoryItem = StoryItem> {
  totalStories: number;
  activeIndex: Signal<number>;
  progress: Signal<number>;
  group: StoriesGroup<T>;
  groupIndex: number;
  isActive: boolean; // false: the signals hold still for a neighbouring group
}

LoadingRenderProps<T>

typescript
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

ErrorRenderProps<T>

typescript
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

GroupPreviewRenderProps<T>

typescript
type DesktopLayout = 'single' | 'carousel';

interface GroupPreviewRenderProps<T extends StoryItem = StoryItem> {
  group: StoriesGroup<T>;
  groupIndex: number;
  story: T | undefined; // the story the group would open on
  offset: number; // negative on the left, positive on the right
  viewedCount: number; // from viewedState, 0 without it
  onOpen: () => void;
}

StoriesApi

typescript
interface StoriesApi {
  nextStory: () => void;
  prevStory: () => void;
  nextGroup: () => void;
  prevGroup: () => void;
  goToGroup: (index: number) => void;
  pause: () => void;
  resume: () => void;
}

Custom Story Types

Extend StoryItem with custom fields and pass the type parameter to StoriesOverlay. All render props will receive your extended type:

tsx
import {
  StoriesOverlay,
  ImageStorySlide,
  type StoryItem,
  type StoriesGroup,
  type SlideRenderProps,
} from '@reelkit/react-stories-player';

interface PromoStory extends StoryItem {
  title: string;
  subtitle?: string;
  bgGradient?: string;
  ctaText?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'brand', name: 'My Brand', avatar: '/brand.png', verified: true },
    stories: [
      {
        id: 'promo-1',
        mediaType: 'image',
        src: '/sale-banner.jpg',
        title: 'Flash Sale',
        subtitle: 'Up to 50% off',
        ctaText: 'Shop Now',
      },
      {
        id: 'promo-2',
        mediaType: 'image',
        src: '',
        title: 'Thank You',
        subtitle: '10K followers!',
        bgGradient: 'linear-gradient(135deg, #a18cd1, #fbc2eb)',
      },
    ],
  },
];

function CustomSlide({ story, size, onReady, onError }: SlideRenderProps<PromoStory>) {
  const [w, h] = size;
  const hasImage = story.src && story.mediaType === 'image';

  return (
    <div style={{ width: w, height: h, background: story.bgGradient ?? '#000', position: 'relative' }}>
      {hasImage && <ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />}
      <div style={{ position: 'relative', zIndex: 1, textAlign: 'center', padding: 32, color: '#fff' }}>
        <h2>{story.title}</h2>
        {story.subtitle && <p>{story.subtitle}</p>}
        {story.ctaText && (
          <button style={{ marginTop: 16, padding: '8px 24px', borderRadius: 20, background: '#fff', color: '#000', border: 'none' }}>
            {story.ctaText}
          </button>
        )}
      </div>
    </div>
  );
}

<StoriesOverlay<PromoStory>
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderSlide={(props) => <CustomSlide {...props} />}
/>

CSS Classes

All CSS classes are plain (not CSS modules), so they can be targeted with higher-specificity selectors in a stylesheet loaded after @reelkit/react-stories-player/styles.css. For color, size, and z-index changes, prefer the CSS custom properties documented in the Theming section below.

ClassComponentDescription
.rk-stories-overlayOverlayFixed full-screen backdrop (background, z-index)
.rk-stories-swipe-wrapperOverlaySwipe-to-close wrapper (hosts nav buttons + canvas)
.rk-stories-containerOverlayRounded story canvas (position, overflow)
.rk-stories-ui-layerOverlayUI overlay container (header, progress, navigation). Inside every group slide with chromePlacement="group"
.rk-stories-ui-layer--hiddenOverlayUI hidden state (toggled by hideUIOnPause)
.rk-stories-errorOverlayError state (centered icon + text)
.rk-stories-error-textOverlayError message text
.rk-stories-nav-btnNavigationDesktop prev/next arrow
.rk-stories-progress-barProgressBarCanvas progress bar positioning wrapper
.rk-stories-slide-wrapperGroupOne group of stories (outer slide)
.rk-stories-storyStoryA single story (inner slide root)
.rk-stories-headerStoryHeaderHeader bar (avatar, name, actions)
.rk-stories-header--hiddenStoryHeaderHeader hidden state (visible=false)
.rk-stories-header-avatarStoryHeaderAuthor avatar image
.rk-stories-header-nameStoryHeaderAuthor name text
.rk-stories-header-verifiedStoryHeaderVerified badge container
.rk-stories-header-timeStoryHeaderTime-ago text
.rk-stories-header-actionsStoryHeaderRight-side actions (close, mute, pause)
.rk-stories-header-btnStoryHeaderHeader action button
.rk-stories-header-btn--desktopStoryHeaderPause button, shown only above 768px wide
.rk-stories-header-spinnerStoryHeaderVideo buffering spinner
.rk-stories-imageImageStorySlideImage story element
.rk-stories-videoVideoStorySlideVideo story container
.rk-stories-video-elementVideoStorySlideThe shared <video> element
.rk-stories-video-posterVideoStorySlideVideo poster image (fades out on play)
.rk-stories-video-poster--visibleVideoStorySlidePoster visible state (pre-playback)
.rk-stories-heartHeartAnimationDouble-tap heart pop animation
.rk-stories-ringStoriesRingStory ring (avatar with animated gradient border)
.rk-stories-ring--activeStoriesRingRing with stories left to watch (animates)
.rk-stories-ring-avatarStoriesRingAvatar image inside the ring
.rk-stories-ring-listStoriesRingListHorizontal ring list container
.rk-stories-ring-list-itemStoriesRingListRing + name column
.rk-stories-ring-list-nameStoriesRingListAuthor name below each ring
.rk-stories-overlay--carouselOverlayOverlay with the desktop carousel showing
.rk-stories-overlay--slidingOverlayCarousel slide running; the player canvas is hidden
.rk-stories-carouselCarouselLayer of preview cards behind the player
.rk-stories-carousel--instantCarouselFirst frame of a slide, laid out without a transition
.rk-stories-cardCarouselOne positioned preview card
.rk-stories-card--centerCarouselCard in the center slot during a slide
.rk-stories-card--hiddenCarouselCard past the two shown on a side
.rk-stories-card-buttonCarouselDefault card content (the button that opens the group)
.rk-stories-card-imageCarouselPreview frame (poster or image)
.rk-stories-card-frameCarouselScaled-down renderSlide preview of a story with no image or poster; inert
.rk-stories-card-scrimCarouselDimming layer over the frame
.rk-stories-card-infoCarouselRing, author name and time column
.rk-stories-card-nameCarouselAuthor name
.rk-stories-card-timeCarouselTime-ago text

Theming

Every color, size, z-index, and transition lives in a CSS custom property. Override one or many at :root (or any ancestor of the overlay) to retheme without touching component source.

TokenDefaultControls
--rk-stories-overlay-bg#000Full-screen backdrop color
--rk-stories-overlay-z9999Overlay z-index
--rk-stories-swipe-gap16pxGap between nav buttons and the story canvas
--rk-stories-container-radius12pxRounded corners on the story canvas (desktop)
--rk-stories-container-radius-mobile0Corners of the story canvas up to 768px wide
--rk-stories-ui-z15z-index of the UI layer (header, progress bar)
--rk-stories-ui-transition200msFade duration when hideUIOnPause toggles
--rk-stories-top-shade-height120pxTop gradient scrim height behind the header
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Top gradient scrim color
--rk-stories-error-z5z-index of the error state
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)Error state background gradient
--rk-stories-error-fgrgba(255, 255, 255, 0.5)Error icon and text color
--rk-stories-error-gap12pxSpace between the error icon and its text
--rk-stories-error-text-size13pxError message font size
--rk-stories-nav-z20Desktop nav button z-index
--rk-stories-nav-size44pxDesktop prev/next button size
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)Desktop nav button background
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)Desktop nav button hover background
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)Desktop nav button icon color
--rk-stories-nav-fg-hover#fffDesktop nav button hover icon color
--rk-stories-nav-transition150msNav button background and color transition
--rk-stories-video-bg#000Letterbox background behind <video>
--rk-stories-video-poster-transition200msPoster fade duration when the video starts playing
--rk-stories-progress-bar-z10Progress bar z-index
--rk-stories-progress-bar-padding8px 8px 0Space around the progress bar
--rk-stories-header-z5Header z-index
--rk-stories-header-top18pxVertical offset of the header from the top of the story
--rk-stories-header-padding12px 16pxInner padding of the header row
--rk-stories-header-gap8pxSpace between the avatar, name and time
--rk-stories-header-transition200msHeader fade when it is hidden
--rk-stories-header-avatar-size32pxAvatar width/height
--rk-stories-header-name-fg#fffAuthor name color
--rk-stories-header-name-size14pxAuthor name font size
--rk-stories-header-name-weight600Author name font weight
--rk-stories-header-time-fgrgba(255, 255, 255, 0.6)Time-ago text color
--rk-stories-header-time-size12pxTime-ago font size
--rk-stories-header-actions-gap8pxSpace between the header action buttons
--rk-stories-header-btn-fg#fffHeader action icon color (close, mute, pause)
--rk-stories-header-btn-padding4pxHeader action button padding
--rk-stories-header-spinner-size20pxBuffering spinner size
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Buffering spinner track color
--rk-stories-header-spinner-fg#fffBuffering spinner arc color
--rk-stories-header-spinner-duration0.8sOne turn of the buffering spinner
--rk-stories-heart-z20Double-tap heart z-index
--rk-stories-heart-duration800msPop-in/fade-out animation duration
--rk-stories-ring-spin-duration4sRotation duration of a ring with stories left to watch
--rk-stories-ring-active-scale0.95Ring scale while pressed
--rk-stories-ring-gradientnoneInternal: the ring component writes its gradient here on every render, so an override has no effect
--rk-stories-ring-list-gap12pxSpacing between rings in the list
--rk-stories-ring-list-padding12pxInner padding around the ring list
--rk-stories-ring-list-item-gap4pxSpace between a ring and its name
--rk-stories-ring-list-name-size12pxAuthor name font size below each ring
--rk-stories-card-bg#262626Carousel card background, shown when there is no preview frame
--rk-stories-card-radius8pxCarousel card corner radius
--rk-stories-card-scrimrgba(0, 0, 0, 0.45)Dimming over a side card
--rk-stories-card-fg#fffCard text color
--rk-stories-card-name-size14pxCard author name font size
--rk-stories-card-time-fgrgba(255, 255, 255, 0.7)Card time-ago text color
--rk-stories-card-time-size13pxCard time-ago font size
--rk-stories-card-gap6pxSpacing between the ring, name and time on a card
--rk-stories-card-transition300msCarousel slide duration

Drop the snippet below into a stylesheet loaded after @reelkit/react-stories-player/styles.css.

css
/* Brand the stories overlay */
:root {
  --rk-stories-overlay-bg: #0f172a;
  --rk-stories-container-radius: 24px;
  --rk-stories-nav-bg: rgba(99, 102, 241, 0.25);
  --rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.55);
  --rk-stories-top-shade-bg: linear-gradient(
    to bottom,
    rgba(99, 102, 241, 0.5) 0%,
    transparent 100%
  );
  --rk-stories-header-name-fg: #fef3c7;
  --rk-stories-ring-spin-duration: 2s;
}

Accessibility

The overlay root is a modal dialog (role="dialog", aria-modal="true"). Set ariaLabel to change the screen-reader announcement; it defaults to "Stories player".

The overlay captures focus on open and returns it to the trigger on close. Tab and Shift+Tab cycle through focusable elements inside; focus that escapes (click outside, programmatic focus) gets pulled back. Implemented with captureFocusForReturn and createFocusTrap from @reelkit/core, re-exported by @reelkit/react.

With the desktop carousel, each default card is a button labelled "Open stories by" and the author's name. The cards come after the player controls in the tab order, and are left out of it while a slide runs.

Server-Side Rendering

The overlay renders nothing while closed and portals into document.body only once it opens, which happens on the client. StoriesUrlOverlay reads the address bar in an effect after mount, so a server render never opens it. StoriesRingList renders every ring unwatched on the server: the viewed store is read only after mount, so the server markup and the first client render agree. See the SSR guide for the wider picture.

Keyboard Shortcuts

KeyAction
ArrowLeftPrevious story. On a group's first story, the previous group, on the story it was left on (else its resume story); nothing on the first group
ArrowRightNext story. Past a group's last story, the next group; past the last group, the player closes
EscapeClose player