Stories Core

The engine behind @reelkit/react-stories-player. Pure TypeScript, no framework deps. Use it to build stories players for Angular, Vue, or vanilla JS.

Framework-Agnostic
Pure TypeScript, zero DOM framework deps
Two-Level Navigation
Groups + stories within each group
RAF Timer
requestAnimationFrame auto-advance with pause/resume
Canvas Progress
Retina-ready segmented progress bar with sliding window
Tap Zones
Configurable left/right tap detection
Reactive Signals
Built on @reelkit/core signal primitives

Installation

bash
npm i @reelkit/stories-core

Stories Controller

createStoriesController(config, events?) returns a StoriesController that manages navigation across groups and stories. Tracks pause/resume state, remembers the last viewed story per group, and fires callbacks on each transition.

Config (StoriesControllerConfig)

PropertyTypeDefaultDescription
groupCountnumberrequiredTotal number of story groups
storyCountsnumber[]requiredNumber of stories in each group
initialGroupIndexnumber0Initial group index
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), else 0Initial story index within the group. Naming one wins over anything remembered; leave it out and the opening group resumes like every other.
defaultImageDurationnumber5000Default auto-advance duration for image stories in ms
resumeStoryIndex(groupIndex: number) => numberundefinedStory an unvisited group opens on, bounded to a story the group has

Events (StoriesControllerEvents)

EventTypeDescription
onStoryChange(groupIndex, storyIndex) => voidFired when the active story changes
onGroupChange(groupIndex) => voidFired when the active group changes
onStoryViewed(groupIndex, storyIndex) => voidFired when a story becomes visible
onStoryComplete(groupIndex, storyIndex) => voidFired when a story's timer completes (before advancing)
onComplete() => voidFired when the last story of the last group finishes
onClose() => voidFired when the overlay should close

State (reactive signals)

SignalTypeDescription
state.activeGroupIndexSignal<number>Currently active group index
state.activeStoryIndexSignal<number>Currently active story index within the group
state.isPausedSignal<boolean>Whether auto-advance is paused

Methods

MethodTypeDescription
nextStory()() => voidAdvance within group; crosses boundary to next group
prevStory()() => voidGo back within group; crosses boundary to prev group
nextGroup()() => voidSwitch to next group, resuming at last viewed story
prevGroup()() => voidSwitch to previous group, resuming at last viewed story
goToGroup(index)(number) => voidJump to a specific group by index
pause()() => voidPause auto-advance
resume()() => voidResume auto-advance
onStoryTimerComplete()() => voidCalled when the timer finishes; fires onStoryComplete then advances
getLastStoryIndex(groupIndex)(number) => numberWhere a group opens: the story it was left on this session, else the one resumeStoryIndex names
reportInitialView()() => voidReports the story the player opened on as viewed, once. Call after mounting, not while rendering.
updateConfig({ groupCount, storyCounts })(config) => voidReplaces the group and story counts when the feed changes while the player is open, so groups paged in later can be reached. Fires no event, keeps where each group was left, and pulls positions back inside a feed that shrank.

Example

typescript
import {
  createStoriesController,
  createTimerController,
} from '@reelkit/stories-core';
import { reaction } from '@reelkit/core';

const groups = [
  { stories: ['s1', 's2', 's3'] },
  { stories: ['s4', 's5'] },
];

const controller = createStoriesController(
  {
    groupCount: groups.length,
    storyCounts: groups.map((g) => g.stories.length),
    defaultImageDuration: 5000,
  },
  {
    onStoryChange(groupIndex, storyIndex) {
      console.log('Story changed:', groupIndex, storyIndex);
    },
    onComplete() {
      console.log('All stories viewed');
    },
    onClose() {
      console.log('Overlay closed');
    },
  },
);

// Wire up a timer for auto-advance
const timer = createTimerController({
  duration: 5000,
  onComplete: () => controller.onStoryTimerComplete(),
});

// React to story changes and restart the timer
const dispose = reaction(
  () => [
    controller.state.activeGroupIndex,
    controller.state.activeStoryIndex,
  ],
  () => timer.start(),
);

// Start playback
timer.start();

// Navigation
controller.nextStory();
controller.pause();
controller.resume();

// Cleanup
dispose();
timer.dispose();

Timer Controller

createTimerController(config) drives auto-advance with a requestAnimationFrame loop. The progress signal (0 to 1) feeds the progress bar. Pause and resume preserve the exact position.

Config (TimerControllerConfig)

PropertyTypeDefaultDescription
durationnumberrequiredDefault duration in milliseconds
onComplete() => voidundefinedCalled when the timer reaches 100%

State

SignalTypeDescription
progressSignal<number>Progress signal (0 to 1)
isRunningSignal<boolean>Whether the timer is currently running

Methods

MethodTypeDescription
start(duration?)(number?) => voidStart (or restart) the timer with an optional duration override
pause()() => voidFreeze progress at the current position
resume()() => voidContinue from the frozen position
reset()() => voidReset progress to 0 and stop
dispose()() => voidClean up resources

Example

typescript
import { createTimerController } from '@reelkit/stories-core';
import { reaction } from '@reelkit/core';

const timer = createTimerController({
  duration: 5000,
  onComplete: () => console.log('Timer finished!'),
});

// Observe progress (0 to 1)
const dispose = reaction(
  () => [timer.progress],
  () => {
    console.log('Progress:', timer.progress.value);
  },
);

// Start with default duration
timer.start();

// Or override duration for a specific story
timer.start(8000);

// Pause/resume preserves exact position
timer.pause();
timer.resume();

// Reset to 0
timer.reset();

// Cleanup
dispose();
timer.dispose();

Canvas Progress Renderer

