Stories Player para Vue

Overlay de stories no estilo Instagram para Vue 3, com @reelkit/vue-stories-player. Ele usa o mesmo motor @reelkit/stories-core do player React, então o comportamento, as classes CSS e os tokens de tema são os mesmos.

Ver demo ao vivo →

Features

Navegação aninhada
Toque avança stories, deslize troca de autor
Stories em vídeo
Reprodução automática com controle de som
Avanço automático
Timer configurável por story
Transições 3D
Cubo, flip, fade, zoom, slide
Barra de progresso
Barra segmentada em canvas
Imagem e vídeo
Os dois tipos de mídia
Virtualizado
Apenas 3 slides no DOM
Curtida com toque duplo
Animação de coração no toque duplo
Navegação no desktop
Botões de seta
Anéis de stories
Avatares com anel no estilo Instagram
Tipos genéricos
Estenda StoryItem com seus campos
Slots com escopo
Troque qualquer parte da interface
Estado na URL
Links ?story=group.story para compartilhar
Estado do que foi visto
Anéis e retomada sobrevivem ao reload

Instalação

bash
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-next

Importe a folha de estilos uma vez no ponto de entrada do app (ou em qualquer componente):

typescript
import '@reelkit/vue-stories-player/styles.css';
Ícones

O cabeçalho padrão e as setas de navegação usam ícones do lucide-vue-next. Se você prefere outra biblioteca de ícones, substitua-os pelos slots com escopo #header e #navigation.

Vue 3.3 ou mais recente

Os overlays são componentes genéricos, que o Vue suporta a partir da 3.3. Num Vue mais antigo o runtime descarta as props e os eventos deles, por isso o pacote exige vue 3.3 ou mais recente como peer dependency.

Início rápido

StoriesOverlay renderiza o player em tela cheia. Combine-o com StoriesRingList para os pontos de entrada no estilo Instagram, passe um array de objetos StoriesGroup e use v-model:is-open.

vue
<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 na URL

Ver demo ao vivo →

StoriesUrlOverlay é um componente separado cujo estado de abertura vive na barra de endereços. Os dois eixos viajam em um único parâmetro — ?story=<group>.<story> — então o story em reprodução tem um link que dá para compartilhar, favoritar e fechar com o botão voltar. Crie um controlador com useOverlayUrlState e urlIndexTwoAxisKey e entregue-o como controller.

Chaves prontas

Stories têm dois eixos, então espalhe uma chave de dois eixos no controlador: urlIndexTwoAxisKey (autor e story por posição) ou urlStableIdTwoAxisKey (o autor por um id estável) — as duas são reexportadas de @reelkit/vue-stories-player. Veja o guia de estado na URL e a Core API.

vue
<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 empilha uma entrada no histórico. Andar pelos stories e trocar de autor apenas a substituem, então N navegações não acrescentam entradas e um passo para trás sempre fecha o player. Voltar fecha; não retrocede um story.
  • A navegação interna também vai na URL. Avançar dentro dos stories de um autor atualiza ?story=2.n, então um link direto cai no story exato.
  • Voltar só fecha quando o player foi aberto de dentro do app — o link empilhou uma entrada. Um link compartilhado aberto numa aba nova não tem histórico atrás dele, então o voltar do navegador sai do site; o botão ✕ ou Escape remove o parâmetro no lugar e permanece.
  • Um parâmetro que não nomeia autor nem story — um favorito velho, um valor editado à mão, um story além do fim do grupo — é removido da URL em vez de abrir o vizinho.
  • Um app com rotas deve passar um adaptador ligado ao roteador, para que ele continue sendo a única fonte de verdade da navegação. useVueRouterUrlAdapter, de @reelkit/vue/vue-router-url-adapter, é o adaptador pronto para o Vue Router; escrever no histórico por trás do roteador deixa a location dele desatualizada e derruba o parâmetro na navegação seguinte.

Links estáveis. O autor é posicional por padrão, então um ?story=2.0 favoritado abre outra pessoa assim que o feed é reordenado. Enderece o autor por um id estável — outerCodec escreve o id na URL e outerLocator encontra onde ele está. Passe innerCodec com innerLocate e innerIdentify para fazer o mesmo com o story dentro do grupo; sem eles, a metade do story continua sendo um índice simples dentro do grupo resolvido. Um id de story não pode conter ., o delimitador entre as duas metades.

