Stories Player

Un overlay de stories al estilo de Instagram para React, con @reelkit/react-stories-player.

Ver la demo en vivo →

Características

Navegación anidada
Toca para avanzar entre stories, desliza para cambiar de grupo
Stories de vídeo
Reproducción automática con botón de sonido
Avance automático
Temporizador configurable por story
Transiciones 3D
Cubo, volteo, fundido, zoom, deslizamiento
Barra de progreso
Progreso segmentado dibujado en canvas
Imagen y vídeo
Admite los dos tipos de medio
Virtualizado
Solo 3 slides en el DOM
Me gusta con doble toque
Animación de corazón al tocar dos veces
Navegación en escritorio
Botones con flechas en escritorio
Anillos de stories
Anillos de avatar al estilo de Instagram
Tipos genéricos
Extiende StoryItem con datos propios
Render props
Personaliza cada elemento de la interfaz
Estado en la URL
Enlaces ?story=grupo.story que se pueden compartir
Elementos ya vistos
Los anillos vistos y la reanudación sobreviven a las recargas

Instalación

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

No olvides importar los estilos:

typescript
import '@reelkit/react-stories-player/styles.css';
Iconos

La cabecera por defecto usa lucide-react para los iconos. Si prefieres otra librería de iconos, usa renderHeader y renderNavigation para poner los tuyos.

Inicio rápido

El componente StoriesOverlay renderiza un visor de stories a pantalla completa. Combínalo con StoriesRingList para tener puntos de entrada al estilo de Instagram. Pásale un array de objetos StoriesGroup y controla su visibilidad con isOpen.

tsx
import { useState, useMemo } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  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);
  const viewedState = useMemo(() => new Map<string, number>(), []);

  const openStories = (groupIndex: number) => {
    setSelectedGroup(groupIndex);
    setIsOpen(true);
  };

  return (
    <div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
      <StoriesRingList
        groups={groups}
        viewedState={viewedState}
        onSelect={openStories}
      />

      <StoriesOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        groups={groups}
        initialGroupIndex={selectedGroup}
        onStoryViewed={(gi, si) => {
          const author = groups[gi].author;
          const current = viewedState.get(author.id) ?? 0;
          viewedState.set(author.id, Math.max(current, si + 1));
        }}
      />
    </div>
  );
}

Demo en vivo

StoriesPlayer.tsx
Alice
Alice
Bob
Bob
Charlie
Charlie

Pulsa un anillo de stories para abrir el visor. Toca los lados izquierdo o derecho para navegar y desliza para cambiar de usuario.

Estado en la URL

Ver la demo en vivo →

StoriesUrlOverlay es un componente aparte cuyo estado abierto vive en la barra de direcciones. Los dos ejes van en un solo parámetro, ?story=<group>.<story>, así que la story que se reproduce tiene un enlace que se puede compartir, guardar y cerrar con el botón de volver. Crea un controlador con useOverlayUrlState y urlIndexTwoAxisKey y pásalo como controller.

Claves incluidas

Las stories tienen dos ejes, así que pasa con spread una clave de dos ejes al controlador: urlIndexTwoAxisKey (grupo y story por posición) o urlStableIdTwoAxisKey (el grupo por un id estable); las dos se reexportan desde @reelkit/react. Consulta la guía del estado en la URL y la API del core.

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} />
  • Abrir añade una entrada al historial. Deslizar entre stories y cambiar de usuario la sustituyen, así que N navegaciones no añaden entradas y volver una vez siempre cierra el visor. Volver cierra; no cambia de story.
  • La navegación interior también va en la URL. El índice de la story no se queda fijo por grupo: al avanzar por las stories de un usuario se actualiza ?story=2.n, así que un enlace directo abre la story exacta.
  • Volver solo cierra cuando el visor se abrió desde dentro de la aplicación, es decir, cuando el enlace añadió una entrada. Un enlace compartido abierto directamente en una pestaña nueva no tiene historial detrás, así que el botón de volver del navegador sale del sitio; el botón ✕ o Escape quitan el parámetro en el sitio y se quedan.
  • Un parámetro que no nombra ningún grupo o story (un marcador desfasado, un valor editado a mano, una story más allá del final de un grupo) se quita de la URL en lugar de abrir una vecina.

Aplicación con router: pasa un adaptador. Escribir con history.pushState a espaldas de un router deja su ubicación desfasada, y su siguiente navegación pierde el parámetro:

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

