Vue Stories Player

Ein Stories-Overlay im Stil von Instagram für Vue 3 mit @reelkit/vue-stories-player. Es teilt die Engine @reelkit/stories-core mit dem Player für React, also sind Verhalten, CSS-Klassen und Theming-Tokens dieselben.

Live-Demo ansehen →

Funktionen

Verschachtelte Navigation
Antippen für die nächste Story, Wischen für die nächste Gruppe
Video-Stories
Autoplay mit Ton-Schalter
Automatisch weiter
Einstellbarer Timer pro Story
3D-Übergänge
Cube, Flip, Fade, Zoom, Slide
Fortschrittsbalken
Segmentierter Fortschritt auf Canvas
Bild und Video
Beide Medientypen
Virtualisiert
Nur 3 Slides im DOM
Like per Doppeltipp
Herz-Animation beim Doppeltippen
Desktop-Navigation
Pfeil-Buttons auf dem Desktop
Story-Ringe
Avatar-Ringe im Stil von Instagram
Generische Typen
StoryItem um eigene Daten erweitern
Scoped Slots
Jedes UI-Element anpassen
URL-State
Teilbare Links mit ?story=group.story
Gesehen-Status
Gesehene Ringe und Fortsetzen überstehen ein Neuladen

Installation

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

Importiere das Stylesheet einmal im Einstieg deiner App (oder in einer beliebigen Komponente):

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

Der Standard-Header und die Navigationspfeile nutzen lucide-vue-next für die Icons. Wenn du lieber eine andere Icon-Bibliothek verwendest, ersetze sie über die Scoped Slots #header und #navigation.

Vue 3.3 oder neuer

Die Overlays sind generische Komponenten, die Vue ab 3.3 unterstützt. In einem älteren Vue verwirft die Runtime ihre Props und Events, deshalb verlangt das Paket vue 3.3 oder neuer als Peer Dependency.

Schnellstart

StoriesOverlay rendert den Player im Vollbild. Kombiniere ihn mit StoriesRingList für die Einstiegspunkte im Stil von Instagram, übergib ein Array von StoriesGroup-Objekten und binde 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

Live-Demo ansehen →

StoriesUrlOverlay ist eine eigene Komponente, deren Öffnungszustand in der Adresszeile liegt. Beide Achsen stecken in einem Parameter — ?story=<group>.<story> —, also hat die laufende Story einen Link, den man teilen, als Lesezeichen speichern und mit dem Zurück-Button schließen kann. Erzeuge mit useOverlayUrlState und urlIndexTwoAxisKey einen Controller und gib ihn als controller weiter.

Eingebaute Keys

Stories haben zwei Achsen, also verteile einen Key für zwei Achsen in den Controller: urlIndexTwoAxisKey (Gruppe und Story nach Position) oder urlStableIdTwoAxisKey (die Gruppe nach einer stabilen id) — beide aus @reelkit/vue-stories-player re-exportiert. Siehe den Leitfaden zum URL-State und die 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>
  • Das Öffnen legt einen Verlaufseintrag an. Das Blättern durch Stories und der Wechsel zwischen Nutzern ersetzen ihn beide, also fügen N Navigationen keine Einträge hinzu und ein Schritt zurück schließt den Player immer. Zurück schließt; es blättert nicht durch Stories.
  • Die innere Navigation wird mitgeführt. Das Weiterschalten innerhalb der Stories eines Nutzers aktualisiert ?story=2.n, also landet ein Deep Link genau auf der Story.
  • Zurück schließt nur, wenn der Player aus der App heraus geöffnet wurde — der Link hat einen Eintrag angelegt. Ein geteilter Link, der direkt in einem neuen Tab geöffnet wird, hat keinen Verlauf dahinter, also verlässt Zurück im Browser die Seite; der Button ✕ oder Escape entfernt den Parameter an Ort und Stelle und bleibt.
  • Ein Parameter, der keine Gruppe oder Story nennt — ein veraltetes Lesezeichen, ein von Hand geänderter Wert, eine Story jenseits des Gruppenendes —, wird aus der URL entfernt, statt einen Nachbarn zu öffnen.
  • Eine App mit Router sollte einen Adapter auf Basis des Routers übergeben, damit der Router die einzige Quelle für die Navigation bleibt. useVueRouterUrlAdapter aus @reelkit/vue/vue-router-url-adapter ist der fertige für Vue Router; wer am Router vorbei in den Verlauf schreibt, lässt seine Location veralten und verliert den Parameter bei der nächsten Navigation.

Stabile Links. Die Gruppe ist standardmäßig positionsbasiert, also öffnet ein gespeichertes ?story=2.0 einen anderen Nutzer, sobald der Feed neu sortiert wird. Adressiere die Gruppe stattdessen über eine stabile id — outerCodec schreibt die id in die URL, outerLocator findet, wo sie steht. Übergib innerCodec mit innerLocate und innerIdentify, um dasselbe für die Story in ihrer Gruppe zu tun; lässt du sie weg, bleibt die Story-Hälfte ein einfacher Index in der gefundenen Gruppe. Eine Story-id darf keinen . enthalten, das Trennzeichen zwischen den beiden Hälften.

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,
    },
  }),
});

