Vue Stories Player

An Instagram-style stories player overlay for Vue 3 using @reelkit/vue-stories-player. It shares the @reelkit/stories-core engine with the React player, 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
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
Scoped Slots
Customize every UI element
URL State
Shareable ?story=group.story links
Viewed State
Seen rings and resume survive reloads

Installation

bash
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-next

Import the stylesheet once in your app entry (or any component):

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

The default header and navigation arrows use lucide-vue-next for icons. If you prefer a different icon library, replace them through the #header and #navigation scoped slots.

Quick Start

StoriesOverlay renders the full-screen player. Pair it with StoriesRingList for the Instagram-style entry points, pass an array of StoriesGroup objects, and bind v-model:is-open.

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import '@reelkit/vue-stories-player/styles.css';

const 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' }],
  },
];

const isOpen = ref(false);
const groupIndex = ref(0);

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <StoriesRingList :groups="groups" @select="openGroup" />

  <!-- v-model:is-open keeps the ref in sync however the viewer closes it:
       the ✕ button, a swipe down, Escape, or the last story finishing. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :initial-group-index="groupIndex"
  />
</template>

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

vue
<script setup lang="ts">
import {
  StoriesUrlOverlay,
  useOverlayUrlState,
  urlIndexTwoAxisKey,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import { useVueRouterUrlAdapter } from '@reelkit/vue/vue-router-url-adapter';
import '@reelkit/vue-stories-player/styles.css';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The outer axis is the group, the inner one the story inside it.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => props.groups.length,
    innerCounts: () => props.groups.map((group) => group.stories.length),
  }),
});
</script>

<template>
  <!-- Opening is a link — the overlay reads the URL and opens itself. -->
  <RouterLink
    v-for="(group, index) in props.groups"
    :key="group.author.id"
    :to="`?story=${index}.0`"
  >
    {{ group.author.name }}
  </RouterLink>

  <StoriesUrlOverlay :controller="stories" :groups="props.groups" />
</template>
  • 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. useVueRouterUrlAdapter from @reelkit/vue/vue-router-url-adapter is the ready-made one for Vue Router; writing history behind the router 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 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 index within the resolved group. A story id must not contain a ., the delimiter between the two halves.

