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.
Installation
npm i @reelkit/stories-coreStories 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
import { createStoriesController } from '@reelkit/stories-core';
import { createTimerController, 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
The timer that drives each story's progress lives in @reelkit/core as createTimerController, shared with the lightbox slideshow. Import it from @reelkit/core or from your binding (@reelkit/react, @reelkit/vue, @reelkit/angular); this package no longer exports it. See Autoplay in the core API for its config, state and methods, and the Stories Controller example above for wiring it to onStoryTimerComplete.
Canvas Progress Renderer
createCanvasProgressRenderer(config?) draws segmented progress bars on a canvas. Scales for Retina displays, follows its container's width through observeElementSize from @reelkit/core, 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 |
onResize | () => void | - | Called after the container changed size and the canvas was re-measured. Resizing a canvas clears it, so redraw here. |
Methods
createCanvasProgressRenderer returns a CanvasProgressRenderer, the object to keep when you type a component field or a ref that holds the renderer:
| Member | Type | Description |
|---|---|---|
attach(canvas) | (HTMLCanvasElement) => void | Attach to a canvas element; starts watching its parent's size |
draw(totalStories, activeIndex, progress) | (number, number, number) => void | Draw the progress bar for the given state |
width | number (readonly) | Current measured width in CSS pixels |
dispose() | () => void | Stop watching the parent's size and clear internal state |
Example
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)
| 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
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.
| 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 |
getVisibleWindow(totalStories, activeIndex, progress, containerWidth, minSegmentWidth?, gap?) | (number, number, number, number, number?, number?) => VisibleWindow | Computes the visible sliding window of segments when total count exceeds container capacity |
Layout Helpers
Pure functions every binding's player shares, so the React, Vue and Angular players size and lay out identically. A binding calls them; an app only needs them when it builds a player of its own.
Sizing
| Function | Type | Description |
|---|---|---|
getStoriesSize() | () => [number, number] | Size of the story canvas. A phone fills the screen; a desktop fills the window height less a 16px margin at 9:16, narrowing only when the canvas and both arrows would not fit across. Returns [0, 0] with no window, so server rendering is safe. |
parseDurationMs(value) | (string) => number | Longest time in a computed transition-duration list, in milliseconds. Anything that is not a time counts as none. |
The phone breakpoint getStoriesSize uses is isMobileWidth, exported from @reelkit/core and every binding: 768px and narrower is a phone. See Size Detection.
Desktop carousel geometry
Offsets are relative to the active group: negative to the left, positive to the right, 0 for the active story itself.
| Function | Type | Description |
|---|---|---|
getCardSize(activeSize) | ([number, number]) => [number, number] | Side card size: 0.4 of the active story's height, at the same 9:16 shape. |
getCarouselSlot(offset, activeSize) | (number, [number, number]) => CarouselSlot | Where a card sits relative to the window center: { x, width, height }. The first card on each side clears the arrow; each further one adds a card width and a gap. |
getCardOffsets(activeGroupIndex, groupCount) | (number, number) => number[] | Offsets of the cards drawn around the active group at rest, up to two a side, the active one excluded. |
isCardShown(offset) | (number) => boolean | Whether a card that far out is drawn rather than faded. |
getSlotOffset(offset) | (number) => number | Slot a card moves to, clamped one place past the shown ones, so a card slides in from beside the last visible one. |
getSlideGroupIndexes(from, to, groupCount) | (number, number, number) => number[] | Groups a slide between two groups draws: the cards around each end, in group order. The groups a far jump passes over are left out. |
Presentation
| Function | Type | Description |
|---|---|---|
getRingPresentation(options) | (RingPresentationOptions) => RingPresentation | Class name, inline style and avatar size of a story ring, from totalStories, viewedCount and size. Anything left to watch gets the rotating --rk-stories-ring-active-gradient, a fully watched group the flat --rk-stories-ring-viewed-color, an empty group no ring; the style points at those tokens, so a page themes every ring from CSS, and gradientColors or viewedColor set the colour for one ring. Sizes carry their px unit, so the style object works in any framework. |
kStoriesRingSize, kStoriesRingListRingSize, kStoriesCardRingSize | number | Ring diameters every binding draws by default, in pixels: 68 for a single ring, 64 in the ring list, 52 on a desktop carousel card. Pass one as size to match the players. |
getPreviewSource(story) | (StoryItem | undefined) => string | undefined | Picture a carousel card previews: the story's poster, the image itself when it is an image, and nothing for a video without a poster. A card with no picture falls back to the plain card drawn for a story with nothing to preview. |
formatTimeAgo(date) | (string | Date) => string | Short relative time the header and the carousel cards show: now, 5m, 3h, 2d, 2w. |
The CarouselSlot, RingPresentation and RingPresentationOptions types are exported alongside them.
Types
All type definitions exported from @reelkit/stories-core.
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';
interface CarouselSlot {
x: number;
width: number;
height: number;
}
interface RingPresentationOptions {
totalStories: number;
viewedCount: number;
size: number; // px
gradientColors?: string[];
viewedColor?: string;
}
interface RingPresentation {
avatarSize: number;
className: string;
style: Record<string, string>;
}This package is the engine, not a player: it holds the state machine and the geometry, and leaves the rendering to a binding. Reach for a ready-made player first, and for the framework binding — the virtualized Reel, the signal bridge, the overlay helpers — when you build one of your own.
Next Steps
- Stories Player - the ready-made player built on this engineStories Player - the ready-made player built on this engineStories Player - the ready-made player built on this engine
- Framework Guide - the binding a player of your own renders withFramework Guide - the binding a player of your own renders withFramework Guide - the binding a player of your own renders with
- Framework API Reference - slider props, signal bridges, overlay helpersFramework API Reference - slider props, signal bridges, overlay helpersFramework API Reference - slider props, signal bridges, overlay helpers