Endlose Feeds. Das Nachladen ist Sache von outerLocator und unabhängig vom Codec. locate ist synchron und antwortet daher nur für bereits geladene Gruppen — ein geteilter Link auf Gruppe 400 eines Feeds, der 20 geladen hat, findet nichts. locateAsync ist der Rückfall, der nur aufgerufen wird, wenn locate nichts findet; die Story wird gegen die Gruppe neu begrenzt, bei der es landet.

Dasselbe locateAsync, äußere Achse

Das ist derselbe Pager locateAsync, den die Keys für eine Achse nehmen — bei einem Key für zwei Achsen hängt er am outerLocator, den du übergibst, also lädt die Gruppenachse nach, während die Story ein lokaler Index in der gefundenen Gruppe bleibt.

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;
  },
},
  • Solange locateAsync aussteht, bleibt der Player geschlossen und der Parameter unangetastet, damit der Deep Link das Laden übersteht. Ein null oder eine Ablehnung entfernt den Parameter.
  • Eine Antwort, die eintrifft, nachdem die URL weitergewandert ist, nach dem Schließen oder nach dem Unmount, wird verworfen — ein langsamer Abruf kann keine Story öffnen, die niemand angefragt hat.
  • Alle Optionen von useOverlayUrlState stehen in der API-Referenz für Vue.

Merken, was gesehen wurde

Ringe zeigen einen Verlauf, bis eine Gruppe bis zum Ende angesehen wurde, und eine Gruppe öffnet sich wieder bei der ersten Story, die noch nicht gesehen wurde. Beides kommt aus einem createStoriesViewedStateController, einmal dort erzeugt, wo der Feed lebt, und der Ringliste und dem Player als viewed übergeben: Beide lesen den Speicher, folgen ihm und erfassen selbst, was gezeigt wird. Ein gespeicherter Eintrag nennt Autor und Story per id, also übersteht er eine neue Sortierung des Feeds, und derselbe Controller funktioniert in React und 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>
  • Die erste Story zählt. Die Story, die schon auf dem Bildschirm ist, wird als gesehen gemeldet, sobald der Player gemountet ist, also gilt eine Gruppe mit einer Story nach Öffnen und Schließen als angesehen.
  • Auch das Weiterwischen setzt fort. resumeStoryIndex wird für jede Gruppe befragt, die in dieser Sitzung zum ersten Mal erreicht wird, die Startgruppe eingeschlossen, außer initialStoryIndex nennt ausdrücklich eine Story; eine schon durchgewischte Gruppe öffnet sich dort, wo sie verlassen wurde.
  • Ein Link gewinnt trotzdem. Mit StoriesUrlOverlay entscheidet der Parameter, wo der Player sich öffnet, egal was gespeichert ist. Überall sonst entscheidet der Resume-Callback.
  • Ein Zähler ist eine Position, keine Summe. Ein Eintrag nennt die weiteste erreichte Story, also leuchtet der Ring einer angesehenen Gruppe wieder auf, wenn eine Story dazukommt, und das Entfernen einer Story aus der Mitte verkürzt den Zähler.
  • Der Speicher ist austauschbar. Übergib storage: createSessionStorageAdapter(), um beim Schließen des Tabs zu vergessen, oder deinen eigenen StorageAdapter. Zwei offene Tabs bleiben über das Storage-Event des Browsers im Gleichschritt.
  • Ein Player, der erst beim Öffnen gemountet wird, liest zu spät. Das Overlay liest den Speicher, sobald es gemountet ist, solange es noch geschlossen ist. Mit v-if="open" gibt es diesen Moment nicht, also rufe useAttachViewedState(() => viewed) in der Komponente auf, der der Controller gehört; es verbindet sich nach dem Mount und löst sich beim Unmount. Die Optionen und die Bausteine darunter stehen auf der Seite Stories Core.

Setze desktop-layout="carousel", um den Player wie den Web-Viewer von Instagram anzuordnen: Die aktive Story bleibt in normaler Größe in der Mitte, und bis zu zwei benachbarte Gruppen liegen auf jeder Seite als kleinere, abgedunkelte Karten. Ein Klick auf eine Karte öffnet diese Gruppe, und die Karten gleiten beim Gruppenwechsel hinüber. Übergib denselben Controller viewed, den die Ringliste nimmt, und die Karte einer angesehenen Gruppe bekommt den gedämpften Ring. Die Karten folgen ihm selbst, also zeichnen nur sie sich neu, wenn eine Story als gesehen markiert wird, und der Player rendert nicht erneut.

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>
  • Auf dem Smartphone bleibt der einfache Player. Bis 768px Breite werden die Karten nicht gerendert, und Wischgesten nutzen group-transition, wie beim Standard-Layout 'single'. Eine Größenänderung über diese Breite hinweg schaltet sofort um.
  • Jede Karte zeigt, wo sich die Gruppe öffnet: die Story, bei der sie in dieser Sitzung verlassen wurde, sonst das, was resumeStoryIndex zurückgibt. Sie zeigt das Video-Poster oder das Bild. Eine Story mit keinem von beiden, etwa eine Text-Story auf einem Verlauf, wird von deinem Slot #slide in Player-Größe gezeichnet und verkleinert; ein Video ohne Poster zeigt den Hintergrund der Karte. Diese Vorschau erhält isActive: false und ist nur ein Bild: Nichts darin lässt sich fokussieren oder anklicken. Lege alles, was ein Slide beim Mount tut, etwa Wiedergabe oder Analytics, hinter isActive.
  • Der Timer wartet auf den Slide. Die Story schaltet nicht weiter, und story-viewed feuert für sie nicht, bis die Karten fertig gleiten.
  • Jeder Weg des Gruppenwechsels gleitet: ein Klick auf eine Karte, StoriesApi.goToGroup, nextGroup und prevGroup sowie die Pfeiltasten über das Ende einer Gruppe hinaus. Eine Wischgeste bewegt den Player selbst, also gleitet darauf nichts zusätzlich.

