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.
Funktionen
Installation
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-nextImportiere das Stylesheet einmal im Einstieg deiner App (oder in einer beliebigen Komponente):
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.
<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.
<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.
useVueRouterUrlAdapteraus@reelkit/vue/vue-router-url-adapterist 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.
// `?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.
// 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
locateAsyncaussteht, bleibt der Player geschlossen und der Parameter unangetastet, damit der Deep Link das Laden übersteht. Einnulloder 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
useOverlayUrlStatestehen 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.
<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.
resumeStoryIndexwird für jede Gruppe befragt, die in dieser Sitzung zum ersten Mal erreicht wird, die Startgruppe eingeschlossen, außerinitialStoryIndexnennt ausdrücklich eine Story; eine schon durchgewischte Gruppe öffnet sich dort, wo sie verlassen wurde. - Ein Link gewinnt trotzdem. Mit
StoriesUrlOverlayentscheidet 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 eigenenStorageAdapter. 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 rufeuseAttachViewedState(() => 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.
Desktop-Karussell
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.
<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
resumeStoryIndexzurü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#slidein Player-Größe gezeichnet und verkleinert; ein Video ohne Poster zeigt den Hintergrund der Karte. Diese Vorschau erhältisActive: falseund ist nur ein Bild: Nichts darin lässt sich fokussieren oder anklicken. Lege alles, was ein Slide beim Mount tut, etwa Wiedergabe oder Analytics, hinterisActive. - Der Timer wartet auf den Slide. Die Story schaltet nicht weiter, und
story-viewedfeuert für sie nicht, bis die Karten fertig gleiten. - Jeder Weg des Gruppenwechsels gleitet: ein Klick auf eine Karte,
StoriesApi.goToGroup,nextGroupundprevGroupsowie 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.
<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
#progressBarund#headertragengroupIndexundisActive; für eine Nachbargruppe istisActivegleichfalse, und die Fortschrittssignale halten still. - Ein Slot
#headerliegt im Wischbereich. Ein Tippen darauf wechselt zwischen Stories, außer es trifft einenbutton, einen Link oder ein Element mitrole="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>
| Prop | Typ | Standard | Beschreibung |
|---|---|---|---|
aria-label | string | '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-duration | number | 5000 | Dauer 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-keyboard | boolean | true | Aktiviert die Tastaturnavigation (Pfeile links/rechts, Escape) |
groups | StoriesGroup<T>[] | required | Anzuzeigende Story-Gruppen. Gruppen oder Stories, die bei offenem Player hinzukommen, werden übernommen, egal ob das Array ersetzt oder erweitert wird. |
group-transition | TransitionTransformFn | cubeTransition | Übergang für den äußeren Slider (Gruppen). Wird ignoriert, solange das Desktop-Karussell sichtbar ist, das stattdessen gleitet. |
hide-ui-on-pause | boolean | true | Blendet Fortschrittsbalken und Header aus, solange ein langes Drücken pausiert |
initial-group-index | number | 0 | Nullbasierter Index der zuerst sichtbaren Gruppe |
initial-story-index | number | fortsetzen, sonst 0 | Nullbasierter 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-duration | number | 200 | Dauer der Überblendung zwischen Stories, in Millisekunden |
is-open | boolean | required | Steuert die Sichtbarkeit und sperrt das Scrollen des Body, solange offen. Binde es mit v-model:is-open. |
min-segment-width | number | 8 | Mindestbreite 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-split | number | 0.3 | Aufteilung der Tippzonen (0–1). Der linke Teil geht zurück, der Rest vorwärts. |
viewed | StoriesViewedStateController | — | 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.
| Prop | Typ | Standard | Beschreibung |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | required | Controller 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
| Event | Payload | Beschreibung |
|---|---|---|
| @api-ready | StoriesApi | Wird gesendet, sobald der Player gemountet ist, mit der imperativen API |
| @close | void | Wird gesendet, wenn der Player schließt. Bei StoriesUrlOverlay ist der Parameter dann schon entfernt. |
| @double-tap | groupIndex: number, storyIndex: number | Wird bei einem Doppeltipp gesendet, zusammen mit der Herz-Animation |
| @group-change | groupIndex: number | Wird gesendet, nachdem die aktive Gruppe gewechselt hat |
| @pause | void | Wird bei einem langen Drücken oder beim Pause-Button im Header gesendet |
| @resume | void | Wird beim Loslassen oder beim Play-Button im Header gesendet |
| @story-change | groupIndex: number, storyIndex: number | Wird gesendet, nachdem die aktive Story gewechselt hat |
| @story-complete | groupIndex: number, storyIndex: number | Wird gesendet, wenn der Timer einer Story abläuft |
| @story-viewed | groupIndex: number, storyIndex: number | Wird gesendet, wenn eine Story sichtbar wird, die erste eingeschlossen |
| @update:is-open | boolean | Wird 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.
<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.
| Slot | Scope | Beschreibung |
|---|---|---|
| #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 |
<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:
| Callback | Wann aufrufen |
|---|---|
onReady | Das Bild ist geladen oder das Video spielt. Beendet den Ladezustand und startet den Timer. |
onWaiting | Das Video puffert mitten in der Wiedergabe. Zeigt den Spinner und pausiert den Timer. |
onError | Der Inhalt konnte nicht geladen werden. Zeigt den Fehlerzustand, und die URL wird als defekt gemerkt, damit ein erneutes Öffnen keinen neuen Versuch startet. |
onDurationReady | Meldet die echte Medienlänge in Millisekunden, damit der Timer so lange läuft wie das Video. |
onEnded | Das Medium ist zu Ende. Schaltet zur nächsten Story weiter. |
<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
<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:
<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
<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>Eigene Karussellkarten
<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:
<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.
<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
| Methode | Typ | Beschreibung |
|---|---|---|
nextStory() | () => void | Schaltet zur nächsten Story in der aktuellen Gruppe |
prevStory() | () => void | Geht zur vorherigen Story in der aktuellen Gruppe |
nextGroup() | () => void | Wechselt zur nächsten Nutzergruppe |
prevGroup() | () => void | Wechselt zur vorherigen Nutzergruppe |
goToGroup(index) | (index: number) => void | Springt per Index zu einer bestimmten Gruppe |
pause() | () => void | Pausiert das automatische Weiterschalten und den Fortschritts-Timer |
resume() | () => void | Setzt 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.
<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.
: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.
| Komponente | Was sie ist |
|---|---|
CanvasProgressBar | Der 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. |
HeartAnimation | Das Herz beim Doppeltippen. Sendet complete, wenn die Animation endet, damit die Elternkomponente es entfernen kann. |
ImageStorySlide | Bild-Slide über die volle Fläche mit object-fit: cover, meldet Laden und Fehler über Callbacks. |
StoriesRing | Runder Avatar mit dem Verlaufsring. Zwei Zustände: Ist noch etwas anzusehen, dreht er sich, vollständig angesehen wird er flach. Sendet click. |
StoriesRingList | Horizontale Reihe von Ringen mit den Namen der Autoren, einer pro Gruppe. Sendet select mit dem Gruppenindex. |
StoryHeader | Der Standard-Header: Avatar, Name, Verifiziert-Abzeichen, relative Zeit, Pause, Ton, Schließen und der Ladespinner. |
VideoStorySlide | Video-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. |
<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.
| Aus | Re-exportiert |
|---|---|
@reelkit/vue | Observe, 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-core | createStoriesViewedStateController, 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
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:
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:
<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.
| Klasse | Komponente | Beschreibung |
|---|---|---|
.rk-stories-overlay | Overlay | Fester Hintergrund im Vollbild (Hintergrund, z-index) |
.rk-stories-overlay--carousel | Overlay | Overlay mit sichtbarem Desktop-Karussell |
.rk-stories-overlay--sliding | Overlay | Karussell gleitet gerade; die Fläche des Players ist verborgen |
.rk-stories-swipe-wrapper | Overlay | Wrapper für Wischen zum Schließen (enthält Navigationsbuttons + Fläche) |
.rk-stories-container | Overlay | Abgerundete Story-Fläche (Position, Overflow) |
.rk-stories-ui-layer | Overlay | Container der UI-Ebene (Header, Fortschritt). Mit chrome-placement="group" in jedem Gruppen-Slide |
.rk-stories-ui-layer--hidden | Overlay | Ausgeblendete UI (geschaltet über hide-ui-on-pause) |
.rk-stories-error | Overlay | Fehlerzustand (zentriertes Icon + Text) |
.rk-stories-error-text | Overlay | Text der Fehlermeldung |
.rk-stories-nav-btn | Navigation | Pfeil zurück/weiter auf dem Desktop |
.rk-stories-progress-bar | CanvasProgressBar | Positionierungs-Wrapper des Canvas-Fortschrittsbalkens |
.rk-stories-slide-wrapper | Group | Eine Gruppe von Stories (äußerer Slide) |
.rk-stories-story | Story | Eine einzelne Story (Wurzel des inneren Slides) |
.rk-stories-header | StoryHeader | Header-Leiste (Avatar, Name, Aktionen) |
.rk-stories-header--hidden | StoryHeader | Ausgeblendeter Header |
.rk-stories-header-avatar | StoryHeader | Avatarbild des Autors |
.rk-stories-header-name | StoryHeader | Name des Autors |
.rk-stories-header-verified | StoryHeader | Verifiziert-Abzeichen |
.rk-stories-header-time | StoryHeader | Text mit der vergangenen Zeit |
.rk-stories-header-actions | StoryHeader | Aktionen rechts (Ton, Pause, Schließen) |
.rk-stories-header-btn | StoryHeader | Aktions-Button im Header |
.rk-stories-header-btn--desktop | StoryHeader | Pause-Button, nur auf Bildschirmen breiter als 768px |
.rk-stories-header-spinner | StoryHeader | Ladespinner |
.rk-stories-image | ImageStorySlide | Element einer Bild-Story |
.rk-stories-video | VideoStorySlide | Container einer Video-Story |
.rk-stories-video-element | VideoStorySlide | Das gemeinsame Element <video> |
.rk-stories-video-poster | VideoStorySlide | Posterbild des Videos (blendet beim Abspielen aus) |
.rk-stories-video-poster--visible | VideoStorySlide | Sichtbares Poster (vor der Wiedergabe) |
.rk-stories-heart | HeartAnimation | Herz-Animation beim Doppeltippen |
.rk-stories-ring | StoriesRing | Story-Ring (Avatar mit animiertem Verlaufsrand) |
.rk-stories-ring--active | StoriesRing | Ring mit noch anzusehenden Stories (animiert) |
.rk-stories-ring-avatar | StoriesRing | Avatarbild im Ring |
.rk-stories-ring-list | StoriesRingList | Container der horizontalen Ringliste |
.rk-stories-ring-list-item | StoriesRingList | Spalte aus Ring + Name |
.rk-stories-ring-list-name | StoriesRingList | Name des Autors unter jedem Ring |
.rk-stories-carousel | Carousel | Ebene der Vorschaukarten hinter dem Player |
.rk-stories-carousel--instant | Carousel | Erster Frame eines Gleitens, ohne Übergang angeordnet |
.rk-stories-card | Carousel | Eine positionierte Vorschaukarte |
.rk-stories-card--center | Carousel | Karte im mittleren Platz während eines Gleitens |
.rk-stories-card--hidden | Carousel | Karte jenseits der zwei sichtbaren auf einer Seite |
.rk-stories-card-button | Carousel | Standardinhalt der Karte (der Button, der die Gruppe öffnet) |
.rk-stories-card-image | Carousel | Vorschaubild (Poster oder Bild) |
.rk-stories-card-frame | Carousel | Eine Story ohne Poster oder Bild, vom Slot #slide in Player-Größe gezeichnet und verkleinert |
.rk-stories-card-scrim | Carousel | Abdunkelnde Ebene über dem Frame |
.rk-stories-card-info | Carousel | Spalte aus Ring, Name des Autors und Zeit |
.rk-stories-card-name | Carousel | Name des Autors |
.rk-stories-card-time | Carousel | Text 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.
| Token | Standard | Steuert |
|---|---|---|
--rk-stories-overlay-bg | #000 | Farbe des Hintergrunds im Vollbild |
--rk-stories-overlay-z | 9999 | z-index des Overlays |
--rk-stories-container-radius | 12px | Abgerundete Ecken der Story-Fläche (Desktop) |
--rk-stories-container-radius-mobile | 0 | Ecken der Story-Fläche auf dem Smartphone, bis 768px Breite |
--rk-stories-swipe-gap | 16px | Abstand zwischen Navigationsbuttons und Story-Fläche |
--rk-stories-top-shade-height | 120px | Höhe des oberen Verlaufs hinter dem Header |
--rk-stories-top-shade-bg | linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%) | Farbe des oberen Verlaufs |
--rk-stories-ui-transition | 200ms | Dauer des Ausblendens, wenn hide-ui-on-pause umschaltet |
--rk-stories-ui-z | 15 | z-index der UI-Ebene (Fortschrittsbalken und Header) |
--rk-stories-nav-size | 44px | Größe der Buttons zurück/weiter auf dem Desktop |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | Hintergrund der Navigationsbuttons auf dem Desktop |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | Hover-Hintergrund der Navigationsbuttons auf dem Desktop |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | Icon-Farbe der Navigationsbuttons auf dem Desktop |
--rk-stories-nav-fg-hover | #fff | Hover-Icon-Farbe der Navigationsbuttons auf dem Desktop |
--rk-stories-nav-transition | 150ms | Hover-Übergang der Navigationsbuttons auf dem Desktop |
--rk-stories-nav-z | 20 | z-index der Navigationsbuttons auf dem Desktop |
--rk-stories-error-bg | linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) | Verlauf im Hintergrund des Fehlerzustands |
--rk-stories-error-fg | rgba(255, 255, 255, 0.5) | Farbe von Fehlersymbol und Text |
--rk-stories-error-text-size | 13px | Schriftgröße der Fehlermeldung |
--rk-stories-error-gap | 12px | Abstand zwischen Fehlersymbol und Text |
--rk-stories-error-z | 5 | z-index des Fehlerzustands |
--rk-stories-video-bg | #000 | Hintergrund der Letterbox hinter <video> |
--rk-stories-video-poster-transition | 200ms | Dauer des Ausblendens des Posters, wenn das Video startet |
--rk-stories-progress-bar-padding | 8px 8px 0 | Innenabstand um den Fortschrittsbalken |
--rk-stories-progress-bar-z | 10 | z-index des Fortschrittsbalkens |
--rk-stories-header-top | 18px | Vertikaler Abstand des Headers vom oberen Rand der Story |
--rk-stories-header-padding | 12px 16px | Innenabstand der Header-Zeile |
--rk-stories-header-z | 5 | z-index des Headers |
--rk-stories-header-transition | 200ms | Dauer des Ausblendens des Headers |
--rk-stories-header-gap | 8px | Abstand zwischen Avatar, Name und Zeit |
--rk-stories-header-avatar-size | 32px | Breite/Höhe des Avatars |
--rk-stories-header-name-fg | #fff | Farbe des Autorennamens |
--rk-stories-header-name-size | 14px | Schriftgröße des Autorennamens |
--rk-stories-header-name-weight | 600 | Schriftstärke des Autorennamens |
--rk-stories-header-time-fg | rgba(255, 255, 255, 0.6) | Farbe der vergangenen Zeit |
--rk-stories-header-time-size | 12px | Schriftgröße der vergangenen Zeit |
--rk-stories-header-btn-fg | #fff | Icon-Farbe der Header-Aktionen (Schließen, Ton, Pause) |
--rk-stories-header-btn-padding | 4px | Innenabstand eines Aktions-Buttons im Header |
--rk-stories-header-actions-gap | 8px | Abstand zwischen den Aktions-Buttons im Header |
--rk-stories-header-spinner-size | 20px | Durchmesser des Ladespinners |
--rk-stories-header-spinner-fg | #fff | Farbe des Bogens im Ladespinner |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | Farbe der Spur im Ladespinner |
--rk-stories-header-spinner-duration | 0.8s | Rotationsdauer des Ladespinners |
--rk-stories-heart-duration | 800ms | Dauer der Animation zum Einblenden und Ausblenden |
--rk-stories-heart-z | 20 | z-index des Herzens beim Doppeltippen |
--rk-stories-ring-spin-duration | 4s | Rotationsdauer eines Rings mit noch anzusehenden Stories |
--rk-stories-ring-active-scale | 0.95 | Skalierung des Rings, solange er gedrückt wird |
--rk-stories-ring-active-gradient | conic-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-color | rgba(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-gradient | none | Intern: 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-gap | 12px | Abstand zwischen den Ringen in der Liste |
--rk-stories-ring-list-padding | 12px | Innenabstand um die Ringliste |
--rk-stories-ring-list-item-gap | 4px | Abstand zwischen einem Ring und seinem Namen |
--rk-stories-ring-list-name-size | 12px | Schriftgröße des Autorennamens unter jedem Ring |
--rk-stories-card-bg | #262626 | Hintergrund der Karussellkarte, sichtbar ohne Vorschaubild |
--rk-stories-card-radius | 8px | Eckenradius der Karussellkarte |
--rk-stories-card-scrim | rgba(0, 0, 0, 0.45) | Abdunkelung über einer seitlichen Karte |
--rk-stories-card-fg | #fff | Textfarbe der Karte |
--rk-stories-card-name-size | 14px | Schriftgröße des Autorennamens auf der Karte |
--rk-stories-card-time-fg | rgba(255, 255, 255, 0.7) | Farbe der vergangenen Zeit auf der Karte |
--rk-stories-card-time-size | 13px | Schriftgröße der vergangenen Zeit auf der Karte |
--rk-stories-card-gap | 6px | Abstand zwischen Ring, Name und Zeit auf einer Karte |
--rk-stories-card-transition | 300ms | Dauer des Gleitens im Karussell |
/* 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
| Taste | Aktion |
|---|---|
ArrowLeft | Vorherige Story. Auf der ersten Story einer Gruppe öffnet sie die vorherige Gruppe dort, wo sie verlassen wurde; in der ersten Gruppe passiert nichts. |
ArrowRight | Nächste Story. Nach der letzten Story einer Gruppe öffnet sie die nächste Gruppe; in der letzten Gruppe schließt sie den Player. |
Escape | Player schließen |