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)
Property
Type
Default
Description
groupCount
number
required
Total number of story groups
storyCounts
number[]
required
Number of stories in each group
initialGroupIndex
number
0
Initial group index
initialStoryIndex
number
resumeStoryIndex(initialGroupIndex), else 0
Initial story index within the group. Naming one wins over anything remembered; leave it out and the opening group resumes like every other.
defaultImageDuration
number
5000
Default auto-advance duration for image stories in ms
resumeStoryIndex
(groupIndex: number) => number
undefined
Story an unvisited group opens on, bounded to a story the group has
Events (StoriesControllerEvents)
Event
Type
Description
onStoryChange
(groupIndex, storyIndex) => void
Fired when the active story changes
onGroupChange
(groupIndex) => void
Fired when the active group changes
onStoryViewed
(groupIndex, storyIndex) => void
Fired when a story becomes visible
onStoryComplete
(groupIndex, storyIndex) => void
Fired when a story's timer completes (before advancing)
onComplete
() => void
Fired when the last story of the last group finishes
onClose
() => void
Fired when the overlay should close
State (reactive signals)
Signal
Type
Description
state.activeGroupIndex
Signal<number>
Currently active group index
state.activeStoryIndex
Signal<number>
Currently active story index within the group
state.isPaused
Signal<boolean>
Whether auto-advance is paused
Methods
Method
Type
Description
nextStory()
() => void
Advance within group; crosses boundary to next group
prevStory()
() => void
Go back within group; crosses boundary to prev group
nextGroup()
() => void
Switch to next group, resuming at last viewed story
prevGroup()
() => void
Switch to previous group, resuming at last viewed story
goToGroup(index)
(number) => void
Jump to a specific group by index
pause()
() => void
Pause auto-advance
resume()
() => void
Resume auto-advance
onStoryTimerComplete()
() => void
Called when the timer finishes; fires onStoryComplete then advances
getLastStoryIndex(groupIndex)
(number) => number
Where a group opens: the story it was left on this session, else the one resumeStoryIndex names
reportInitialView()
() => void
Reports the story the player opened on as viewed, once. Call after mounting, not while rendering.
updateConfig({ groupCount, storyCounts })
(config) => void
Replaces 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-advanceconst timer = createTimerController({ duration: 5000, onComplete: () => controller.onStoryTimerComplete(),});// React to story changes and restart the timerconst dispose = reaction( () => [ controller.state.activeGroupIndex, controller.state.activeStoryIndex, ], () => timer.start(),);// Start playbacktimer.start();// Navigationcontroller.nextStory();controller.pause();controller.resume();// Cleanupdispose();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)
Property
Type
Default
Description
duration
number
required
Default duration in milliseconds
onComplete
() => void
undefined
Called when the timer reaches 100%
State
Signal
Type
Description
progress
Signal<number>
Progress signal (0 to 1)
isRunning
Signal<boolean>
Whether the timer is currently running
Methods
Method
Type
Description
start(duration?)
(number?) => void
Start (or restart) the timer with an optional duration override
pause()
() => void
Freeze progress at the current position
resume()
() => void
Continue from the frozen position
reset()
() => void
Reset progress to 0 and stop
dispose()
() => void
Clean 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 durationtimer.start();// Or override duration for a specific storytimer.start(8000);// Pause/resume preserves exact positiontimer.pause();timer.resume();// Reset to 0timer.reset();// Cleanupdispose();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)
Property
Type
Default
Description
gap
number
2
Gap in pixels between segments
barHeight
number
2
Bar height in pixels
minSegmentWidth
number
8
Minimum segment width before the sliding window kicks in
bgColor
string
'rgba(255,255,255,0.3)'
Background color of unfilled segments
fillColor
string
'#ffffff'
Fill color of completed/active segments
Methods
Member
Type
Description
attach(canvas)
(HTMLCanvasElement) => void
Attach to a canvas element; starts ResizeObserver on parent
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)
Property
Type
Default
Description
storageKey
string
required
Storage key the entries are written under
groups
() => StoriesGroup<T>[]
required
Reads 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.
key
UrlKey<Id, TwoAxisPosition>
stable ids
How a position is spelled in storage. Pass the key the URL controller uses and a stored entry reads like a shared link.
storage
StorageAdapter
localStorage
Where the entries are kept. A custom adapter is also how a seen store of your own plugs in.
ttlMs
number
never expires
How long a group's entry stays remembered after it was last recorded. Each group expires on its own clock.
maxTracks
number
every group kept
How 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
Member
Type
Description
viewedState
Subscribable<Map<string, number>>
Stories seen per author id, as a signal. Empty before attach().
controller
ViewedStateController<TwoAxisPosition>
The store underneath, for whatever this does not cover
resumeStoryIndex(groupIndex)
(number) => number
First unseen story, or 0 once the group has been watched to the end
markViewed(groupIndex, storyIndex)
(number, number) => void
Records a story as seen. The player calls it for every story shown.
forget()
() => void
Forgets everything seen, here and in storage
attach()
() => Dispose
Reads 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 followviewed.resumeStoryIndex(0); // 2 — the first story not yet seenviewed.markViewed(0, 2); // furthest point wins; a rewatch never rewindsviewed.forget(); // clears it alldetach();
Utility Functions
Pure functions for tap zone detection and progress bar math.
Function
Type
Description
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