Fortschrittsbalken und Header pro Gruppe

Standardmäßig liegen ein Fortschrittsbalken und ein Header über dem Player und wechseln zur neuen Gruppe, sobald sie gewechselt hat. Setze chrome-placement="group", damit jede Gruppe ihre eigenen bekommt, gezeichnet in ihrem Slide, sodass sich beide mit der Gruppe drehen, wie bei 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>
  • Eine Nachbargruppe zeigt, wo sie steht. Während der Player zu ihr dreht, zeigen ihr Balken und ihr Header die Story, bei der sie sich öffnen wird, ohne dass davon schon etwas abgespielt ist.
  • Die verlassene Gruppe hält still. Ihr Balken behält seinen Fortschritt bis zum Ende der Drehung, also bleibt eine bis zum Ende gespielte Gruppe voll, statt sich mitten in der Drehung zu leeren.
  • Die Slots werden für jede sichtbare Gruppe gerendert. Die Scopes von #progressBar und #header tragen groupIndex und isActive; für eine Nachbargruppe ist isActive gleich false, und die Fortschrittssignale halten still.
  • Ein Slot #header liegt im Wischbereich. Ein Tippen darauf wechselt zwischen Stories, außer es trifft einen button, einen Link oder ein Element mit role="button", also mach jedes Control darin zu einem davon.
  • Das Desktop-Karussell ist während des Gleitens nicht betroffen. Der Player ist verborgen, während die Karten gleiten, also zeigt sich die Wahl, sobald die Gruppe offen ist.

API-Referenz

Props von StoriesOverlay

StoriesOverlayProps<T>

PropTypStandardBeschreibung
aria-labelstring'Stories player'Zugängliches Label für den Dialogbereich; Screenreader sagen es beim Öffnen des Overlays an
chrome-placement'overlay' | 'group''overlay'Wo Fortschrittsbalken und Header liegen. 'overlay' zeichnet eine Kopie über dem Player; 'group' zeichnet je eine in jedem Gruppen-Slide, sodass sich beide mit der Gruppe drehen. Der Typ wird als ChromePlacement exportiert.
default-image-durationnumber5000Dauer bis zum Weiterschalten bei einer Bild-Story, in Millisekunden
desktop-layout'single' | 'carousel''single'Layout auf dem Desktop. 'carousel' zeigt benachbarte Gruppen als Vorschaukarten neben der aktiven Story und gleitet zwischen den Gruppen. Smartphones zeigen die Story immer allein. Der Typ wird als DesktopLayout exportiert.
enable-keyboardbooleantrueAktiviert die Tastaturnavigation (Pfeile links/rechts, Escape)
groupsStoriesGroup<T>[]requiredAnzuzeigende Story-Gruppen. Gruppen oder Stories, die bei offenem Player hinzukommen, werden übernommen, egal ob das Array ersetzt oder erweitert wird.
group-transitionTransitionTransformFncubeTransitionÜbergang für den äußeren Slider (Gruppen). Wird ignoriert, solange das Desktop-Karussell sichtbar ist, das stattdessen gleitet.
hide-ui-on-pausebooleantrueBlendet Fortschrittsbalken und Header aus, solange ein langes Drücken pausiert
initial-group-indexnumber0Nullbasierter Index der zuerst sichtbaren Gruppe
initial-story-indexnumberfortsetzen, sonst 0Nullbasierter Index der zuerst sichtbaren Story. Nennst du eine, gewinnt sie über alles Gemerkte; lässt du es weg, setzt die Startgruppe fort wie jede andere.
inner-transition-durationnumber200Dauer der Überblendung zwischen Stories, in Millisekunden
is-openbooleanrequiredSteuert die Sichtbarkeit und sperrt das Scrollen des Body, solange offen. Binde es mit v-model:is-open.
min-segment-widthnumber8Mindestbreite eines Fortschrittssegments in Pixeln, bevor das gleitende Fenster greift
resume-story-index(groupIndex: number) => number—Bei welcher Story sich eine Gruppe beim ersten Erreichen öffnet. Wird nur für eine in diesem Öffnen noch nicht besuchte Gruppe befragt und gewinnt über viewed.
tap-zone-splitnumber0.3Aufteilung der Tippzonen (0–1). Der linke Teil geht zurück, der Rest vorwärts.
viewedStoriesViewedStateController—Was gesehen wurde, aus createStoriesViewedStateController(). Gruppen setzen bei ihrer ersten ungesehenen Story fort, jede gezeigte Story wird erfasst, und Karussellkarten dämpfen den Ring einer bis zum Ende angesehenen Gruppe. Gib denselben Controller an StoriesRingList.