createCanvasProgressRenderer(config?) draws segmented progress bars on a canvas. Scales for Retina displays, measures its container via ResizeObserver, and uses a sliding window when segments don't fit.

Config (CanvasProgressRendererConfig)

PropertyTypeDefaultDescription
gapnumber2Gap in pixels between segments
barHeightnumber2Bar height in pixels
minSegmentWidthnumber8Minimum segment width before the sliding window kicks in
bgColorstring'rgba(255,255,255,0.3)'Background color of unfilled segments
fillColorstring'#ffffff'Fill color of completed/active segments

Methods

MemberTypeDescription
attach(canvas)(HTMLCanvasElement) => voidAttach to a canvas element; starts ResizeObserver on parent
draw(totalStories, activeIndex, progress)(number, number, number) => voidDraw the progress bar for the given state
widthnumber (readonly)Current measured width in CSS pixels
dispose()() => voidClean up ResizeObserver and internal state

Example

typescript
import { createCanvasProgressRenderer } from '@reelkit/stories-core';

const renderer = createCanvasProgressRenderer({
  gap: 2,
  barHeight: 2,
  fillColor: '#ffffff',
  bgColor: 'rgba(255, 255, 255, 0.3)',
});

// Attach to a canvas element
const canvas = document.querySelector('canvas')!;
renderer.attach(canvas);

// Draw on each animation frame
let frameId: number;

function loop() {
  const totalStories = 5;
  const activeIndex = 2;
  const progress = timer.progress.value; // 0-1

  renderer.draw(totalStories, activeIndex, progress);
  frameId = requestAnimationFrame(loop);
}

frameId = requestAnimationFrame(loop);

// Cleanup
cancelAnimationFrame(frameId);
renderer.dispose();

Viewed Controller

createStoriesViewedStateController(config) is everything a feed needs to remember what was seen, from one call: ring counts, where each group resumes, and the recorder, over storage that survives a reload. It imports no framework, so React, Vue and Angular use it the same way: create it once where the feed lives and hand it to the ring list and the player, which attach it and follow it themselves. It is built on core's createViewedStateController with the stable-id two-axis key; reach for those directly when this does not fit. An entry names the furthest story reached rather than a tally of views, so a group's place survives the feed being reordered, while a story removed from the middle of a group shortens its count and lights its ring again.

Config (StoriesViewedStateControllerConfig)

PropertyTypeDefaultDescription
storageKeystringrequiredStorage key the entries are written under
groups() => StoriesGroup<T>[]requiredReads the current groups. A getter, called every time, so a feed that pages in more groups only has to return them. Keep it current: a getter stuck on the first array miscounts every group loaded later.
keyUrlKey<Id, TwoAxisPosition>stable idsHow a position is spelled in storage. Pass the key the URL controller uses and a stored entry reads like a shared link.
storageStorageAdapterlocalStorageWhere the entries are kept. A custom adapter is also how a seen store of your own plugs in.
ttlMsnumbernever expiresHow long a group's entry stays remembered after it was last recorded. Each group expires on its own clock.
maxTracksnumberevery group keptHow many groups to keep. Past it, the group recorded longest ago is dropped on the next write.

storageKey, key and storage are read once, when the controller is created. To switch storage, create another controller.

StoriesViewedStateController

MemberTypeDescription
viewedStateSubscribable<Map<string, number>>Stories seen per author id, as a signal. Empty before attach().
controllerViewedStateController<TwoAxisPosition>The store underneath, for whatever this does not cover
resumeStoryIndex(groupIndex)(number) => numberFirst unseen story, or 0 once the group has been watched to the end
markViewed(groupIndex, storyIndex)(number, number) => voidRecords a story as seen. The player calls it for every story shown.
forget()() => voidForgets everything seen, here and in storage
attach()() => DisposeReads storage and follows other tabs. Counted, so a ring list and a player can both attach and unmount in any order. The player components call it themselves.

Example

typescript
import { createStoriesViewedStateController } from '@reelkit/stories-core';

// Once, where the feed lives. The getter is read every time, so it has to
// return the groups as they are now.
const viewed = createStoriesViewedStateController({
  storageKey: 'stories-seen',
  groups: () => groups,
});

// The player components do this themselves when given `viewed`.
const detach = viewed.attach();

viewed.viewedState.value; // Map { 'user_42' => 2 }, and a signal to follow
viewed.resumeStoryIndex(0); // 2 — the first story not yet seen
viewed.markViewed(0, 2); // furthest point wins; a rewatch never rewinds
viewed.forget(); // clears it all

detach();

Utility Functions

Pure functions for tap zone detection and progress bar math.

FunctionTypeDescription
getTapAction(tapX, containerWidth, splitRatio?)(number, number, number?) => 'prev' | 'next'Determines whether a tap triggers 'prev' or 'next' based on position. Default splitRatio is 0.3.
getSegments(totalStories, activeIndex, progress)(number, number, number) => SegmentState[]Computes status and fill percentage of each segment in a progress bar
getVisibleWindow(totalStories, activeIndex, progress, containerWidth, minSegmentWidth?, gap?)(number, number, number, number, number?, number?) => VisibleWindowComputes the visible sliding window of segments when total count exceeds container capacity

Types

All type definitions exported from @reelkit/stories-core.

typescript
type MediaType = 'image' | 'video';

interface StoryItem {
  id: string;
  mediaType: MediaType;
  src: string;
  poster?: string;
  duration?: number;
  createdAt?: string | Date;
  aspectRatio?: number;
}

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

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

type SegmentStatus = 'completed' | 'active' | 'upcoming';

interface SegmentState {
  status: SegmentStatus;
  fillPercentage: number; // 0-100
}

interface VisibleWindow {
  startIndex: number;
  endIndex: number;
  segments: SegmentState[];
}

type TapAction = 'prev' | 'next';