typescript
// `?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 é assunto do outerLocator, independente do codec. locate é síncrono, então só responde por grupos já carregados — um link compartilhado para o grupo 400 de um feed que carregou 20 não encontra nada. locateAsync é o plano B, chamado apenas quando locate erra; o story é então limitado ao grupo em que tudo parou.

O mesmo locateAsync, eixo externo

É o mesmo paginador locateAsync que as chaves de um eixo aceitam — numa chave de dois eixos ele viaja no outerLocator que você passa, então o eixo dos autores pagina enquanto o story continua um índice local dentro do grupo resolvido.

typescript
// 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;
  },
},
  • Enquanto locateAsync está pendente, o player fica fechado e o parâmetro intacto, então o link direto sobrevive à busca. Um null ou uma rejeição removem o parâmetro.
  • Uma resposta que chega depois de a URL ter mudado, depois do fechamento ou depois da desmontagem é descartada — uma busca lenta não abre um story que ninguém pediu mais.
  • As opções completas de useOverlayUrlState estão na referência da API Vue.

Lembrando o que já foi visto

Os anéis mostram o gradiente até um autor ser visto até o fim, e um grupo reabre no primeiro story que a pessoa ainda não viu. Os dois vêm de um único createStoriesViewedStateController, criado onde o feed vive e entregue à lista de anéis e ao player como viewed: eles leem o armazenamento, acompanham-no e registram o que foi mostrado por conta própria. Cada entrada guarda autor e story por id, então sobrevive à reordenação do feed, e o mesmo controlador funciona em React e Angular.

vue
<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>
  • O story de abertura conta. O story que já está na tela é marcado como visto assim que o player monta, então abrir um grupo de um story só e fechar já o marca como assistido.
  • Seguir em frente também retoma. resumeStoryIndex é consultado para cada grupo alcançado pela primeira vez nesta sessão, inclusive aquele em que o player abre, a menos que initialStoryIndex nomeie um story explicitamente; um grupo já percorrido reabre onde foi deixado.
  • O link ainda ganha. Com StoriesUrlOverlay, o parâmetro decide onde o player abre, independentemente do que foi armazenado. Em todo o resto, quem decide é o callback de retomada.
  • A contagem é uma posição, não um total. A entrada guarda o story mais distante alcançado, então acrescentar um story a um grupo já visto acende o anel de novo, e remover um do meio encurta a contagem.
  • O armazenamento é trocável. Passe storage: createSessionStorageAdapter() para esquecer ao fechar a aba, ou o seu próprio StorageAdapter. Duas abas abertas ficam em sincronia pelo evento de storage do navegador.
  • Um player montado só na hora de abrir lê tarde demais. O overlay lê o armazenamento depois de montar, ainda fechado. Com v-if="open" esse momento não existe, então chame useAttachViewedState(() => viewed) no componente dono do controlador; ele se conecta depois de montar e solta ao desmontar. As opções, e as peças de nível mais baixo por trás disso, estão na página Stories Core.

Use desktop-layout="carousel" para dispor o player como o visualizador web do Instagram: o story ativo fica no centro no tamanho de sempre e até dois grupos vizinhos ficam de cada lado, como cartões menores e escurecidos. Clicar num cartão abre aquele autor, e os cartões deslizam enquanto o grupo muda. Passe o mesmo controlador viewed que a lista de anéis recebe e o cartão de um grupo já visto ganha o anel apagado. Os cartões acompanham o controlador sozinhos, então marcar um story como visto repinta só eles, sem re-renderizar o player.

vue
<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>
  • No celular fica o player simples. Até 768px de largura os cartões não são renderizados e os deslizes usam group-transition, como no layout 'single' padrão. Redimensionar através dessa largura troca o layout na hora.
  • Cada cartão mostra por onde o grupo abre: o story em que foi deixado nesta sessão, senão o que resumeStoryIndex devolve. Ele exibe o poster do vídeo ou a imagem. Um story sem nenhum dos dois, como um story de texto sobre um gradiente, é desenhado pelo seu slot #slide no tamanho do player e reduzido; um vídeo sem poster mostra o fundo do cartão. Essa prévia recebe isActive: false e é só uma imagem: nada nela recebe foco ou clique. Deixe o que um slide faz ao montar, como reprodução ou analytics, atrás de isActive.
  • O timer espera o deslize. O story não avança, e story-viewed não dispara para ele, enquanto os cartões não terminam de se mover.
  • Toda forma de trocar de grupo desliza: clique num cartão, StoriesApi.goToGroup, nextGroup e prevGroup, e as setas do teclado além do fim de um grupo. Um deslize de toque move o próprio player, então não ganha um deslize por cima.