Props von StoriesUrlOverlay

StoriesUrlOverlayProps<T>

Nimmt jedes Prop von oben außer dem Trio für den Öffnungszustand — is-open, initial-group-index, initial-story-index —, das stattdessen vom Controller kommt.

PropTypStandardBeschreibung
controllerUrlStateController<TwoAxisPosition>requiredController aus useOverlayUrlState, verteilt mit urlIndexTwoAxisKey. Seine position — ein Objekt { outer, inner } — entscheidet, ob der Player offen ist und wo er sich öffnet; das Overlay schreibt bei jeder Navigation und beim Schließen zurück.

Events

EventPayloadBeschreibung
@api-readyStoriesApiWird gesendet, sobald der Player gemountet ist, mit der imperativen API
@closevoidWird gesendet, wenn der Player schließt. Bei StoriesUrlOverlay ist der Parameter dann schon entfernt.
@double-tapgroupIndex: number, storyIndex: numberWird bei einem Doppeltipp gesendet, zusammen mit der Herz-Animation
@group-changegroupIndex: numberWird gesendet, nachdem die aktive Gruppe gewechselt hat
@pausevoidWird bei einem langen Drücken oder beim Pause-Button im Header gesendet
@resumevoidWird beim Loslassen oder beim Play-Button im Header gesendet
@story-changegroupIndex: number, storyIndex: numberWird gesendet, nachdem die aktive Story gewechselt hat
@story-completegroupIndex: number, storyIndex: numberWird gesendet, wenn der Timer einer Story abläuft
@story-viewedgroupIndex: number, storyIndex: numberWird gesendet, wenn eine Story sichtbar wird, die erste eingeschlossen
@update:is-openbooleanWird beim Schließen gesendet; ermöglicht `v-model:is-open`

v-model:is-open

Mit v-model:is-open steuerst du das Overlay über eine einzige Bindung. Das Paar :is-open + @close funktioniert weiterhin, wenn du das explizite Event brauchst.

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

Acht Scoped Slots ersetzen jeden Teil des Players. Jeder erhält ein typisiertes Scope-Objekt. Ein weggelassener Slot behält den Standard — ebenso einer, der nichts rendert, also fällt ein v-if, das false wird, auf den Standard zurück, statt eine Lücke zu lassen.

SlotScopeBeschreibung
#error{ story, storyIndex, groupIndex }Ersetzt das eingebaute Panel "Content unavailable", das über der aktiven Story liegt
#footer{ author, story, storyIndex }Fügt unter der Story der aktiven Gruppe einen Footer hinzu
#groupPreview{ group, groupIndex, story, offset, viewedCount, onOpen }Füllt eine Karte des Desktop-Karussells; der Player positioniert, skaliert und bewegt sie weiterhin
#header{ author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }Ersetzt den Standard-Header (Autor, Pause, Ton, Schließen). Mit chrome-placement="group" liegt er im Wischbereich; siehe Fortschrittsbalken und Header pro Gruppe.
#loading{ story, storyIndex, groupIndex }Ersetzt den Standard-Spinner im Header, solange die aktive Story lädt
#navigation{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }Ersetzt beide Pfeile auf dem Desktop
#progressBar{ totalStories, activeIndex, progress, group, groupIndex, isActive }Ersetzt den Canvas-Fortschrittsbalken. activeIndex und progress sind Core-Signale — überbrücke sie mit toVueRef.
#slide{ story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }Ersetzt die Standard-Slides für Bild und Video
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>

Eigene Slides

Setze ImageStorySlide und VideoStorySlide in #slide ein, um Autoplay, Poster-Erfassung und Ton-Synchronisation zu behalten und trotzdem dein eigenes Layout zu zeichnen. Rufe die Lifecycle-Callbacks aus dem Scope auf, sonst startet der Timer nie:

CallbackWann aufrufen
onReadyDas Bild ist geladen oder das Video spielt. Beendet den Ladezustand und startet den Timer.
onWaitingDas Video puffert mitten in der Wiedergabe. Zeigt den Spinner und pausiert den Timer.
onErrorDer Inhalt konnte nicht geladen werden. Zeigt den Fehlerzustand, und die URL wird als defekt gemerkt, damit ein erneutes Öffnen keinen neuen Versuch startet.
onDurationReadyMeldet die echte Medienlänge in Millisekunden, damit der Timer so lange läuft wie das Video.
onEndedDas Medium ist zu Ende. Schaltet zur nächsten Story weiter.
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>
Caching im Preloader

