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 } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';
const groups: StoriesGroup[] = [
{
author: {
id: 'user-1',
name: 'Alice',
avatar: '/cdn/samples/avatars/avatar-06.jpg',
verified: true,
},
stories: [
{
id: 's1-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-001.jpg',
},
{
id: 's1-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-002.jpg',
},
{
id: 's1-3',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-003.jpg',
},
],
},
{
author: {
id: 'user-2',
name: 'Bob',
avatar: '/cdn/samples/avatars/avatar-07.jpg',
},
stories: [
{
id: 's2-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-004.jpg',
},
{
id: 's2-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-005.jpg',
},
],
},
{
author: {
id: 'user-3',
name: 'Charlie',
avatar: '/cdn/samples/avatars/avatar-08.jpg',
verified: true,
},
stories: [
{
id: 's3-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-006.jpg',
},
],
},
];
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(0);
// What was seen: rings, resume and recording from one controller, kept in
// localStorage across reloads.
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groups,
}),
);
const openStories = (groupIndex: number) => {
setSelectedGroup(groupIndex);
setIsOpen(true);
};
return (
<div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
<StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />
<StoriesOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
groups={groups}
initialGroupIndex={selectedGroup}
viewed={viewed}
/>
</div>
);
}import { useState } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';
const groups: StoriesGroup[] = [
{
author: {
id: 'user-1',
name: 'Alice',
avatar: '/cdn/samples/avatars/avatar-06.jpg',
verified: true,
},
stories: [
{
id: 's1-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-001.jpg',
},
{
id: 's1-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-002.jpg',
},
{
id: 's1-3',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-003.jpg',
},
],
},
{
author: {
id: 'user-2',
name: 'Bob',
avatar: '/cdn/samples/avatars/avatar-07.jpg',
},
stories: [
{
id: 's2-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-004.jpg',
},
{
id: 's2-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-005.jpg',
},
],
},
{
author: {
id: 'user-3',
name: 'Charlie',
avatar: '/cdn/samples/avatars/avatar-08.jpg',
verified: true,
},
stories: [
{
id: 's3-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-006.jpg',
},
],
},
];
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(0);
// What was seen: rings, resume and recording from one controller, kept in
// localStorage across reloads.
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groups,
}),
);
const openStories = (groupIndex: number) => {
setSelectedGroup(groupIndex);
setIsOpen(true);
};
return (
<div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
<StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />
<StoriesOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
groups={groups}
initialGroupIndex={selectedGroup}
viewed={viewed}
/>
</div>
);
}Para una fila de anillos que recuerda lo que el usuario ya vio, incluso tras recargar, consulta la receta Bandeja de historias.
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á. Pasa innerCodec junto con innerLocate e innerIdentify para hacer lo mismo con la story dentro de su grupo; si los omites, la parte de la story sigue siendo un índice local simple.
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,
}),
});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 único createStoriesViewedStateController, creado una vez donde vive el feed y pasado a la lista de anillos y al reproductor como viewed: ellos leen el almacén, lo siguen y registran lo que se muestra por su cuenta. Una entrada guardada nombra al autor y a la story por id, así que sobrevive a que el feed se reordene, y el mismo controlador funciona en Vue y en Angular.
import { useRef, useState } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
function Feed({ groups }: { groups: StoriesGroup[] }) {
const [open, setOpen] = useState(false);
const [group, setGroup] = useState(0);
// The controller reads the groups through a getter every time it needs
// them, so keep the getter current: a ref for a plain prop, or a signal.
const groupsRef = useRef(groups);
groupsRef.current = groups;
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groupsRef.current,
}),
);
return (
<>
{/* The rings follow the controller by themselves. */}
<StoriesRingList
groups={groups}
viewed={viewed}
onSelect={(index) => {
setGroup(index);
setOpen(true);
}}
/>
{/* The player resumes, records and draws its card rings from it. */}
<StoriesOverlay
isOpen={open}
onClose={() => setOpen(false)}
groups={groups}
initialGroupIndex={group}
viewed={viewed}
/>
</>
);
}- 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
storage: createSessionStorageAdapter()para olvidarlo al cerrar la pestaña, o tu propioStorageAdapter. Dos pestañas abiertas se mantienen sincronizadas con el evento storage del navegador. - Un reproductor que se monta solo al abrirse lee demasiado tarde. El reproductor lee el almacén en un efecto que se ejecuta mientras aún está cerrado. Con
{open && <StoriesOverlay … />}ese momento no existe, así que llama auseAttachViewedState(viewed)en el componente dueño del controlador; se conecta al montar y lo suelta al desmontar. Las opciones, y las piezas de más bajo nivel con las que está construido, están en la página de Stories Core.
Carrusel en escritorio
Pon desktopLayout="carousel" para disponer el reproductor como Instagram en la web: la historia activa se queda en el centro con su tamaño de siempre, y a cada lado aparecen hasta dos grupos vecinos como tarjetas más pequeñas y oscurecidas. Al hacer clic en una tarjeta se abre ese grupo, y las tarjetas se deslizan mientras cambia el grupo. Pasa el mismo controlador viewed que recibe la lista de anillos, y la tarjeta de un grupo ya visto tendrá el anillo apagado. Las tarjetas lo siguen por su cuenta, así que solo ellas se repintan cuando una historia se marca como vista y el reproductor no vuelve a renderizarse.
// 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}
/>;- En el móvil el reproductor sigue igual. Hasta 768px de ancho no se renderizan tarjetas y los deslizamientos usan
groupTransition, como con el diseño por defecto'single'. Si la ventana cruza ese ancho al redimensionarse, el diseño cambia al momento. - Cada tarjeta muestra dónde se abre el grupo: la historia en la que se dejó durante esta sesión o, si no, la que devuelve
resumeStoryIndex. Enseña el póster del vídeo o la propia imagen. Una historia sin ninguno de los dos, como un texto sobre un degradado, la dibuja turenderSlideal tamaño del reproductor y se muestra reducida; un vídeo sin póster muestra el fondo de la tarjeta. Esa vista previa llama arenderSlideconisActive: falsey es solo una imagen: nada de lo que contiene recibe el foco ni los clics. Deja detrás deisActivetodo lo que una diapositiva haga al montarse, como la reproducción o la analítica. - El temporizador espera al deslizamiento. La historia no avanza, y
onStoryViewedno se dispara para ella, hasta que las tarjetas terminan de moverse. - Todas las formas de cambiar de grupo deslizan: el clic en una tarjeta,
StoriesApi.goToGroup,nextGroupyprevGroupy las flechas del teclado pasado el final de un grupo. Un deslizamiento táctil ya mueve el propio reproductor, así que no lleva otro deslizamiento encima.
Barra de progreso y cabecera en cada grupo
Por defecto, una barra de progreso y una cabecera quedan encima del reproductor y pasan al nuevo grupo cuando ya ha cambiado. Define chromePlacement="group" para que cada grupo tenga las suyas dentro de su diapositiva, y las dos girarán con el grupo, como en Instagram.
// Every group carries its own progress bar and header, so both turn with the
// group instead of switching above the player once it has changed.
<StoriesOverlay
isOpen={open}
onClose={() => setOpen(false)}
groups={groups}
chromePlacement="group"
/>;- Un grupo vecino muestra dónde se quedó. Mientras el reproductor gira hacia él, su barra y su cabecera muestran la story en la que se abrirá, sin nada reproducido aún.
- El grupo que se abandona se queda como estaba. Su barra conserva el progreso hasta que termina el giro, así que un grupo visto hasta el final sigue lleno en lugar de vaciarse a mitad del giro.
- Los renderizadores propios se llaman para cada grupo en pantalla.
renderProgressBaryrenderHeaderrecibengroupIndexeisActive; para un grupo vecinoisActiveesfalsey las señales de progreso no cambian. - Una cabecera propia está en la zona de deslizamiento. Un toque en ella cambia de story salvo que caiga en un
button, un enlace o un elemento conrole="button", así que haz que cada control sea uno de ellos. - El carrusel en escritorio no cambia durante el deslizamiento. El reproductor está oculto mientras las tarjetas se deslizan, así que la opción se ve cuando el grupo ya está abierto.
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 | Oculta la barra de progreso y la cabecera mientras dura la pausa por 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 |
desktopLayout | 'single' | 'carousel' | 'single' | Diseño en escritorio. 'carousel' muestra los grupos vecinos como tarjetas de vista previa junto a la historia activa y desliza entre grupos. En el móvil la historia siempre se muestra sola. El tipo se exporta como DesktopLayout. |
chromePlacement | 'overlay' | 'group' | 'overlay' | Dónde viven la barra de progreso y la cabecera. 'overlay' dibuja una copia encima del reproductor; 'group' dibuja una en cada diapositiva de grupo, y las dos giran con el grupo. El tipo se exporta como ChromePlacement. |
viewed | StoriesViewedStateController | - | Lo que ya ha visto quien mira, de createStoriesViewedStateController(). Los grupos se reanudan en su primera story sin ver, cada story mostrada se registra y las tarjetas del carrusel apagan el anillo de un grupo visto hasta el final. Pasa el mismo controlador a StoriesRingList. |
renderHeader | (props: HeaderRenderProps<T>) => ReactNode | - | Renderizador de cabecera propio. Recibe el autor, la story y el estado de pausa y de silencio. Con chromePlacement="group" la cabecera está en la zona de deslizamiento; consulta Barra de progreso y cabecera en cada grupo. |
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. |
renderGroupPreview | (props: GroupPreviewRenderProps<T>) => ReactNode | - | Tarjeta propia del carrusel en escritorio. Sustituye el contenido de la tarjeta 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>
)}
/>renderGroupPreview
Sustituye el contenido de una tarjeta del carrusel en escritorio. El reproductor sigue colocando, escalando y deslizando la tarjeta; llama a onOpen para abrir el grupo:
<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
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. El tipo de props de cada una se exporta a su lado — CanvasProgressBarProps, StoryHeaderProps, ImageStorySlideProps, VideoStorySlideProps, StoriesRingProps, StoriesRingListProps y HeartAnimationProps — para un componente que envuelva una:
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. Pasa live={false} para una barra que solo debe mostrar dónde se quedó un grupo: se dibuja cuando cambian sus señales y tras un cambio de tamaño, sin bucle de animación.
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
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';
// `viewed` is the same createStoriesViewedStateController the player takes; the
// rings follow it by themselves.
<StoriesRingList
groups={groups}
viewed={viewed}
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')} />Reexportaciones
Todo lo que necesita quien usa el paquete sale de aquí, así que nunca hace falta importar directamente de @reelkit/core.
| Desde | Reexportado |
|---|---|
@reelkit/react | SoundProvider, useSoundState, useOverlayUrlState, createViewedStateController, twoAxisViewedTracking, createLocalStorageAdapter, createSessionStorageAdapter, createMemoryStorageAdapter, urlIndexTwoAxisKey, urlStableIdTwoAxisKey, base64UrlCodec y los tipos UrlAdapter, UrlCodec, UrlLocator, UrlKey, UrlStateController, TwoAxisPosition, TwoAxisIdentity, UrlIndexTwoAxisKeyOptions, OverlayUrlStateOptions, ViewedStateController, ViewedStateOptions, StorageAdapter |
@reelkit/stories-core | createStoriesViewedStateController, StoriesViewedStateController, StoriesViewedStateControllerConfig y los tipos de contenido StoryItem, AuthorInfo, StoriesGroup, MediaType |
SoundProvider y useSoundState llevan el estado del sonido que el reproductor comparte con sus slides. Un slide personalizado que reproduce su propio contenido multimedia lee el botón de silencio con useSoundState. Dentro del reproductor ya hay un provider; un VideoStorySlide o un slide personalizado que se renderice en cualquier otro sitio necesita un SoundProvider por encima, o useSoundState lanza un error.
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;
groupIndex: number;
isActive: boolean; // false for a neighbouring group's header
onToggleSound: () => void;
onTogglePause: () => void;
onClose: () => void;
}FooterRenderProps<T>
interface FooterRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
}SlideRenderProps<T>
interface SlideRenderProps<T extends StoryItem = StoryItem> {
story: T;
index: number;
groupIndex: number;
isActive: boolean;
size: [number, number];
activeGroupIndex: Signal<number>;
activeStoryIndex: Signal<number>;
onDurationReady: (durationMs: number) => void;
onReady: () => void;
onWaiting: () => void;
onError: () => void;
onEnded: () => void;
}NavigationRenderProps
interface NavigationRenderProps {
onPrevStory: () => void;
onNextStory: () => void;
onPrevGroup: () => void;
onNextGroup: () => void;
}ProgressBarRenderProps<T>
interface ProgressBarRenderProps<T extends StoryItem = StoryItem> {
totalStories: number;
activeIndex: Signal<number>;
progress: Signal<number>;
group: StoriesGroup<T>;
groupIndex: number;
isActive: boolean; // false: the signals hold still for a neighbouring group
}LoadingRenderProps<T>
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}ErrorRenderProps<T>
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}GroupPreviewRenderProps<T>
type DesktopLayout = 'single' | 'carousel';
interface GroupPreviewRenderProps<T extends StoryItem = StoryItem> {
group: StoriesGroup<T>;
groupIndex: number;
story: T | undefined; // the story the group would open on
offset: number; // negative on the left, positive on the right
viewedCount: number; // from viewedState, 0 without it
onOpen: () => void;
}StoriesApi
interface StoriesApi {
nextStory: () => void;
prevStory: () => void;
nextGroup: () => void;
prevGroup: () => void;
goToGroup: (index: number) => void;
pause: () => void;
resume: () => void;
}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). Dentro de cada diapositiva de grupo con chromePlacement="group" |
.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-btn--desktop | StoryHeader | Botón de pausa, visible solo a partir de 768px de ancho |
.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 |
.rk-stories-overlay--carousel | Overlay | Overlay con el carrusel de escritorio a la vista |
.rk-stories-overlay--sliding | Overlay | Deslizamiento del carrusel en curso; el lienzo del reproductor está oculto |
.rk-stories-carousel | Carousel | Capa de tarjetas de vista previa detrás del reproductor |
.rk-stories-carousel--instant | Carousel | Primer fotograma de un deslizamiento, colocado sin transición |
.rk-stories-card | Carousel | Una tarjeta de vista previa ya colocada |
.rk-stories-card--center | Carousel | Tarjeta en la posición central durante un deslizamiento |
.rk-stories-card--hidden | Carousel | Tarjeta más allá de las dos que se ven a cada lado |
.rk-stories-card-button | Carousel | Contenido por defecto de la tarjeta (el botón que abre el grupo) |
.rk-stories-card-image | Carousel | Imagen de vista previa (póster o imagen) |
.rk-stories-card-frame | Carousel | Vista previa reducida de renderSlide para una story sin imagen ni póster; inerte |
.rk-stories-card-scrim | Carousel | Capa que oscurece la imagen |
.rk-stories-card-info | Carousel | Columna con el anillo, el nombre del autor y la hora |
.rk-stories-card-name | Carousel | Nombre del autor |
.rk-stories-card-time | Carousel | Texto de tiempo transcurrido |
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-swipe-gap | 16px | Espacio entre los botones de navegación y el lienzo de la story |
--rk-stories-container-radius | 12px | Esquinas redondeadas del lienzo de la story (escritorio) |
--rk-stories-container-radius-mobile | 0 | Esquinas del lienzo de la story hasta 768px de ancho |
--rk-stories-ui-z | 15 | z-index de la capa de interfaz (cabecera, barra de progreso) |
--rk-stories-ui-transition | 200ms | Duración del fundido al activarse hideUIOnPause |
--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-error-z | 5 | z-index del estado de error |
--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-gap | 12px | Espacio entre el icono de error y su texto |
--rk-stories-error-text-size | 13px | Tamaño de letra del mensaje de error |
--rk-stories-nav-z | 20 | z-index de los botones de navegación en escritorio |
--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-nav-transition | 150ms | Transición del fondo y el color de los botones de navegación |
--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-progress-bar-z | 10 | z-index de la barra de progreso |
--rk-stories-progress-bar-padding | 8px 8px 0 | Espacio alrededor de la barra de progreso |
--rk-stories-header-z | 5 | z-index de la cabecera |
--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-gap | 8px | Espacio entre el avatar, el nombre y la hora |
--rk-stories-header-transition | 200ms | Fundido de la cabecera cuando se oculta |
--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-name-weight | 600 | Grosor 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-time-size | 12px | Tamaño de letra del tiempo transcurrido |
--rk-stories-header-actions-gap | 8px | Espacio entre los botones de acción de la cabecera |
--rk-stories-header-btn-fg | #fff | Color de los iconos de acción de la cabecera (cerrar, silencio, pausa) |
--rk-stories-header-btn-padding | 4px | Relleno de los botones de acción de la cabecera |
--rk-stories-header-spinner-size | 20px | Tamaño del spinner de carga |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | Color de la pista del spinner de carga |
--rk-stories-header-spinner-fg | #fff | Color del arco del spinner de carga |
--rk-stories-header-spinner-duration | 0.8s | Duración de una vuelta del spinner de carga |
--rk-stories-heart-z | 20 | z-index del corazón del doble toque |
--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-active-scale | 0.95 | Escala del anillo mientras se pulsa |
--rk-stories-ring-active-gradient | conic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433) | Lo que muestra un anillo con stories por ver: cualquier imagen o color CSS. En una página clara, defínelo en .rk-stories-ring-list y no en :root, para que el carrusel del reproductor conserve el suyo |
--rk-stories-ring-viewed-color | rgba(255, 255, 255, 0.25) | Lo que muestra un anillo visto entero, pensado para una página oscura. En una clara, defínelo en .rk-stories-ring-list y no en :root |
--rk-stories-ring-gradient | none | Interno: el anillo lo escribe en cada render y apunta a los dos tokens de arriba, salvo que el anillo reciba gradientColors o viewedColor |
--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-item-gap | 4px | Espacio entre un anillo y su nombre |
--rk-stories-ring-list-name-size | 12px | Tamaño de letra del nombre del autor debajo de cada anillo |
--rk-stories-card-bg | #262626 | Fondo de la tarjeta del carrusel, visible cuando no hay imagen de vista previa |
--rk-stories-card-radius | 8px | Radio de las esquinas de la tarjeta del carrusel |
--rk-stories-card-scrim | rgba(0, 0, 0, 0.45) | Oscurecimiento sobre una tarjeta lateral |
--rk-stories-card-fg | #fff | Color del texto de la tarjeta |
--rk-stories-card-name-size | 14px | Tamaño de letra del nombre del autor en la tarjeta |
--rk-stories-card-time-fg | rgba(255, 255, 255, 0.7) | Color del tiempo transcurrido en la tarjeta |
--rk-stories-card-time-size | 13px | Tamaño de letra del tiempo transcurrido en la tarjeta |
--rk-stories-card-gap | 6px | Espacio entre el anillo, el nombre y la hora en la tarjeta |
--rk-stories-card-transition | 300ms | Duración del deslizamiento del carrusel |
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;
}
/* 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;
}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, reexportados por @reelkit/react.
En el carrusel de escritorio, cada tarjeta por defecto es un botón con la etiqueta "Open stories by" seguida del nombre del autor. Las tarjetas van después de los controles del reproductor en el orden de Tab y salen de él mientras dura un deslizamiento.
Renderizado en el servidor
El overlay no renderiza nada mientras está cerrado y solo hace el portal a document.body cuando se abre, lo que ocurre en el cliente. StoriesUrlOverlay lee la barra de direcciones en un efecto después del montaje, así que un render en el servidor nunca lo abre. StoriesRingList renderiza en el servidor todos los anillos como no vistos: el almacén de lo visto solo se lee después del montaje, así que el HTML del servidor y el primer render en el cliente coinciden. Consulta la guía de SSR para ver el panorama completo.
Atajos de teclado
| Tecla | Acción |
|---|---|
ArrowLeft | Story anterior. En la primera story de un grupo, el grupo anterior, en la story donde se dejó (si no, en su story de reanudación); en el primer grupo no hace nada |
ArrowRight | Story siguiente. Después de la última story de un grupo, el grupo siguiente; después del último grupo, el reproductor se cierra |
Escape | Cierra el visor |