Barra de progresso e cabeçalho em cada grupo

Por padrão, uma barra de progresso e um cabeçalho ficam acima do player e passam para o novo grupo depois que ele muda. Defina chrome-placement="group" para dar a cada grupo os seus, dentro do próprio slide, e os dois giram junto com o grupo, como no Instagram.

vue
<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>
  • Um grupo vizinho mostra onde parou. Enquanto o player gira até ele, a barra e o cabeçalho mostram o story em que ele vai abrir, sem nada reproduzido ainda.
  • O grupo que sai fica como estava. A barra dele mantém o progresso até o giro terminar, então um grupo visto até o fim continua cheio em vez de se esvaziar no meio do giro.
  • Os slots são renderizados para cada grupo na tela. Os escopos de #progressBar e #header trazem groupIndex e isActive; para um grupo vizinho isActive é false e os sinais de progresso ficam parados.
  • Um slot #header fica na área de swipe. Um toque nele troca de story, a menos que caia em um button, um link ou um elemento com role="button", então faça de cada controle um desses.
  • O carrossel no desktop não muda durante o deslize. O player fica oculto enquanto os cartões deslizam, então a escolha aparece quando o grupo já está aberto.

Referência da API

Props de StoriesOverlay

StoriesOverlayProps<T>

PropTipoPadrãoDescrição
aria-labelstring'Stories player'Nome acessível da região de diálogo; anunciado por leitores de tela quando o overlay abre
chrome-placement'overlay' | 'group''overlay'Onde ficam a barra de progresso e o cabeçalho. 'overlay' desenha uma cópia acima do player; 'group' desenha uma em cada slide de grupo, e os dois giram junto com o grupo. O tipo é exportado como ChromePlacement.
default-image-durationnumber5000Duração do avanço automático de um story em imagem, em milissegundos
desktop-layout'single' | 'carousel''single'Layout no desktop. 'carousel' mostra grupos vizinhos como cartões ao lado do story ativo e desliza entre grupos. No celular o story aparece sempre sozinho. O tipo é exportado como DesktopLayout.
enable-keyboardbooleantrueAtiva a navegação por teclado (setas esquerda/direita, Escape)
groupsStoriesGroup<T>[]requiredGrupos de stories a exibir. Grupos ou stories acrescentados com o player aberto são detectados, tanto ao trocar o array quanto ao acrescentar nele.
group-transitionTransitionTransformFncubeTransitionTransição do slider externo (de grupos). Ignorada enquanto o carrossel do desktop está visível, que desliza em vez disso.
hide-ui-on-pausebooleantrueEsconde a barra de progresso e o cabeçalho enquanto a pausa por toque longo dura
initial-group-indexnumber0Índice do grupo visível no início, começando em zero
initial-story-indexnumberretomada, senão 0Índice do story visível no início, começando em zero. Nomear um vence qualquer coisa lembrada; deixe de fora e o grupo de abertura retoma como todos os outros.
inner-transition-durationnumber200Duração do fade entre stories, em milissegundos
is-openbooleanrequiredControla a visibilidade e trava a rolagem da página enquanto está aberto. Use com v-model:is-open.
min-segment-widthnumber8Largura mínima de um segmento de progresso, em pixels, antes de a janela deslizante entrar
resume-story-index(groupIndex: number) => number—Em que story um grupo abre na primeira vez que é alcançado. Consultado apenas para um grupo ainda não visitado nesta abertura, e vence viewed.
tap-zone-splitnumber0.3Divisão da zona de toque (0–1). A parte esquerda volta, o resto avança.
viewedStoriesViewedStateController—O que a pessoa já viu, vindo de createStoriesViewedStateController(). Os grupos retomam no primeiro story não visto, todo story exibido é registrado e os cartões do carrossel apagam o anel de um grupo visto até o fim. Passe o mesmo controlador para StoriesRingList.

Props de StoriesUrlOverlay