Die eingebauten Komponenten ImageStorySlide und VideoStorySlide laden die nächste Story im Hintergrund vor. Erreicht man eine vorgeladene Story, erscheint der Inhalt sofort, ohne Ladespinner. Auch eine URL, die einmal fehlgeschlagen ist, wird gemerkt, also zeigt eine defekte Story beim erneuten Öffnen sofort den Fehler, statt es noch einmal zu versuchen.

Eigene 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>

Eigener Fortschrittsbalken

Der Scope trägt Core-Signale statt einfacher Zahlen, damit der Timer den Balken antreiben kann, ohne den Player neu zu rendern. Überbrücke sie mit toVueRef aus @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>

Eigenes Laden und eigene Fehler

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>

Übergänge

group-transition steuert den 3D-Effekt beim Wechsel zwischen Nutzern. Importiere die Übergangsfunktionen aus @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

Setze eine Template-Ref auf das Overlay für die Steuerung aus dem Code, oder nimm die API aus dem Event @api-ready. Aufrufe bei geschlossenem Player bewirken nichts.

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>

Methoden

MethodeTypBeschreibung
nextStory()() => voidSchaltet zur nächsten Story in der aktuellen Gruppe
prevStory()() => voidGeht zur vorherigen Story in der aktuellen Gruppe
nextGroup()() => voidWechselt zur nächsten Nutzergruppe
prevGroup()() => voidWechselt zur vorherigen Nutzergruppe
goToGroup(index)(index: number) => voidSpringt per Index zu einer bestimmten Gruppe
pause()() => voidPausiert das automatische Weiterschalten und den Fortschritts-Timer
resume()() => voidSetzt das automatische Weiterschalten und den Fortschritts-Timer fort

Doppeltipp und Likes

Beim Doppeltippen spielt eine Herz-Animation und gibt sofort sichtbares Feedback. Das Event @double-tap feuert mit dem Index von Gruppe und Story, damit du das Like in deinem eigenen Zustand speichern kannst. Der Player selbst speichert keine Likes.

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>

Die Herz-Animation anpassen

Die Geschwindigkeit stellst du mit dem Token --rk-stories-heart-duration ein (siehe Theming). Für Farbe, Größe oder um das Herz ganz auszublenden, sprich die Klasse .rk-stories-heart an. Die Komponente HeartAnimation wird für die eigenständige Nutzung exportiert.

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;
}

Die eingebaute Herz-Animation lässt sich noch nicht über einen Slot ersetzen. Gestalte sie mit CSS um, oder blende sie mit display: none aus und starte deine eigene Animation über das Event @double-tap. Wenn du einen Slot heart brauchst, sag es uns über GitHub Issues.

Unterkomponenten

Exportierte Bausteine, zum Zusammensetzen in den Slots oder für die eigenständige Nutzung: StoriesRing, StoriesRingList, StoryHeader, CanvasProgressBar, ImageStorySlide, VideoStorySlide und HeartAnimation.

KomponenteWas sie ist
CanvasProgressBarDer segmentierte Fortschrittsbalken auf Canvas, direkt aus den Timer-Signalen gezeichnet. Hat ein gleitendes Fenster für Gruppen mit vielen Stories. Mit :live="false" zeichnet er nur, wenn sich seine Signale ändern, und nach einer Größenänderung, ohne Animationsschleife.
HeartAnimationDas Herz beim Doppeltippen. Sendet complete, wenn die Animation endet, damit die Elternkomponente es entfernen kann.
ImageStorySlideBild-Slide über die volle Fläche mit object-fit: cover, meldet Laden und Fehler über Callbacks.
StoriesRingRunder Avatar mit dem Verlaufsring. Zwei Zustände: Ist noch etwas anzusehen, dreht er sich, vollständig angesehen wird er flach. Sendet click.
StoriesRingListHorizontale Reihe von Ringen mit den Namen der Autoren, einer pro Gruppe. Sendet select mit dem Gruppenindex.
StoryHeaderDer Standard-Header: Avatar, Name, Verifiziert-Abzeichen, relative Zeit, Pause, Ton, Schließen und der Ladespinner.
VideoStorySlideVideo-Slide auf Basis des einen gemeinsamen <video>-Elements, für durchgehenden Ton unter iOS. Kümmert sich um Autoplay, Posterbilder und das Melden der Dauer.
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-Exporte

Alles, was eine App braucht, kommt aus diesem Paket, also ist ein direkter Import aus @reelkit/core nie nötig.

AusRe-exportiert
@reelkit/vueObserve, SoundProvider, useSoundState, useOverlayUrlState, createViewedStateController, twoAxisViewedTracking, createLocalStorageAdapter, createSessionStorageAdapter, createMemoryStorageAdapter, urlIndexTwoAxisKey, urlStableIdTwoAxisKey, base64UrlCodec und die Typen UrlAdapter, UrlCodec, UrlLocator, UrlKey, UrlStateController, TwoAxisPosition, TwoAxisIdentity, UrlIndexTwoAxisKeyOptions, OverlayUrlStateOptions, ViewedStateController, ViewedStateOptions, StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController, StoriesViewedStateController, StoriesViewedStateControllerConfig und die Inhaltstypen StoryItem, AuthorInfo, StoriesGroup, MediaType

