Angular Stories Player

An Instagram-style stories player overlay for Angular using @reelkit/angular-stories-player. It shares the @reelkit/stories-core engine with the React and Vue players, so the behaviour, the CSS classes and the theming tokens are the same.

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
Template Slots
Eight ng-template regions
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
Theming Tokens
Rebrand with --rk-stories-* alone
URL State
Shareable ?story=group.story links
Viewed State
Seen rings and resume survive reloads

Installation

bash
npm install @reelkit/angular-stories-player @reelkit/angular lucide-angular

Register the stylesheet once:

typescript
// Anywhere that runs at startup — main.ts, app.config.ts, a component file.
import '@reelkit/angular-stories-player/styles.css';

// Or, if you keep CSS out of TypeScript, from your global stylesheet:
//   @import '@reelkit/angular-stories-player/styles.css';
// Or register it in angular.json:
//   "styles": ["node_modules/@reelkit/angular-stories-player/styles.css"]
Icons

The default header and navigation arrows use lucide-angular for icons. If you prefer a different icon library, replace them through the rkStoriesHeader and rkStoriesNavigation template slots.

Quick Start

rk-stories-overlay renders the full-screen player. Pair it with rk-stories-ring-list for the Instagram-style entry points: a ring emits (selected) with its group index, which the host keeps as initialGroupIndex before setting isOpen. The player never closes itself; it emits (closed), and the host sets isOpen back to false.

typescript
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
  RkStoriesOverlayComponent,
  RkStoriesRingListComponent,
  type StoriesGroup,
} from '@reelkit/angular-stories-player';

@Component({
  selector: 'app-feed',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [RkStoriesOverlayComponent, RkStoriesRingListComponent],
  template: `
    <rk-stories-ring-list [groups]="groups" (selected)="open($event)" />

    <!-- The player reports a close instead of closing itself, so the host
         decides: the ✕ button, a swipe down, Escape and the last story
         finishing all arrive as (closed). -->
    <rk-stories-overlay
      [isOpen]="isOpen()"
      [groups]="groups"
      [initialGroupIndex]="groupIndex()"
      (closed)="isOpen.set(false)"
    />
  `,
})
export class FeedComponent {
  protected readonly groups: StoriesGroup[] = [
    {
      author: { id: 'alice', name: 'Alice', avatar: '/alice.jpg', verified: true },
      stories: [
        { id: 's1', mediaType: 'image', src: '/story-1.jpg' },
        { id: 's2', mediaType: 'video', src: '/story-2.mp4', poster: '/poster-2.jpg' },
      ],
    },
    {
      author: { id: 'bob', name: 'Bob', avatar: '/bob.jpg' },
      stories: [{ id: 's3', mediaType: 'image', src: '/story-3.jpg' }],
    },
  ];

  protected readonly isOpen = signal(false);
  protected readonly groupIndex = signal(0);

  protected open(index: number): void {
    this.groupIndex.set(index);
    this.isOpen.set(true);
  }
}
Where the overlay renders

Unlike the React and Vue players, which move their overlay to the end of document.body, rk-stories-overlay renders where you place it and covers the screen with position: fixed. Keep it out of any ancestor with transform, perspective, filter or will-change: transform: that ancestor becomes the overlay's containing block, so the player is sized to it instead of the viewport and clipped by its overflow. An ancestor that creates its own stacking context also caps how far --rk-stories-overlay-z can lift the player. In return, --rk-stories-* tokens set on any ancestor reach the player.

URL State

View live demo →

RkStoriesUrlOverlayComponent 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 createOverlayUrlState 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/angular-stories-player. See the URL State guide and Core API.

typescript
import { ChangeDetectionStrategy, Component, Injector, inject, runInInjectionContext, type OnInit } from '@angular/core';
import {
  RkStoriesUrlOverlayComponent,
  RkStoriesRingListComponent,
  type StoriesGroup,
} from '@reelkit/angular-stories-player';
import {
  createOverlayUrlState,
  urlIndexTwoAxisKey,
  type TwoAxisPosition,
  type UrlStateController,
} from '@reelkit/angular';
import { createRouterUrlAdapter } from '@reelkit/angular/ng-router-url-adapter';