typescript
// `?story=alice.0` — the group by the author's stable id, the story by its
// position inside whichever group that id resolves to.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.value.length,
    innerCounts: () => groups.value.map((group) => group.stories.length),
    outerCodec: { decode: (raw) => raw, encode: (id) => id },
    outerLocator: {
      locate: (id) =>
        groups.value.findIndex((group) => group.author.id === id),
      identify: (index) => groups.value[index].author.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
// A feed that pages. `locate` answers for the groups already loaded;
// `locateAsync` runs only when it misses, so a shared link to a group past
// the window fetches up to it and then opens.
outerLocator: {
  locate: (index) => (index < loaded.value.length ? index : null),
  identify: (index) => index,
  locateAsync: async (index) => {
    if (index >= totalGroups) return null;
    loaded.value = await fetchGroupsUpTo(index);
    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 Vue API reference.

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 Angular.

vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

const isOpen = ref(false);
const groupIndex = shallowRef(0);

// One controller for the rings and the player. It reads the groups through
// the getter every time it needs them, so a feed that pages in more groups
// is counted too.
const viewed = createStoriesViewedStateController({
  storageKey: 'stories-seen',
  groups: () => props.groups,
});

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <!-- The rings follow the controller by themselves. -->
  <StoriesRingList
    :groups="props.groups"
    :viewed="viewed"
    @select="openGroup"
  />

  <!-- The player resumes, records, and draws its card rings from it. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    :initial-group-index="groupIndex"
    :viewed="viewed"
  />
</template>
  • The opening story counts. The story already on screen is reported viewed once the player mounts, 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.
  • 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 overlay reads the store once mounted, while it is still closed. With v-if="open" there is no such moment, so call useAttachViewedState(() => viewed) in the component that owns the controller; it attaches once mounted and lets go on unmount. The options, and the lower-level pieces this is built from, are on the Stories Core page.

Set desktop-layout="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.

vue
<template>
  <!-- Neighbouring groups sit beside the active story as dimmed cards on a
       desktop screen. Phones keep the plain player whatever this says. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  />
</template>
  • Phones keep the plain player. Up to 768px wide the cards are not rendered and swipes use group-transition, 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 #slide slot at the player's size and scaled down; a video without a poster shows the card background. That preview receives 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 story-viewed 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 chrome-placement="group" to give every group its own, drawn inside its slide, so both turn with the group the way they do on Instagram.

vue
<template>
  <!-- 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
    v-model:is-open="isOpen"
    :groups="groups"
    chrome-placement="group"
  />
</template>
  • 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 slots are rendered for every group on screen. The #progressBar and #header scopes carry groupIndex and isActive; for a neighbouring group isActive is false and the progress signals hold still.
  • A #header slot 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

StoriesOverlay Props

StoriesOverlayProps

PropTypeDefaultDescription
aria-labelstring'Stories player'Accessible label for the dialog region; announced by screen readers when the overlay opens
chrome-placement'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.
default-image-durationnumber5000Auto-advance duration for an image story, in milliseconds
desktop-layout'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.
enable-keyboardbooleantrueEnable keyboard navigation (left/right arrows, Escape)
groupsStoriesGroup<T>[]requiredStory groups to display. Adding groups or stories while the player is open is picked up, whether the array is replaced or pushed into.
group-transitionTransitionTransformFncubeTransitionTransition for the outer (group) slider. Ignored while the desktop carousel is showing, which slides instead.
hide-ui-on-pausebooleantrueHide the progress bar and header while paused by a long press
initial-group-indexnumber0Zero-based index of the initially visible group
initial-story-indexnumberresume, else 0Zero-based index of the initially visible story. Naming one wins over anything remembered; leave it out and the opening group resumes like every other.
inner-transition-durationnumber200Crossfade duration between stories, in milliseconds
is-openbooleanrequiredControls visibility, and locks body scroll while open. Bind with v-model:is-open.
min-segment-widthnumber8Minimum progress segment width in pixels, before the sliding window kicks in
resume-story-index(groupIndex: number) => numberWhich 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.
tap-zone-splitnumber0.3Tap zone split ratio (0–1). The left portion goes back, the rest goes forward.
viewedStoriesViewedStateControllerWhat 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.

StoriesUrlOverlay Props

StoriesUrlOverlayProps

Takes every prop above except the open-state trio — is-open, initial-group-index, initial-story-index — 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.

Events

EventPayloadDescription
@api-readyStoriesApiEmitted once the player is mounted, with the imperative API
@closevoidEmitted when the player closes. On StoriesUrlOverlay the parameter is already cleared by then.
@double-tapgroupIndex: number, storyIndex: numberEmitted on a double-tap, alongside the heart animation
@group-changegroupIndex: numberEmitted after the active group changes
@pausevoidEmitted on a long press, or the header pause button
@resumevoidEmitted on release, or the header play button
@story-changegroupIndex: number, storyIndex: numberEmitted after the active story changes
@story-completegroupIndex: number, storyIndex: numberEmitted when a story's timer runs out
@story-viewedgroupIndex: number, storyIndex: numberEmitted when a story becomes visible, the opening one included
@update:is-openbooleanEmitted on close; enables `v-model:is-open`

v-model:is-open

Use v-model:is-open to drive the overlay with a single binding. The :is-open + @close pair still works when you need the explicit event.

vue
<script setup lang="ts">
import { ref } from 'vue';
import { StoriesOverlay } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
</script>

<template>
  <button type="button" @click="isOpen = true">Open stories</button>

  <!-- One binding, in both directions. -->
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups" />

  <!-- The explicit pair still works when you need the event itself. -->
  <StoriesOverlay
    :is-open="isOpen"
    :groups="groups"
    @close="isOpen = false"
  />
</template>

Scoped Slots

Eight scoped slots replace any part of the player. Each receives a typed scope object. A slot you leave out keeps the default — and so does one that renders nothing, so a v-if that goes false falls back rather than leaving a hole.

SlotScopeDescription
#error{ story, storyIndex, groupIndex }Replaces the built-in "Content unavailable" panel, drawn over the active story
#footer{ author, story, storyIndex }Adds a footer under the active group's story
#groupPreview{ group, groupIndex, story, offset, viewedCount, onOpen }Fills a desktop carousel card; the player still positions, scales and slides it
#header{ author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }Replaces the default header (author, pause, sound, close). With chrome-placement="group" it sits in the swipe area; see Progress Bar and Header per Group.
#loading{ story, storyIndex, groupIndex }Replaces the default header spinner while the active story loads
#navigation{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }Replaces both desktop arrows
#progressBar{ totalStories, activeIndex, progress, group, groupIndex, isActive }Replaces the canvas progress bar. activeIndex and progress are core signals — bridge them with toVueRef.
#slide{ story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }Replaces the default image and video slides
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Every slot is optional; one you leave out keeps the default, and so
         does one that renders nothing. -->
    <template #header="{ author, isPaused, onTogglePause, onClose }">
      <header class="my-header">
        <img :src="author.avatar" :alt="author.name" />
        <span>{{ author.name }}</span>
        <button type="button" @click="onTogglePause">
          {{ isPaused ? 'Play' : 'Pause' }}
        </button>
        <button type="button" @click="onClose">Close</button>
      </header>
    </template>

    <template #footer="{ author, story }">
      <form class="my-reply" @submit.prevent="reply(author, story)">
        <input v-model="draft" :placeholder="`Reply to ${author.name}…`" />
      </form>
    </template>
  </StoriesOverlay>
</template>

Custom Slides

Compose ImageStorySlide and VideoStorySlide inside #slide to keep autoplay, poster capture and sound sync while drawing your own layout. Call the lifecycle callbacks from the scope, or the timer never starts:

CallbackWhen to call
onReadyThe image loaded, or the video started playing. Clears the loading state and starts the timer.
onWaitingThe video is buffering mid-playback. Shows the spinner and pauses the timer.
onErrorThe content failed to load. Shows the error state, and the URL is remembered as broken so reopening it skips the retry.
onDurationReadyReport the real media length in milliseconds, so the timer runs for as long as the video does.
onEndedThe media finished. Advances to the next story.
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Compose ImageStorySlide and VideoStorySlide to keep autoplay, poster
         capture and sound sync while drawing your own layout around them.
         Pass the callbacks through, or the timer never starts. -->
    <template #slide="scope">
      <div class="my-slide">
        <VideoStorySlide
          v-if="scope.story.mediaType === 'video'"
          :src="scope.story.src"
          :poster="scope.story.poster"
          :group-index="scope.groupIndex"
          :story-index="scope.index"
          :active-group-index="scope.activeGroupIndex"
          :active-story-index="scope.activeStoryIndex"
          :on-duration-ready="scope.onDurationReady"
          :on-playing="scope.onReady"
          :on-waiting="scope.onWaiting"
          :on-ended="scope.onEnded"
          :on-error="scope.onError"
        />
        <ImageStorySlide
          v-else
          :src="scope.story.src"
          :on-load="scope.onReady"
          :on-error="scope.onError"
        />

        <!-- A carousel card draws this same slot with isActive: false, and
             nothing in it can be clicked. Keep playback or analytics behind
             the flag. -->
        <span v-if="scope.isActive" class="my-badge">{{ scope.story.id }}</span>
      </div>
    </template>
  </StoriesOverlay>
</template>
Preloader caching

The built-in ImageStorySlide and VideoStorySlide components 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

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Replaces both desktop arrows at once. The first node sits left of
         the story, the rest to its right. -->
    <template #navigation="{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }">
      <div class="my-nav">
        <button type="button" @click="onPrevGroup">Previous user</button>
        <button type="button" @click="onPrevStory">Back</button>
      </div>
      <div class="my-nav">
        <button type="button" @click="onNextGroup">Next user</button>
        <button type="button" @click="onNextStory">Forward</button>
      </div>
    </template>
  </StoriesOverlay>
</template>

Custom Progress Bar

The scope carries core signals rather than plain numbers, so the timer can drive the bar without re-rendering the player. Bridge them with toVueRef from @reelkit/vue:

vue
<script setup lang="ts">
import { toVueRef, type Signal } from '@reelkit/vue';

const props = defineProps<{
  totalStories: number;
  activeIndex: Signal<number>;
  progress: Signal<number>;
}>();

// Core signals do not re-render a Vue component on their own: bridge them.
const active = toVueRef(props.activeIndex);
const progress = toVueRef(props.progress);
</script>

<template>
  <div class="my-progress">
    <span
      v-for="index in totalStories"
      :key="index"
      class="my-segment"
      :style="{
        '--fill':
          index - 1 < active
            ? '100%'
            : index - 1 === active
              ? `${progress * 100}%`
              : '0%',
      }"
    />
  </div>
</template>

Custom Loading & Error

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Both are drawn over the active story only. Without the loading slot
         the default is the header spinner; without the error slot it is the
         built-in "Content unavailable" panel. -->
    <template #loading="{ story }">
      <p class="my-loading">Loading {{ story.mediaType }}…</p>
    </template>

    <template #error="{ story, groupIndex }">
      <div class="my-error">
        <p>That {{ story.mediaType }} would not load.</p>
        <button type="button" @click="report(groupIndex, story)">Report</button>
      </div>
    </template>
  </StoriesOverlay>
</template>
vue
<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  >
    <!-- The player still positions, scales and slides the card; this fills
         it. `offset` is negative on the left, positive on the right, and 0
         for the card passing through the center during a slide. -->
    <template #groupPreview="{ group, story, viewedCount, offset, onOpen }">
      <button type="button" class="my-card" @click="onOpen">
        <img v-if="story?.poster" :src="story.poster" alt="" />
        <span>{{ group.author.name }}</span>
        <small>{{ viewedCount }} / {{ group.stories.length }} seen</small>
        <em v-if="offset === 0">opening…</em>
      </button>
    </template>
  </StoriesOverlay>
</template>

Transitions

group-transition controls the 3D effect when switching between users. Import the transition functions from @reelkit/vue:

vue
<script setup lang="ts">
import { StoriesOverlay } from '@reelkit/vue-stories-player';
import {
  cubeTransition,
  flipTransition,
  fadeTransition,
  zoomTransition,
  slideTransition,
} from '@reelkit/vue';
</script>

<template>
  <!-- cubeTransition is the default; flip, fade, zoom and slide ship too. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :group-transition="flipTransition"
  />
</template>

StoriesApi

Put a template ref on the overlay for imperative control, or take the API from the @api-ready event. Calls made while the player is closed do nothing.

vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { StoriesOverlay, type StoriesApi } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
const player = useTemplateRef<StoriesApi>('player');

// Calls while the player is closed are safely ignored.
const skipUser = () => player.value?.nextGroup();
</script>

<template>
  <button type="button" @click="skipUser">Skip this user</button>

  <StoriesOverlay
    ref="player"
    v-model:is-open="isOpen"
    :groups="groups"
    @api-ready="(api) => api.pause()"
  />
</template>

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 heart animation plays on double-tap, giving instant visual feedback. The @double-tap event fires with the group and story index so you can persist the like in your own state. The player keeps no like state itself.

vue
<script setup lang="ts">
import { StoriesOverlay, type StoriesGroup } from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The heart animation is built in; the like itself is yours to keep.
const like = (groupIndex: number, storyIndex: number) => {
  const story = props.groups[groupIndex].stories[storyIndex];
  void fetch(`/api/stories/${story.id}/like`, { method: 'POST' });
};
</script>

<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    @double-tap="like"
  />
</template>

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. The HeartAnimation component 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 the @double-tap event. If you need a heart slot, let us know via GitHub Issues.

Sub-Components

Exported building blocks, for composing inside the slots or using on their own: StoriesRing, StoriesRingList, StoryHeader, CanvasProgressBar, ImageStorySlide, VideoStorySlide and HeartAnimation.

ComponentWhat it is
CanvasProgressBarThe 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.
HeartAnimationThe double-tap heart. Emits complete when the animation ends, so the parent can remove it.
ImageStorySlideFull-bleed image slide with object-fit: cover, reporting load and error through callbacks.
StoriesRingCircular avatar with the gradient ring. Two states: anything left to watch rotates, fully watched goes flat. Emits click.
StoriesRingListHorizontal row of rings with author names, one per group. Emits select with the group index.
StoryHeaderThe default header: avatar, name, verified badge, relative time, pause, sound, close, and the loading spinner.
VideoStorySlideVideo slide backed by the one shared <video> element, for iOS sound continuity. Handles autoplay, poster frames and duration reporting.
vue
<script setup lang="ts">
import {
  CanvasProgressBar,
  HeartAnimation,
  ImageStorySlide,
  StoriesRing,
  StoriesRingList,
  StoryHeader,
  VideoStorySlide,
} from '@reelkit/vue-stories-player';
</script>

<template>
  <!-- One ring, and the horizontal row of them. -->
  <StoriesRing
    :author="group.author"
    :total-stories="group.stories.length"
    :viewed-count="2"
    :size="72"
    @click="open(group)"
  />
  <StoriesRingList :groups="groups" :viewed="viewed" @select="open" />

  <!-- Inside a #header slot: the default header, with your own additions. -->
  <StoryHeader
    :author="group.author"
    :created-at="story.createdAt"
    :is-paused="isPaused"
    :is-video="story.mediaType === 'video'"
    :is-muted="isMuted"
    :on-toggle-pause="onTogglePause"
    :on-toggle-sound="onToggleSound"
    :on-close="onClose"
  />

  <!-- Inside a #progressBar slot: the canvas bar, driven by the signals. -->
  <CanvasProgressBar
    :total-stories="group.stories.length"
    :active-index="activeIndex"
    :progress="progress"
    :min-segment-width="12"
  />

  <!-- Inside a #slide slot; see the slide slot example above. -->
  <ImageStorySlide :src="story.src" :on-load="onReady" :on-error="onError" />
  <VideoStorySlide
    :src="story.src"
    :group-index="groupIndex"
    :story-index="index"
    :active-group-index="activeGroupIndex"
    :active-story-index="activeStoryIndex"
  />

  <!-- Standalone, outside the player. -->
  <HeartAnimation @complete="remove(heart.id)" />
</template>

Re-exports

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

FromRe-exported
@reelkit/vueObserve, SoundProvider, 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

Observe is the render boundary the player uses internally: it re-renders only its slot when a core signal changes, which is how a story change repaints the header and the progress bar without redrawing the sliders. Use it in a custom slot when a whole component following the signal through toVueRef would be too coarse — see the Vue API reference.

Each component's props type is exported too: StoriesOverlayProps, StoriesUrlOverlayProps, StoriesRingProps, StoriesRingListProps, StoryHeaderProps, CanvasProgressBarProps, ImageStorySlideProps and VideoStorySlideProps.

Types

StoryItem, AuthorInfo, StoriesGroup

typescript
interface StoryItem {
  /** Unique within its group. Stored viewed state keys on it. */
  id: string;
  mediaType: 'image' | 'video';
  src: string;
  /** Poster for a video, also used as the carousel card preview. */
  poster?: string;
  /** Overrides defaultImageDuration for this story, in milliseconds. */
  duration?: number;
  createdAt?: string | Date;
  /** Media aspect ratio (width / height). */
  aspectRatio?: number;
}

interface AuthorInfo {
  /** Stable id. Viewed state and stable-id URLs key on it. */
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

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

Slot scopes

Every scope type is exported, so a slot can be annotated where the compiler cannot infer it — HeaderSlotScope, FooterSlotScope, SlideSlotScope, NavigationSlotScope, ProgressBarSlotScope, LoadingSlotScope, ErrorSlotScope and GroupPreviewSlotScope, alongside StoriesApi:

typescript
interface HeaderSlotScope<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
  isPaused: boolean;
  isMuted: boolean;
  /** True while the active story is a video — gate your sound button on it. */
  isVideo: boolean;
  groupIndex: number;
  /** False for a neighbouring group's header with chrome-placement="group". */
  isActive: boolean;
  onToggleSound: () => void;
  onTogglePause: () => void;
  onClose: () => void;
}

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

interface SlideSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  index: number;
  groupIndex: number;
  /** False for the copy a carousel card draws. */
  isActive: boolean;
  size: [number, number];
  activeGroupIndex: Signal<number>;
  activeStoryIndex: Signal<number>;
  /** Report the real media length, in milliseconds. */
  onDurationReady: (durationMs: number) => void;
  onReady: () => void;
  onWaiting: () => void;
  onError: () => void;
  onEnded: () => void;
}

interface NavigationSlotScope {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

interface ProgressBarSlotScope<T extends StoryItem = StoryItem> {
  totalStories: number;
  activeIndex: Signal<number>;
  /** 0 to 1 for the active story. Bridge it with toVueRef. */
  progress: Signal<number>;
  group: StoriesGroup<T>;
  groupIndex: number;
  /** False: the signals hold still for a neighbouring group. */
  isActive: boolean;
}

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

/** Same shape as LoadingSlotScope. */
interface ErrorSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

interface GroupPreviewSlotScope<T extends StoryItem = StoryItem> {
  group: StoriesGroup<T>;
  groupIndex: number;
  /** The story the group would open on, if it has one. */
  story: T | undefined;
  /** Negative left, positive right, 0 while sliding through the center. */
  offset: number;
  viewedCount: number;
  onOpen: () => void;
}

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

Custom Story Types

Extend StoryItem with your own fields and type the groups with it. Unlike the React player, StoriesOverlay and StoriesUrlOverlay are not generic: the story type of groups does not reach the slots, whose scopes stay untyped until you annotate them. Annotate a slot scope to keep the extra fields visible inside the template. Nothing checks the annotation against groups, so keep the two in step:

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  type SlideSlotScope,
  type StoriesGroup,
  type StoryItem,
} from '@reelkit/vue-stories-player';

const isOpen = ref(false);

interface PromoStory extends StoryItem {
  title?: string;
  ctaHref?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'shop', name: 'Shop', avatar: '/shop.jpg' },
    stories: [
      {
        id: 'sale',
        mediaType: 'image',
        src: '/sale.jpg',
        title: 'Spring sale',
        ctaHref: '/sale',
      },
    ],
  },
];
</script>