Observe ist die Render-Grenze, die der Player intern nutzt: Observe rendert nur seinen Slot neu, wenn sich ein Core-Signal ändert, und so zeichnet ein Story-Wechsel Header und Fortschrittsbalken neu, ohne die Slider neu zu zeichnen. Nutze Observe in einem eigenen Slot, wenn eine ganze Komponente, die dem Signal über toVueRef folgt, zu grob wäre — siehe die API-Referenz für Vue.

Auch der Props-Typ jeder Komponente wird exportiert: StoriesOverlayProps, StoriesUrlOverlayProps, StoriesRingProps, StoriesRingListProps, StoryHeaderProps, CanvasProgressBarProps, ImageStorySlideProps und VideoStorySlideProps.

Typen

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

Jeder Scope-Typ wird exportiert, also lässt sich ein Slot dort annotieren, wo der Compiler ihn nicht ableiten kann — HeaderSlotScope, FooterSlotScope, SlideSlotScope, NavigationSlotScope, ProgressBarSlotScope, LoadingSlotScope, ErrorSlotScope und GroupPreviewSlotScope, neben 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;
}

Eigene Story-Typen

Erweitere StoryItem um deine eigenen Felder und typisiere die Gruppen damit. StoriesOverlay und StoriesUrlOverlay sind generisch, wie der Player für React: Der Story-Typ wird aus groups abgeleitet, also fallen Gruppen, deren Stories nicht zu StoryItem passen, bei der Typprüfung durch. Slots können diesen Typ in Vue nicht tragen, also bleiben ihre Scopes untypisiert, bis du sie annotierst. Annotiere einen Slot-Scope, damit die zusätzlichen Felder im Template sichtbar bleiben. Nichts prüft die Annotation gegen groups, also halte beide im Gleichschritt:

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-Klassen

Die Klassennamen sind normal (nicht scoped), also kann ein Stylesheet, das nach @reelkit/vue-stories-player/styles.css geladen wird, jede davon überschreiben. Sie sind identisch mit @reelkit/react-stories-player, also funktioniert ein Stylesheet für das eine Binding auch für das andere. Für Farben, Größen und z-index nimm lieber die Custom Properties aus Theming.

KlasseKomponenteBeschreibung
.rk-stories-overlayOverlayFester Hintergrund im Vollbild (Hintergrund, z-index)
.rk-stories-overlay--carouselOverlayOverlay mit sichtbarem Desktop-Karussell
.rk-stories-overlay--slidingOverlayKarussell gleitet gerade; die Fläche des Players ist verborgen
.rk-stories-swipe-wrapperOverlayWrapper für Wischen zum Schließen (enthält Navigationsbuttons + Fläche)
.rk-stories-containerOverlayAbgerundete Story-Fläche (Position, Overflow)
.rk-stories-ui-layerOverlayContainer der UI-Ebene (Header, Fortschritt). Mit chrome-placement="group" in jedem Gruppen-Slide
.rk-stories-ui-layer--hiddenOverlayAusgeblendete UI (geschaltet über hide-ui-on-pause)
.rk-stories-errorOverlayFehlerzustand (zentriertes Icon + Text)
.rk-stories-error-textOverlayText der Fehlermeldung
.rk-stories-nav-btnNavigationPfeil zurück/weiter auf dem Desktop
.rk-stories-progress-barCanvasProgressBarPositionierungs-Wrapper des Canvas-Fortschrittsbalkens
.rk-stories-slide-wrapperGroupEine Gruppe von Stories (äußerer Slide)
.rk-stories-storyStoryEine einzelne Story (Wurzel des inneren Slides)
.rk-stories-headerStoryHeaderHeader-Leiste (Avatar, Name, Aktionen)
.rk-stories-header--hiddenStoryHeaderAusgeblendeter Header
.rk-stories-header-avatarStoryHeaderAvatarbild des Autors
.rk-stories-header-nameStoryHeaderName des Autors
.rk-stories-header-verifiedStoryHeaderVerifiziert-Abzeichen
.rk-stories-header-timeStoryHeaderText mit der vergangenen Zeit
.rk-stories-header-actionsStoryHeaderAktionen rechts (Ton, Pause, Schließen)
.rk-stories-header-btnStoryHeaderAktions-Button im Header
.rk-stories-header-btn--desktopStoryHeaderPause-Button, nur auf Bildschirmen breiter als 768px
.rk-stories-header-spinnerStoryHeaderLadespinner
.rk-stories-imageImageStorySlideElement einer Bild-Story
.rk-stories-videoVideoStorySlideContainer einer Video-Story
.rk-stories-video-elementVideoStorySlideDas gemeinsame Element <video>
.rk-stories-video-posterVideoStorySlidePosterbild des Videos (blendet beim Abspielen aus)
.rk-stories-video-poster--visibleVideoStorySlideSichtbares Poster (vor der Wiedergabe)
.rk-stories-heartHeartAnimationHerz-Animation beim Doppeltippen
.rk-stories-ringStoriesRingStory-Ring (Avatar mit animiertem Verlaufsrand)
.rk-stories-ring--activeStoriesRingRing mit noch anzusehenden Stories (animiert)
.rk-stories-ring-avatarStoriesRingAvatarbild im Ring
.rk-stories-ring-listStoriesRingListContainer der horizontalen Ringliste
.rk-stories-ring-list-itemStoriesRingListSpalte aus Ring + Name
.rk-stories-ring-list-nameStoriesRingListName des Autors unter jedem Ring
.rk-stories-carouselCarouselEbene der Vorschaukarten hinter dem Player
.rk-stories-carousel--instantCarouselErster Frame eines Gleitens, ohne Übergang angeordnet
.rk-stories-cardCarouselEine positionierte Vorschaukarte
.rk-stories-card--centerCarouselKarte im mittleren Platz während eines Gleitens
.rk-stories-card--hiddenCarouselKarte jenseits der zwei sichtbaren auf einer Seite
.rk-stories-card-buttonCarouselStandardinhalt der Karte (der Button, der die Gruppe öffnet)
.rk-stories-card-imageCarouselVorschaubild (Poster oder Bild)
.rk-stories-card-frameCarouselEine Story ohne Poster oder Bild, vom Slot #slide in Player-Größe gezeichnet und verkleinert
.rk-stories-card-scrimCarouselAbdunkelnde Ebene über dem Frame
.rk-stories-card-infoCarouselSpalte aus Ring, Name des Autors und Zeit
.rk-stories-card-nameCarouselName des Autors
.rk-stories-card-timeCarouselText mit der vergangenen Zeit

