Stories Player

Ein Overlay für Stories im Stil von Instagram für React, gebaut mit @reelkit/react-stories-player.

Live-Demo ansehen →

Funktionen

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

Installation

bash
npm i @reelkit/react-stories-player @reelkit/react lucide-react

Vergiss nicht, die Styles zu importieren:

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

tsx
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>
  );
}
StoriesPlayer.tsx
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.

tsx
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:

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

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

tsx
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 locateAsync läuft, bleibt der Player geschlossen und der Parameter unangetastet, damit der Deep Link das Laden übersteht. Ein null oder eine Ablehnung entfernt den Parameter.
  • Eine Antwort, die 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 useOverlayUrlState stehen 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.

tsx
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. resumeStoryIndex wird für jede Gruppe befragt, die in dieser Sitzung zum ersten Mal erreicht wird, die Startgruppe des Players eingeschlossen, außer initialStoryIndex benennt ausdrücklich eine Story; eine Gruppe, durch die schon gewischt wurde, öffnet sich dort, wo sie verlassen wurde.
  • Ein Link gewinnt trotzdem. Mit StoriesUrlOverlay entscheidet 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 eigenen StorageAdapter. Zwei offene Tabs bleiben über das Storage-Event des Browsers im Gleichschritt.
  • Ein Player, der erst beim Öffnen gemountet wird, liest zu spät. 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 rufe useAttachViewedState(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.

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.

tsx
// 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 resumeStoryIndex zurückgibt. Sie zeigt das Posterbild des Videos oder das Bild. Eine Story ohne beides, etwa eine Text-Story auf einem Verlauf, zeichnet dein renderSlide in der Größe des Players und verkleinert sie; ein Video ohne Poster zeigt den Hintergrund der Karte. Diese Vorschau ruft renderSlide mit isActive: false auf 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, hinter isActive.
  • Der Timer wartet auf die Slide. Die Story blättert nicht weiter, und onStoryViewed feuert für sie nicht, bis die Karten fertig gleiten.
  • Jeder Weg, die Gruppe zu wechseln, gleitet: ein Klick auf eine Karte, StoriesApi.goToGroup, nextGroup und prevGroup sowie 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.

tsx
// 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. renderProgressBar und renderHeader bekommen groupIndex und isActive; für eine benachbarte Gruppe ist isActive false, 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 mit role="button", also mach jedes Control darin zu einem davon.
  • Das Desktop-Karussell ist während des Gleitens nicht betroffen. Der Player ist verborgen, solange die Karten gleiten, daher zeigt sich die Wahl, sobald die Gruppe offen ist.

API-Referenz

StoriesOverlayProps

StoriesOverlayProps<T>

PropTypStandardBeschreibung
isOpenbooleanPflichtSteuert die Sichtbarkeit des Overlays. Ist es true, wird das Scrollen des Body gesperrt.
groupsStoriesGroup<T>[]PflichtArray der anzuzeigenden Story-Gruppen
onClose() => voidPflichtCallback zum Schließen des Overlays
ariaLabelstring'Stories player'Zugängliche Beschriftung des Dialogbereichs; Screenreader lesen sie vor, wenn sich das Overlay öffnet
initialGroupIndexnumber0Nullbasierter Index der zuerst sichtbaren Gruppe
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), sonst 0Nullbasierter 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.
groupTransitionTransitionTransformFncubeTransitionÜbergangseffekt für den äußeren Slider (Gruppen)
defaultImageDurationnumber5000Standarddauer für das Weiterblättern bei Bild-Stories in Millisekunden
tapZoneSplitnumber0.3Aufteilung der Tap-Zonen (0–1). Der linke Teil löst zurück aus, der rechte weiter.
hideUIOnPausebooleantrueBlendet Fortschrittsleiste und Header aus, solange ein langes Drücken pausiert
enableKeyboardbooleantrueAktiviert die Tastaturnavigation (Pfeile links/rechts, Escape)
innerTransitionDurationnumber200Dauer der inneren Übergangsanimation (Stories) in Millisekunden
minSegmentWidthnumber8Minimale Segmentbreite der Fortschrittsleiste in Pixeln
apiRefMutableRefObject<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.
viewedStoriesViewedStateController-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.