StoriesUrlOverlayProps<T>

Aceita todas as props acima, menos o trio do estado de abertura — is-open, initial-group-index, initial-story-index —, que vem do controlador.

PropTipoPadrãoDescrição
controllerUrlStateController<TwoAxisPosition>requiredControlador de useOverlayUrlState espalhado com urlIndexTwoAxisKey. Sua position — um objeto { outer, inner } — decide se o player está aberto e onde abre; o overlay escreve de volta a cada navegação e no fechamento.

Eventos

EventoPayloadDescrição
@api-readyStoriesApiEmitido assim que o player monta, com a API imperativa
@closevoidEmitido quando o player fecha. No StoriesUrlOverlay o parâmetro já foi limpo nesse momento.
@double-tapgroupIndex: number, storyIndex: numberEmitido no toque duplo, junto com a animação de coração
@group-changegroupIndex: numberEmitido depois que o grupo ativo muda
@pausevoidEmitido no toque longo ou no botão de pausa do cabeçalho
@resumevoidEmitido ao soltar ou no botão de play do cabeçalho
@story-changegroupIndex: number, storyIndex: numberEmitido depois que o story ativo muda
@story-completegroupIndex: number, storyIndex: numberEmitido quando o timer de um story termina
@story-viewedgroupIndex: number, storyIndex: numberEmitido quando um story fica visível, inclusive o de abertura
@update:is-openbooleanEmitido no fechamento; habilita `v-model:is-open`

v-model:is-open

Use v-model:is-open para controlar o overlay com um único binding. O par :is-open + @close continua funcionando quando você precisa do evento explícito.

vue
<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 com escopo

Oito slots com escopo substituem qualquer parte do player. Cada um recebe um objeto de escopo tipado. Um slot que você não passa mantém o padrão — e um que não renderiza nada também, então um v-if que fica falso volta ao padrão em vez de deixar um buraco.

SlotEscopoDescrição
#error{ story, storyIndex, groupIndex }Substitui o painel interno "Content unavailable", desenhado sobre o story ativo
#footer{ author, story, storyIndex }Acrescenta um rodapé sob o story do grupo ativo
#groupPreview{ group, groupIndex, story, offset, viewedCount, onOpen }Preenche um cartão do carrossel; o player continua posicionando, escalando e deslizando o cartão
#header{ author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }Substitui o cabeçalho padrão (autor, pausa, som, fechar). Com chrome-placement="group" ele fica na área de swipe; veja Barra de progresso e cabeçalho em cada grupo.
#loading{ story, storyIndex, groupIndex }Substitui o indicador padrão do cabeçalho enquanto o story ativo carrega
#navigation{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }Substitui as duas setas do desktop
#progressBar{ totalStories, activeIndex, progress, group, groupIndex, isActive }Substitui a barra de progresso em canvas. activeIndex e progress são sinais do core — converta-os com toVueRef.
#slide{ story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }Substitui os slides padrão de imagem e vídeo
vue
<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>

Slides personalizados

Componha ImageStorySlide e VideoStorySlide dentro de #slide para manter reprodução automática, captura de poster e sincronia de som enquanto desenha o seu layout. Chame os callbacks de ciclo de vida do escopo, ou o timer nunca começa:

CallbackQuando chamar
onReadyA imagem carregou ou o vídeo começou a tocar. Limpa o estado de carregamento e inicia o timer.
onWaitingO vídeo está em buffer no meio da reprodução. Mostra o indicador e pausa o timer.
onErrorO conteúdo falhou ao carregar. Mostra o estado de erro, e a URL fica marcada como quebrada, então reabrir pula a nova tentativa.
onDurationReadyInforme a duração real da mídia em milissegundos, para o timer durar o mesmo que o vídeo.
onEndedA mídia terminou. Avança para o próximo story.
vue
<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>
Cache do pré-carregador

Os componentes internos ImageStorySlide e VideoStorySlide pré-carregam o próximo story em segundo plano. Quando a pessoa chega a um story já pré-carregado, o conteúdo aparece na hora, sem indicador de carregamento. Uma URL que falhou uma vez também fica guardada, então reabrir um story quebrado mostra o erro de imediato em vez de tentar de novo.

Navegação personalizada

vue
<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 progresso personalizada