Theming

Jede Farbe, Größe, jeder z-index und jeder Übergang liegt in einer CSS Custom Property, mit dem Standardwert auf :root. Überschreibe eine oder viele auf :root oder auf einem beliebigen Vorfahren des Overlays, um das Aussehen zu ändern, ohne den Quellcode der Komponenten anzufassen. Die Tokens stimmen mit @reelkit/react-stories-player überein, also lässt sich ein Theme zwischen den Bindings übertragen.

TokenStandardSteuert
--rk-stories-overlay-bg#000Farbe des Hintergrunds im Vollbild
--rk-stories-overlay-z9999z-index des Overlays
--rk-stories-container-radius12pxAbgerundete Ecken der Story-Fläche (Desktop)
--rk-stories-container-radius-mobile0Ecken der Story-Fläche auf dem Smartphone, bis 768px Breite
--rk-stories-swipe-gap16pxAbstand zwischen Navigationsbuttons und Story-Fläche
--rk-stories-top-shade-height120pxHöhe des oberen Verlaufs hinter dem Header
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Farbe des oberen Verlaufs
--rk-stories-ui-transition200msDauer des Ausblendens, wenn hide-ui-on-pause umschaltet
--rk-stories-ui-z15z-index der UI-Ebene (Fortschrittsbalken und Header)
--rk-stories-nav-size44pxGröße der Buttons zurück/weiter auf dem Desktop
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)Hintergrund der Navigationsbuttons auf dem Desktop
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)Hover-Hintergrund der Navigationsbuttons auf dem Desktop
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)Icon-Farbe der Navigationsbuttons auf dem Desktop
--rk-stories-nav-fg-hover#fffHover-Icon-Farbe der Navigationsbuttons auf dem Desktop
--rk-stories-nav-transition150msHover-Übergang der Navigationsbuttons auf dem Desktop
--rk-stories-nav-z20z-index der Navigationsbuttons auf dem Desktop
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)Verlauf im Hintergrund des Fehlerzustands
--rk-stories-error-fgrgba(255, 255, 255, 0.5)Farbe von Fehlersymbol und Text
--rk-stories-error-text-size13pxSchriftgröße der Fehlermeldung
--rk-stories-error-gap12pxAbstand zwischen Fehlersymbol und Text
--rk-stories-error-z5z-index des Fehlerzustands
--rk-stories-video-bg#000Hintergrund der Letterbox hinter <video>
--rk-stories-video-poster-transition200msDauer des Ausblendens des Posters, wenn das Video startet
--rk-stories-progress-bar-padding8px 8px 0Innenabstand um den Fortschrittsbalken
--rk-stories-progress-bar-z10z-index des Fortschrittsbalkens
--rk-stories-header-top18pxVertikaler Abstand des Headers vom oberen Rand der Story
--rk-stories-header-padding12px 16pxInnenabstand der Header-Zeile
--rk-stories-header-z5z-index des Headers
--rk-stories-header-transition200msDauer des Ausblendens des Headers
--rk-stories-header-gap8pxAbstand zwischen Avatar, Name und Zeit
--rk-stories-header-avatar-size32pxBreite/Höhe des Avatars
--rk-stories-header-name-fg#fffFarbe des Autorennamens
--rk-stories-header-name-size14pxSchriftgröße des Autorennamens
--rk-stories-header-name-weight600Schriftstärke des Autorennamens
--rk-stories-header-time-fgrgba(255, 255, 255, 0.6)Farbe der vergangenen Zeit
--rk-stories-header-time-size12pxSchriftgröße der vergangenen Zeit
--rk-stories-header-btn-fg#fffIcon-Farbe der Header-Aktionen (Schließen, Ton, Pause)
--rk-stories-header-btn-padding4pxInnenabstand eines Aktions-Buttons im Header
--rk-stories-header-actions-gap8pxAbstand zwischen den Aktions-Buttons im Header
--rk-stories-header-spinner-size20pxDurchmesser des Ladespinners
--rk-stories-header-spinner-fg#fffFarbe des Bogens im Ladespinner
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Farbe der Spur im Ladespinner
--rk-stories-header-spinner-duration0.8sRotationsdauer des Ladespinners
--rk-stories-heart-duration800msDauer der Animation zum Einblenden und Ausblenden
--rk-stories-heart-z20z-index des Herzens beim Doppeltippen
--rk-stories-ring-spin-duration4sRotationsdauer eines Rings mit noch anzusehenden Stories
--rk-stories-ring-active-scale0.95Skalierung des Rings, solange er gedrückt wird
--rk-stories-ring-active-gradientconic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433)Was ein Ring mit noch anzusehenden Stories zeigt, ein beliebiges CSS-Bild oder eine Farbe. Auf einer hellen Seite setze es auf .rk-stories-ring-list, nicht auf :root, damit das Karussell des Players sein eigenes behält
--rk-stories-ring-viewed-colorrgba(255, 255, 255, 0.25)Was ein vollständig angesehener Ring zeigt, gedacht für eine dunkle Seite. Auf einer hellen Seite setze es auf .rk-stories-ring-list, nicht auf :root
--rk-stories-ring-gradientnoneIntern: Der Ring schreibt es bei jedem Rendern und verweist auf die beiden Tokens oben, außer der Ring bekommt gradientColors oder viewedColor
--rk-stories-ring-list-gap12pxAbstand zwischen den Ringen in der Liste
--rk-stories-ring-list-padding12pxInnenabstand um die Ringliste
--rk-stories-ring-list-item-gap4pxAbstand zwischen einem Ring und seinem Namen
--rk-stories-ring-list-name-size12pxSchriftgröße des Autorennamens unter jedem Ring
--rk-stories-card-bg#262626Hintergrund der Karussellkarte, sichtbar ohne Vorschaubild
--rk-stories-card-radius8pxEckenradius der Karussellkarte
--rk-stories-card-scrimrgba(0, 0, 0, 0.45)Abdunkelung über einer seitlichen Karte
--rk-stories-card-fg#fffTextfarbe der Karte
--rk-stories-card-name-size14pxSchriftgröße des Autorennamens auf der Karte
--rk-stories-card-time-fgrgba(255, 255, 255, 0.7)Farbe der vergangenen Zeit auf der Karte
--rk-stories-card-time-size13pxSchriftgröße der vergangenen Zeit auf der Karte
--rk-stories-card-gap6pxAbstand zwischen Ring, Name und Zeit auf einer Karte
--rk-stories-card-transition300msDauer des Gleitens im Karussell
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;
}