Enlaces estables. Por defecto el grupo va por posición, así que un ?story=2.0 guardado abre otro usuario en cuanto el feed se reordena. Apunta al grupo por un id estable: outerCodec escribe el id en la URL y outerLocator encuentra dónde está. La parte de la story sigue siendo un índice simple dentro del grupo encontrado.

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

Feeds infinitos. La paginación es cosa de outerLocator, independiente del codec. locate es síncrono, así que solo responde por los grupos ya cargados: un enlace compartido al grupo 400 de un feed que ha cargado 20 no encuentra nada. locateAsync es la alternativa y solo se llama cuando locate falla; la story se vuelve a acotar según el grupo en el que acabe.

El mismo locateAsync, en el eje exterior

Es el mismo paginador locateAsync que aceptan las claves de un eje: en una clave de dos ejes va en el outerLocator que pasas, así que el eje del grupo pagina mientras la story sigue siendo un índice local dentro del grupo encontrado.

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;
      },
    },
  }),
});
  • Mientras locateAsync está pendiente, el visor sigue cerrado y el parámetro no se toca, así que el enlace directo sobrevive a la carga. Un null o un rechazo quitan el parámetro.
  • Una respuesta que llega después de que la URL haya cambiado, después de cerrar o después de desmontar se descarta: una carga lenta no puede abrir una story que nadie ha pedido.
  • Las opciones completas de useOverlayUrlState están en la referencia de la API de React, y la explicación paso a paso está en la guía de React.

Recordar lo que ya se ha visto

Los anillos muestran un degradado hasta que un grupo se ha visto hasta el final, y un grupo se vuelve a abrir en la primera story que no se ha visto. Las dos cosas salen de un ViewedStateController creado con la misma clave que usa la barra de direcciones, así que una entrada guardada se lee exactamente igual que el parámetro de un enlace compartido y sobrevive a que el feed se reordene cuando la clave va por id.

tsx
import { useMemo, useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  useViewedState,
  createStoriesViewedState,
  urlStableIdTwoAxisKey,
  twoAxisViewedTracking,
  type StoriesGroup,
} from '@reelkit/react-stories-player';
import { Observe } from '@reelkit/react';

function Feed({ groups }: { groups: StoriesGroup[] }) {
  const [open, setOpen] = useState(false);
  const [group, setGroup] = useState(0);

  const seen = useViewedState({
    storageKey: 'stories-seen',
    ...urlStableIdTwoAxisKey({
      outerItems: () => groups.map((g) => ({ id: g.author.id })),
      innerItems: (outer) =>
        groups.find((g) => g.author.id === outer.id)?.stories ?? [],
    }),
    ...twoAxisViewedTracking,
  });
  const viewed = useMemo(
    () => createStoriesViewedState(seen, () => groups),
    [seen, groups],
  );

  return (
    <>
      {/* entries is a signal, so the rings repaint as stories are seen */}
      <Observe signals={[seen.entries]}>
        {() => (
          <StoriesRingList
            groups={groups}
            viewedState={viewed.viewedCounts()}
            onSelect={(index) => {
              setGroup(index);
              setOpen(true);
            }}
          />
        )}
      </Observe>

      <StoriesOverlay
        isOpen={open}
        onClose={() => setOpen(false)}
        groups={groups}
        initialGroupIndex={group}
        resumeStoryIndex={viewed.resumeStoryIndex}
        onStoryViewed={viewed.markViewed}
      />
    </>
  );
}
  • La story inicial cuenta. La story que ya está en pantalla se marca como vista al montar, así que abrir un grupo de una sola story y cerrarlo lo marca como visto.
  • Deslizar hacia delante también reanuda. Se consulta resumeStoryIndex para cada grupo al que se llega por primera vez en esta sesión, incluido el grupo con el que se abre el visor, salvo que initialStoryIndex indique una story concreta; un grupo por el que ya se ha pasado se vuelve a abrir donde se dejó.
  • Un enlace sigue mandando. Con StoriesUrlOverlay, el parámetro decide dónde se abre el visor, se haya guardado lo que se haya guardado. En los demás casos decide el callback de reanudación.
  • Una cuenta es una posición, no un recuento. Una entrada indica la story más lejana a la que se llegó, así que añadir una story a un grupo visto vuelve a encender su anillo, y quitar una del medio acorta la cuenta. Es la misma corrección automática que tiene un enlace compartido.
  • El almacenamiento es intercambiable. Pasa createSessionStorageAdapter() para olvidarlo al cerrar la pestaña, o tu propio StorageAdapter. Dos pestañas abiertas se mantienen sincronizadas con el evento storage del navegador.

