Stories Player
Un overlay de stories al estilo de Instagram para React, con @reelkit/react-stories-player.
Características
Instalación
npm i @reelkit/react-stories-player @reelkit/react lucide-reactNo olvides importar los estilos:
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.
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
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.
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:
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.
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.
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
locateAsyncestá pendiente, el visor sigue cerrado y el parámetro no se toca, así que el enlace directo sobrevive a la carga. Unnullo 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
useOverlayUrlStateestá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.
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
resumeStoryIndexpara cada grupo al que se llega por primera vez en esta sesión, incluido el grupo con el que se abre el visor, salvo queinitialStoryIndexindique 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 propioStorageAdapter. Dos pestañas abiertas se mantienen sincronizadas con el evento storage del navegador.
Referencia de la API
StoriesOverlayProps
StoriesOverlayProps<T>
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
isOpen | boolean | obligatorio | Controla la visibilidad del overlay. Con true, el scroll del body se bloquea. |
groups | StoriesGroup<T>[] | obligatorio | Array de grupos de stories que mostrar |
onClose | () => void | obligatorio | Callback para cerrar el overlay |
ariaLabel | string | 'Stories player' | Etiqueta accesible de la región del diálogo; los lectores de pantalla la anuncian al abrirse el overlay |
initialGroupIndex | number | 0 | Índice, empezando en cero, del grupo visible al principio |
initialStoryIndex | number | resumeStoryIndex(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) => number | — | Con 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ó. |
groupTransition | TransitionTransformFn | cubeTransition | Efecto de transición del slider exterior (grupos) |
defaultImageDuration | number | 5000 | Duración por defecto del avance automático en las stories con imagen, en milisegundos |
tapZoneSplit | number | 0.3 | Proporción de las zonas de toque (0–1). La parte izquierda lanza anterior y la derecha, siguiente. |
hideUIOnPause | boolean | true | Indica si se oculta la interfaz de la story (cabecera, pie) al pausar con una pulsación larga |
enableKeyboard | boolean | true | Activa la navegación con teclado (flechas izquierda y derecha, Escape) |
innerTransitionDuration | number | 200 | Duración de la animación de transición interior (stories) en milisegundos |
minSegmentWidth | number | 8 | Ancho mínimo de un segmento de la barra de progreso en píxeles |
apiRef | MutableRefObject<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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | obligatorio | Controlador 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
| Prop | Tipo | Descripción |
|---|---|---|
onClose | () => void | Se 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) => void | Se lanza al cambiar la story activa |
onGroupChange | (groupIndex: number) => void | Se lanza al cambiar el grupo activo |
onStoryViewed | (groupIndex: number, storyIndex: number) => void | Se lanza cuando una story se hace visible |
onStoryComplete | (groupIndex: number, storyIndex: number) => void | Se lanza al terminar el temporizador de una story |
onDoubleTap | (groupIndex: number, storyIndex: number) => void | Se lanza con un gesto de doble toque |
onPause | () => void | Se lanza al pausar el visor |
onResume | () => void | Se 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:
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:
| Callback | Cuándo |
|---|---|
onReady | El contenido está listo (imagen cargada, vídeo reproduciéndose). Arranca el temporizador de progreso. |
onWaiting | El contenido se atasca (el vídeo carga a mitad de la reproducción). Aparece el spinner y el temporizador se pausa. |
onError | El contenido no se ha podido cargar. Se muestra el overlay de error. |
onDurationReady | Indica la duración real del medio (por ejemplo, con los metadatos del vídeo) para reiniciar el temporizador con la duración correcta. |
onEnded | Indica 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):
<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:
<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:
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:
<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:
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:
<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:
<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:
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étodo | Tipo | Descripción |
|---|---|---|
nextStory() | () => void | Avanza a la story siguiente dentro del grupo actual |
prevStory() | () => void | Va a la story anterior dentro del grupo actual |
nextGroup() | () => void | Cambia al grupo de usuario siguiente |
prevGroup() | () => void | Cambia al grupo de usuario anterior |
goToGroup(index) | (index: number) => void | Salta a un grupo concreto por su índice |
pause() | () => void | Pausa el avance automático y el temporizador de progreso |
resume() | () => void | Reanuda 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.
<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.
/* 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.
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.
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.
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.
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.
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.
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).
import { HeartAnimation } from '@reelkit/react-stories-player';
<HeartAnimation onComplete={() => console.log('animation done')} />Tipos
StoryItem
interface StoryItem {
id: string;
mediaType: 'image' | 'video';
src: string;
poster?: string;
duration?: number; // ms, images default to 5000, videos use natural duration
createdAt?: string | Date;
aspectRatio?: number; // width / height
}AuthorInfo
interface AuthorInfo {
id: string;
name: string;
avatar: string;
verified?: boolean;
}StoriesGroup<T>
interface StoriesGroup<T extends StoryItem = StoryItem> {
author: AuthorInfo;
stories: T[];
}HeaderRenderProps<T>
interface HeaderRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
isPaused: boolean;
isMuted: boolean;
isVideo: boolean;
onToggleSound: () => void;
onTogglePause: () => void;
onClose: () => void;
}FooterRenderProps<T>
interface FooterRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
}SlideRenderProps<T>
interface SlideRenderProps<T extends StoryItem = StoryItem> {
story: T;
index: number;
groupIndex: number;
isActive: boolean;
size: [number, number];
activeGroupIndex: Signal<number>;
activeStoryIndex: Signal<number>;
onDurationReady: (durationMs: number) => void;
onReady: () => void;
onWaiting: () => void;
onError: () => void;
onEnded: () => void;
}NavigationRenderProps
interface NavigationRenderProps {
onPrevStory: () => void;
onNextStory: () => void;
onPrevGroup: () => void;
onNextGroup: () => void;
}ProgressBarRenderProps<T>
interface ProgressBarRenderProps<T extends StoryItem = StoryItem> {
totalStories: number;
activeIndex: Signal<number>;
progress: Signal<number>;
group: StoriesGroup<T>;
}LoadingRenderProps<T>
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}ErrorRenderProps<T>
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}StoriesApi
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:
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.
| Clase | Componente | Descripción |
|---|---|---|
.rk-stories-overlay | Overlay | Fondo fijo a pantalla completa (fondo, z-index) |
.rk-stories-swipe-wrapper | Overlay | Envoltorio de deslizar para cerrar (contiene los botones de navegación y el lienzo) |
.rk-stories-container | Overlay | Lienzo redondeado de la story (posición, desbordamiento) |
.rk-stories-ui-layer | Overlay | Contenedor del overlay de la interfaz (cabecera, progreso, navegación) |
.rk-stories-ui-layer--hidden | Overlay | Estado oculto de la interfaz (lo activa hideUIOnPause) |
.rk-stories-error | Overlay | Estado de error (icono y texto centrados) |
.rk-stories-error-text | Overlay | Texto del mensaje de error |
.rk-stories-nav-btn | Navigation | Flecha de anterior o siguiente en escritorio |
.rk-stories-progress-bar | ProgressBar | Envoltorio que posiciona la barra de progreso en canvas |
.rk-stories-slide-wrapper | Group | Un grupo de stories (slide exterior) |
.rk-stories-story | Story | Una sola story (raíz del slide interior) |
.rk-stories-header | StoryHeader | Barra de cabecera (avatar, nombre, acciones) |
.rk-stories-header--hidden | StoryHeader | Estado oculto de la cabecera (visible=false) |
.rk-stories-header-avatar | StoryHeader | Imagen del avatar del autor |
.rk-stories-header-name | StoryHeader | Texto con el nombre del autor |
.rk-stories-header-verified | StoryHeader | Contenedor de la insignia de verificado |
.rk-stories-header-time | StoryHeader | Texto con el tiempo transcurrido |
.rk-stories-header-actions | StoryHeader | Acciones del lado derecho (cerrar, silencio, pausa) |
.rk-stories-header-btn | StoryHeader | Botón de acción de la cabecera |
.rk-stories-header-spinner | StoryHeader | Spinner mientras el vídeo carga |
.rk-stories-image | ImageStorySlide | Elemento de la story de imagen |
.rk-stories-video | VideoStorySlide | Contenedor de la story de vídeo |
.rk-stories-video-element | VideoStorySlide | El elemento <video> compartido |
.rk-stories-video-poster | VideoStorySlide | Imagen del póster del vídeo (se desvanece al reproducir) |
.rk-stories-video-poster--visible | VideoStorySlide | Estado visible del póster (antes de reproducir) |
.rk-stories-heart | HeartAnimation | Animación del corazón al tocar dos veces |
.rk-stories-ring | StoriesRing | Anillo de stories (avatar con borde de degradado animado) |
.rk-stories-ring--active | StoriesRing | Anillo con stories por ver (animado) |
.rk-stories-ring-avatar | StoriesRing | Imagen del avatar dentro del anillo |
.rk-stories-ring-list | StoriesRingList | Contenedor de la lista horizontal de anillos |
.rk-stories-ring-list-item | StoriesRingList | Columna con anillo y nombre |
.rk-stories-ring-list-name | StoriesRingList | Nombre 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.
| Token | Por defecto | Controla |
|---|---|---|
--rk-stories-overlay-bg | #000 | Color del fondo a pantalla completa |
--rk-stories-overlay-z | 9999 | z-index del overlay |
--rk-stories-container-radius | 12px | Esquinas redondeadas del lienzo de la story (escritorio) |
--rk-stories-swipe-gap | 16px | Espacio entre los botones de navegación y el lienzo de la story |
--rk-stories-top-shade-height | 120px | Altura del degradado superior detrás de la cabecera |
--rk-stories-top-shade-bg | linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%) | Color del degradado superior |
--rk-stories-ui-transition | 200ms | Duración del fundido al activarse hideUIOnPause |
--rk-stories-nav-size | 44px | Tamaño de los botones de anterior y siguiente en escritorio |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | Fondo de los botones de navegación en escritorio |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | Fondo de los botones de navegación en escritorio al pasar el ratón |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | Color del icono de los botones de navegación en escritorio |
--rk-stories-nav-fg-hover | #fff | Color del icono de los botones de navegación en escritorio al pasar el ratón |
--rk-stories-error-bg | linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) | Degradado del fondo del estado de error |
--rk-stories-error-fg | rgba(255, 255, 255, 0.5) | Color del icono y el texto de error |
--rk-stories-error-text-size | 13px | Tamaño de letra del mensaje de error |
--rk-stories-video-bg | #000 | Fondo de las bandas detrás del <video> |
--rk-stories-video-poster-transition | 200ms | Duración del fundido del póster cuando el vídeo empieza a reproducirse |
--rk-stories-header-top | 18px | Separación vertical de la cabecera desde la parte superior de la story |
--rk-stories-header-padding | 12px 16px | Relleno interior de la fila de la cabecera |
--rk-stories-header-avatar-size | 32px | Ancho y alto del avatar |
--rk-stories-header-name-fg | #fff | Color del nombre del autor |
--rk-stories-header-name-size | 14px | Tamaño de letra del nombre del autor |
--rk-stories-header-time-fg | rgba(255, 255, 255, 0.6) | Color del texto con el tiempo transcurrido |
--rk-stories-header-btn-fg | #fff | Color de los iconos de acción de la cabecera (cerrar, silencio, pausa) |
--rk-stories-heart-duration | 800ms | Duración de la animación de aparición y desvanecimiento |
--rk-stories-ring-spin-duration | 4s | Duración del giro de un anillo con stories por ver |
--rk-stories-ring-list-gap | 12px | Espacio entre los anillos de la lista |
--rk-stories-ring-list-padding | 12px | Relleno interior alrededor de la lista de anillos |
--rk-stories-ring-list-name-size | 12px | Tamañ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.
/* 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
| Tecla | Acción |
|---|---|
ArrowLeft | Story anterior |
ArrowRight | Story siguiente |
Escape | Cierra el visor |