/* Rings on a light page. Set on the ring list, not :root, so the rings of the
   player's own carousel keep the colours made for its dark backdrop. */
.rk-stories-ring-list {
  --rk-stories-ring-viewed-color: #d4d4d8;
}

Barrierefreiheit

Die Wurzel des Overlays ist ein modaler Dialog (role="dialog", aria-modal="true"). Mit aria-label änderst du die Ansage für Screenreader; Standard ist "Stories player".

Das Overlay übernimmt beim Öffnen den Fokus und gibt ihn beim Schließen an den Auslöser zurück. Tab und Shift+Tab wechseln zwischen den fokussierbaren Elementen darin; Fokus, der entwischt, wird zurückgeholt. Umgesetzt mit captureFocusForReturn und createFocusTrap aus @reelkit/core, re-exportiert von @reelkit/vue.

Mit dem Desktop-Karussell ist jede Standardkarte ein Button mit dem Label "Open stories by" und dem Namen des Autors. Die Karten folgen in der Tab-Reihenfolge nach den Controls des Players und fallen während eines Gleitens aus ihr heraus; eine Karte, die ihre Gruppe öffnet, gibt den Fokus an den Dialog zurück, statt ihn auf der Seite fallen zu lassen.

Serverseitiges Rendern

Das Overlay rendert nichts, solange es geschlossen ist, und StoriesRingList rendert auf dem Server jeden Ring als ungesehen: Der Speicher für den Gesehen-Status wird erst nach dem Mount gelesen, also stimmen das Server-Markup und das erste Rendern im Client überein. Siehe den Leitfaden zu SSR für das größere Bild.

Tastenkürzel

TasteAktion
ArrowLeftVorherige Story. Auf der ersten Story einer Gruppe öffnet sie die vorherige Gruppe dort, wo sie verlassen wurde; in der ersten Gruppe passiert nichts.
ArrowRightNächste Story. Nach der letzten Story einer Gruppe öffnet sie die nächste Gruppe; in der letzten Gruppe schließt sie den Player.
EscapePlayer schließen