Referencia de la API

StoriesOverlayProps

StoriesOverlayProps<T>

PropTipoPor defectoDescripción
isOpenbooleanobligatorioControla la visibilidad del overlay. Con true, el scroll del body se bloquea.
groupsStoriesGroup<T>[]obligatorioArray de grupos de stories que mostrar
onClose() => voidobligatorioCallback para cerrar el overlay
ariaLabelstring'Stories player'Etiqueta accesible de la región del diálogo; los lectores de pantalla la anuncian al abrirse el overlay
initialGroupIndexnumber0Índice, empezando en cero, del grupo visible al principio
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), si no 0Índice, empezando en cero, de la story visible al principio dentro del grupo. Si lo indicas, se impone a lo que se haya recordado; si no, el grupo inicial se reanuda igual que los demás.
resumeStoryIndex(groupIndex: number) => numberCon qué story se abre un grupo la primera vez que se llega a él, para que se siga donde se dejó. Solo se consulta para un grupo que aún no se ha visitado desde que se abrió.
groupTransitionTransitionTransformFncubeTransitionEfecto de transición del slider exterior (grupos)
defaultImageDurationnumber5000Duración por defecto del avance automático en las stories con imagen, en milisegundos
tapZoneSplitnumber0.3Proporción de las zonas de toque (0–1). La parte izquierda lanza anterior y la derecha, siguiente.
hideUIOnPausebooleantrueIndica si se oculta la interfaz de la story (cabecera, pie) al pausar con una pulsación larga
enableKeyboardbooleantrueActiva la navegación con teclado (flechas izquierda y derecha, Escape)
innerTransitionDurationnumber200Duración de la animación de transición interior (stories) en milisegundos
minSegmentWidthnumber8Ancho mínimo de un segmento de la barra de progreso en píxeles
apiRefMutableRefObject<StoriesApi | null>-Ref para acceder a la StoriesApi imperativa
renderHeader(props: HeaderRenderProps<T>) => ReactNode-Renderizador de cabecera propio. Recibe el autor, la story y el estado de pausa y de silencio.
renderFooter(props: FooterRenderProps<T>) => ReactNode-Renderizador de pie propio. Recibe la información del autor y de la story.
renderSlide(props: SlideRenderProps<T>) => ReactNode-Renderizador de slide propio que sustituye los slides de imagen y vídeo por defecto.
renderNavigation(props: NavigationRenderProps) => ReactNode-Navegación propia en escritorio. Sustituye los botones con flechas de anterior y siguiente por defecto.
renderProgressBar(props: ProgressBarRenderProps<T>) => ReactNode-Barra de progreso propia. Sustituye la barra de progreso en canvas por defecto.
renderLoading(props: LoadingRenderProps<T>) => ReactNode-Renderizador propio de la interfaz de carga. Si no se pasa, se muestra el spinner de la cabecera por defecto.
renderError(props: ErrorRenderProps<T>) => ReactNode-Renderizador propio de la interfaz de error. Si no se pasa, se muestra el overlay con el icono de error por defecto.

StoriesUrlOverlayProps

StoriesUrlOverlayProps<T>

Acepta todas las props de StoriesOverlay salvo las tres del estado abierto (isOpen, initialGroupIndex, initialStoryIndex), que vienen del controlador.

PropTipoPor defectoDescripción
controllerUrlStateController<TwoAxisPosition>obligatorioControlador de useOverlayUrlState con urlIndexTwoAxisKey. Su posición, un objeto { outer, inner }, decide si el visor está abierto y dónde se abre; el overlay escribe de vuelta en cada navegación y al cerrar.

Callbacks

PropTipoDescripción
onClose() => voidSe llama al cerrar el visor. Obligatorio en StoriesOverlay (el estado abierto es tuyo, así que tienes que gestionar el cierre); opcional en StoriesUrlOverlay, donde la URL controla el cierre: pásalo solo para reaccionar después de cerrar.
onStoryChange(groupIndex: number, storyIndex: number) => voidSe lanza al cambiar la story activa
onGroupChange(groupIndex: number) => voidSe lanza al cambiar el grupo activo
onStoryViewed(groupIndex: number, storyIndex: number) => voidSe lanza cuando una story se hace visible
onStoryComplete(groupIndex: number, storyIndex: number) => voidSe lanza al terminar el temporizador de una story
onDoubleTap(groupIndex: number, storyIndex: number) => voidSe lanza con un gesto de doble toque
onPause() => voidSe lanza al pausar el visor
onResume() => voidSe lanza al reanudar el visor

