Stories Player
Ein Overlay für Stories im Stil von Instagram für React, gebaut mit @reelkit/react-stories-player.
Funktionen
Installation
npm i @reelkit/react-stories-player @reelkit/react lucide-reactVergiss nicht, die Styles zu importieren:
import '@reelkit/react-stories-player/styles.css';Icons
Der Standard-Header nutzt lucide-react für die Icons. Wenn du eine andere Icon-Bibliothek bevorzugst, gib mit renderHeader und renderNavigation deine eigenen vor.
Schnellstart
Die Komponente StoriesOverlay rendert einen Player für Stories im Vollbild. Kombiniere sie mit StoriesRingList für Einstiegspunkte im Stil von Instagram. Übergib ein Array von StoriesGroup-Objekten und steuere die Sichtbarkeit mit isOpen.
import { useState } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';
const groups: StoriesGroup[] = [
{
author: {
id: 'user-1',
name: 'Alice',
avatar: '/cdn/samples/avatars/avatar-06.jpg',
verified: true,
},
stories: [
{
id: 's1-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-001.jpg',
},
{
id: 's1-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-002.jpg',
},
{
id: 's1-3',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-003.jpg',
},
],
},
{
author: {
id: 'user-2',
name: 'Bob',
avatar: '/cdn/samples/avatars/avatar-07.jpg',
},
stories: [
{
id: 's2-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-004.jpg',
},
{
id: 's2-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-005.jpg',
},
],
},
{
author: {
id: 'user-3',
name: 'Charlie',
avatar: '/cdn/samples/avatars/avatar-08.jpg',
verified: true,
},
stories: [
{
id: 's3-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-006.jpg',
},
],
},
];
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(0);
// What was seen: rings, resume and recording from one controller, kept in
// localStorage across reloads.
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groups,
}),
);
const openStories = (groupIndex: number) => {
setSelectedGroup(groupIndex);
setIsOpen(true);
};
return (
<div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
<StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />
<StoriesOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
groups={groups}
initialGroupIndex={selectedGroup}
viewed={viewed}
/>
</div>
);
}import { useState } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';
const groups: StoriesGroup[] = [
{
author: {
id: 'user-1',
name: 'Alice',
avatar: '/cdn/samples/avatars/avatar-06.jpg',
verified: true,
},
stories: [
{
id: 's1-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-001.jpg',
},
{
id: 's1-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-002.jpg',
},
{
id: 's1-3',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-003.jpg',
},
],
},
{
author: {
id: 'user-2',
name: 'Bob',
avatar: '/cdn/samples/avatars/avatar-07.jpg',
},
stories: [
{
id: 's2-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-004.jpg',
},
{
id: 's2-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-005.jpg',
},
],
},
{
author: {
id: 'user-3',
name: 'Charlie',
avatar: '/cdn/samples/avatars/avatar-08.jpg',
verified: true,
},
stories: [
{
id: 's3-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-006.jpg',
},
],
},
];
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(0);
// What was seen: rings, resume and recording from one controller, kept in
// localStorage across reloads.
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groups,
}),
);
const openStories = (groupIndex: number) => {
setSelectedGroup(groupIndex);
setIsOpen(true);
};
return (
<div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
<StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />
<StoriesOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
groups={groups}
initialGroupIndex={selectedGroup}
viewed={viewed}
/>
</div>
);
}Eine Reihe von Ringen, die sich über ein Neuladen hinweg merkt, was gesehen wurde, zeigt das Rezept „Stories-Leiste“.
URL-State
Live-Demo ansehen →StoriesUrlOverlay ist eine eigene Komponente, deren Öffnungszustand in der Adressleiste steht. Beide Achsen stecken in einem Parameter, ?story=<group>.<story>, sodass die laufende Story einen Link hat, den man teilen, als Lesezeichen speichern und mit dem Zurück-Button schließen kann. Erstelle mit useOverlayUrlState und urlIndexTwoAxisKey einen Controller und übergib ihn als controller.
Eingebaute Schlüssel
Stories haben zwei Achsen, also übergibst du per Spread einen Schlüssel für zwei Achsen an den Controller: urlIndexTwoAxisKey (Gruppe und Story nach Position) oder urlStableIdTwoAxisKey (die Gruppe nach einer stabilen id). Beide werden von @reelkit/react re-exportiert. Mehr dazu im Guide zum URL-State und in der Core API.
import {
StoriesUrlOverlay,
useOverlayUrlState,
urlIndexTwoAxisKey,
} from '@reelkit/react-stories-player';
import { Link } from 'react-router-dom';
const stories = useOverlayUrlState({
param: 'story',
...urlIndexTwoAxisKey({
outerCount: () => groups.length,
innerCounts: () => groups.map((g) => g.stories.length),
}),
});
// Opening a user is a link — the overlay reads the URL and opens itself.
{groups.map((g, i) => (
<Link key={g.author.id} to={`?story=${i}.0`}>{g.author.name}</Link>
))}
<StoriesUrlOverlay controller={stories} groups={groups} />- Das Öffnen legt einen Eintrag im Verlauf an. Das Wischen durch Stories und der Wechsel zwischen Personen ersetzen ihn beide, sodass N Navigationen keine Einträge hinzufügen und ein Schritt zurück den Player immer schließt. Zurück schließt; es blättert nicht durch die Stories.
- Die innere Navigation wird mitgeführt. Der Story-Index bleibt nicht auf Ebene der Gruppe stehen: Das Weiterblättern innerhalb der Stories einer Person aktualisiert
?story=2.n, sodass ein Deep Link genau auf der richtigen Story landet. - Zurück schließt nur, wenn der Player aus der App heraus geöffnet wurde, denn dann hat der Link einen Eintrag angelegt. Ein geteilter Link, der direkt in einem frischen Tab geöffnet wird, hat keinen Verlauf hinter sich, daher verlässt Zurück im Browser die Website; der Button ✕ oder Escape entfernt den Parameter an Ort und Stelle und bleibt auf der Seite.
- Ein Parameter, der keine Gruppe oder Story benennt (ein veraltetes Lesezeichen, ein von Hand geänderter Wert, eine Story hinter dem Ende einer Gruppe), wird aus der URL entfernt, statt eine benachbarte zu öffnen.
App mit Router: Übergib einen Adapter. Wer hinter einem Router history.pushState schreibt, lässt dessen Location veraltet zurück, und seine nächste Navigation verwirft den Parameter:
import { useReactRouterUrlAdapter } from '@reelkit/react/react-router-url-adapter';
const adapter = useReactRouterUrlAdapter();
const stories = useOverlayUrlState({
param: 'story',
adapter,
...urlIndexTwoAxisKey({
outerCount: () => groups.length,
innerCounts: () => groups.map((g) => g.stories.length),
}),
});
<StoriesUrlOverlay controller={stories} groups={groups} />Stabile Links. Die Gruppe ist standardmäßig eine Position, daher öffnet ein gespeichertes ?story=2.0 eine andere Person, sobald der Feed umsortiert wird. Sprich die Gruppe stattdessen über eine stabile id an: outerCodec schreibt die id in die URL, outerLocator findet, wo sie steht. Übergib innerCodec mit innerLocate und innerIdentify, um dasselbe für die Story innerhalb ihrer Gruppe zu tun; lässt du sie weg, bleibt die Story-Hälfte ein einfacher lokaler Index.
const stories = useOverlayUrlState({
param: 'story',
...urlIndexTwoAxisKey({
outerCount: () => groups.length,
innerCounts: () => groups.map((g) => g.stories.length),
// ?story=user_42.3
outerCodec: { decode: (raw) => raw, encode: (id) => id },
outerLocator: {
locate: (id) => groups.findIndex((g) => g.author.id === id),
identify: (index) => groups[index].author.id,
},
// Optional: ?story=user_42.story_7 — the story by id too
innerCodec: { decode: (raw) => raw, encode: (id) => id },
innerLocate: (outer, id) => {
const index = groups[outer].stories.findIndex((s) => s.id === id);
return index === -1 ? null : index;
},
innerIdentify: (outer, index) => groups[outer].stories[index].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 erst 20 geladen hat, findet nichts. locateAsync ist der Fallback und wird nur aufgerufen, wenn locate nichts findet; die Story wird dann an die Grenzen der Gruppe angepasst, bei der es landet.
Dasselbe locateAsync, äußere Achse
Das ist derselbe Pager locateAsync, den auch die Schlüssel für eine Achse nehmen. Bei einem Schlüssel für zwei Achsen hängt er am übergebenen outerLocator, sodass die Achse der Gruppen nachlädt, während die Story ein lokaler Index innerhalb der gefundenen Gruppe bleibt.
const stories = useOverlayUrlState({
param: 'story',
...urlIndexTwoAxisKey({
outerCount: () => groups.length,
innerCounts: () => groups.map((g) => g.stories.length),
outerLocator: {
locate: (index) => (index < groups.length ? index : null),
identify: (index) => index,
locateAsync: async (index) => {
const loaded = await loadUntilGroup(index); // page up to it
if (!loaded) return null; // exhausted — link names no group
setGroups(loaded); // commit — the overlay renders from this state
return index;
},
},
}),
});- Solange
locateAsyncläuft, bleibt der Player geschlossen und der Parameter unangetastet, damit der Deep Link das Laden übersteht. Einnulloder eine Ablehnung entfernt den Parameter. - Eine Antwort, die erst eintrifft, nachdem sich die URL geändert hat, nach dem Schließen oder nach dem Unmount, wird verworfen: Ein langsames Laden kann keine Story öffnen, nach der niemand gefragt hat.
- Alle Optionen von
useOverlayUrlStatestehen in der React-API-Referenz, die Schritt-für-Schritt-Erklärung im React-Guide.
Merken, was gesehen wurde
Ringe zeigen einen Verlauf, bis eine Gruppe bis zum Ende angeschaut wurde, und eine Gruppe öffnet sich wieder bei der ersten Story, die noch nicht gesehen wurde. Beides kommt aus einem einzigen createStoriesViewedStateController, der einmal dort erstellt wird, wo der Feed lebt, und der Ringliste und dem Player als viewed übergeben wird. Beide lesen den Speicher, folgen ihm und zeichnen selbst auf, was gezeigt wird. Ein gespeicherter Eintrag benennt die Person und die Story per id, übersteht also ein Umsortieren des Feeds, und derselbe Controller funktioniert in Vue und Angular.
import { useRef, useState } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
function Feed({ groups }: { groups: StoriesGroup[] }) {
const [open, setOpen] = useState(false);
const [group, setGroup] = useState(0);
// The controller reads the groups through a getter every time it needs
// them, so keep the getter current: a ref for a plain prop, or a signal.
const groupsRef = useRef(groups);
groupsRef.current = groups;
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groupsRef.current,
}),
);
return (
<>
{/* The rings follow the controller by themselves. */}
<StoriesRingList
groups={groups}
viewed={viewed}
onSelect={(index) => {
setGroup(index);
setOpen(true);
}}
/>
{/* The player resumes, records and draws its card rings from it. */}
<StoriesOverlay
isOpen={open}
onClose={() => setOpen(false)}
groups={groups}
initialGroupIndex={group}
viewed={viewed}
/>
</>
);
}- Die erste Story zählt. Die Story, die schon auf dem Bildschirm ist, wird beim Mount als gesehen gemeldet, sodass das Öffnen und Schließen einer Gruppe mit einer einzigen Story sie als angeschaut markiert.
- Auch beim Weiterwischen wird fortgesetzt.
resumeStoryIndexwird für jede Gruppe befragt, die in dieser Sitzung zum ersten Mal erreicht wird, die Startgruppe des Players eingeschlossen, außerinitialStoryIndexbenennt ausdrücklich eine Story; eine Gruppe, durch die schon gewischt wurde, öffnet sich dort, wo sie verlassen wurde. - Ein Link gewinnt trotzdem. Mit
StoriesUrlOverlayentscheidet der Parameter, wo sich der Player öffnet, egal was gespeichert ist. Überall sonst entscheidet der Callback zum Fortsetzen. - Eine Zahl ist eine Position, keine Strichliste. Ein Eintrag benennt die am weitesten erreichte Story, daher leuchtet der Ring einer angeschauten Gruppe wieder auf, wenn eine Story dazukommt, und das Entfernen einer Story aus der Mitte verkürzt die Zahl. Dieselbe Selbstheilung bekommt auch ein geteilter Link.
- Der Speicher ist austauschbar. Übergib
storage: createSessionStorageAdapter(), um beim Schließen des Tabs alles 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. Der Player liest den Speicher in einem Effect, der läuft, während er noch geschlossen ist. Mit
{open && <StoriesOverlay … />}gibt es diesen Moment nicht, also rufeuseAttachViewedState(viewed)in der Komponente auf, der der Controller gehört; der Hook hängt sich beim Mount an und löst sich beim Unmount. Die Optionen und die Bausteine darunter stehen auf der Seite Stories Core.
Desktop-Karussell
Setze desktopLayout="carousel", um den Player wie den Web-Viewer von Instagram anzuordnen: Die aktive Story bleibt in normaler Größe in der Mitte, und auf jeder Seite stehen bis zu zwei benachbarte Gruppen als kleinere, abgedunkelte Karten. Ein Klick auf eine Karte öffnet diese Gruppe, und die Karten gleiten hinüber, während die Gruppe wechselt. Übergib denselben Controller viewed, den auch die Ringliste nimmt, dann bekommt die Karte einer angeschauten Gruppe den gedämpften Ring. Die Karten folgen ihm selbst, sodass nur sie neu zeichnen, wenn eine Story als gesehen markiert wird, und der Player nicht noch einmal rendert.
// The same controller the ring list takes: the cards draw the same rings and
// repaint by themselves when a story is marked seen.
<StoriesOverlay
isOpen={open}
onClose={() => setOpen(false)}
groups={groups}
initialGroupIndex={group}
desktopLayout="carousel"
viewed={viewed}
/>;- Smartphones behalten den einfachen Player. Bis 768px Breite werden die Karten nicht gerendert, und Wischbewegungen nutzen
groupTransition, wie beim Standard-Layout'single'. Wird über diese Breite hinweg die Größe geändert, wechselt das Layout sofort. - Jede Karte zeigt eine Vorschau dort, wo sich die Gruppe öffnet: die Story, bei der sie in dieser Sitzung verlassen wurde, sonst das, was
resumeStoryIndexzurückgibt. Sie zeigt das Posterbild des Videos oder das Bild. Eine Story ohne beides, etwa eine Text-Story auf einem Verlauf, zeichnet deinrenderSlidein der Größe des Players und verkleinert sie; ein Video ohne Poster zeigt den Hintergrund der Karte. Diese Vorschau ruftrenderSlidemitisActive: falseauf und ist nur ein Bild: Nichts darin lässt sich fokussieren oder anklicken. Halte alles, was eine Slide beim Mount tut, etwa Wiedergabe oder Analytics, hinterisActive. - Der Timer wartet auf die Slide. Die Story blättert nicht weiter, und
onStoryViewedfeuert für sie nicht, bis die Karten fertig gleiten. - Jeder Weg, die Gruppe zu wechseln, gleitet: ein Klick auf eine Karte,
StoriesApi.goToGroup,nextGroupundprevGroupsowie die Pfeiltasten über das Ende einer Gruppe hinaus. Eine Touch-Wischbewegung bewegt den Player selbst, daher gleitet obendrauf nichts.
Fortschrittsleiste und Header pro Gruppe
Standardmäßig sitzen eine Fortschrittsleiste und ein Header über dem Player und wechseln zur neuen Gruppe, sobald sich diese geändert hat. Setze chromePlacement="group", damit jede Gruppe ihre eigenen bekommt, gezeichnet in ihrer Slide, sodass sich beide mit der Gruppe drehen, wie bei Instagram.
// 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
isOpen={open}
onClose={() => setOpen(false)}
groups={groups}
chromePlacement="group"
/>;- Eine benachbarte Gruppe zeigt, wo sie steht. Während sich der Player zu ihr dreht, zeigen ihre Leiste und ihr Header die Story, bei der sie sich öffnen wird, noch ohne abgespielten Fortschritt.
- Die verlassene Gruppe hält still. Ihre Leiste behält den Fortschritt, den sie hatte, bis die Drehung endet, sodass eine bis zum Ende abgespielte Gruppe voll bleibt, statt sich mitten in der Drehung zu leeren.
- Eigene Renderer werden für jede Gruppe auf dem Bildschirm aufgerufen.
renderProgressBarundrenderHeaderbekommengroupIndexundisActive; für eine benachbarte Gruppe istisActivefalse, und die Signale des Fortschritts halten still. - Ein eigener Header liegt im Wischbereich. Ein Tippen darauf wechselt zwischen Stories, außer es trifft einen
button, 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, solange die Karten gleiten, daher zeigt sich die Wahl, sobald die Gruppe offen ist.
API-Referenz
StoriesOverlayProps
StoriesOverlayProps<T>
| Prop | Typ | Standard | Beschreibung |
|---|---|---|---|
isOpen | boolean | Pflicht | Steuert die Sichtbarkeit des Overlays. Ist es true, wird das Scrollen des Body gesperrt. |
groups | StoriesGroup<T>[] | Pflicht | Array der anzuzeigenden Story-Gruppen |
onClose | () => void | Pflicht | Callback zum Schließen des Overlays |
ariaLabel | string | 'Stories player' | Zugängliche Beschriftung des Dialogbereichs; Screenreader lesen sie vor, wenn sich das Overlay öffnet |
initialGroupIndex | number | 0 | Nullbasierter Index der zuerst sichtbaren Gruppe |
initialStoryIndex | number | resumeStoryIndex(initialGroupIndex), sonst 0 | Nullbasierter Index der zuerst sichtbaren Story in der Gruppe. Eine hier benannte Story gewinnt gegen alles Gemerkte; lässt du es weg, setzt die Startgruppe fort wie jede andere. |
resumeStoryIndex | (groupIndex: number) => number | — | Bei welcher Story sich eine Gruppe öffnet, wenn sie zum ersten Mal erreicht wird, sodass man dort weitermacht, wo man aufgehört hat. Wird nur für eine Gruppe befragt, die in diesem Öffnen noch nicht besucht wurde. |
groupTransition | TransitionTransformFn | cubeTransition | Übergangseffekt für den äußeren Slider (Gruppen) |
defaultImageDuration | number | 5000 | Standarddauer für das Weiterblättern bei Bild-Stories in Millisekunden |
tapZoneSplit | number | 0.3 | Aufteilung der Tap-Zonen (0–1). Der linke Teil löst zurück aus, der rechte weiter. |
hideUIOnPause | boolean | true | Blendet Fortschrittsleiste und Header aus, solange ein langes Drücken pausiert |
enableKeyboard | boolean | true | Aktiviert die Tastaturnavigation (Pfeile links/rechts, Escape) |
innerTransitionDuration | number | 200 | Dauer der inneren Übergangsanimation (Stories) in Millisekunden |
minSegmentWidth | number | 8 | Minimale Segmentbreite der Fortschrittsleiste in Pixeln |
apiRef | MutableRefObject<StoriesApi | null> | - | Ref für den Zugriff auf die imperative StoriesApi |
desktopLayout | '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. |
chromePlacement | 'overlay' | 'group' | 'overlay' | Wo Fortschrittsleiste und Header sitzen. 'overlay' zeichnet eine Kopie über dem Player; 'group' zeichnet eine in jeder Gruppen-Slide, sodass sich beide mit der Gruppe drehen. Der Typ wird als ChromePlacement exportiert. |
viewed | StoriesViewedStateController | - | Was gesehen wurde, aus createStoriesViewedStateController(). Gruppen setzen bei ihrer ersten ungesehenen Story fort, jede gezeigte Story wird aufgezeichnet, und Karussellkarten dämpfen den Ring einer bis zum Ende angeschauten Gruppe. Übergib denselben Controller an StoriesRingList. |
renderHeader | (props: HeaderRenderProps<T>) => ReactNode | - | Eigener Renderer für den Header. Bekommt Person, Story sowie den Zustand von Pause und Ton. Mit chromePlacement="group" liegt er im Wischbereich; siehe Fortschrittsleiste und Header pro Gruppe. |
renderFooter | (props: FooterRenderProps<T>) => ReactNode | - | Eigener Renderer für den Footer. Bekommt Informationen zu Person und Story. |
renderSlide | (props: SlideRenderProps<T>) => ReactNode | - | Eigener Renderer für Slides, ersetzt die Standard-Slides für Bild und Video. |
renderNavigation | (props: NavigationRenderProps) => ReactNode | - | Eigene Navigation auf dem Desktop. Ersetzt die Standard-Pfeil-Buttons für zurück und weiter. |
renderProgressBar | (props: ProgressBarRenderProps<T>) => ReactNode | - | Eigene Fortschrittsleiste. Ersetzt die Standard-Fortschrittsleiste auf Canvas. |
renderLoading | (props: LoadingRenderProps<T>) => ReactNode | - | Eigener Renderer für die Ladeanzeige. Ohne Angabe erscheint der Standard-Spinner im Header. |
renderError | (props: ErrorRenderProps<T>) => ReactNode | - | Eigener Renderer für Fehler. Ohne Angabe erscheint das Standard-Overlay mit Fehlersymbol. |
renderGroupPreview | (props: GroupPreviewRenderProps<T>) => ReactNode | - | Eigene Karte für das Desktop-Karussell. Ersetzt den Standardinhalt der Karte. |
StoriesUrlOverlayProps
StoriesUrlOverlayProps<T>
Nimmt jedes Prop von StoriesOverlay außer dem Trio für den Öffnungszustand, isOpen, initialGroupIndex und initialStoryIndex, das stattdessen vom Controller kommt.
| Prop | Typ | Standard | Beschreibung |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | Pflicht | Controller aus useOverlayUrlState, per Spread 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. |
Callbacks
| Prop | Typ | Beschreibung |
|---|---|---|
onClose | () => void | Wird aufgerufen, wenn sich der Player schließt. Erforderlich bei StoriesOverlay (du steuerst, ob er offen ist, also musst du das Schließen behandeln); optional bei StoriesUrlOverlay, wo die URL das Schließen steuert. Übergib es dort nur, um nach dem Schließen zu reagieren. |
onStoryChange | (groupIndex: number, storyIndex: number) => void | Wird ausgelöst, wenn sich die aktive Story ändert |
onGroupChange | (groupIndex: number) => void | Wird ausgelöst, wenn sich die aktive Gruppe ändert |
onStoryViewed | (groupIndex: number, storyIndex: number) => void | Wird ausgelöst, wenn eine Story sichtbar wird |
onStoryComplete | (groupIndex: number, storyIndex: number) => void | Wird ausgelöst, wenn der Timer einer Story abläuft |
onDoubleTap | (groupIndex: number, storyIndex: number) => void | Wird bei einem Doppeltippen ausgelöst |
onPause | () => void | Wird ausgelöst, wenn der Player pausiert |
onResume | () => void | Wird ausgelöst, wenn der Player weiterläuft |
Transitions
Das Prop groupTransition steuert den 3D-Übergangseffekt beim Wischen zwischen den Gruppen der Personen. Importiere die Transition-Funktionen aus @reelkit/react:
import {
cubeTransition, // default — 3D cube rotation
flipTransition, // card flip
fadeTransition, // crossfade
zoomTransition, // zoom in/out
slideTransition, // horizontal slide
} from '@reelkit/react';
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
groupTransition={flipTransition}
/>Lebenszyklus beim Laden von Inhalten
Jede Story-Slide meldet ihren Ladezustand über Callbacks, die SlideRenderProps bereitstellt:
| Callback | Wann |
|---|---|
onReady | Der Inhalt ist bereit (Bild geladen, Video spielt). Der Timer für den Fortschritt startet. |
onWaiting | Der Inhalt stockt (Video puffert während der Wiedergabe). Der Spinner erscheint, und der Timer pausiert. |
onError | Der Inhalt konnte nicht geladen werden. Das Fehler-Overlay wird gezeigt. |
onDurationReady | Meldet die tatsächliche Dauer des Mediums (zum Beispiel aus den Metadaten des Videos), damit der Timer mit der richtigen Dauer neu startet. |
onEnded | Meldet, dass das Medium zu Ende ist (zum Beispiel ein fertiges Video). Blättert zur nächsten Story weiter. |
Caching beim Vorladen
Die eingebauten Komponenten ImageStorySlide und VideoStorySlide laden die nächste Story im Hintergrund vor. Wechselt man zu einer vorgeladenen Story, erscheint der Inhalt sofort, ohne Lade-Spinner.
Render Props
Jedes UI-Element lässt sich über Render Props ersetzen. Jedes bekommt typisierte Props mit allem nötigen Zustand und allen Callbacks.
renderHeader
Ersetze den Standard-Header (Informationen zur Person, Buttons für Pause und Ton, Schließen-Button):
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderHeader={({ author, story, isPaused, isMuted, isVideo, onToggleSound, onTogglePause, onClose }) => (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
zIndex: 10,
}}>
<img src={author.avatar} style={{ width: 32, height: 32, borderRadius: '50%' }} />
<span style={{ color: '#fff', fontWeight: 600 }}>{author.name}</span>
{isVideo && <button onClick={onToggleSound}>{isMuted ? 'Unmute' : 'Mute'}</button>}
<button onClick={onClose} style={{ marginLeft: 'auto' }}>Close</button>
</div>
)}
/>renderFooter
Füge unter dem Inhalt der Story einen Footer hinzu:
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderFooter={({ author, story, storyIndex }) => (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 16,
background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
color: '#fff',
zIndex: 10,
}}>
<span>{author.name} — Story {storyIndex + 1}</span>
</div>
)}
/>renderSlide
Ersetze die Standard-Slides für Bild und Video komplett. Nutze die Unterkomponenten ImageStorySlide und VideoStorySlide für die eingebaute Behandlung der Medien:
import {
StoriesOverlay,
ImageStorySlide,
VideoStorySlide,
} from '@reelkit/react-stories-player';
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderSlide={({ story, index, groupIndex, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }) => {
const [w, h] = size;
if (story.mediaType === 'video') {
return (
<div style={{ width: w, height: h, background: '#000' }}>
<VideoStorySlide
src={story.src}
poster={story.poster}
groupIndex={groupIndex}
storyIndex={index}
activeGroupIndex={activeGroupIndex}
activeStoryIndex={activeStoryIndex}
onDurationReady={onDurationReady}
onPlaying={onReady}
onWaiting={onWaiting}
onEnded={onEnded}
onError={onError}
/>
</div>
);
}
return (
<div style={{ width: w, height: h, background: '#000' }}>
<ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />
</div>
);
}}
/>renderNavigation
Ersetze die Standard-Pfeil-Buttons auf dem Desktop:
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderNavigation={({ onPrevStory, onNextStory, onPrevGroup, onNextGroup }) => (
<div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, display: 'flex', justifyContent: 'center', gap: 8, zIndex: 10 }}>
<button onClick={onPrevGroup}>Prev Group</button>
<button onClick={onPrevStory}>Prev</button>
<button onClick={onNextStory}>Next</button>
<button onClick={onNextGroup}>Next Group</button>
</div>
)}
/>renderProgressBar
Ersetze die Standard-Fortschrittsleiste auf Canvas durch eine eigene Umsetzung. Das Signal progress liefert Werte von 0 bis 1:
import { Observe } from '@reelkit/react';
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderProgressBar={({ totalStories, activeIndex, progress }) => (
<div style={{ display: 'flex', gap: 4, padding: '8px 16px' }}>
{Array.from({ length: totalStories }, (_, i) => (
<div key={i} style={{ flex: 1, height: 2, background: 'rgba(255,255,255,0.3)', borderRadius: 1, overflow: 'hidden' }}>
<Observe signals={[activeIndex, progress]}>
{() => {
const fill = i < activeIndex.value ? 1 : i === activeIndex.value ? progress.value : 0;
return <div style={{ width: `${fill * 100}%`, height: '100%', background: '#fff' }} />;
}}
</Observe>
</div>
))}
</div>
)}
/>renderLoading
Eigene Ladeanzeige, solange der Inhalt geladen wird:
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderLoading={({ story, storyIndex, groupIndex }) => (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}>
<span>Loading story {storyIndex + 1}...</span>
</div>
)}
/>renderError
Eigenes Fehler-Overlay, wenn der Inhalt nicht geladen werden kann:
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderError={({ story, storyIndex, groupIndex }) => (
<div style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
color: '#fff',
background: 'rgba(0,0,0,0.8)',
}}>
<span style={{ fontSize: 48 }}>!</span>
<span>Failed to load story</span>
</div>
)}
/>renderGroupPreview
Ersetze den Inhalt einer Karte im Desktop-Karussell. Der Player positioniert, skaliert und bewegt die Karte weiterhin; rufe onOpen auf, um die Gruppe zu öffnen:
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
desktopLayout="carousel"
renderGroupPreview={({ group, story, onOpen }) => (
<button
type="button"
className="my-story-card"
aria-label={`Open stories by ${group.author.name}`}
onClick={onOpen}
>
{story?.poster && <img src={story.poster} alt="" />}
<span>{group.author.name}</span>
</button>
)}
/>StoriesApi
Nutze das Prop apiRef für die imperative Steuerung:
import { useRef } from 'react';
import { StoriesOverlay, type StoriesApi } from '@reelkit/react-stories-player';
function App() {
const apiRef = useRef<StoriesApi | null>(null);
return (
<>
<button onClick={() => apiRef.current?.nextStory()}>Next Story</button>
<button onClick={() => apiRef.current?.nextGroup()}>Next Group</button>
<button onClick={() => apiRef.current?.pause()}>Pause</button>
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
apiRef={apiRef}
/>
</>
);
}Methoden
| Methode | Typ | Beschreibung |
|---|---|---|
nextStory() | () => void | Blättert zur nächsten Story in der aktuellen Gruppe |
prevStory() | () => void | Geht zur vorherigen Story in der aktuellen Gruppe |
nextGroup() | () => void | Wechselt zur nächsten Gruppe |
prevGroup() | () => void | Wechselt zur vorherigen Gruppe |
goToGroup(index) | (index: number) => void | Springt per Index zu einer bestimmten Gruppe |
pause() | () => void | Pausiert das Weiterblättern und den Timer des Fortschritts |
resume() | () => void | Setzt das Weiterblättern und den Timer des Fortschritts fort |
Doppeltippen und Likes
Beim Doppeltippen spielt eine eingebaute Herz-Animation und gibt sofort sichtbares Feedback. Der Callback onDoubleTap feuert mit dem Index von Gruppe und Story, damit du das Like in deinem eigenen Zustand speichern kannst (API-Aufruf, Local Storage und so weiter). Der Player verwaltet keinen eigenen Like-Zustand.
<StoriesOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
groups={groups}
onDoubleTap={(groupIndex, storyIndex) => {
// Built-in heart animation plays automatically.
// Handle the like in your own state:
const story = groups[groupIndex].stories[storyIndex];
toggleLike(story.id);
}}
/>Herz-Animation anpassen
Die Geschwindigkeit der Animation stellst du mit dem Token --rk-stories-heart-duration ein (siehe Theming). Für Farbe, Größe oder das vollständige Ausblenden des Herzens sprichst du die Klasse .rk-stories-heart direkt an. Die Komponente HeartAnimation wird auch für den eigenständigen Einsatz exportiert.
/* Speed up the pop via the token */
:root {
--rk-stories-heart-duration: 1s;
}
/* Restyle color and size via the class */
.rk-stories-heart {
color: #ff3b5c;
font-size: 80px;
}
/* Or hide the built-in heart entirely */
.rk-stories-heart {
display: none;
}Die eingebaute Herz-Animation lässt sich noch nicht über ein Render Prop ersetzen. Du kannst sie mit CSS umgestalten oder mit display: none ausblenden und im Callback onDoubleTap deine eigene Animation abspielen. Wenn du ein Render Prop renderDoubleTap brauchst, sag uns über GitHub Issues Bescheid.
Unterkomponenten
Wiederverwendbare Bausteine, die für das Zusammenstellen eigener Render Props exportiert werden. Der Props-Typ jeder Komponente wird daneben exportiert, CanvasProgressBarProps, StoryHeaderProps, ImageStorySlideProps, VideoStorySlideProps, StoriesRingProps, StoriesRingListProps und HeartAnimationProps, für eine Komponente, die eine davon umschließt:
CanvasProgressBar
Schnelle, segmentierte Fortschrittsleiste auf Canvas. Zeichnet ein Segment pro Story und animiert die Füllung des aktiven Segments über requestAnimationFrame. Unterstützt ein gleitendes Fenster für Gruppen mit vielen Stories. Übergib live={false} für eine Leiste, die nur zeigen soll, wo eine Gruppe steht. Diese Leiste zeichnet, wenn sich ihre Signale ändern und nach einer Größenänderung, ohne Animationsschleife.
import { CanvasProgressBar } from '@reelkit/react-stories-player';
<CanvasProgressBar
totalStories={group.stories.length}
activeIndex={activeIndexSignal}
progress={progressSignal}
minSegmentWidth={8}
gap={2}
barHeight={2}
// A bar that is not live draws only when its signals change, with no
// animation loop: enough for a group that is not playing.
live={isActiveGroup}
/>StoryHeader
Standard-Header mit Avatar, Name, Verifiziert-Badge, relativem Zeitstempel, Schalter für Pause und Wiedergabe, Schalter für den Ton, Lade-Spinner und Schließen-Button. Wird automatisch verwendet, wenn kein renderHeader angegeben ist.
import { StoryHeader } from '@reelkit/react-stories-player';
<StoryHeader
author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg', verified: true }}
createdAt={new Date(Date.now() - 3600_000)}
onClose={handleClose}
isPaused={false}
onTogglePause={togglePause}
isMuted={true}
onToggleSound={toggleSound}
isVideo={true}
isLoading={false}
/>ImageStorySlide
Bild-Slide über die ganze Fläche mit object-fit: cover. Meldet Laden und Fehler über Callbacks für den Lebenszyklus.
import { ImageStorySlide } from '@reelkit/react-stories-player';
<ImageStorySlide
src="/photo.jpg"
aspectRatio={9 / 16}
onLoad={() => console.log('loaded')}
onError={() => console.log('failed')}
/>VideoStorySlide
Video-Slide mit einem gemeinsamen Element <video>, damit der Ton auf iOS nicht abreißt. Kümmert sich um Autoplay, Posterbilder und den Abgleich des Tons und meldet die Dauer sowie Ereignisse im Lebenszyklus der Wiedergabe.
import { VideoStorySlide } from '@reelkit/react-stories-player';
<VideoStorySlide
src="/clip.mp4"
poster="/clip-poster.jpg"
groupIndex={0}
storyIndex={2}
activeGroupIndex={activeGroupSignal}
activeStoryIndex={activeStorySignal}
onDurationReady={(ms) => console.log('duration:', ms)}
onPlaying={() => console.log('playing')}
onWaiting={() => console.log('buffering')}
onEnded={() => console.log('ended')}
onError={() => console.log('error')}
/>StoriesRing
Runder Avatar mit einem Verlaufsring im Stil von Instagram. Zwei Zustände: Eine Gruppe mit noch ungesehenen Stories bekommt den rotierenden Verlauf, eine komplett angeschaute einen flachen, gedämpften Ring.
import { StoriesRing } from '@reelkit/react-stories-player';
<StoriesRing
author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg' }}
totalStories={5}
viewedCount={2}
onClick={() => openStories(0)}
/>StoriesRingList
Horizontal scrollbare Reihe von StoriesRing-Komponenten mit den Namen der Personen. Ein Ring pro Gruppe.
import { StoriesRingList } from '@reelkit/react-stories-player';
// `viewed` is the same createStoriesViewedStateController the player takes; the
// rings follow it by themselves.
<StoriesRingList
groups={groups}
viewed={viewed}
onSelect={(groupIndex) => openStories(groupIndex)}
/>;HeartAnimation
Animiertes Herz-Overlay, das beim Doppeltippen erscheint. Wird größer und blendet über 800ms aus. Anpassbar über CSS (siehe den Abschnitt zu Doppeltippen und Likes).
import { HeartAnimation } from '@reelkit/react-stories-player';
<HeartAnimation onComplete={() => console.log('animation done')} />Re-Exporte
Alles, was du brauchst, kommt aus diesem Paket, daher ist ein direkter Import aus @reelkit/core nie nötig.
| Aus | Re-exportiert |
|---|---|
@reelkit/react | 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 |
SoundProvider und useSoundState tragen den Tonzustand, den der Player mit seinen Slides teilt. Eine eigene Slide, die ihre eigenen Medien abspielt, liest den Ton-Schalter über useSoundState. Im Player ist bereits ein Provider vorhanden; eine VideoStorySlide oder eigene Slide, die irgendwo anders gerendert wird, braucht einen SoundProvider darüber, sonst wirft useSoundState einen Fehler.
Typen
StoryItem
interface StoryItem {
id: string;
mediaType: 'image' | 'video';
src: string;
poster?: string;
duration?: number; // ms, images default to 5000, videos use natural duration
createdAt?: string | Date;
aspectRatio?: number; // width / height
}AuthorInfo
interface AuthorInfo {
id: string;
name: string;
avatar: string;
verified?: boolean;
}StoriesGroup<T>
interface StoriesGroup<T extends StoryItem = StoryItem> {
author: AuthorInfo;
stories: T[];
}HeaderRenderProps<T>
interface HeaderRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
isPaused: boolean;
isMuted: boolean;
isVideo: boolean;
groupIndex: number;
isActive: boolean; // false for a neighbouring group's header
onToggleSound: () => void;
onTogglePause: () => void;
onClose: () => void;
}FooterRenderProps<T>
interface FooterRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
}SlideRenderProps<T>
interface SlideRenderProps<T extends StoryItem = StoryItem> {
story: T;
index: number;
groupIndex: number;
isActive: boolean;
size: [number, number];
activeGroupIndex: Signal<number>;
activeStoryIndex: Signal<number>;
onDurationReady: (durationMs: number) => void;
onReady: () => void;
onWaiting: () => void;
onError: () => void;
onEnded: () => void;
}NavigationRenderProps
interface NavigationRenderProps {
onPrevStory: () => void;
onNextStory: () => void;
onPrevGroup: () => void;
onNextGroup: () => void;
}ProgressBarRenderProps<T>
interface ProgressBarRenderProps<T extends StoryItem = StoryItem> {
totalStories: number;
activeIndex: Signal<number>;
progress: Signal<number>;
group: StoriesGroup<T>;
groupIndex: number;
isActive: boolean; // false: the signals hold still for a neighbouring group
}LoadingRenderProps<T>
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}ErrorRenderProps<T>
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}GroupPreviewRenderProps<T>
type DesktopLayout = 'single' | 'carousel';
interface GroupPreviewRenderProps<T extends StoryItem = StoryItem> {
group: StoriesGroup<T>;
groupIndex: number;
story: T | undefined; // the story the group would open on
offset: number; // negative on the left, positive on the right
viewedCount: number; // from viewedState, 0 without it
onOpen: () => void;
}StoriesApi
interface StoriesApi {
nextStory: () => void;
prevStory: () => void;
nextGroup: () => void;
prevGroup: () => void;
goToGroup: (index: number) => void;
pause: () => void;
resume: () => void;
}Eigene Story-Typen
Erweitere StoryItem um eigene Felder und übergib den Typparameter an StoriesOverlay. Alle Render Props bekommen dann deinen erweiterten Typ:
import {
StoriesOverlay,
ImageStorySlide,
type StoryItem,
type StoriesGroup,
type SlideRenderProps,
} from '@reelkit/react-stories-player';
interface PromoStory extends StoryItem {
title: string;
subtitle?: string;
bgGradient?: string;
ctaText?: string;
}
const groups: StoriesGroup<PromoStory>[] = [
{
author: { id: 'brand', name: 'My Brand', avatar: '/brand.png', verified: true },
stories: [
{
id: 'promo-1',
mediaType: 'image',
src: '/sale-banner.jpg',
title: 'Flash Sale',
subtitle: 'Up to 50% off',
ctaText: 'Shop Now',
},
{
id: 'promo-2',
mediaType: 'image',
src: '',
title: 'Thank You',
subtitle: '10K followers!',
bgGradient: 'linear-gradient(135deg, #a18cd1, #fbc2eb)',
},
],
},
];
function CustomSlide({ story, size, onReady, onError }: SlideRenderProps<PromoStory>) {
const [w, h] = size;
const hasImage = story.src && story.mediaType === 'image';
return (
<div style={{ width: w, height: h, background: story.bgGradient ?? '#000', position: 'relative' }}>
{hasImage && <ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />}
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', padding: 32, color: '#fff' }}>
<h2>{story.title}</h2>
{story.subtitle && <p>{story.subtitle}</p>}
{story.ctaText && (
<button style={{ marginTop: 16, padding: '8px 24px', borderRadius: 20, background: '#fff', color: '#000', border: 'none' }}>
{story.ctaText}
</button>
)}
</div>
</div>
);
}
<StoriesOverlay<PromoStory>
isOpen={isOpen}
onClose={handleClose}
groups={groups}
renderSlide={(props) => <CustomSlide {...props} />}
/>CSS-Klassen
Alle CSS-Klassen sind einfache Klassen (keine CSS Modules), daher kannst du sie mit spezifischeren Selektoren in einem Stylesheet ansprechen, das nach @reelkit/react-stories-player/styles.css geladen wird. Für Änderungen an Farbe, Größe und z-index nimm lieber die CSS Custom Properties, die unten im Abschnitt Theming beschrieben sind.
| Klasse | Komponente | Beschreibung |
|---|---|---|
.rk-stories-overlay | Overlay | Fester Vollbild-Hintergrund (Hintergrund, z-index) |
.rk-stories-swipe-wrapper | Overlay | Wrapper für das Wischen zum Schließen (enthält die Navigations-Buttons und die Fläche) |
.rk-stories-container | Overlay | Abgerundete Fläche der Story (Position, Overflow) |
.rk-stories-ui-layer | Overlay | Container des UI-Overlays (Header, Fortschritt, Navigation). Mit chromePlacement="group" in jeder Gruppen-Slide |
.rk-stories-ui-layer--hidden | Overlay | Ausgeblendeter Zustand der UI (umgeschaltet durch hideUIOnPause) |
.rk-stories-error | Overlay | Fehlerzustand (zentriertes Symbol mit Text) |
.rk-stories-error-text | Overlay | Text der Fehlermeldung |
.rk-stories-nav-btn | Navigation | Pfeil für zurück und weiter auf dem Desktop |
.rk-stories-progress-bar | ProgressBar | Wrapper für die Position der Fortschrittsleiste auf Canvas |
.rk-stories-slide-wrapper | Group | Eine Gruppe von Stories (äußere Slide) |
.rk-stories-story | Story | Eine einzelne Story (Wurzel der inneren Slide) |
.rk-stories-header | StoryHeader | Header-Leiste (Avatar, Name, Aktionen) |
.rk-stories-header--hidden | StoryHeader | Ausgeblendeter Zustand des Headers (visible=false) |
.rk-stories-header-avatar | StoryHeader | Avatarbild der Person |
.rk-stories-header-name | StoryHeader | Name der Person |
.rk-stories-header-verified | StoryHeader | Container des Verifiziert-Badges |
.rk-stories-header-time | StoryHeader | Text mit der vergangenen Zeit |
.rk-stories-header-actions | StoryHeader | Aktionen rechts (Schließen, Ton, Pause) |
.rk-stories-header-btn | StoryHeader | Aktions-Button im Header |
.rk-stories-header-btn--desktop | StoryHeader | Pause-Button, nur ab 768px Breite sichtbar |
.rk-stories-header-spinner | StoryHeader | Spinner beim Puffern des Videos |
.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 | Sichtbarer Zustand des Posters (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 ungesehenen 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 mit Ring und Name |
.rk-stories-ring-list-name | StoriesRingList | Name der Person unter jedem Ring |
.rk-stories-overlay--carousel | Overlay | Overlay, während das Desktop-Karussell sichtbar ist |
.rk-stories-overlay--sliding | Overlay | Das Karussell gleitet; die Fläche des Players ist verborgen |
.rk-stories-carousel | Carousel | Ebene der Vorschaukarten hinter dem Player |
.rk-stories-carousel--instant | Carousel | Erster Frame eines Gleitens, ohne Transition 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 | Verkleinerte Vorschau per renderSlide für eine Story ohne Bild oder Poster; inaktiv |
.rk-stories-card-scrim | Carousel | Abdunkelnde Ebene über dem Frame |
.rk-stories-card-info | Carousel | Spalte mit Ring, Name und Zeit |
.rk-stories-card-name | Carousel | Name der Person |
.rk-stories-card-time | Carousel | Text mit der vergangenen Zeit |
Theming
Jede Farbe, Größe, jeder z-index und jede Transition steckt in einer CSS Custom Property. Überschreibe eine oder mehrere auf :root (oder einem beliebigen Vorfahren des Overlays), um das Aussehen zu ändern, ohne den Quellcode der Komponente anzufassen.
| Token | Standard | Steuert |
|---|---|---|
--rk-stories-overlay-bg | #000 | Farbe des Vollbild-Hintergrunds |
--rk-stories-overlay-z | 9999 | z-index des Overlays |
--rk-stories-swipe-gap | 16px | Abstand zwischen den Navigations-Buttons und der Fläche der Story |
--rk-stories-container-radius | 12px | Abgerundete Ecken der Story-Fläche (Desktop) |
--rk-stories-container-radius-mobile | 0 | Ecken der Story-Fläche bis 768px Breite |
--rk-stories-ui-z | 15 | z-index der UI-Ebene (Header, Fortschrittsleiste) |
--rk-stories-ui-transition | 200ms | Dauer des Ausblendens, wenn hideUIOnPause umschaltet |
--rk-stories-top-shade-height | 120px | Höhe des Verlaufs oben hinter dem Header |
--rk-stories-top-shade-bg | linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%) | Farbe des Verlaufs oben |
--rk-stories-error-z | 5 | z-index des Fehlerzustands |
--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 Fehlertext |
--rk-stories-error-gap | 12px | Abstand zwischen dem Fehlersymbol und seinem Text |
--rk-stories-error-text-size | 13px | Schriftgröße der Fehlermeldung |
--rk-stories-nav-z | 20 | z-index der Navigations-Buttons auf dem Desktop |
--rk-stories-nav-size | 44px | Größe der Buttons für zurück und weiter auf dem Desktop |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | Hintergrund der Navigations-Buttons auf dem Desktop |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | Hover-Hintergrund der Navigations-Buttons auf dem Desktop |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | Icon-Farbe der Navigations-Buttons auf dem Desktop |
--rk-stories-nav-fg-hover | #fff | Icon-Farbe der Navigations-Buttons beim Hover |
--rk-stories-nav-transition | 150ms | Transition von Hintergrund und Farbe der Navigations-Buttons |
--rk-stories-video-bg | #000 | Letterbox-Hintergrund hinter <video> |
--rk-stories-video-poster-transition | 200ms | Dauer des Ausblendens des Posters, wenn das Video startet |
--rk-stories-progress-bar-z | 10 | z-index der Fortschrittsleiste |
--rk-stories-progress-bar-padding | 8px 8px 0 | Abstand um die Fortschrittsleiste |
--rk-stories-header-z | 5 | z-index des Headers |
--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-gap | 8px | Abstand zwischen Avatar, Name und Zeit |
--rk-stories-header-transition | 200ms | Ausblenden des Headers, wenn er verborgen wird |
--rk-stories-header-avatar-size | 32px | Breite und Höhe des Avatars |
--rk-stories-header-name-fg | #fff | Farbe des Namens |
--rk-stories-header-name-size | 14px | Schriftgröße des Namens |
--rk-stories-header-name-weight | 600 | Schriftstärke des Namens |
--rk-stories-header-time-fg | rgba(255, 255, 255, 0.6) | Farbe des Texts mit der vergangenen Zeit |
--rk-stories-header-time-size | 12px | Schriftgröße des Texts mit der vergangenen Zeit |
--rk-stories-header-actions-gap | 8px | Abstand zwischen den Aktions-Buttons im Header |
--rk-stories-header-btn-fg | #fff | Icon-Farbe der Aktionen im Header (Schließen, Ton, Pause) |
--rk-stories-header-btn-padding | 4px | Innenabstand der Aktions-Buttons im Header |
--rk-stories-header-spinner-size | 20px | Größe des Spinners beim Puffern |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | Farbe der Spur des Spinners beim Puffern |
--rk-stories-header-spinner-fg | #fff | Farbe des Bogens des Spinners beim Puffern |
--rk-stories-header-spinner-duration | 0.8s | Eine Umdrehung des Spinners beim Puffern |
--rk-stories-heart-z | 20 | z-index des Herzens beim Doppeltippen |
--rk-stories-heart-duration | 800ms | Dauer der Animation für Einblenden und Ausblenden |
--rk-stories-ring-spin-duration | 4s | Dauer der Drehung eines Rings mit noch ungesehenen Stories |
--rk-stories-ring-active-scale | 0.95 | Skalierung des Rings beim Drücken |
--rk-stories-ring-active-gradient | conic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433) | Was ein Ring mit noch ungesehenen Stories zeigt, ein beliebiges CSS-Bild oder eine Farbe. Auf einer hellen Seite setzt du 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 komplett angeschauter Ring zeigt, gedacht für eine dunkle Seite. Auf einer hellen Seite setzt du 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 darüber, 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 Namens 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 Namens auf der Karte |
--rk-stories-card-time-fg | rgba(255, 255, 255, 0.7) | Farbe des Texts mit der vergangenen Zeit auf der Karte |
--rk-stories-card-time-size | 13px | Schriftgröße des Texts mit 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 |
Füge das Snippet unten in ein Stylesheet ein, das nach @reelkit/react-stories-player/styles.css geladen wird.
/* Brand the stories overlay */
:root {
--rk-stories-overlay-bg: #0f172a;
--rk-stories-container-radius: 24px;
--rk-stories-nav-bg: rgba(99, 102, 241, 0.25);
--rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.55);
--rk-stories-top-shade-bg: linear-gradient(
to bottom,
rgba(99, 102, 241, 0.5) 0%,
transparent 100%
);
--rk-stories-header-name-fg: #fef3c7;
--rk-stories-ring-spin-duration: 2s;
}
/* 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 ariaLabel änderst du, was der Screenreader ansagt; standardmäßig ist es "Stories player".
Das Overlay holt beim Öffnen den Fokus und gibt ihn beim Schließen an den Auslöser zurück. Tab und Umschalt+Tab wechseln zwischen den fokussierbaren Elementen darin; Fokus, der entwischt (ein Klick daneben, ein Fokus per Code), wird zurückgeholt. Umgesetzt mit captureFocusForReturn und createFocusTrap aus @reelkit/core, re-exportiert von @reelkit/react.
Mit dem Desktop-Karussell ist jede Standardkarte ein Button mit der Beschriftung "Open stories by" und dem Namen der Person. Die Karten folgen in der Tab-Reihenfolge nach den Controls des Players und sind daraus ausgenommen, solange ein Gleiten läuft.
Serverseitiges Rendering
Das Overlay rendert nichts, solange es geschlossen ist, und rendert erst beim Öffnen per Portal in document.body, was auf dem Client geschieht. StoriesUrlOverlay liest die Adressleiste in einem Effect nach dem Mount, daher öffnet ein Server-Rendering es nie. StoriesRingList rendert auf dem Server jeden Ring als ungesehen: Der Speicher für Gesehenes wird erst nach dem Mount gelesen, sodass das Server-Markup und das erste Rendering auf dem Client übereinstimmen. Das Gesamtbild zeigt der SSR-Guide.
Tastenkürzel
| Taste | Aktion |
|---|---|
ArrowLeft | Vorherige Story. Auf der ersten Story einer Gruppe die vorherige Gruppe, bei der Story, bei der sie verlassen wurde (sonst ihrer Story zum Fortsetzen); in der ersten Gruppe passiert nichts |
ArrowRight | Nächste Story. Nach der letzten Story einer Gruppe die nächste Gruppe; nach der letzten Gruppe schließt sich der Player |
Escape | Schließt den Player |