Stories Player para Vue
Overlay de stories al estilo Instagram para Vue 3, con @reelkit/vue-stories-player. Usa el mismo motor @reelkit/stories-core que el player de React, así que el comportamiento, las clases CSS y los tokens de tema son los mismos.
Features
Instalación
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-nextImporta la hoja de estilos una vez en el punto de entrada de la aplicación (o en cualquier componente):
import '@reelkit/vue-stories-player/styles.css';Iconos
La cabecera y las flechas de navegación por defecto usan iconos de lucide-vue-next. Si prefieres otra biblioteca de iconos, sustitúyelos con los slots con ámbito #header y #navigation.
Vue 3.3 o posterior
Los overlays son componentes genéricos, que Vue admite desde la 3.3. En un Vue más antiguo el runtime descarta sus props y eventos, así que el paquete exige vue 3.3 o posterior como peer dependency.
Inicio rápido
StoriesOverlay dibuja el player a pantalla completa. Combínalo con StoriesRingList como entrada al estilo Instagram, pásale un array de objetos StoriesGroup y enlaza v-model:is-open.
<script setup lang="ts">
import { ref } from 'vue';
import {
StoriesOverlay,
StoriesRingList,
type StoriesGroup,
} from '@reelkit/vue-stories-player';
import '@reelkit/vue-stories-player/styles.css';
const groups: StoriesGroup[] = [
{
author: { id: 'alice', name: 'Alice', avatar: '/alice.jpg', verified: true },
stories: [
{ id: 's1', mediaType: 'image', src: '/story-1.jpg' },
{ id: 's2', mediaType: 'video', src: '/story-2.mp4', poster: '/poster-2.jpg' },
],
},
{
author: { id: 'bob', name: 'Bob', avatar: '/bob.jpg' },
stories: [{ id: 's3', mediaType: 'image', src: '/story-3.jpg' }],
},
];
const isOpen = ref(false);
const groupIndex = ref(0);
const openGroup = (index: number) => {
groupIndex.value = index;
isOpen.value = true;
};
</script>
<template>
<StoriesRingList :groups="groups" @select="openGroup" />
<!-- v-model:is-open keeps the ref in sync however the viewer closes it:
the ✕ button, a swipe down, Escape, or the last story finishing. -->
<StoriesOverlay
v-model:is-open="isOpen"
:groups="groups"
:initial-group-index="groupIndex"
/>
</template>Estado en la URL
Ver demo en vivo →StoriesUrlOverlay es un componente aparte cuyo estado de apertura vive en la barra de direcciones. Los dos ejes viajan en un solo parámetro — ?story=<group>.<story> —, así que la story en reproducción tiene un enlace que se puede compartir, guardar en marcadores y cerrar con el botón atrás. Crea un controlador con useOverlayUrlState y urlIndexTwoAxisKey, y pásalo como controller.
Claves incluidas
Las stories tienen dos ejes, así que despliega una clave de dos ejes en el controlador: urlIndexTwoAxisKey (autor y story por posición) o urlStableIdTwoAxisKey (el autor por un id estable); las dos se reexportan desde @reelkit/vue-stories-player. Consulta la guía de estado en la URL y la Core API.
<script setup lang="ts">
import {
StoriesUrlOverlay,
useOverlayUrlState,
urlIndexTwoAxisKey,
type StoriesGroup,
} from '@reelkit/vue-stories-player';
import { useVueRouterUrlAdapter } from '@reelkit/vue/vue-router-url-adapter';
import '@reelkit/vue-stories-player/styles.css';
const props = defineProps<{ groups: StoriesGroup[] }>();
// The outer axis is the group, the inner one the story inside it.
const stories = useOverlayUrlState({
param: 'story',
adapter: useVueRouterUrlAdapter(),
...urlIndexTwoAxisKey({
outerCount: () => props.groups.length,
innerCounts: () => props.groups.map((group) => group.stories.length),
}),
});
</script>
<template>
<!-- Opening is a link — the overlay reads the URL and opens itself. -->
<RouterLink
v-for="(group, index) in props.groups"
:key="group.author.id"
:to="`?story=${index}.0`"
>
{{ group.author.name }}
</RouterLink>
<StoriesUrlOverlay :controller="stories" :groups="props.groups" />
</template>- Abrir añade una entrada al historial. Moverse por las stories y cambiar de autor solo la sustituyen, así que N navegaciones no añaden entradas y un paso atrás siempre cierra el player. Atrás cierra; no retrocede una story.
- La navegación interior también viaja en la URL. Avanzar dentro de las stories de un autor actualiza
?story=2.n, así que un enlace directo aterriza en la story exacta. - Atrás solo cierra cuando el player se abrió desde dentro de la aplicación, porque ese enlace añadió una entrada. Un enlace compartido y abierto en una pestaña nueva no tiene historial detrás, así que el atrás 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 ni autor ni story —un marcador viejo, un valor editado a mano, una story más allá del final del grupo— se elimina de la URL en lugar de abrir la vecina.
- Una aplicación con enrutador debería pasar un adaptador basado en él, para que el enrutador siga siendo la única fuente de verdad de la navegación.
useVueRouterUrlAdapter, de@reelkit/vue/vue-router-url-adapter, es el adaptador listo para Vue Router; escribir el historial por detrás del enrutador deja su location desactualizada y tira el parámetro en la siguiente navegación.
Enlaces estables. El autor se direcciona por posición de forma predeterminada, así que un ?story=2.0 guardado abre a otra persona en cuanto se reordena el feed. Direcciona al autor 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 mitad de la story sigue siendo un índice normal dentro del grupo ya resuelto. Un id de story no puede contener un ., el delimitador entre las dos mitades.
// `?story=alice.0` — the group by the author's stable id, the story by its
// position inside whichever group that id resolves to.
const stories = useOverlayUrlState({
param: 'story',
adapter: useVueRouterUrlAdapter(),
...urlIndexTwoAxisKey({
outerCount: () => groups.value.length,
innerCounts: () => groups.value.map((group) => group.stories.length),
outerCodec: { decode: (raw) => raw, encode: (id) => id },
outerLocator: {
locate: (id) =>
groups.value.findIndex((group) => group.author.id === id),
identify: (index) => groups.value[index].author.id,
},
}),
});Feeds infinitos. Paginar es cosa de outerLocator, con independencia del códec. 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 se queda sin respuesta. locateAsync es el recurso siguiente, y solo se llama cuando locate falla; la story se vuelve a acotar al grupo en el que todo acaba deteniéndose.
El mismo locateAsync, eje exterior
Es el mismo paginador locateAsync que aceptan las claves de un eje: en una clave de dos ejes viaja en el outerLocator que le pasas, así que la paginación ocurre en el eje de los autores mientras la story sigue siendo un índice local dentro del grupo resuelto.
// A feed that pages. `locate` answers for the groups already loaded;
// `locateAsync` runs only when it misses, so a shared link to a group past
// the window fetches up to it and then opens.
outerLocator: {
locate: (index) => (index < loaded.value.length ? index : null),
identify: (index) => index,
locateAsync: async (index) => {
if (index >= totalGroups) return null;
loaded.value = await fetchGroupsUpTo(index);
return index;
},
},- Mientras
locateAsyncestá pendiente, el player sigue cerrado y el parámetro intacto, así que el enlace directo sobrevive a la petición. Unnullo un rechazo eliminan 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 petición lenta no puede abrir una story que ya nadie pidió.
- Todas las opciones de
useOverlayUrlStateestán en la referencia de la API de Vue.
Recordar lo que ya se vio
Los anillos muestran el degradado hasta que un autor se ha visto entero, y un grupo vuelve a abrirse en la primera story que la persona no ha visto. Ambas cosas salen de un único createStoriesViewedStateController, creado donde vive el feed y entregado a la lista de anillos y al player como viewed: ellos leen el almacenamiento, lo siguen y registran lo que se muestra por su cuenta. Cada entrada guarda autor y story por id, así que sobrevive a la reordenación del feed, y el mismo controlador funciona en React y en Angular.
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/vue-stories-player';
const props = defineProps<{ groups: StoriesGroup[] }>();
const isOpen = ref(false);
const groupIndex = shallowRef(0);
// One controller for the rings and the player. It reads the groups through
// the getter every time it needs them, so a feed that pages in more groups
// is counted too.
const viewed = createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => props.groups,
});
const openGroup = (index: number) => {
groupIndex.value = index;
isOpen.value = true;
};
</script>
<template>
<!-- The rings follow the controller by themselves. -->
<StoriesRingList
:groups="props.groups"
:viewed="viewed"
@select="openGroup"
/>
<!-- The player resumes, records, and draws its card rings from it. -->
<StoriesOverlay
v-model:is-open="isOpen"
:groups="props.groups"
:initial-group-index="groupIndex"
:viewed="viewed"
/>
</template>- La story de apertura cuenta. La story que ya está en pantalla se marca como vista en cuanto el player se monta, así que abrir un grupo de una sola story y cerrar ya la deja como vista.
- Seguir adelante también retoma.
resumeStoryIndexse consulta para cada grupo alcanzado por primera vez en esta sesión, incluido aquel con el que abre el player, salvo queinitialStoryIndexnombre una story de forma explícita; un grupo por el que ya se pasó vuelve a abrirse donde se dejó. - El enlace sigue mandando. Con
StoriesUrlOverlay, el parámetro decide dónde abre el player, se haya guardado lo que se haya guardado. En el resto de los casos decide la función de retomada. - La cuenta es una posición, no un total. La entrada guarda la story más lejana alcanzada, así que añadir una story a un grupo ya visto vuelve a encender su anillo, y quitar una del medio acorta la cuenta.
- El almacenamiento es intercambiable. Pasa
storage: createSessionStorageAdapter()para olvidar al cerrar la pestaña, o tu propioStorageAdapter. Dos pestañas abiertas se mantienen a la par gracias al evento de storage del navegador. - Un player que solo se monta al abrirse lee demasiado tarde. El overlay lee el almacenamiento una vez montado, todavía cerrado. Con
v-if="open"ese momento no existe, así que llama auseAttachViewedState(() => viewed)en el componente dueño del controlador; se conecta una vez montado y lo suelta al desmontar. Las opciones, y las piezas de más bajo nivel sobre las que está construido, están en la página de Stories Core.
Carrusel en escritorio
Pon desktop-layout="carousel" para colocar el player como el visor web de Instagram: la story activa se queda en el centro con su tamaño de siempre y hasta dos grupos vecinos se sitúan a cada lado como tarjetas más pequeñas y atenuadas. Al pulsar una tarjeta se abre ese autor, y las tarjetas se desplazan 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 solas, así que cuando una story se marca como vista solo se repintan ellas y el player no se vuelve a renderizar.
<template>
<!-- Neighbouring groups sit beside the active story as dimmed cards on a
desktop screen. Phones keep the plain player whatever this says. -->
<StoriesOverlay
v-model:is-open="isOpen"
:groups="groups"
:viewed="viewed"
desktop-layout="carousel"
/>
</template>- En el móvil se queda el player normal. Hasta 768px de ancho no se dibujan las tarjetas y los deslizamientos usan
group-transition, igual que con la disposición'single'predeterminada. Redimensionar cruzando ese ancho cambia la disposición al momento. - Cada tarjeta muestra por dónde abrirá el grupo: la story en la que se quedó esta sesión o, si no, la que devuelve
resumeStoryIndex. Muestra el póster del vídeo o la imagen. Una story sin ninguno de los dos, como una de texto sobre un degradado, la dibuja tu slot#slideal tamaño del player y luego se reduce; un vídeo sin póster muestra el fondo de la tarjeta. Esa vista previa recibeisActive: falsey es solo una imagen: nada en ella recibe foco ni clic. Deja todo lo que una diapositiva hace al montarse, como la reproducción o la analítica, detrás deisActive. - El temporizador espera al desplazamiento. La story no avanza, y
story-viewedno se emite para ella, hasta que las tarjetas terminan de moverse. - Cada forma de cambiar de grupo provoca el desplazamiento: pulsar una tarjeta,
StoriesApi.goToGroup,nextGroupyprevGroup, y las flechas del teclado más allá del final de un grupo. Un deslizamiento táctil mueve el propio player, así que no recibe otro desplazamiento 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 chrome-placement="group" para que cada grupo tenga las suyas dentro de su diapositiva, y las dos girarán con el grupo, como en Instagram.
<template>
<!-- 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
v-model:is-open="isOpen"
:groups="groups"
chrome-placement="group"
/>
</template>- 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 slots se renderizan para cada grupo en pantalla. Los ámbitos de
#progressBary#headerincluyengroupIndexeisActive; para un grupo vecinoisActiveesfalsey las señales de progreso no cambian. - Un slot
#headerestá en la zona de deslizamiento. Un toque en él cambia de story salvo que caiga en unbutton, 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
Props de StoriesOverlay
StoriesOverlayProps<T>
| Prop | Tipo | Valor por defecto | Descripción |
|---|---|---|---|
aria-label | string | 'Stories player' | Nombre accesible de la región de diálogo; los lectores de pantalla lo anuncian al abrirse el overlay |
chrome-placement | '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. |
default-image-duration | number | 5000 | Duración del avance automático de una story de imagen, en milisegundos |
desktop-layout | 'single' | 'carousel' | 'single' | Disposición en escritorio. 'carousel' muestra los grupos vecinos como tarjetas junto a la story activa y se desplaza entre grupos. En el móvil la story aparece siempre sola. El tipo se exporta como DesktopLayout. |
enable-keyboard | boolean | true | Activa la navegación por teclado (flechas izquierda/derecha, Escape) |
groups | StoriesGroup<T>[] | required | Grupos de stories que se muestran. Los grupos o stories añadidos con el player abierto se recogen, tanto si se sustituye el array como si se añade al mismo. |
group-transition | TransitionTransformFn | cubeTransition | Transición del slider exterior (el de grupos). Se ignora mientras se ve el carrusel de escritorio, que se desplaza en su lugar. |
hide-ui-on-pause | boolean | true | Oculta la barra de progreso y la cabecera mientras dura la pausa por pulsación larga |
initial-group-index | number | 0 | Índice del grupo visible al principio, empezando en cero |
initial-story-index | number | retomada; si no, 0 | Índice de la story visible al principio, empezando en cero. Nombrar una gana a cualquier cosa recordada; omítela y el grupo de apertura retoma como todos los demás. |
inner-transition-duration | number | 200 | Duración del fundido entre stories, en milisegundos |
is-open | boolean | required | Controla la visibilidad y bloquea el desplazamiento de la página mientras está abierto. Enlázalo con v-model:is-open. |
min-segment-width | number | 8 | Ancho mínimo de un segmento de progreso, en píxeles, antes de que entre la ventana deslizante |
resume-story-index | (groupIndex: number) => number | — | Con qué story abre un grupo la primera vez que se llega a él. Solo se consulta para un grupo aún no visitado en esta apertura, y gana a viewed. |
tap-zone-split | number | 0.3 | Reparto de la zona de toque (0–1). La parte izquierda retrocede, el resto avanza. |
viewed | StoriesViewedStateController | — | Lo que la persona ya ha visto, de createStoriesViewedStateController(). Los grupos retoman en la primera story no vista, cada story mostrada se registra y las tarjetas del carrusel apagan el anillo de un grupo visto entero. Pasa el mismo controlador a StoriesRingList. |
Props de StoriesUrlOverlay
StoriesUrlOverlayProps<T>
Acepta todas las props anteriores salvo el trío del estado de apertura — is-open, initial-group-index, initial-story-index —, que viene del controlador.
| Prop | Tipo | Valor por defecto | Descripción |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | required | Controlador de useOverlayUrlState desplegado con urlIndexTwoAxisKey. Su position —un objeto { outer, inner }— decide si el player está abierto y por dónde abre; el overlay escribe de vuelta en cada navegación y al cerrarse. |
Eventos
| Evento | Carga | Descripción |
|---|---|---|
| @api-ready | StoriesApi | Se emite en cuanto el player se monta, con la API imperativa |
| @close | void | Se emite cuando el player se cierra. En StoriesUrlOverlay el parámetro ya se ha limpiado para entonces. |
| @double-tap | groupIndex: number, storyIndex: number | Se emite al tocar dos veces, junto con la animación de corazón |
| @group-change | groupIndex: number | Se emite después de cambiar el grupo activo |
| @pause | void | Se emite con la pulsación larga o con el botón de pausa de la cabecera |
| @resume | void | Se emite al soltar o con el botón de reproducción de la cabecera |
| @story-change | groupIndex: number, storyIndex: number | Se emite después de cambiar la story activa |
| @story-complete | groupIndex: number, storyIndex: number | Se emite cuando se agota el temporizador de una story |
| @story-viewed | groupIndex: number, storyIndex: number | Se emite cuando una story se hace visible, incluida la de apertura |
| @update:is-open | boolean | Se emite al cerrar; habilita `v-model:is-open` |
v-model:is-open
Usa v-model:is-open para manejar el overlay con un solo enlace. La pareja :is-open + @close sigue funcionando cuando necesitas el evento explícito.
<script setup lang="ts">
import { ref } from 'vue';
import { StoriesOverlay } from '@reelkit/vue-stories-player';
const isOpen = ref(false);
</script>
<template>
<button type="button" @click="isOpen = true">Open stories</button>
<!-- One binding, in both directions. -->
<StoriesOverlay v-model:is-open="isOpen" :groups="groups" />
<!-- The explicit pair still works when you need the event itself. -->
<StoriesOverlay
:is-open="isOpen"
:groups="groups"
@close="isOpen = false"
/>
</template>Slots con ámbito
Ocho slots con ámbito sustituyen cualquier parte del player. Cada uno recibe un objeto de ámbito tipado. El slot que no pasas mantiene el aspecto por defecto, y lo mismo ocurre con uno que no dibuja nada, así que un v-if que se vuelve falso cae al valor por defecto en lugar de dejar un hueco.
| Slot | Ámbito | Descripción |
|---|---|---|
| #error | { story, storyIndex, groupIndex } | Sustituye el panel interno «Content unavailable», dibujado sobre la story activa |
| #footer | { author, story, storyIndex } | Añade un pie bajo la story del grupo activo |
| #groupPreview | { group, groupIndex, story, offset, viewedCount, onOpen } | Rellena una tarjeta del carrusel de escritorio; el player sigue colocándola, escalándola y desplazándola |
| #header | { author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose } | Sustituye la cabecera por defecto (autor, pausa, sonido, cerrar). Con chrome-placement="group" está en la zona de deslizamiento; consulta Barra de progreso y cabecera en cada grupo. |
| #loading | { story, storyIndex, groupIndex } | Sustituye el indicador de la cabecera mientras carga la story activa |
| #navigation | { onPrevStory, onNextStory, onPrevGroup, onNextGroup } | Sustituye las dos flechas de escritorio |
| #progressBar | { totalStories, activeIndex, progress, group, groupIndex, isActive } | Sustituye la barra de progreso en canvas. activeIndex y progress son señales del core: puéntealas con toVueRef. |
| #slide | { story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded } | Sustituye las diapositivas de imagen y vídeo por defecto |
<template>
<StoriesOverlay v-model:is-open="isOpen" :groups="groups">
<!-- Every slot is optional; one you leave out keeps the default, and so
does one that renders nothing. -->
<template #header="{ author, isPaused, onTogglePause, onClose }">
<header class="my-header">
<img :src="author.avatar" :alt="author.name" />
<span>{{ author.name }}</span>
<button type="button" @click="onTogglePause">
{{ isPaused ? 'Play' : 'Pause' }}
</button>
<button type="button" @click="onClose">Close</button>
</header>
</template>
<template #footer="{ author, story }">
<form class="my-reply" @submit.prevent="reply(author, story)">
<input v-model="draft" :placeholder="`Reply to ${author.name}…`" />
</form>
</template>
</StoriesOverlay>
</template>Diapositivas a medida
Combina ImageStorySlide y VideoStorySlide dentro de #slide para conservar la reproducción automática, la captura del póster y la sincronía del sonido mientras dibujas tu propia disposición. Llama a los callbacks del ciclo de vida desde el ámbito, o el temporizador no arrancará nunca:
| Callback | Cuándo llamarlo |
|---|---|
onReady | La imagen ha cargado o el vídeo ha empezado a reproducirse. Quita el estado de carga y arranca el temporizador. |
onWaiting | El vídeo está almacenando en búfer a mitad de reproducción. Muestra el indicador y pausa el temporizador. |
onError | El contenido no ha podido cargarse. Muestra el estado de error y la URL queda recordada como rota, así que al volver a abrirla no se reintenta. |
onDurationReady | Informa de la duración real del medio en milisegundos, para que el temporizador dure lo mismo que el vídeo. |
onEnded | El medio ha terminado. Avanza a la siguiente story. |
<template>
<StoriesOverlay v-model:is-open="isOpen" :groups="groups">
<!-- Compose ImageStorySlide and VideoStorySlide to keep autoplay, poster
capture and sound sync while drawing your own layout around them.
Pass the callbacks through, or the timer never starts. -->
<template #slide="scope">
<div class="my-slide">
<VideoStorySlide
v-if="scope.story.mediaType === 'video'"
:src="scope.story.src"
:poster="scope.story.poster"
:group-index="scope.groupIndex"
:story-index="scope.index"
:active-group-index="scope.activeGroupIndex"
:active-story-index="scope.activeStoryIndex"
:on-duration-ready="scope.onDurationReady"
:on-playing="scope.onReady"
:on-waiting="scope.onWaiting"
:on-ended="scope.onEnded"
:on-error="scope.onError"
/>
<ImageStorySlide
v-else
:src="scope.story.src"
:on-load="scope.onReady"
:on-error="scope.onError"
/>
<!-- A carousel card draws this same slot with isActive: false, and
nothing in it can be clicked. Keep playback or analytics behind
the flag. -->
<span v-if="scope.isActive" class="my-badge">{{ scope.story.id }}</span>
</div>
</template>
</StoriesOverlay>
</template>Caché del precargador
Los componentes internos ImageStorySlide y VideoStorySlide precargan la siguiente story en segundo plano. Cuando la persona llega a una story ya precargada, el contenido aparece al instante, sin indicador de carga. Una URL que falló una vez también queda recordada, así que volver a abrir una story rota muestra el error de inmediato en lugar de reintentar.
Navegación a medida
<template>
<StoriesOverlay v-model:is-open="isOpen" :groups="groups">
<!-- Replaces both desktop arrows at once. The first node sits left of
the story, the rest to its right. -->
<template #navigation="{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }">
<div class="my-nav">
<button type="button" @click="onPrevGroup">Previous user</button>
<button type="button" @click="onPrevStory">Back</button>
</div>
<div class="my-nav">
<button type="button" @click="onNextGroup">Next user</button>
<button type="button" @click="onNextStory">Forward</button>
</div>
</template>
</StoriesOverlay>
</template>Barra de progreso a medida
El ámbito lleva señales del core en lugar de números normales, así que el temporizador puede mover la barra sin volver a renderizar el player. Puéntealas con toVueRef, de @reelkit/vue:
<script setup lang="ts">
import { toVueRef, type Signal } from '@reelkit/vue';
const props = defineProps<{
totalStories: number;
activeIndex: Signal<number>;
progress: Signal<number>;
}>();
// Core signals do not re-render a Vue component on their own: bridge them.
const active = toVueRef(props.activeIndex);
const progress = toVueRef(props.progress);
</script>
<template>
<div class="my-progress">
<span
v-for="index in totalStories"
:key="index"
class="my-segment"
:style="{
'--fill':
index - 1 < active
? '100%'
: index - 1 === active
? `${progress * 100}%`
: '0%',
}"
/>
</div>
</template>Carga y error a medida
<template>
<StoriesOverlay v-model:is-open="isOpen" :groups="groups">
<!-- Both are drawn over the active story only. Without the loading slot
the default is the header spinner; without the error slot it is the
built-in "Content unavailable" panel. -->
<template #loading="{ story }">
<p class="my-loading">Loading {{ story.mediaType }}…</p>
</template>
<template #error="{ story, groupIndex }">
<div class="my-error">
<p>That {{ story.mediaType }} would not load.</p>
<button type="button" @click="report(groupIndex, story)">Report</button>
</div>
</template>
</StoriesOverlay>
</template>Tarjetas de carrusel a medida
<template>
<StoriesOverlay
v-model:is-open="isOpen"
:groups="groups"
:viewed="viewed"
desktop-layout="carousel"
>
<!-- The player still positions, scales and slides the card; this fills
it. `offset` is negative on the left, positive on the right, and 0
for the card passing through the center during a slide. -->
<template #groupPreview="{ group, story, viewedCount, offset, onOpen }">
<button type="button" class="my-card" @click="onOpen">
<img v-if="story?.poster" :src="story.poster" alt="" />
<span>{{ group.author.name }}</span>
<small>{{ viewedCount }} / {{ group.stories.length }} seen</small>
<em v-if="offset === 0">opening…</em>
</button>
</template>
</StoriesOverlay>
</template>Transiciones
group-transition controla el efecto 3D al cambiar de autor. Importa las funciones de transición desde @reelkit/vue:
<script setup lang="ts">
import { StoriesOverlay } from '@reelkit/vue-stories-player';
import {
cubeTransition,
flipTransition,
fadeTransition,
zoomTransition,
slideTransition,
} from '@reelkit/vue';
</script>
<template>
<!-- cubeTransition is the default; flip, fade, zoom and slide ship too. -->
<StoriesOverlay
v-model:is-open="isOpen"
:groups="groups"
:group-transition="flipTransition"
/>
</template>StoriesApi
Pon una template ref en el overlay para controlarlo de forma imperativa, o toma la API del evento @api-ready. Las llamadas hechas con el player cerrado no hacen nada.
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { StoriesOverlay, type StoriesApi } from '@reelkit/vue-stories-player';
const isOpen = ref(false);
const player = useTemplateRef<StoriesApi>('player');
// Calls while the player is closed are safely ignored.
const skipUser = () => player.value?.nextGroup();
</script>
<template>
<button type="button" @click="skipUser">Skip this user</button>
<StoriesOverlay
ref="player"
v-model:is-open="isOpen"
:groups="groups"
@api-ready="(api) => api.pause()"
/>
</template>Métodos
| Método | Tipo | Descripción |
|---|---|---|
nextStory() | () => void | Avanza a la siguiente story del grupo actual |
prevStory() | () => void | Vuelve a la story anterior del grupo actual |
nextGroup() | () => void | Cambia al siguiente grupo de autor |
prevGroup() | () => void | Cambia al grupo de autor 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
Al tocar dos veces se reproduce una animación de corazón, con respuesta visual inmediata. El evento @double-tap se emite con los índices de grupo y story, así que el me gusta lo guardas en tu propio estado. El player no guarda ningún estado de me gusta.
<script setup lang="ts">
import { StoriesOverlay, type StoriesGroup } from '@reelkit/vue-stories-player';
const props = defineProps<{ groups: StoriesGroup[] }>();
// The heart animation is built in; the like itself is yours to keep.
const like = (groupIndex: number, storyIndex: number) => {
const story = props.groups[groupIndex].stories[storyIndex];
void fetch(`/api/stories/${story.id}/like`, { method: 'POST' });
};
</script>
<template>
<StoriesOverlay
v-model:is-open="isOpen"
:groups="props.groups"
@double-tap="like"
/>
</template>Ajustar la animación de corazón
Ajusta la velocidad con el token --rk-stories-heart-duration (consulta Tema). Para el color, el tamaño o para ocultar el corazón por completo, apunta a la clase .rk-stories-heart. El componente HeartAnimation también se exporta para usarlo por separado.
:root {
/* Pop-in and fade-out duration. */
--rk-stories-heart-duration: 500ms;
}
/* Colour and size are plain CSS on the element itself. */
.rk-stories-heart svg {
width: 72px;
height: 72px;
fill: #ff4d6d;
}
/* Or hide it and animate your own from the double-tap event. */
.rk-stories-heart {
display: none;
}La animación de corazón interna todavía no se puede sustituir con un slot. Cámbiale los estilos con CSS, u ocúltala con display: none y reproduce tu propia animación desde el evento @double-tap. Si necesitas un slot heart, cuéntanoslo en GitHub Issues.
Subcomponentes
Piezas exportadas, para componer dentro de los slots o usar por separado: StoriesRing, StoriesRingList, StoryHeader, CanvasProgressBar, ImageStorySlide, VideoStorySlide y HeartAnimation.
| Componente | Qué es |
|---|---|
CanvasProgressBar | La barra de progreso segmentada en canvas, dibujada directamente desde las señales del temporizador. Gestiona la ventana deslizante en grupos con muchas stories. Con :live="false" solo se dibuja cuando cambian sus señales y tras un cambio de tamaño, sin bucle de animación. |
HeartAnimation | El corazón del doble toque. Emite complete cuando acaba la animación, para que el padre lo quite. |
ImageStorySlide | Diapositiva de imagen a sangre con object-fit: cover, que informa de carga y error mediante callbacks. |
StoriesRing | Avatar circular con el anillo en degradado. Dos estados: si queda algo por ver gira, y una vez visto entero se queda liso. Emite click. |
StoriesRingList | Fila horizontal de anillos con el nombre del autor, uno por grupo. Emite select con el índice del grupo. |
StoryHeader | La cabecera por defecto: avatar, nombre, distintivo de verificado, tiempo relativo, pausa, sonido, cerrar y el indicador de carga. |
VideoStorySlide | Diapositiva de vídeo apoyada en el único elemento <video> compartido, para la continuidad del sonido en iOS. Gestiona la reproducción automática, los fotogramas de póster y el aviso de duración. |
<script setup lang="ts">
import {
CanvasProgressBar,
HeartAnimation,
ImageStorySlide,
StoriesRing,
StoriesRingList,
StoryHeader,
VideoStorySlide,
} from '@reelkit/vue-stories-player';
</script>
<template>
<!-- One ring, and the horizontal row of them. -->
<StoriesRing
:author="group.author"
:total-stories="group.stories.length"
:viewed-count="2"
:size="72"
@click="open(group)"
/>
<StoriesRingList :groups="groups" :viewed="viewed" @select="open" />
<!-- Inside a #header slot: the default header, with your own additions. -->
<StoryHeader
:author="group.author"
:created-at="story.createdAt"
:is-paused="isPaused"
:is-video="story.mediaType === 'video'"
:is-muted="isMuted"
:on-toggle-pause="onTogglePause"
:on-toggle-sound="onToggleSound"
:on-close="onClose"
/>
<!-- Inside a #progressBar slot: the canvas bar, driven by the signals. -->
<CanvasProgressBar
:total-stories="group.stories.length"
:active-index="activeIndex"
:progress="progress"
:min-segment-width="12"
/>
<!-- Inside a #slide slot; see the slide slot example above. -->
<ImageStorySlide :src="story.src" :on-load="onReady" :on-error="onError" />
<VideoStorySlide
:src="story.src"
:group-index="groupIndex"
:story-index="index"
:active-group-index="activeGroupIndex"
:active-story-index="activeStoryIndex"
/>
<!-- Standalone, outside the player. -->
<HeartAnimation @complete="remove(heart.id)" />
</template>Reexportaciones
Todo lo que necesita quien lo usa viene de este paquete, así que nunca hace falta importar @reelkit/core directamente.
| De | Reexportado |
|---|---|
@reelkit/vue | Observe, 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 |
Observe es la frontera de renderizado que el player usa por dentro: vuelve a renderizar solo su slot cuando cambia una señal del core, y así el cambio de story repinta la cabecera y la barra de progreso sin redibujar los sliders. Úsalo en un slot propio cuando un componente entero siguiendo la señal con toVueRef resulte demasiado grueso; consulta la referencia de la API de Vue.
El tipo de props de cada componente también se exporta: StoriesOverlayProps, StoriesUrlOverlayProps, StoriesRingProps, StoriesRingListProps, StoryHeaderProps, CanvasProgressBarProps, ImageStorySlideProps y VideoStorySlideProps.
Tipos
StoryItem, AuthorInfo, StoriesGroup
interface StoryItem {
/** Unique within its group. Stored viewed state keys on it. */
id: string;
mediaType: 'image' | 'video';
src: string;
/** Poster for a video, also used as the carousel card preview. */
poster?: string;
/** Overrides defaultImageDuration for this story, in milliseconds. */
duration?: number;
createdAt?: string | Date;
/** Media aspect ratio (width / height). */
aspectRatio?: number;
}
interface AuthorInfo {
/** Stable id. Viewed state and stable-id URLs key on it. */
id: string;
name: string;
avatar: string;
verified?: boolean;
}
interface StoriesGroup<T extends StoryItem = StoryItem> {
author: AuthorInfo;
stories: T[];
}Ámbitos de los slots
Todos los tipos de ámbito se exportan, así que puedes anotar un slot donde el compilador no lo infiera: HeaderSlotScope, FooterSlotScope, SlideSlotScope, NavigationSlotScope, ProgressBarSlotScope, LoadingSlotScope, ErrorSlotScope y GroupPreviewSlotScope, junto a StoriesApi:
interface HeaderSlotScope<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
isPaused: boolean;
isMuted: boolean;
/** True while the active story is a video — gate your sound button on it. */
isVideo: boolean;
groupIndex: number;
/** False for a neighbouring group's header with chrome-placement="group". */
isActive: boolean;
onToggleSound: () => void;
onTogglePause: () => void;
onClose: () => void;
}
interface FooterSlotScope<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
}
interface SlideSlotScope<T extends StoryItem = StoryItem> {
story: T;
index: number;
groupIndex: number;
/** False for the copy a carousel card draws. */
isActive: boolean;
size: [number, number];
activeGroupIndex: Signal<number>;
activeStoryIndex: Signal<number>;
/** Report the real media length, in milliseconds. */
onDurationReady: (durationMs: number) => void;
onReady: () => void;
onWaiting: () => void;
onError: () => void;
onEnded: () => void;
}
interface NavigationSlotScope {
onPrevStory: () => void;
onNextStory: () => void;
onPrevGroup: () => void;
onNextGroup: () => void;
}
interface ProgressBarSlotScope<T extends StoryItem = StoryItem> {
totalStories: number;
activeIndex: Signal<number>;
/** 0 to 1 for the active story. Bridge it with toVueRef. */
progress: Signal<number>;
group: StoriesGroup<T>;
groupIndex: number;
/** False: the signals hold still for a neighbouring group. */
isActive: boolean;
}
interface LoadingSlotScope<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}
/** Same shape as LoadingSlotScope. */
interface ErrorSlotScope<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}
interface GroupPreviewSlotScope<T extends StoryItem = StoryItem> {
group: StoriesGroup<T>;
groupIndex: number;
/** The story the group would open on, if it has one. */
story: T | undefined;
/** Negative left, positive right, 0 while sliding through the center. */
offset: number;
viewedCount: number;
onOpen: () => void;
}
interface StoriesApi {
nextStory: () => void;
prevStory: () => void;
nextGroup: () => void;
prevGroup: () => void;
goToGroup: (index: number) => void;
pause: () => void;
resume: () => void;
}Tipos de story propios
Amplía StoryItem con tus campos y tipa los grupos con él. StoriesOverlay y StoriesUrlOverlay son genéricos, como el reproductor de React: el tipo de story se infiere de groups, así que los grupos cuyas stories no encajen con StoryItem no pasan la comprobación de tipos. En Vue los slots no pueden llevar ese tipo, así que sus ámbitos quedan sin tipo hasta que los anotas. Anota el ámbito del slot para que los campos añadidos sigan visibles dentro de la plantilla. Nada comprueba la anotación contra groups, así que mantén ambos al día:
<script setup lang="ts">
import { ref } from 'vue';
import {
StoriesOverlay,
type SlideSlotScope,
type StoriesGroup,
type StoryItem,
} from '@reelkit/vue-stories-player';
const isOpen = ref(false);
interface PromoStory extends StoryItem {
title?: string;
ctaHref?: string;
}
const groups: StoriesGroup<PromoStory>[] = [
{
author: { id: 'shop', name: 'Shop', avatar: '/shop.jpg' },
stories: [
{
id: 'sale',
mediaType: 'image',
src: '/sale.jpg',
title: 'Spring sale',
ctaHref: '/sale',
},
],
},
];
</script>
<template>
<StoriesOverlay v-model:is-open="isOpen" :groups="groups">
<!-- Annotate the scope with your own item type to keep the slot typed. -->
<template #slide="scope: SlideSlotScope<PromoStory>">
<article class="promo">
<h2>{{ scope.story.title }}</h2>
<a v-if="scope.story.ctaHref" :href="scope.story.ctaHref">Shop now</a>
</article>
</template>
</StoriesOverlay>
</template>Clases CSS
Las clases son normales (no tienen ámbito), así que una hoja de estilos cargada después de @reelkit/vue-stories-player/styles.css puede sobrescribir cualquiera. Son idénticas a las de @reelkit/react-stories-player, así que una hoja escrita para un binding sirve para el otro. Para colores, tamaños y z-index es mejor usar las propiedades personalizadas de la sección Tema.
| Clase | Componente | Descripción |
|---|---|---|
.rk-stories-overlay | Overlay | Fondo fijo a pantalla completa (color, z-index) |
.rk-stories-overlay--carousel | Overlay | Overlay con el carrusel de escritorio a la vista |
.rk-stories-overlay--sliding | Overlay | Desplazamiento del carrusel en curso; el lienzo del player queda oculto |
.rk-stories-swipe-wrapper | Overlay | Envoltorio del deslizar para cerrar (aloja los botones de navegación y el lienzo) |
.rk-stories-container | Overlay | Lienzo redondeado de la story (posición, overflow) |
.rk-stories-ui-layer | Overlay | Contenedor de la interfaz (cabecera, progreso). Dentro de cada diapositiva de grupo con chrome-placement="group" |
.rk-stories-ui-layer--hidden | Overlay | Estado oculto de la interfaz (lo alterna hide-ui-on-pause) |
.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/siguiente en escritorio |
.rk-stories-progress-bar | CanvasProgressBar | Envoltorio de posicionamiento de la barra en canvas |
.rk-stories-slide-wrapper | Group | Un grupo de stories (diapositiva exterior) |
.rk-stories-story | Story | Una story (raíz de la diapositiva interior) |
.rk-stories-header | StoryHeader | Barra de cabecera (avatar, nombre, acciones) |
.rk-stories-header--hidden | StoryHeader | Estado oculto de la cabecera |
.rk-stories-header-avatar | StoryHeader | Imagen del avatar del autor |
.rk-stories-header-name | StoryHeader | Texto del nombre del autor |
.rk-stories-header-verified | StoryHeader | Distintivo de verificado |
.rk-stories-header-time | StoryHeader | Texto de tiempo relativo |
.rk-stories-header-actions | StoryHeader | Acciones de la derecha (sonido, pausa, cerrar) |
.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 en pantallas de más de 768px de ancho |
.rk-stories-header-spinner | StoryHeader | Indicador de 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 | Póster del vídeo (se desvanece al reproducir) |
.rk-stories-video-poster--visible | VideoStorySlide | Estado del póster visible (antes de reproducir) |
.rk-stories-heart | HeartAnimation | Animación del corazón del doble toque |
.rk-stories-ring | StoriesRing | Anillo de la story (avatar con borde en degradado animado) |
.rk-stories-ring--active | StoriesRing | Anillo con stories por ver (se anima) |
.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 de anillo y nombre |
.rk-stories-ring-list-name | StoriesRingList | Nombre del autor bajo cada anillo |
.rk-stories-carousel | Carousel | Capa de tarjetas detrás del player |
.rk-stories-carousel--instant | Carousel | Primer fotograma de un desplazamiento, colocado sin transición |
.rk-stories-card | Carousel | Una tarjeta ya colocada |
.rk-stories-card--center | Carousel | Tarjeta en la posición central durante un desplazamiento |
.rk-stories-card--hidden | Carousel | Tarjeta más allá de las dos que se muestran 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 la vista previa (póster o imagen) |
.rk-stories-card-frame | Carousel | Una story sin póster ni imagen, dibujada por el slot #slide al tamaño del reproductor y reducida |
.rk-stories-card-scrim | Carousel | Capa que oscurece la vista previa |
.rk-stories-card-info | Carousel | Columna con anillo, nombre del autor y hora |
.rk-stories-card-name | Carousel | Nombre del autor |
.rk-stories-card-time | Carousel | Texto de tiempo relativo |
Tema
Cada color, tamaño, z-index y transición vive en una propiedad personalizada de CSS, con su valor por defecto en :root. Sobrescribe una o varias en :root, o en cualquier ancestro del overlay, para cambiar el tema sin tocar el código de los componentes. Los tokens coinciden con los de @reelkit/react-stories-player, así que un tema se lleva de un binding al otro.
| Token | Valor 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-container-radius-mobile | 0 | Esquinas del lienzo de la story en un móvil, hasta 768px de ancho |
--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 tras 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 cuando se alterna hide-ui-on-pause |
--rk-stories-ui-z | 15 | z-index de la capa de interfaz (barra de progreso y cabecera) |
--rk-stories-nav-size | 44px | Tamaño del botón de anterior/siguiente en escritorio |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | Fondo del botón de navegación en escritorio |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | Fondo del botón de navegación bajo el cursor |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | Color del icono del botón de navegación |
--rk-stories-nav-fg-hover | #fff | Color del icono bajo el cursor |
--rk-stories-nav-transition | 150ms | Transición del botón de navegación en escritorio bajo el cursor |
--rk-stories-nav-z | 20 | z-index del botón de navegación en escritorio |
--rk-stories-error-bg | linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) | Degradado de fondo del estado de error |
--rk-stories-error-fg | rgba(255, 255, 255, 0.5) | Color del icono y del texto de error |
--rk-stories-error-text-size | 13px | Tamaño de letra del mensaje de error |
--rk-stories-error-gap | 12px | Separación entre el icono de error y su texto |
--rk-stories-error-z | 5 | z-index del estado de error |
--rk-stories-video-bg | #000 | Fondo de las franjas tras el <video> |
--rk-stories-video-poster-transition | 200ms | Duración del fundido del póster al empezar el vídeo |
--rk-stories-progress-bar-padding | 8px 8px 0 | Relleno alrededor de la barra de progreso |
--rk-stories-progress-bar-z | 10 | z-index de la barra de progreso |
--rk-stories-header-top | 18px | Desplazamiento vertical de la cabecera desde el borde superior de la story |
--rk-stories-header-padding | 12px 16px | Relleno interior de la fila de cabecera |
--rk-stories-header-z | 5 | z-index de la cabecera |
--rk-stories-header-transition | 200ms | Duración del fundido de la cabecera cuando se oculta |
--rk-stories-header-gap | 8px | Separación entre el avatar, el nombre y la hora |
--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 de tiempo relativo |
--rk-stories-header-time-size | 12px | Tamaño de letra del tiempo relativo |
--rk-stories-header-btn-fg | #fff | Color de los iconos de acción de la cabecera (cerrar, silenciar, pausar) |
--rk-stories-header-btn-padding | 4px | Relleno dentro de un botón de acción de la cabecera |
--rk-stories-header-actions-gap | 8px | Separación entre los botones de acción de la cabecera |
--rk-stories-header-spinner-size | 20px | Diámetro del spinner de carga |
--rk-stories-header-spinner-fg | #fff | Color del arco 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-duration | 0.8s | Duración del giro del spinner de carga |
--rk-stories-heart-duration | 800ms | Duración de la aparición y el desvanecimiento del corazón |
--rk-stories-heart-z | 20 | z-index del corazón del doble toque |
--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 | Separación entre los anillos de la lista |
--rk-stories-ring-list-padding | 12px | Relleno alrededor de la lista de anillos |
--rk-stories-ring-list-item-gap | 4px | Separación entre un anillo y su nombre |
--rk-stories-ring-list-name-size | 12px | Tamaño de letra del nombre bajo cada anillo |
--rk-stories-card-bg | #262626 | Fondo de la tarjeta, visible cuando no hay vista previa |
--rk-stories-card-radius | 8px | Radio de las esquinas de la tarjeta |
--rk-stories-card-scrim | rgba(0, 0, 0, 0.45) | Oscurecimiento de 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 en la tarjeta |
--rk-stories-card-time-fg | rgba(255, 255, 255, 0.7) | Color del texto de hora en la tarjeta |
--rk-stories-card-time-size | 13px | Tamaño de letra de la hora en la tarjeta |
--rk-stories-card-gap | 6px | Separación entre anillo, nombre y hora en la tarjeta |
--rk-stories-card-transition | 300ms | Duración del desplazamiento del carrusel |
/* Loaded after @reelkit/vue-stories-player/styles.css. Every token has a
default on :root, so override only what you want to change. The names
match @reelkit/react-stories-player, so a theme ports between bindings. */
:root {
--rk-stories-overlay-bg: #0f172a;
--rk-stories-container-radius: 24px;
--rk-stories-nav-bg: rgba(99, 102, 241, 0.35);
--rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.65);
--rk-stories-nav-fg: #fff;
--rk-stories-header-name-fg: #fef3c7;
--rk-stories-header-time-fg: rgba(254, 243, 199, 0.7);
--rk-stories-ring-spin-duration: 2s;
--rk-stories-heart-duration: 600ms;
--rk-stories-card-bg: #1e293b;
--rk-stories-card-transition: 260ms;
}
/* 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"). Define aria-label 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 elemento que lo abrió al cerrarse. Tab y Shift+Tab recorren los elementos enfocables de dentro; el foco que se escapa se devuelve. Está hecho con captureFocusForReturn y createFocusTrap, de @reelkit/core, reexportados por @reelkit/vue.
Con el carrusel de escritorio, cada tarjeta por defecto es un botón etiquetado «Open stories by» y el nombre del autor. Las tarjetas van después de los controles del player en el orden de tabulación y quedan fuera de él mientras hay un desplazamiento; la tarjeta que abre su grupo devuelve el foco al diálogo en lugar de soltarlo en la página.
Renderizado en el servidor
Mientras está cerrado, el overlay no dibuja nada, y StoriesRingList dibuja todos los anillos como no vistos en el servidor: el almacenamiento de lo visto solo se lee después del montaje, así que el marcado del servidor coincide con el primer renderizado en el cliente. El panorama completo está en la guía de SSR.
Atajos de teclado
| Tecla | Acción |
|---|---|
ArrowLeft | Story anterior. En la primera story de un grupo abre el grupo anterior, donde se dejó; en el primer grupo no hace nada. |
ArrowRight | Story siguiente. Después de la última story de un grupo abre el grupo siguiente; en el último grupo cierra el reproductor. |
Escape | Cerrar el player |