Transiciones

La prop groupTransition controla el efecto de transición 3D al deslizar entre los grupos de usuarios. Importa las funciones de transición desde @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}
/>

Ciclo de vida de la carga de contenido

Cada slide de story comunica su estado de carga con los callbacks que recibe a través de SlideRenderProps:

CallbackCuándo
onReadyEl contenido está listo (imagen cargada, vídeo reproduciéndose). Arranca el temporizador de progreso.
onWaitingEl contenido se atasca (el vídeo carga a mitad de la reproducción). Aparece el spinner y el temporizador se pausa.
onErrorEl contenido no se ha podido cargar. Se muestra el overlay de error.
onDurationReadyIndica la duración real del medio (por ejemplo, con los metadatos del vídeo) para reiniciar el temporizador con la duración correcta.
onEndedIndica que el medio ha terminado (por ejemplo, el vídeo ha acabado). Avanza a la story siguiente.
Caché del precargador

Los componentes incluidos ImageStorySlide y VideoStorySlide precargan la story siguiente en segundo plano. Cuando el usuario llega a una story precargada, el contenido aparece al instante sin spinner de carga.

Render props

Cada elemento de la interfaz se puede sustituir con render props. Cada una recibe props tipadas con todo el estado y los callbacks necesarios.

renderHeader

Sustituye la cabecera por defecto (información del autor, botones de pausa y silencio, botón de cerrar):

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

Añade un pie debajo del contenido de la story:

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

Sustituye por completo los slides de imagen y vídeo por defecto. Usa los subcomponentes ImageStorySlide y VideoStorySlide para aprovechar la gestión de medios incluida:

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

Sustituye los botones con flechas de escritorio por defecto:

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

Sustituye la barra de progreso en canvas por defecto por una implementación propia. La señal progress emite valores de 0 a 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

Indicador de carga propio mientras se descarga el contenido:

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

Overlay de error propio cuando el contenido no se puede cargar:

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>
  )}
/>

StoriesApi

Usa la prop apiRef para controlarlo de forma imperativa:

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

Métodos

MétodoTipoDescripción
nextStory()() => voidAvanza a la story siguiente dentro del grupo actual
prevStory()() => voidVa a la story anterior dentro del grupo actual
nextGroup()() => voidCambia al grupo de usuario siguiente
prevGroup()() => voidCambia al grupo de usuario anterior
goToGroup(index)(index: number) => voidSalta a un grupo concreto por su índice
pause()() => voidPausa el avance automático y el temporizador de progreso
resume()() => voidReanuda el avance automático y el temporizador de progreso

Doble toque y me gusta

Con un doble toque se reproduce una animación de corazón incluida que da una respuesta visual inmediata. El callback onDoubleTap recibe el índice del grupo y de la story para que guardes el me gusta en tu propio estado (llamada a una API, almacenamiento local, etc.). El visor no gestiona internamente el estado de los me gusta.

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

Personalizar la animación del corazón

Ajusta la velocidad de la animación con el token --rk-stories-heart-duration (mira Temas). Para el color, el tamaño o para ocultar del todo el corazón, apunta directamente a la clase .rk-stories-heart. El componente HeartAnimation también se exporta para usarlo por separado.

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

Por ahora la animación del corazón incluida no se puede sustituir con una render prop. Puedes cambiar su estilo con CSS u ocultarla con display: none y crear tu propia animación en el callback onDoubleTap. Si necesitas una render prop renderDoubleTap, dínoslo en GitHub Issues.

Subcomponentes

Piezas reutilizables exportadas para combinarlas en render props propias:

CanvasProgressBar

Barra de progreso segmentada de alto rendimiento dibujada en canvas. Renderiza un segmento por story y anima el relleno del segmento activo con requestAnimationFrame. Admite una ventana deslizante para grupos con muchas stories.

tsx
import { CanvasProgressBar } from '@reelkit/react-stories-player';