<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Annotate the scope with your own item type to keep the slot typed. -->
    <template #slide="scope: SlideSlotScope<PromoStory>">
      <article class="promo">
        <h2>{{ scope.story.title }}</h2>
        <a v-if="scope.story.ctaHref" :href="scope.story.ctaHref">Shop now</a>
      </article>
    </template>
  </StoriesOverlay>
</template>

CSS Classes

Class names are plain (not scoped), so a stylesheet loaded after @reelkit/vue-stories-player/styles.css can override any of them. They are identical to @reelkit/react-stories-player, so a stylesheet written for one binding works for the other. 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-layerOverlayUI overlay container (header, progress). Inside every group slide with chrome-placement="group"
.rk-stories-ui-layer--hiddenOverlayUI hidden state (toggled by hide-ui-on-pause)
.rk-stories-errorOverlayError state (centered icon + text)
.rk-stories-error-textOverlayError message text
.rk-stories-nav-btnNavigationDesktop prev/next arrow
.rk-stories-progress-barCanvasProgressBarCanvas 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
.rk-stories-header-avatarStoryHeaderAuthor avatar image
.rk-stories-header-nameStoryHeaderAuthor name text
.rk-stories-header-verifiedStoryHeaderVerified badge
.rk-stories-header-timeStoryHeaderTime-ago text
.rk-stories-header-actionsStoryHeaderRight-side actions (sound, pause, close)
.rk-stories-header-btnStoryHeaderHeader action button
.rk-stories-header-btn--desktopStoryHeaderPause button, shown only on screens wider than 768px
.rk-stories-header-spinnerStoryHeaderLoading 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-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-frameCarouselA story with no poster or image, drawn by the #slide slot at player size and scaled down
.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 colour, size, z-index and transition lives in a CSS custom property, with its default on :root. Override one or many at :root, or on any ancestor of the overlay, to retheme without touching component source. The tokens match @reelkit/react-stories-player, so a theme ports between bindings.