O escopo carrega sinais do core em vez de números simples, então o timer move a barra sem re-renderizar o player. Converta-os com toVueRef, de @reelkit/vue:

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>

Carregamento e erro personalizados

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

Transições

group-transition controla o efeito 3D ao trocar de autor. Importe as funções de transição de @reelkit/vue:

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

Ponha um template ref no overlay para controle imperativo, ou pegue a API pelo evento @api-ready. Chamadas feitas com o player fechado não fazem nada.

vue
<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étodoTipoDescrição
nextStory()() => voidAvança para o próximo story do grupo atual
prevStory()() => voidVolta ao story anterior do grupo atual
nextGroup()() => voidTroca para o próximo grupo de autor
prevGroup()() => voidTroca para o grupo de autor anterior
goToGroup(index)(index: number) => voidSalta para um grupo específico pelo índice
pause()() => voidPausa o avanço automático e o timer de progresso
resume()() => voidRetoma o avanço automático e o timer de progresso

Toque duplo e curtidas

Uma animação de coração toca no toque duplo, dando retorno visual imediato. O evento @double-tap dispara com os índices de grupo e story, para você guardar a curtida no seu próprio estado. O player não mantém estado de curtida.

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

Personalizando a animação de coração

Ajuste a velocidade pelo token --rk-stories-heart-duration (veja Tema). Para cor, tamanho ou para esconder o coração por completo, use a classe .rk-stories-heart. O componente HeartAnimation também é exportado para uso avulso.

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

A animação de coração interna ainda não pode ser substituída por um slot. Reestilize-a com CSS, ou esconda-a com display: none e rode a sua própria animação a partir do evento @double-tap. Se você precisa de um slot heart, conte para a gente pelo GitHub Issues.

Subcomponentes

Blocos exportados, para compor dentro dos slots ou usar sozinhos: StoriesRing, StoriesRingList, StoryHeader, CanvasProgressBar, ImageStorySlide, VideoStorySlide e HeartAnimation.

ComponenteO que é
CanvasProgressBarA barra de progresso segmentada em canvas, desenhada direto dos sinais do timer. Cuida da janela deslizante em grupos com muitos stories. Com :live="false" só desenha quando os sinais mudam e depois de um redimensionamento, sem loop de animação.
HeartAnimationO coração do toque duplo. Emite complete quando a animação termina, para o pai removê-lo.
ImageStorySlideSlide de imagem em tela cheia com object-fit: cover, relatando carga e erro por callbacks.
StoriesRingAvatar circular com o anel em gradiente. Dois estados: com algo por ver ele gira, visto até o fim fica liso. Emite click.
StoriesRingListFileira horizontal de anéis com o nome do autor, um por grupo. Emite select com o índice do grupo.
StoryHeaderO cabeçalho padrão: avatar, nome, selo de verificado, tempo relativo, pausa, som, fechar e o indicador de carregamento.
VideoStorySlideSlide de vídeo apoiado no único elemento <video> compartilhado, para continuidade de som no iOS. Cuida da reprodução automática, dos quadros de poster e do relato de duração.
vue
<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>

Reexportações

Tudo de que quem usa precisa vem deste pacote, então um import direto de @reelkit/core nunca é necessário.

DeReexportado
@reelkit/vueObserve, SoundProvider, useSoundState, useOverlayUrlState, createViewedStateController, twoAxisViewedTracking, createLocalStorageAdapter, createSessionStorageAdapter, createMemoryStorageAdapter, urlIndexTwoAxisKey, urlStableIdTwoAxisKey, base64UrlCodec e os tipos UrlAdapter, UrlCodec, UrlLocator, UrlKey, UrlStateController, TwoAxisPosition, TwoAxisIdentity, UrlIndexTwoAxisKeyOptions, OverlayUrlStateOptions, ViewedStateController, ViewedStateOptions, StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController, StoriesViewedStateController, StoriesViewedStateControllerConfig e os tipos de conteúdo StoryItem, AuthorInfo, StoriesGroup, MediaType

Observe é a fronteira de renderização que o player usa por dentro: ela re-renderiza apenas o seu slot quando um sinal do core muda, e é assim que a troca de story repinta o cabeçalho e a barra de progresso sem redesenhar os sliders. Use-a num slot seu quando um componente inteiro seguindo o sinal por toVueRef for grosso demais — veja a referência da API Vue.