PropTypStandardBeschreibung
controllerUrlStateController<TwoAxisPosition>PflichtController 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

PropTypBeschreibung
onClose() => voidWird 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) => voidWird ausgelöst, wenn sich die aktive Story ändert
onGroupChange(groupIndex: number) => voidWird ausgelöst, wenn sich die aktive Gruppe ändert
onStoryViewed(groupIndex: number, storyIndex: number) => voidWird ausgelöst, wenn eine Story sichtbar wird
onStoryComplete(groupIndex: number, storyIndex: number) => voidWird ausgelöst, wenn der Timer einer Story abläuft
onDoubleTap(groupIndex: number, storyIndex: number) => voidWird bei einem Doppeltippen ausgelöst
onPause() => voidWird ausgelöst, wenn der Player pausiert
onResume() => voidWird 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:

tsx
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:

CallbackWann
onReadyDer Inhalt ist bereit (Bild geladen, Video spielt). Der Timer für den Fortschritt startet.
onWaitingDer Inhalt stockt (Video puffert während der Wiedergabe). Der Spinner erscheint, und der Timer pausiert.
onErrorDer Inhalt konnte nicht geladen werden. Das Fehler-Overlay wird gezeigt.
onDurationReadyMeldet die tatsächliche Dauer des Mediums (zum Beispiel aus den Metadaten des Videos), damit der Timer mit der richtigen Dauer neu startet.
onEndedMeldet, 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):

tsx
<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:

tsx
<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:

tsx
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:

tsx
<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:

tsx
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:

tsx
<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:

tsx
<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:

tsx
<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:

tsx
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

MethodeTypBeschreibung
nextStory()() => voidBlättert zur nächsten Story in der aktuellen Gruppe
prevStory()() => voidGeht zur vorherigen Story in der aktuellen Gruppe
nextGroup()() => voidWechselt zur nächsten Gruppe
prevGroup()() => voidWechselt zur vorherigen Gruppe
goToGroup(index)(index: number) => voidSpringt per Index zu einer bestimmten Gruppe
pause()() => voidPausiert das Weiterblättern und den Timer des Fortschritts
resume()() => voidSetzt 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.

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