<CanvasProgressBar
  totalStories={group.stories.length}
  activeIndex={activeIndexSignal}
  progress={progressSignal}
  minSegmentWidth={8}
  gap={2}
  barHeight={2}
/>

StoryHeader

Cabecera por defecto con el avatar del autor, el nombre, la insignia de verificado, la hora relativa, el botón de pausa y reproducción, el botón de silencio, el spinner de carga y el botón de cerrar. Se usa automáticamente cuando no se pasa renderHeader.

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

Slide de imagen a sangre con object-fit: cover. Indica la carga y los errores con callbacks para seguir el ciclo de vida.

tsx
import { ImageStorySlide } from '@reelkit/react-stories-player';

<ImageStorySlide
  src="/photo.jpg"
  aspectRatio={9 / 16}
  onLoad={() => console.log('loaded')}
  onError={() => console.log('failed')}
/>

VideoStorySlide

Slide de vídeo que usa un elemento <video> compartido para que el sonido no se corte en iOS. Gestiona la reproducción automática, los fotogramas de póster y la sincronización del sonido, e informa de la duración y de los eventos del ciclo de reproducción.

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

Avatar circular con un anillo con degradado al estilo de Instagram. Tiene dos estados: un grupo con stories por ver lleva el degradado giratorio, y uno visto por completo lleva un anillo plano y apagado.

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

Fila horizontal con scroll de componentes StoriesRing con los nombres de los autores. Un anillo por grupo.

tsx
import { StoriesRingList } from '@reelkit/react-stories-player';

<StoriesRingList
  groups={groups}
  viewedState={viewedMap}
  onSelect={(groupIndex) => openStories(groupIndex)}
/>

HeartAnimation

Overlay con un corazón animado que aparece con un doble toque. Crece y se desvanece en 800ms. Personalízalo con CSS (mira la sección de doble toque y me gusta).

tsx
import { HeartAnimation } from '@reelkit/react-stories-player';

<HeartAnimation onComplete={() => console.log('animation done')} />

Tipos

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

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

StoriesApi

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

Tipos de Story personalizados

Extiende StoryItem con campos propios y pasa el parámetro de tipo a StoriesOverlay. Todas las render props recibirán tu tipo extendido:

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

Clases CSS

Todas las clases CSS son normales (no CSS modules), así que puedes apuntar a ellas con selectores más específicos en una hoja de estilos cargada después de @reelkit/react-stories-player/styles.css. Para cambiar colores, tamaños y z-index, es mejor usar las propiedades personalizadas de CSS de la sección Temas de más abajo.

ClaseComponenteDescripción
.rk-stories-overlayOverlayFondo fijo a pantalla completa (fondo, z-index)
.rk-stories-swipe-wrapperOverlayEnvoltorio de deslizar para cerrar (contiene los botones de navegación y el lienzo)
.rk-stories-containerOverlayLienzo redondeado de la story (posición, desbordamiento)
.rk-stories-ui-layerOverlayContenedor del overlay de la interfaz (cabecera, progreso, navegación)
.rk-stories-ui-layer--hiddenOverlayEstado oculto de la interfaz (lo activa hideUIOnPause)
.rk-stories-errorOverlayEstado de error (icono y texto centrados)
.rk-stories-error-textOverlayTexto del mensaje de error
.rk-stories-nav-btnNavigationFlecha de anterior o siguiente en escritorio
.rk-stories-progress-barProgressBarEnvoltorio que posiciona la barra de progreso en canvas
.rk-stories-slide-wrapperGroupUn grupo de stories (slide exterior)
.rk-stories-storyStoryUna sola story (raíz del slide interior)
.rk-stories-headerStoryHeaderBarra de cabecera (avatar, nombre, acciones)
.rk-stories-header--hiddenStoryHeaderEstado oculto de la cabecera (visible=false)
.rk-stories-header-avatarStoryHeaderImagen del avatar del autor
.rk-stories-header-nameStoryHeaderTexto con el nombre del autor
.rk-stories-header-verifiedStoryHeaderContenedor de la insignia de verificado
.rk-stories-header-timeStoryHeaderTexto con el tiempo transcurrido
.rk-stories-header-actionsStoryHeaderAcciones del lado derecho (cerrar, silencio, pausa)
.rk-stories-header-btnStoryHeaderBotón de acción de la cabecera
.rk-stories-header-spinnerStoryHeaderSpinner mientras el vídeo carga
.rk-stories-imageImageStorySlideElemento de la story de imagen
.rk-stories-videoVideoStorySlideContenedor de la story de vídeo
.rk-stories-video-elementVideoStorySlideEl elemento <video> compartido
.rk-stories-video-posterVideoStorySlideImagen del póster del vídeo (se desvanece al reproducir)
.rk-stories-video-poster--visibleVideoStorySlideEstado visible del póster (antes de reproducir)
.rk-stories-heartHeartAnimationAnimación del corazón al tocar dos veces
.rk-stories-ringStoriesRingAnillo de stories (avatar con borde de degradado animado)
.rk-stories-ring--activeStoriesRingAnillo con stories por ver (animado)
.rk-stories-ring-avatarStoriesRingImagen del avatar dentro del anillo
.rk-stories-ring-listStoriesRingListContenedor de la lista horizontal de anillos
.rk-stories-ring-list-itemStoriesRingListColumna con anillo y nombre
.rk-stories-ring-list-nameStoriesRingListNombre del autor debajo de cada anillo