@Component({
  selector: 'app-url-feed',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [RkStoriesUrlOverlayComponent, RkStoriesRingListComponent],
  template: `
    <rk-stories-ring-list [groups]="groups" (selected)="open($event)" />
    <rk-stories-url-overlay [controller]="stories" [groups]="groups" />
  `,
})
export class UrlFeedComponent implements OnInit {
  private readonly injector = inject(Injector);

  protected groups: StoriesGroup[] = [];
  protected stories!: UrlStateController<TwoAxisPosition>;

  ngOnInit(): void {
    // Both the controller and the router adapter need an injection context,
    // and ngOnInit does not run in one, so borrow the component's.
    this.stories = runInInjectionContext(this.injector, () =>
      createOverlayUrlState({
        param: 'story',
        adapter: createRouterUrlAdapter(),
        ...urlIndexTwoAxisKey({
          outerCount: () => this.groups.length,
          innerCounts: () => this.groups.map((group) => group.stories.length),
        }),
      }),
    ) as UrlStateController<TwoAxisPosition>;
  }

  // Swiping only replaces the entry, so one back step always closes the
  // player rather than walking back through every story.
  protected open(groupIndex: number): void {
    this.stories.set(`${groupIndex}.0`);
  }
}
  • Opening pushes one history entry. Moving through 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. 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 neighbour.
  • A routed app should pass a router-backed adapter, so the router stays the single source of navigation truth. createRouterUrlAdapter from @reelkit/angular/ng-router-url-adapter is the ready-made one for the Angular router; writing history behind it leaves its location stale and drops the parameter on the next navigation.

Stable links. The group is positional by default, so a bookmarked ?story=2.0 opens a different user once the feed is reordered. Address it 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.

typescript
// ?story=alice.s3 — each axis addressed by a stable id, so a link survives
// the feed being reordered or paged.
import { urlStableIdKey } from '@reelkit/angular';

const key = urlIndexTwoAxisKey({
  outerCodec: urlStableIdKey({ items: () => groups }).codec,
  outerLocator: {
    locate: (id) => groups.findIndex((group) => group.author.id === id),
    identify: (index) => groups[index].author.id,
  },
  outerCount: () => groups.length,
  innerCounts: () => groups.map((group) => group.stories.length),
  innerCodec: urlStableIdKey({ items: () => [] }).codec,
  innerLocate: (outer, id) =>
    groups[outer].stories.findIndex((story) => story.id === id),
  innerIdentify: (outer, index) => groups[outer].stories[index].id,
});

Infinite feeds. Paging is an 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.