TokenDefaultControls
--rk-stories-overlay-bg#000Full-screen backdrop color
--rk-stories-overlay-z9999Overlay z-index
--rk-stories-container-radius12pxRounded corners on the story canvas (desktop)
--rk-stories-container-radius-mobile0Story canvas corners on a phone, up to 768px wide
--rk-stories-swipe-gap16pxGap between nav buttons and the story canvas
--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-ui-transition200msFade duration when hide-ui-on-pause toggles
--rk-stories-ui-z15UI layer z-index (progress bar and header)
--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-nav-z20Desktop nav button 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-text-size13pxError message font size
--rk-stories-error-gap12pxGap between the error icon and its text
--rk-stories-error-z5Error state z-index
--rk-stories-video-bg#000Letterbox background behind <video>
--rk-stories-video-poster-transition200msPoster fade duration when the video starts playing
--rk-stories-progress-bar-padding8px 8px 0Padding around the progress bar
--rk-stories-progress-bar-z10Progress bar 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-z5Header z-index
--rk-stories-header-transition200msHeader fade duration when it hides
--rk-stories-header-gap8pxSpacing between the avatar, name and time
--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-btn-fg#fffHeader action icon color (close, mute, pause)
--rk-stories-header-btn-padding4pxPadding inside a header action button
--rk-stories-header-actions-gap8pxSpacing between header action buttons
--rk-stories-header-spinner-size20pxLoading spinner diameter
--rk-stories-header-spinner-fg#fffLoading spinner arc color
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Loading spinner track color
--rk-stories-header-spinner-duration0.8sLoading spinner rotation duration
--rk-stories-heart-duration800msPop-in/fade-out animation duration
--rk-stories-heart-z20Double-tap heart z-index
--rk-stories-ring-spin-duration4sRotation duration of a ring with stories left to watch
--rk-stories-ring-active-scale0.95Ring scale while it is pressed
--rk-stories-ring-gradientnoneInternal: the ring 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-gap4pxSpacing 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
css
/* Loaded after @reelkit/vue-stories-player/styles.css. Every token has a
   default on :root, so override only what you want to change. The names
   match @reelkit/react-stories-player, 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 aria-label 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 gets pulled back. Implemented with captureFocusForReturn and createFocusTrap from @reelkit/core, re-exported by @reelkit/vue.

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 overlay renders nothing while closed, and 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 it opens the previous group, where it was left; on the first group it does nothing.
ArrowRightNext story. Past a group's last story it opens the next group; on the last group it closes the player.
EscapeClose player