css
/* 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.

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

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

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

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

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

tsx
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).

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

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

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

typescript
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

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

StoriesGroup<T>

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

HeaderRenderProps<T>

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

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

SlideRenderProps<T>

typescript
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;
}
typescript
interface NavigationRenderProps {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

ProgressBarRenderProps<T>

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

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

ErrorRenderProps<T>

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

GroupPreviewRenderProps<T>

typescript
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

typescript
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:

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

KlasseKomponenteBeschreibung
.rk-stories-overlayOverlayFester Vollbild-Hintergrund (Hintergrund, z-index)
.rk-stories-swipe-wrapperOverlayWrapper für das Wischen zum Schließen (enthält die Navigations-Buttons und die Fläche)
.rk-stories-containerOverlayAbgerundete Fläche der Story (Position, Overflow)
.rk-stories-ui-layerOverlayContainer des UI-Overlays (Header, Fortschritt, Navigation). Mit chromePlacement="group" in jeder Gruppen-Slide
.rk-stories-ui-layer--hiddenOverlayAusgeblendeter Zustand der UI (umgeschaltet durch hideUIOnPause)
.rk-stories-errorOverlayFehlerzustand (zentriertes Symbol mit Text)
.rk-stories-error-textOverlayText der Fehlermeldung
.rk-stories-nav-btnNavigationPfeil für zurück und weiter auf dem Desktop
.rk-stories-progress-barProgressBarWrapper für die Position der Fortschrittsleiste auf Canvas
.rk-stories-slide-wrapperGroupEine Gruppe von Stories (äußere Slide)
.rk-stories-storyStoryEine einzelne Story (Wurzel der inneren Slide)
.rk-stories-headerStoryHeaderHeader-Leiste (Avatar, Name, Aktionen)
.rk-stories-header--hiddenStoryHeaderAusgeblendeter Zustand des Headers (visible=false)
.rk-stories-header-avatarStoryHeaderAvatarbild der Person
.rk-stories-header-nameStoryHeaderName der Person
.rk-stories-header-verifiedStoryHeaderContainer des Verifiziert-Badges
.rk-stories-header-timeStoryHeaderText mit der vergangenen Zeit
.rk-stories-header-actionsStoryHeaderAktionen rechts (Schließen, Ton, Pause)
.rk-stories-header-btnStoryHeaderAktions-Button im Header
.rk-stories-header-btn--desktopStoryHeaderPause-Button, nur ab 768px Breite sichtbar
.rk-stories-header-spinnerStoryHeaderSpinner beim Puffern des Videos
.rk-stories-imageImageStorySlideElement einer Bild-Story
.rk-stories-videoVideoStorySlideContainer einer Video-Story
.rk-stories-video-elementVideoStorySlideDas gemeinsame Element <video>
.rk-stories-video-posterVideoStorySlidePosterbild des Videos (blendet beim Abspielen aus)
.rk-stories-video-poster--visibleVideoStorySlideSichtbarer Zustand des Posters (vor der Wiedergabe)
.rk-stories-heartHeartAnimationHerz-Animation beim Doppeltippen
.rk-stories-ringStoriesRingStory-Ring (Avatar mit animiertem Verlaufsrand)
.rk-stories-ring--activeStoriesRingRing mit noch ungesehenen Stories (animiert)
.rk-stories-ring-avatarStoriesRingAvatarbild im Ring
.rk-stories-ring-listStoriesRingListContainer der horizontalen Ringliste
.rk-stories-ring-list-itemStoriesRingListSpalte mit Ring und Name
.rk-stories-ring-list-nameStoriesRingListName der Person unter jedem Ring
.rk-stories-overlay--carouselOverlayOverlay, während das Desktop-Karussell sichtbar ist
.rk-stories-overlay--slidingOverlayDas Karussell gleitet; die Fläche des Players ist verborgen
.rk-stories-carouselCarouselEbene der Vorschaukarten hinter dem Player
.rk-stories-carousel--instantCarouselErster Frame eines Gleitens, ohne Transition angeordnet
.rk-stories-cardCarouselEine positionierte Vorschaukarte
.rk-stories-card--centerCarouselKarte im mittleren Platz während eines Gleitens
.rk-stories-card--hiddenCarouselKarte jenseits der zwei sichtbaren auf einer Seite
.rk-stories-card-buttonCarouselStandardinhalt der Karte (der Button, der die Gruppe öffnet)
.rk-stories-card-imageCarouselVorschaubild (Poster oder Bild)
.rk-stories-card-frameCarouselVerkleinerte Vorschau per renderSlide für eine Story ohne Bild oder Poster; inaktiv
.rk-stories-card-scrimCarouselAbdunkelnde Ebene über dem Frame
.rk-stories-card-infoCarouselSpalte mit Ring, Name und Zeit
.rk-stories-card-nameCarouselName der Person
.rk-stories-card-timeCarouselText 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.

TokenStandardSteuert
--rk-stories-overlay-bg#000Farbe des Vollbild-Hintergrunds
--rk-stories-overlay-z9999z-index des Overlays
--rk-stories-swipe-gap16pxAbstand zwischen den Navigations-Buttons und der Fläche der Story
--rk-stories-container-radius12pxAbgerundete Ecken der Story-Fläche (Desktop)
--rk-stories-container-radius-mobile0Ecken der Story-Fläche bis 768px Breite
--rk-stories-ui-z15z-index der UI-Ebene (Header, Fortschrittsleiste)
--rk-stories-ui-transition200msDauer des Ausblendens, wenn hideUIOnPause umschaltet
--rk-stories-top-shade-height120pxHöhe des Verlaufs oben hinter dem Header
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Farbe des Verlaufs oben
--rk-stories-error-z5z-index des Fehlerzustands
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)Verlauf im Hintergrund des Fehlerzustands
--rk-stories-error-fgrgba(255, 255, 255, 0.5)Farbe von Fehlersymbol und Fehlertext
--rk-stories-error-gap12pxAbstand zwischen dem Fehlersymbol und seinem Text
--rk-stories-error-text-size13pxSchriftgröße der Fehlermeldung
--rk-stories-nav-z20z-index der Navigations-Buttons auf dem Desktop
--rk-stories-nav-size44pxGröße der Buttons für zurück und weiter auf dem Desktop
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)Hintergrund der Navigations-Buttons auf dem Desktop
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)Hover-Hintergrund der Navigations-Buttons auf dem Desktop
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)Icon-Farbe der Navigations-Buttons auf dem Desktop
--rk-stories-nav-fg-hover#fffIcon-Farbe der Navigations-Buttons beim Hover
--rk-stories-nav-transition150msTransition von Hintergrund und Farbe der Navigations-Buttons
--rk-stories-video-bg#000Letterbox-Hintergrund hinter <video>
--rk-stories-video-poster-transition200msDauer des Ausblendens des Posters, wenn das Video startet
--rk-stories-progress-bar-z10z-index der Fortschrittsleiste
--rk-stories-progress-bar-padding8px 8px 0Abstand um die Fortschrittsleiste
--rk-stories-header-z5z-index des Headers
--rk-stories-header-top18pxVertikaler Abstand des Headers vom oberen Rand der Story
--rk-stories-header-padding12px 16pxInnenabstand der Header-Zeile
--rk-stories-header-gap8pxAbstand zwischen Avatar, Name und Zeit
--rk-stories-header-transition200msAusblenden des Headers, wenn er verborgen wird
--rk-stories-header-avatar-size32pxBreite und Höhe des Avatars
--rk-stories-header-name-fg#fffFarbe des Namens
--rk-stories-header-name-size14pxSchriftgröße des Namens
--rk-stories-header-name-weight600Schriftstärke des Namens
--rk-stories-header-time-fgrgba(255, 255, 255, 0.6)Farbe des Texts mit der vergangenen Zeit
--rk-stories-header-time-size12pxSchriftgröße des Texts mit der vergangenen Zeit
--rk-stories-header-actions-gap8pxAbstand zwischen den Aktions-Buttons im Header
--rk-stories-header-btn-fg#fffIcon-Farbe der Aktionen im Header (Schließen, Ton, Pause)
--rk-stories-header-btn-padding4pxInnenabstand der Aktions-Buttons im Header
--rk-stories-header-spinner-size20pxGröße des Spinners beim Puffern
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Farbe der Spur des Spinners beim Puffern
--rk-stories-header-spinner-fg#fffFarbe des Bogens des Spinners beim Puffern
--rk-stories-header-spinner-duration0.8sEine Umdrehung des Spinners beim Puffern
--rk-stories-heart-z20z-index des Herzens beim Doppeltippen
--rk-stories-heart-duration800msDauer der Animation für Einblenden und Ausblenden
--rk-stories-ring-spin-duration4sDauer der Drehung eines Rings mit noch ungesehenen Stories
--rk-stories-ring-active-scale0.95Skalierung des Rings beim Drücken
--rk-stories-ring-active-gradientconic-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-colorrgba(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-gradientnoneIntern: 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-gap12pxAbstand zwischen den Ringen in der Liste
--rk-stories-ring-list-padding12pxInnenabstand um die Ringliste
--rk-stories-ring-list-item-gap4pxAbstand zwischen einem Ring und seinem Namen
--rk-stories-ring-list-name-size12pxSchriftgröße des Namens unter jedem Ring
--rk-stories-card-bg#262626Hintergrund der Karussellkarte, sichtbar ohne Vorschaubild
--rk-stories-card-radius8pxEckenradius der Karussellkarte
--rk-stories-card-scrimrgba(0, 0, 0, 0.45)Abdunkelung über einer seitlichen Karte
--rk-stories-card-fg#fffTextfarbe der Karte
--rk-stories-card-name-size14pxSchriftgröße des Namens auf der Karte
--rk-stories-card-time-fgrgba(255, 255, 255, 0.7)Farbe des Texts mit der vergangenen Zeit auf der Karte
--rk-stories-card-time-size13pxSchriftgröße des Texts mit der vergangenen Zeit auf der Karte
--rk-stories-card-gap6pxAbstand zwischen Ring, Name und Zeit auf einer Karte
--rk-stories-card-transition300msDauer des Gleitens im Karussell

Füge das Snippet unten in ein Stylesheet ein, das nach @reelkit/react-stories-player/styles.css geladen wird.

css
/* 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

TasteAktion
ArrowLeftVorherige 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
ArrowRightNächste Story. Nach der letzten Story einer Gruppe die nächste Gruppe; nach der letzten Gruppe schließt sich der Player
EscapeSchließt den Player