O tipo de props de cada componente também é exportado: StoriesOverlayProps, StoriesUrlOverlayProps, StoriesRingProps, StoriesRingListProps, StoryHeaderProps, CanvasProgressBarProps, ImageStorySlideProps e VideoStorySlideProps.

Tipos

StoryItem, AuthorInfo, StoriesGroup

typescript
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[];
}

Escopos dos slots

Todo tipo de escopo é exportado, então dá para anotar um slot onde o compilador não consegue inferir — HeaderSlotScope, FooterSlotScope, SlideSlotScope, NavigationSlotScope, ProgressBarSlotScope, LoadingSlotScope, ErrorSlotScope e GroupPreviewSlotScope, ao lado de StoriesApi:

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

Estenda StoryItem com os seus campos e tipe os grupos com ele. StoriesOverlay e StoriesUrlOverlay são genéricos, como o player React: o tipo de story é inferido de groups, então grupos cujas stories não correspondem a StoryItem falham na checagem de tipos. No Vue os slots não conseguem carregar esse tipo, então os escopos ficam sem tipo até você anotá-los. Anote o escopo do slot para os campos extras continuarem visíveis dentro do template. Nada confere a anotação com groups, então mantenha os dois em sintonia:

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

Classes CSS

As classes são simples (não têm escopo), então uma folha de estilos carregada depois de @reelkit/vue-stories-player/styles.css pode sobrescrever qualquer uma. Elas são idênticas às de @reelkit/react-stories-player, então uma folha escrita para um binding serve para o outro. Para cores, tamanhos e z-index prefira as propriedades customizadas da seção Tema.

ClasseComponenteDescrição
.rk-stories-overlayOverlayFundo fixo em tela cheia (cor, z-index)
.rk-stories-overlay--carouselOverlayOverlay com o carrossel do desktop visível
.rk-stories-overlay--slidingOverlayDeslize do carrossel em andamento; a tela do player fica escondida
.rk-stories-swipe-wrapperOverlayWrapper do deslizar para fechar (abriga os botões de navegação e a tela)
.rk-stories-containerOverlayTela arredondada do story (posição, overflow)
.rk-stories-ui-layerOverlayContêiner da interface (cabeçalho, progresso). Dentro de cada slide de grupo com chrome-placement="group"
.rk-stories-ui-layer--hiddenOverlayEstado escondido da interface (alternado por hide-ui-on-pause)
.rk-stories-errorOverlayEstado de erro (ícone e texto centralizados)
.rk-stories-error-textOverlayTexto da mensagem de erro
.rk-stories-nav-btnNavigationSeta de avançar/voltar no desktop
.rk-stories-progress-barCanvasProgressBarWrapper de posicionamento da barra em canvas
.rk-stories-slide-wrapperGroupUm grupo de stories (slide externo)
.rk-stories-storyStoryUm story (raiz do slide interno)
.rk-stories-headerStoryHeaderBarra do cabeçalho (avatar, nome, ações)
.rk-stories-header--hiddenStoryHeaderEstado escondido do cabeçalho
.rk-stories-header-avatarStoryHeaderImagem do avatar do autor
.rk-stories-header-nameStoryHeaderTexto do nome do autor
.rk-stories-header-verifiedStoryHeaderSelo de verificado
.rk-stories-header-timeStoryHeaderTexto de tempo relativo
.rk-stories-header-actionsStoryHeaderAções à direita (som, pausa, fechar)
.rk-stories-header-btnStoryHeaderBotão de ação do cabeçalho
.rk-stories-header-btn--desktopStoryHeaderBotão de pausa, exibido só em telas com mais de 768px de largura
.rk-stories-header-spinnerStoryHeaderIndicador de carregamento
.rk-stories-imageImageStorySlideElemento do story em imagem
.rk-stories-videoVideoStorySlideContêiner do story em vídeo
.rk-stories-video-elementVideoStorySlideO elemento <video> compartilhado
.rk-stories-video-posterVideoStorySlidePoster do vídeo (some quando a reprodução começa)
.rk-stories-video-poster--visibleVideoStorySlideEstado do poster visível (antes da reprodução)
.rk-stories-heartHeartAnimationAnimação do coração no toque duplo
.rk-stories-ringStoriesRingAnel do story (avatar com borda em gradiente animada)
.rk-stories-ring--activeStoriesRingAnel com stories por ver (anima)
.rk-stories-ring-avatarStoriesRingImagem do avatar dentro do anel
.rk-stories-ring-listStoriesRingListContêiner da lista horizontal de anéis
.rk-stories-ring-list-itemStoriesRingListColuna com anel e nome
.rk-stories-ring-list-nameStoriesRingListNome do autor sob cada anel
.rk-stories-carouselCarouselCamada de cartões atrás do player
.rk-stories-carousel--instantCarouselPrimeiro quadro de um deslize, posicionado sem transição
.rk-stories-cardCarouselUm cartão posicionado
.rk-stories-card--centerCarouselCartão na posição central durante um deslize
.rk-stories-card--hiddenCarouselCartão além dos dois mostrados de cada lado
.rk-stories-card-buttonCarouselConteúdo padrão do cartão (o botão que abre o grupo)
.rk-stories-card-imageCarouselImagem de prévia (poster ou imagem)
.rk-stories-card-frameCarouselUm story sem poster nem imagem, desenhado pelo slot #slide no tamanho do player e reduzido
.rk-stories-card-scrimCarouselCamada que escurece a prévia
.rk-stories-card-infoCarouselColuna com anel, nome do autor e tempo
.rk-stories-card-nameCarouselNome do autor
.rk-stories-card-timeCarouselTexto de tempo relativo