typescript
// locateAsync is asked only when locate misses — the group the link names is
// past what the feed has loaded. Page up to it, commit, and answer with its
// index; the story half is re-bounded against whichever group it settles on.
const key = urlIndexTwoAxisKey({
  outerCount: () => this.groups().length,
  innerCounts: () => this.groups().map((group) => group.stories.length),
  outerLocator: {
    locate: (index) => (index < this.groups().length ? index : null),
    identify: (index) => index,
    locateAsync: async (index) => {
      const loaded = await this.loadUntilGroup(index);
      if (!loaded) return null; // exhausted — the link names no group
      this.groups.set(loaded); // commit — the player renders from this signal
      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 createOverlayUrlState options live in the Angular API reference, and the walkthrough is in the Angular 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 React and Vue.

typescript
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
  RkStoriesOverlayComponent,
  RkStoriesRingListComponent,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/angular-stories-player';

@Component({
  selector: 'app-remembering-feed',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [RkStoriesOverlayComponent, RkStoriesRingListComponent],
  template: `
    <rk-stories-ring-list [groups]="groups()" [viewed]="viewed" (selected)="open($event)" />
    <rk-stories-overlay
      [isOpen]="isOpen()"
      [groups]="groups()"
      [initialGroupIndex]="groupIndex()"
      [viewed]="viewed"
      (closed)="isOpen.set(false)"
    />
  `,
})
export class RememberingFeedComponent {
  protected readonly groups = signal<StoriesGroup[]>([]);

  // One controller for both: the rings mute as a user is watched to the end,
  // and the player opens each user on their first unseen story. It reads the
  // groups through the signal, so users paged in later are counted too.
  protected readonly viewed = createStoriesViewedStateController({
    storageKey: 'my-app-stories-seen',
    groups: () => this.groups(),
  });

  protected readonly isOpen = signal(false);
  protected readonly groupIndex = signal(0);

  protected open(index: number): void {
    this.groupIndex.set(index);
    this.isOpen.set(true);
  }
}
  • The opening story counts. The story already on screen is reported viewed as the player opens, 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. An explicit resumeStoryIndex input wins over the controller's answer.
  • A link still wins. With rk-stories-url-overlay, 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.
  • 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 created only when it opens reads too late. rk-stories-overlay reads the store as soon as it exists, while it is still closed, and so does rk-stories-ring-list. Wrapped in @if (open) the overlay has no such moment: call attachViewedState(() => this.viewed) in the host's constructor, which attaches for as long as the host lives. 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 centre 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.

typescript
// 'carousel' lays the neighbouring users out as preview cards either side of
// the active story, the way Instagram does on the web. A phone always shows
// the active story alone, whatever this says.
@Component({
  template: `
    <rk-stories-overlay
      [isOpen]="isOpen()"
      [groups]="groups"
      desktopLayout="carousel"
      (closed)="isOpen.set(false)"
    />
  `,
})
export class CarouselFeedComponent {}
  • 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. While the cards show, groups slide rather than turn, whatever groupTransition says.
  • 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 rkStoriesSlide template at the player's size and scaled down; a video without a poster shows the card background. That preview receives isActive: false, and its lifecycle callbacks do nothing, so it cannot drive the timer of the story on screen. Keep whatever a slide does when it is created, such as playback or analytics, behind isActive.
  • The timer waits for the slide. The story does not start its timer, and (storyViewed) 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.
  • Restyling a card is the rkStoriesGroupPreview slot, shown under Custom Carousel Cards. The player still positions, scales and slides the card.

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.

typescript
// 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.
@Component({
  template: `
    <rk-stories-overlay
      [isOpen]="isOpen()"
      [groups]="groups"
      chromePlacement="group"
      (closed)="isOpen.set(false)"
    />
  `,
})
export class GroupChromeFeedComponent {}
  • 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.
  • The templates are rendered for every group on screen. The rkStoriesProgressBar and rkStoriesHeader contexts carry groupIndex and isActive; for a neighbouring group isActive is false and the progress signals hold still.
  • An rkStoriesHeader template 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.

Template Slots

Eight ng-template directives replace any part of the player. Each receives a typed context in which every value is named, by the names the React and Vue slots use, so let-author="author" binds the author in the header slot. The main value is the implicit one as well, so let-author alone does the same. A slot you leave out keeps the built-in rendering.

DirectiveClassContextDescription
rkStoriesErrorRkStoriesErrorDirective{ $implicit: story, story, storyIndex, groupIndex }Replaces the built-in "Content unavailable" panel, drawn over the active story
rkStoriesFooterRkStoriesFooterDirective{ $implicit: story, story, author, storyIndex }Adds a footer under the active story; the player draws none of its own
rkStoriesGroupPreviewRkStoriesGroupPreviewDirective{ $implicit: group, group, groupIndex, story, offset, viewedCount, onOpen }Fills a desktop carousel card; the player still positions, scales and slides it
rkStoriesHeaderRkStoriesHeaderDirective{ $implicit: author, author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }Replaces the default header (author, pause, sound, close). With chromePlacement="group" it sits in the swipe area; see Progress Bar and Header per Group.
rkStoriesLoadingRkStoriesLoadingDirective{ $implicit: story, story, storyIndex, groupIndex }Replaces the default header spinner while the active story loads
rkStoriesNavigationRkStoriesNavigationDirective{ onPrevStory, onNextStory, onPrevGroup, onNextGroup, $implicit: { the same four } }Replaces both desktop arrows
rkStoriesProgressBarRkStoriesProgressBarDirective{ $implicit: group, group, totalStories, activeIndex, progress, groupIndex, isActive }Replaces the canvas progress bar. activeIndex and progress are core signals — bridge them with toAngularSignal.
rkStoriesSlideRkStoriesSlideDirective{ $implicit: story, story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }Replaces the default image and video slides
typescript
import {
  RkStoriesOverlayComponent,
  RkStoriesHeaderDirective,
  RkStoriesFooterDirective,
} from '@reelkit/angular-stories-player';

@Component({
  imports: [
    RkStoriesOverlayComponent,
    RkStoriesHeaderDirective,
    RkStoriesFooterDirective,
  ],
  template: `
    <rk-stories-overlay [isOpen]="isOpen()" [groups]="groups" (closed)="isOpen.set(false)">
      <!-- The author is the implicit value; everything else is named. -->
      <ng-template rkStoriesHeader let-author let-onClose="onClose">
        <header class="my-header">
          <img [src]="author.avatar" [alt]="author.name" />
          <strong>{{ author.name }}</strong>
          <button type="button" (click)="onClose()">Close</button>
        </header>
      </ng-template>

      <!-- The player draws no footer of its own; this adds one. -->
      <ng-template rkStoriesFooter let-story let-author="author">
        <footer class="my-footer">Reply to {{ author.name }}</footer>
      </ng-template>
    </rk-stories-overlay>
  `,
})
export class SlottedComponent {}

STORIES_TEMPLATE_SLOT_DIRECTIVES exports all eight classes as one array, for a component that imports several.

Every slot renders through the player's own injector, so a component drawn inside one resolves the player's providers — a rk-video-story-slide in a slide template finds the same sound state the header's button writes.

Custom Slides

typescript
import {
  RkStoriesSlideDirective,
  RkImageStorySlideComponent,
  RkVideoStorySlideComponent,
} from '@reelkit/angular-stories-player';

// A slide template is projected content, so it cannot pick up the player's
// story type on its own — cast to your own feed's type where you know it.
@Component({
  template: `
    <rk-stories-overlay [isOpen]="isOpen()" [groups]="groups" (closed)="isOpen.set(false)">
      <ng-template
        rkStoriesSlide
        let-story
        let-groupIndex="groupIndex"
        let-index="index"
        let-activeGroupIndex="activeGroupIndex"
        let-activeStoryIndex="activeStoryIndex"
        let-onReady="onReady"
        let-onError="onError"
        let-onDurationReady="onDurationReady"
      >
        @if (story.mediaType === 'video') {
          <!-- Every video plays through one shared element, which this slide
               claims while both indexes point at it. -->
          <rk-video-story-slide
            [src]="story.src"
            [poster]="story.poster"
            [groupIndex]="groupIndex"
            [storyIndex]="index"
            [activeGroupIndex]="activeGroupIndex"
            [activeStoryIndex]="activeStoryIndex"
            (durationReady)="onDurationReady($event)"
            (playbackStarted)="onReady()"
            (failed)="onError()"
          />
        } @else {
          <rk-image-story-slide
            [src]="story.src"
            (loaded)="onReady()"
            (failed)="onError()"
          />
        }
      </ng-template>
    </rk-stories-overlay>
  `,
})
export class CustomSlideComponent {}

A custom slide tells the player how its content is doing through callbacks in the rkStoriesSlide context. The player acts on them only for the story on screen; a call from any other slide is ignored.

CallbackWhen
onReadyContent is ready (image loaded, video playing). The progress timer starts, or waits for a carousel slide to end.
onWaitingContent stalls (video buffering mid-playback). The spinner shows and the timer pauses.
onErrorContent failed to load. The error panel is shown and the timer pauses.
onDurationReadyReport the real media length in milliseconds, for example from video metadata, so the timer matches it. A duration set on the story wins over it.
onEndedThe media finished on its own, for example a video played to its end. The player moves to the next story without waiting for the timer.

rk-image-story-slide emits (loaded) and (failed), and rk-video-story-slide emits (playbackStarted), (buffering), (failed), (durationReady) and (finished), so a slide template that wraps them only has to pass each one on to the matching callback.

Preloader caching

The built-in RkImageStorySlideComponent and RkVideoStorySlideComponent preload the next story in the background. When a viewer reaches a preloaded story the content appears instantly, with no loading spinner. A URL that failed once is remembered too, so reopening a broken story shows the error straight away rather than retrying.

Custom Navigation

typescript
// The four moves arrive as the implicit value, so `let-nav` is all a
// template needs. Supplying this replaces both built-in arrows.
<ng-template rkStoriesNavigation let-nav>
  <button type="button" (click)="nav.onPrevGroup()">Previous user</button>
  <button type="button" (click)="nav.onPrevStory()">Back</button>
  <button type="button" (click)="nav.onNextStory()">Next</button>
  <button type="button" (click)="nav.onNextGroup()">Next user</button>
</ng-template>

Custom Progress Bar

typescript
// activeIndex and progress are core signals, the same ones the built-in
// canvas bar reads. Bridge them with toAngularSignal to follow the timer.
<ng-template
  rkStoriesProgressBar
  let-group
  let-total="totalStories"
  let-activeIndex="activeIndex"
  let-progress="progress"
>
  <app-my-progress-bar
    [totalStories]="total"
    [activeIndex]="activeIndex"
    [progress]="progress"
  />
</ng-template>

Custom Loading & Error

typescript
// Both are scoped to the story on screen: the player reports one loading
// state, and these draw only over the slide the viewer is looking at.
<ng-template rkStoriesLoading let-story>
  <div class="my-spinner">Loading {{ story.mediaType }}…</div>
</ng-template>

<ng-template rkStoriesError let-story>
  <div class="my-error">That story could not be loaded.</div>
</ng-template>
typescript
// One side card of the desktop carousel. `offset` is negative on the left
// and positive on the right, and 0 while the card slides through the centre.
<ng-template
  rkStoriesGroupPreview
  let-group
  let-story="story"
  let-offset="offset"
  let-viewedCount="viewedCount"
  let-onOpen="onOpen"
>
  <button type="button" class="my-card" (click)="onOpen()">
    <img [src]="group.author.avatar" [alt]="group.author.name" />
    <span>{{ group.author.name }}</span>
    <span>{{ viewedCount }} / {{ group.stories.length }} seen</span>
  </button>
</ng-template>

Transitions

groupTransition controls the 3D effect when switching between users. Import the transition functions from @reelkit/angular:

typescript
import { cubeTransition, flipTransition, fadeTransition } from '@reelkit/angular';

@Component({
  template: `
    <!-- Between users only. Stories inside one user always cross-fade. -->
    <rk-stories-overlay
      [isOpen]="isOpen()"
      [groups]="groups"
      [groupTransition]="flip"
      [innerTransitionDuration]="200"
      (closed)="isOpen.set(false)"
    />
  `,
})
export class TransitionsComponent {
  protected readonly flip = flipTransition;
}

Double-Tap & Likes

A heart animation plays on double tap, giving instant visual feedback. (doubleTapped) fires with the group and story index so you can persist the like in your own state. The player keeps no like state itself.

typescript
// A double tap plays the heart over the story and reports which story it
// landed on. The heart draws itself; this is only the reporting half.
<rk-stories-overlay
  [isOpen]="isOpen()"
  [groups]="groups"
  (doubleTapped)="like($event.groupIndex, $event.storyIndex)"
  (closed)="isOpen.set(false)"
/>

Customizing the Heart Animation

Tweak the speed with the --rk-stories-heart-duration token (see Theming). For colour, size, or hiding the heart entirely, target the .rk-stories-heart class. RkHeartAnimationComponent is exported for standalone use.

css
:root {
  /* Pop-in and fade-out duration. */
  --rk-stories-heart-duration: 500ms;
}

/* Colour and size are plain CSS on the element itself. */
.rk-stories-heart svg {
  width: 72px;
  height: 72px;
  fill: #ff4d6d;
}

/* Or hide it and animate your own from the double-tap event. */
.rk-stories-heart {
  display: none;
}

The built-in heart animation cannot be replaced through a slot yet. Restyle it with CSS, or hide it with display: none and run your own animation from (doubleTapped). If you need a heart slot, let us know via GitHub Issues.

StoriesApi

Take the API from (apiReady) for imperative control. The player is rebuilt on every open, so a handle kept from a previous open is stale — use the newest one.

typescript
import type { StoriesApi } from '@reelkit/angular-stories-player';

@Component({
  template: `
    <rk-stories-overlay
      [isOpen]="isOpen()"
      [groups]="groups"
      (apiReady)="api = $event"
      (closed)="isOpen.set(false)"
    />
    <button type="button" (click)="api?.nextGroup()">Skip this user</button>
  `,
})
export class DrivenComponent {
  // Handed over once the player is ready to take orders, and again on every
  // open: the player is rebuilt each time, so keep the newest one.
  protected api: StoriesApi | null = null;
}

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

Sub-Components

Exported building blocks, for composing inside the slots or using on their own.

ComponentWhat it is
RkCanvasProgressBarComponentThe canvas segmented progress bar, drawn straight from the timer signals. Handles a sliding window for groups with many stories. With [live]="false" it draws only when its signals change and after a resize, with no animation loop. Sizes and colours are separate inputs: gap, barHeight, minSegmentWidth, bgColor, fillColor.
RkHeartAnimationComponentThe double-tap heart. Emits (completed) when the animation ends, so the parent can remove it.
RkImageStorySlideComponentFull-bleed image slide with object-fit: cover, reporting load and error through outputs.
RkStoriesRingComponentCircular avatar with the gradient ring. Two states: anything left to watch rotates, fully watched goes flat. Emits (clicked).
RkStoriesRingListComponentHorizontal row of rings with author names, one per group. Emits (selected) with the group index.
RkStoryHeaderComponentThe default header: avatar, name, verified badge, relative time, pause, sound, close, and the loading spinner. The pause and sound buttons appear only with [showPauseButton] and [showSoundButton] set, as the player sets them for its own header.
RkVideoStorySlideComponentVideo slide backed by the one shared <video> element, for iOS sound continuity. Handles autoplay, poster frames and duration reporting.

RkStoriesCarouselComponent is exported too, with its CarouselSlide type, but the overlay owns every value it draws from — reach for the rkStoriesGroupPreview slot to restyle a card rather than mounting the carousel yourself.

typescript
import {
  RkStoriesRingComponent,
  RkStoriesRingListComponent,
  RkStoryHeaderComponent,
  RkCanvasProgressBarComponent,
  RkImageStorySlideComponent,
  RkVideoStorySlideComponent,
  RkHeartAnimationComponent,
} from '@reelkit/angular-stories-player';

// Each piece the player draws is exported on its own, so a page can build a
// ring row without the overlay, or a slot can rebuild a region out of the
// same parts the player uses.
@Component({
  imports: [RkStoriesRingComponent],
  template: `
    <rk-stories-ring
      [author]="author"
      [totalStories]="5"
      [viewedCount]="2"
      [size]="68"
      (clicked)="open()"
    />
  `,
})
export class RingOnlyComponent {}

Custom Story Types

Extend StoryItem with your own fields and type the groups with it:

typescript
// The player is generic over the story type, so a feed can carry whatever
// extra fields a slide template needs.
interface PromoStory extends StoryItem {
  title?: string;
  ctaText?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'shop', name: 'Shop', avatar: '/shop.jpg' },
    stories: [
      { id: 'p1', mediaType: 'image', src: '/promo.jpg', title: 'Flash Sale', ctaText: 'Shop now' },
    ],
  },
];

Re-exports

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

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

attachViewedState is exported too, for a component that owns a viewed-state controller and wants it attached for as long as that component lives.

RkStoriesOverlayComponent Inputs

rk-stories-overlay

InputTypeDefaultDescription
ariaLabelstring'Stories player'Accessible label for the dialog region; announced by screen readers when the overlay opens
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.
defaultImageDurationnumber5000Auto-advance duration for an image story, in milliseconds
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.
enableKeyboardbooleantrueEnable keyboard navigation (left/right arrows, Escape)
groupsStoriesGroup<T>[]requiredStory groups to display. Groups or stories added while the player is open are picked up without losing the viewer's place.
groupTransitionTransitionTransformFncubeTransitionTransition between users. Ignored while the desktop carousel is showing, which slides instead.
hideUIOnPausebooleantrueHide the progress bar and header while paused by a long press
initialGroupIndexnumber0Zero-based index of the group the player opens on
initialStoryIndexnumberresume, else 0Zero-based index of the story the player opens on. Naming one wins over anything remembered; leave it out and the opening group resumes like every other.
innerTransitionDurationnumber200Crossfade duration between stories of one user, in milliseconds
isOpenbooleanrequiredRenders the player and locks body scroll while true
minSegmentWidthnumber8Narrowest a progress segment gets before the bar scrolls a window of them instead, in pixels
resumeStoryIndex(groupIndex: number) => number—Which story a group opens on the first time it is reached. Only consulted for a group not yet visited during this open, and it wins over viewed.
tapZoneSplitnumber0.3Tap zone split ratio (0–1). The left portion goes back, the rest goes forward.
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 rk-stories-ring-list.

Each slot also has an input, for a component that holds the TemplateRef itself rather than projecting it: slideTemplate, headerTemplate, footerTemplate, progressBarTemplate, navigationTemplate, loadingTemplate, errorTemplate and groupPreviewTemplate. A template named this way wins over one projected into the player.

RkStoriesOverlayComponent Outputs

OutputPayloadDescription
(apiReady)StoriesApiEmitted once the player is ready to take orders, and again on every open
(closed)voidEmitted when the viewer closes the player. The player stays put; the host decides. On rk-stories-url-overlay the parameter is already cleared by then.
(doubleTapped){ groupIndex, storyIndex }Emitted on a double tap, alongside the heart animation
(groupChanged)numberEmitted after the active group changes
(paused)voidEmitted on a long press, or the header pause button
(resumed)voidEmitted on release, or the header play button
(storyChanged){ groupIndex, storyIndex }Emitted after the active story changes
(storyCompleted){ groupIndex, storyIndex }Emitted when a story's timer runs out
(storyViewed){ groupIndex, storyIndex }Emitted when a story comes on screen, the opening one included

The player reports a close rather than closing itself, so (closed) covers every route out: the ✕ button, a swipe down, Escape, and the last story of the last group finishing.

RkStoriesUrlOverlayComponent Inputs

rk-stories-url-overlay

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

InputTypeDefaultDescription
controllerUrlStateController<TwoAxisPosition>requiredController from createOverlayUrlState 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.

StoryItem, AuthorInfo and StoriesGroup Interfaces

typescript
import type {
  StoryItem,
  AuthorInfo,
  StoriesGroup,
  StoriesApi,
  DesktopLayout,
} from '@reelkit/angular-stories-player';

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

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

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

Template Slot Context Types

Every context type is exported, so a slot can be annotated where the compiler cannot infer it — StoriesSlideContext, StoriesHeaderContext, StoriesFooterContext, StoriesProgressBarContext, StoriesNavigationContext, StoriesLoadingContext, StoriesErrorContext and StoriesGroupPreviewContext, alongside StoriesNavigationActions, DesktopLayout, ChromePlacement and StoriesApi:

typescript
// Every slot context in one place. Every value is named, by the names the
// react and vue slots use; the main one is also what `let-x` with no name
// binds.
import type {
  StoriesSlideContext,
  StoriesHeaderContext,
  StoriesFooterContext,
  StoriesProgressBarContext,
  StoriesNavigationContext,
  StoriesGroupPreviewContext,
  StoriesLoadingContext,
  StoriesErrorContext,
} from '@reelkit/angular-stories-player';

CSS Classes

Class names are plain (not scoped), so a stylesheet loaded after @reelkit/angular-stories-player/styles.css can override any of them. They are identical to @reelkit/react-stories-player and @reelkit/vue-stories-player, so a stylesheet written for one binding works for the others. For colours, sizes and z-indexes prefer the custom properties in Theming.

ClassComponentDescription
.rk-stories-overlayOverlayFixed full-screen backdrop (background, z-index)
.rk-stories-overlay--carouselOverlayOverlay with the desktop carousel showing
.rk-stories-overlay--slidingOverlayCarousel slide running; the player canvas is hidden
.rk-stories-swipe-wrapperOverlaySwipe-to-close wrapper (hosts nav buttons + canvas)
.rk-stories-containerOverlayRounded story canvas (position, overflow)
.rk-stories-ui-layerOverlayOne chrome layer over the player (header, progress, hearts). With chromePlacement="group" one inside every group slide instead (header, progress), and the hearts sit directly in the player
.rk-stories-ui-layer--hiddenOverlayChrome hidden state (toggled by hideUIOnPause)
.rk-stories-errorOverlayError state (centred icon + text)
.rk-stories-error-textOverlayError message text
.rk-stories-nav-btnOverlayDesktop prev/next arrow
.rk-stories-progress-barProgressBarCanvas progress bar positioning wrapper
.rk-stories-slide-wrapperOverlayOne group of stories (outer slide)
.rk-stories-storyOverlayA single story (inner slide root)
.rk-stories-imageImageStorySlideFull-bleed image (object-fit: cover)
.rk-stories-videoVideoStorySlideVideo slide container
.rk-stories-video-elementVideoStorySlideThe shared <video> element
.rk-stories-video-posterVideoStorySlidePoster frame, shown until playback starts
.rk-stories-video-poster--visibleVideoStorySlidePoster visible state (before playback)
.rk-stories-heartHeartAnimationThe double-tap heart
.rk-stories-headerStoryHeaderHeader row (avatar, name, time, controls)
.rk-stories-header--hiddenStoryHeaderHeader hidden state (visible input set to 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 controls (sound, pause, close)
.rk-stories-header-btnStoryHeaderPause, sound and close buttons
.rk-stories-header-btn--desktopStoryHeaderThe pause button, shown only wider than 768px
.rk-stories-header-spinnerStoryHeaderLoading spinner while the active story loads
.rk-stories-ringStoriesRingCircular avatar with the gradient ring
.rk-stories-ring--activeStoriesRingRing with stories left to watch (rotating gradient)
.rk-stories-ring-avatarStoriesRingThe avatar image inside the ring
.rk-stories-ring-listStoriesRingListHorizontal row of rings
.rk-stories-ring-list-itemStoriesRingListRing + name column
.rk-stories-ring-list-nameStoriesRingListAuthor name below each ring
.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 centre 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-imageCarouselThe card's preview picture (poster or image)
.rk-stories-card-frameCarouselA slide template drawn as a card preview, scaled down
.rk-stories-card-scrimCarouselDimming layer over the preview
.rk-stories-card-infoCarouselRing, author name and time column
.rk-stories-card-nameCarouselAuthor name
.rk-stories-card-timeCarouselTime-ago text

Theming

Every colour, size, duration and z-index is a --rk-stories-* custom property on :root, so a rebrand is a stylesheet rather than a component change. The tokens are shared with the React and Vue players.

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 on the story canvas on a phone
--rk-stories-ui-z15Chrome layer z-index (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-z5Error panel z-index
--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 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-transition150msDesktop nav button hover 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-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
--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 setting it 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-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 items of the header row
--rk-stories-header-transition200msHeader fade duration when it hides
--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 buttons
--rk-stories-header-btn-fg#fffHeader action icon color (close, sound, pause)
--rk-stories-header-btn-padding4pxHeader button padding
--rk-stories-header-spinner-size20pxLoading spinner size
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Loading spinner track color
--rk-stories-header-spinner-fg#fffLoading spinner arc color
--rk-stories-header-spinner-duration0.8sOne spinner rotation
--rk-stories-heart-z20Double-tap heart z-index
--rk-stories-heart-duration800msPop-in/fade-out animation duration

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

css
/* Loaded after @reelkit/angular-stories-player/styles.css. Every token has a
   default on :root, so override only what you want to change. The names
   match the React and Vue players, so a theme ports between bindings. */
:root {
  --rk-stories-overlay-bg: #0f172a;
  --rk-stories-container-radius: 24px;

  --rk-stories-nav-bg: rgba(99, 102, 241, 0.35);
  --rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.65);
  --rk-stories-nav-fg: #fff;

  --rk-stories-header-name-fg: #fef3c7;
  --rk-stories-header-time-fg: rgba(254, 243, 199, 0.7);

  --rk-stories-ring-spin-duration: 2s;
  --rk-stories-heart-duration: 600ms;

  --rk-stories-card-bg: #1e293b;
  --rk-stories-card-transition: 260ms;
}

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, wrapping at either end; focus that escapes (click outside, programmatic focus) gets pulled back. Body scroll stays locked for as long as the player is open. Implemented with captureFocusForReturn and createFocusTrap from @reelkit/core, re-exported by @reelkit/angular.

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; a card that opens its group hands focus back to the dialog rather than dropping it on the page.

Server-Side Rendering

The player renders nothing until it is open, and reads no browser API during construction, so it is safe under Angular SSR. Viewed state is read after mount rather than during render, which is what keeps the server render and the first client render in agreement.

Keyboard Shortcuts

KeyAction
ArrowLeftPrevious story. On a group's first story, the previous group, opened where it was left; nothing on the first group.
ArrowRightNext story. On a group's last story, the next group; on the last group, the player closes.
EscapeClose player