Temas

Cada color, tamaño, z-index y transición vive en una propiedad personalizada de CSS. Sobrescribe una o varias en :root (o en cualquier ancestro del overlay) para cambiar el tema sin tocar el código de los componentes.

TokenPor defectoControla
--rk-stories-overlay-bg#000Color del fondo a pantalla completa
--rk-stories-overlay-z9999z-index del overlay
--rk-stories-container-radius12pxEsquinas redondeadas del lienzo de la story (escritorio)
--rk-stories-swipe-gap16pxEspacio entre los botones de navegación y el lienzo de la story
--rk-stories-top-shade-height120pxAltura del degradado superior detrás de la cabecera
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Color del degradado superior
--rk-stories-ui-transition200msDuración del fundido al activarse hideUIOnPause
--rk-stories-nav-size44pxTamaño de los botones de anterior y siguiente en escritorio
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)Fondo de los botones de navegación en escritorio
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)Fondo de los botones de navegación en escritorio al pasar el ratón
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)Color del icono de los botones de navegación en escritorio
--rk-stories-nav-fg-hover#fffColor del icono de los botones de navegación en escritorio al pasar el ratón
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)Degradado del fondo del estado de error
--rk-stories-error-fgrgba(255, 255, 255, 0.5)Color del icono y el texto de error
--rk-stories-error-text-size13pxTamaño de letra del mensaje de error
--rk-stories-video-bg#000Fondo de las bandas detrás del <video>
--rk-stories-video-poster-transition200msDuración del fundido del póster cuando el vídeo empieza a reproducirse
--rk-stories-header-top18pxSeparación vertical de la cabecera desde la parte superior de la story
--rk-stories-header-padding12px 16pxRelleno interior de la fila de la cabecera
--rk-stories-header-avatar-size32pxAncho y alto del avatar
--rk-stories-header-name-fg#fffColor del nombre del autor
--rk-stories-header-name-size14pxTamaño de letra del nombre del autor
--rk-stories-header-time-fgrgba(255, 255, 255, 0.6)Color del texto con el tiempo transcurrido
--rk-stories-header-btn-fg#fffColor de los iconos de acción de la cabecera (cerrar, silencio, pausa)
--rk-stories-heart-duration800msDuración de la animación de aparición y desvanecimiento
--rk-stories-ring-spin-duration4sDuración del giro de un anillo con stories por ver
--rk-stories-ring-list-gap12pxEspacio entre los anillos de la lista
--rk-stories-ring-list-padding12pxRelleno interior alrededor de la lista de anillos
--rk-stories-ring-list-name-size12pxTamaño de letra del nombre del autor debajo de cada anillo

Pega el fragmento de abajo en una hoja de estilos cargada después de @reelkit/react-stories-player/styles.css.

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

Accesibilidad

La raíz del overlay es un diálogo modal (role="dialog", aria-modal="true"). Usa ariaLabel para cambiar lo que anuncia el lector de pantalla; por defecto es "Stories player".

El overlay captura el foco al abrirse y lo devuelve al disparador al cerrarse. Tab y Mayús+Tab recorren los elementos enfocables de dentro; el foco que sale (un clic fuera, un foco por código) vuelve dentro. Está implementado con captureFocusForReturn y createFocusTrap de @reelkit/core.

Atajos de teclado

TeclaAcción
ArrowLeftStory anterior
ArrowRightStory siguiente
EscapeCierra el visor