Tema

Cada cor, tamanho, z-index e transição vive numa propriedade customizada de CSS, com o padrão em :root. Sobrescreva uma ou várias em :root, ou em qualquer ancestral do overlay, para trocar o tema sem tocar no código dos componentes. Os tokens são os mesmos de @reelkit/react-stories-player, então um tema passa de um binding para o outro.

TokenPadrãoControla
--rk-stories-overlay-bg#000Cor do fundo em tela cheia
--rk-stories-overlay-z9999z-index do overlay
--rk-stories-container-radius12pxCantos arredondados da tela do story (desktop)
--rk-stories-container-radius-mobile0Cantos da tela do story no celular, até 768px de largura
--rk-stories-swipe-gap16pxEspaço entre os botões de navegação e a tela do story
--rk-stories-top-shade-height120pxAltura do degradê superior atrás do cabeçalho
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Cor do degradê superior
--rk-stories-ui-transition200msDuração do fade quando hide-ui-on-pause alterna
--rk-stories-ui-z15z-index da camada de interface (barra de progresso e cabeçalho)
--rk-stories-nav-size44pxTamanho do botão de avançar/voltar no desktop
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)Fundo do botão de navegação no desktop
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)Fundo do botão de navegação sob o cursor
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)Cor do ícone do botão de navegação
--rk-stories-nav-fg-hover#fffCor do ícone do botão sob o cursor
--rk-stories-nav-transition150msTransição do botão de navegação sob o cursor no desktop
--rk-stories-nav-z20z-index do botão de navegação no desktop
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)Degradê de fundo do estado de erro
--rk-stories-error-fgrgba(255, 255, 255, 0.5)Cor do ícone e do texto de erro
--rk-stories-error-text-size13pxTamanho da fonte da mensagem de erro
--rk-stories-error-gap12pxEspaço entre o ícone de erro e o texto
--rk-stories-error-z5z-index do estado de erro
--rk-stories-video-bg#000Fundo das bordas atrás do <video>
--rk-stories-video-poster-transition200msDuração do fade do poster quando o vídeo começa
--rk-stories-progress-bar-padding8px 8px 0Espaçamento em volta da barra de progresso
--rk-stories-progress-bar-z10z-index da barra de progresso
--rk-stories-header-top18pxDeslocamento vertical do cabeçalho a partir do topo do story
--rk-stories-header-padding12px 16pxEspaçamento interno da linha do cabeçalho
--rk-stories-header-z5z-index do cabeçalho
--rk-stories-header-transition200msDuração do fade do cabeçalho quando ele se esconde
--rk-stories-header-gap8pxEspaço entre o avatar, o nome e o tempo
--rk-stories-header-avatar-size32pxLargura e altura do avatar
--rk-stories-header-name-fg#fffCor do nome do autor
--rk-stories-header-name-size14pxTamanho da fonte do nome do autor
--rk-stories-header-name-weight600Peso da fonte do nome do autor
--rk-stories-header-time-fgrgba(255, 255, 255, 0.6)Cor do texto de tempo relativo
--rk-stories-header-time-size12pxTamanho da fonte do tempo relativo
--rk-stories-header-btn-fg#fffCor dos ícones de ação do cabeçalho (fechar, mudo, pausa)
--rk-stories-header-btn-padding4pxEspaçamento interno de um botão de ação do cabeçalho
--rk-stories-header-actions-gap8pxEspaço entre os botões de ação do cabeçalho
--rk-stories-header-spinner-size20pxDiâmetro do indicador de carregamento
--rk-stories-header-spinner-fg#fffCor do arco do indicador de carregamento
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Cor do trilho do indicador de carregamento
--rk-stories-header-spinner-duration0.8sDuração da rotação do indicador de carregamento
--rk-stories-heart-duration800msDuração da animação de entrada e saída do coração
--rk-stories-heart-z20z-index do coração do toque duplo
--rk-stories-ring-spin-duration4sDuração da rotação de um anel com stories por ver
--rk-stories-ring-active-scale0.95Escala do anel enquanto é pressionado
--rk-stories-ring-active-gradientconic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433)O que um anel com stories por ver mostra: qualquer imagem ou cor CSS. Numa página clara, defina-o em .rk-stories-ring-list, não em :root, para o carrossel do player manter o seu
--rk-stories-ring-viewed-colorrgba(255, 255, 255, 0.25)O que um anel visto até o fim mostra, pensado para uma página escura. Numa página clara, defina-o em .rk-stories-ring-list, não em :root
--rk-stories-ring-gradientnoneInterno: o anel o escreve a cada renderização e aponta para os dois tokens acima, a menos que o anel receba gradientColors ou viewedColor
--rk-stories-ring-list-gap12pxEspaço entre os anéis da lista
--rk-stories-ring-list-padding12pxEspaçamento interno ao redor da lista de anéis
--rk-stories-ring-list-item-gap4pxEspaço entre um anel e o nome
--rk-stories-ring-list-name-size12pxTamanho da fonte do nome sob cada anel
--rk-stories-card-bg#262626Fundo do cartão, visível quando não há imagem de prévia
--rk-stories-card-radius8pxRaio dos cantos do cartão
--rk-stories-card-scrimrgba(0, 0, 0, 0.45)Escurecimento de um cartão lateral
--rk-stories-card-fg#fffCor do texto do cartão
--rk-stories-card-name-size14pxTamanho da fonte do nome no cartão
--rk-stories-card-time-fgrgba(255, 255, 255, 0.7)Cor do texto de tempo no cartão
--rk-stories-card-time-size13pxTamanho da fonte do tempo no cartão
--rk-stories-card-gap6pxEspaço entre anel, nome e tempo no cartão
--rk-stories-card-transition300msDuração do deslize do carrossel
css
/* 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;
}

Acessibilidade

A raiz do overlay é um diálogo modal (role="dialog", aria-modal="true"). Defina aria-label para mudar o que o leitor de tela anuncia; o padrão é "Stories player".

O overlay captura o foco ao abrir e o devolve ao gatilho ao fechar. Tab e Shift+Tab circulam pelos elementos focáveis de dentro; um foco que escapa é trazido de volta. Feito com captureFocusForReturn e createFocusTrap, de @reelkit/core, reexportados por @reelkit/vue.

Com o carrossel do desktop, cada cartão padrão é um botão rotulado "Open stories by" mais o nome do autor. Os cartões vêm depois dos controles do player na ordem de tabulação e ficam fora dela enquanto um deslize acontece; o cartão que abre o seu grupo devolve o foco ao diálogo em vez de largá-lo na página.

Renderização no servidor

Fechado, o overlay não renderiza nada, e StoriesRingList renderiza todos os anéis como não vistos no servidor: o armazenamento do que foi visto só é lido depois da montagem, então a marcação do servidor bate com a primeira renderização no cliente. O quadro completo está no guia de SSR.

Atalhos de teclado

TeclaAção
ArrowLeftStory anterior. No primeiro story de um grupo, abre o grupo anterior onde ele foi deixado; no primeiro grupo, não faz nada.
ArrowRightPróximo story. Depois do último story de um grupo, abre o próximo grupo; no último grupo, fecha o player.
EscapeFechar o player