Stories Player

Um player de stories em tela cheia no estilo Instagram para React, com @reelkit/react-stories-player.

Ver demonstração ao vivo →

Recursos

Navegação aninhada
Toque para avançar os stories, deslize para trocar de grupo
Stories em vídeo
Tocam sozinhos, com botão de som
Avanço automático
Tempo configurável por story
Transições 3D
Cubo, virada, esmaecer, aproximar, deslizar
Barra de progresso
Progresso segmentado desenhado em canvas
Imagem e vídeo
Dá conta dos dois tipos de mídia
Virtualizado
Apenas 3 slides no DOM
Curtida no toque duplo
Animação de coração no toque duplo
Navegação no desktop
Botões de seta no desktop
Anéis de story
Anéis em volta do avatar, como no Instagram
Tipos genéricos
Estenda StoryItem com dados próprios
Render Props
Personalize cada elemento da interface
Estado na URL
Links ?story=grupo.story para compartilhar
Itens já vistos
Anéis vistos e retomada sobrevivem ao recarregamento

Instalação

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

Não esqueça de importar os estilos:

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

O cabeçalho padrão usa lucide-react para os ícones. Se você prefere outra biblioteca de ícones, use renderHeader e renderNavigation para fornecer os seus.

Início rápido

O componente StoriesOverlay renderiza um player de stories em tela cheia. Combine-o com StoriesRingList para ter pontos de entrada no estilo Instagram. Passe um array de objetos StoriesGroup e controle a visibilidade com isOpen.

tsx
import { useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: {
      id: 'user-1',
      name: 'Alice',
      avatar: '/cdn/samples/avatars/avatar-06.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's1-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-001.jpg',
      },
      {
        id: 's1-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-002.jpg',
      },
      {
        id: 's1-3',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-003.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-2',
      name: 'Bob',
      avatar: '/cdn/samples/avatars/avatar-07.jpg',
    },
    stories: [
      {
        id: 's2-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-004.jpg',
      },
      {
        id: 's2-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-005.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-3',
      name: 'Charlie',
      avatar: '/cdn/samples/avatars/avatar-08.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's3-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-006.jpg',
      },
    ],
  },
];

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedGroup, setSelectedGroup] = useState(0);
  // What was seen: rings, resume and recording from one controller, kept in
  // localStorage across reloads.
  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groups,
    }),
  );

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

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

      <StoriesOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        groups={groups}
        initialGroupIndex={selectedGroup}
        viewed={viewed}
      />
    </div>
  );
}
StoriesPlayer.tsx
import { useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: {
      id: 'user-1',
      name: 'Alice',
      avatar: '/cdn/samples/avatars/avatar-06.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's1-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-001.jpg',
      },
      {
        id: 's1-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-002.jpg',
      },
      {
        id: 's1-3',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-003.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-2',
      name: 'Bob',
      avatar: '/cdn/samples/avatars/avatar-07.jpg',
    },
    stories: [
      {
        id: 's2-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-004.jpg',
      },
      {
        id: 's2-2',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-005.jpg',
      },
    ],
  },
  {
    author: {
      id: 'user-3',
      name: 'Charlie',
      avatar: '/cdn/samples/avatars/avatar-08.jpg',
      verified: true,
    },
    stories: [
      {
        id: 's3-1',
        mediaType: 'image',
        src: '/cdn/samples/images/stories/story-006.jpg',
      },
    ],
  },
];

export default function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedGroup, setSelectedGroup] = useState(0);
  // What was seen: rings, resume and recording from one controller, kept in
  // localStorage across reloads.
  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groups,
    }),
  );

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

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

      <StoriesOverlay
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        groups={groups}
        initialGroupIndex={selectedGroup}
        viewed={viewed}
      />
    </div>
  );
}

Para uma fileira de anéis que lembra o que o usuário já viu, mesmo após recarregar, veja a receita Bandeja de stories.

Estado na URL

Ver demonstração ao vivo →

StoriesUrlOverlay é um componente à parte cujo estado aberto vive na barra de endereços. Os dois eixos viajam em um único parâmetro — ?story=<grupo>.<story> — então o story em exibição ganha um link que dá para compartilhar, favoritar e fechar com o botão voltar. Monte 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 (grupo e story por posição) ou urlStableIdTwoAxisKey (o grupo por um id estável) — as duas reexportadas por @reelkit/react. Veja o guia de estado na URL e a API do core.

tsx
import {
  StoriesUrlOverlay,
  useOverlayUrlState,
  urlIndexTwoAxisKey,
} from '@reelkit/react-stories-player';
import { Link } from 'react-router-dom';

const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
  }),
});

// Opening a user is a link — the overlay reads the URL and opens itself.
{groups.map((g, i) => (
  <Link key={g.author.id} to={`?story=${i}.0`}>{g.author.name}</Link>
))}

<StoriesUrlOverlay controller={stories} groups={groups} />
  • Abrir empilha uma entrada no histórico. Passar de story e trocar de pessoa substituem essa entrada, então N navegações não acrescentam nenhuma e um único passo para trás sempre fecha o player. Voltar fecha; não retrocede stories.
  • A navegação interna vai junto. O índice do story não fica congelado na granularidade do grupo — avançar dentro dos stories de uma pessoa 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 — foi o link que empilhou a entrada. Um link compartilhado aberto direto em uma aba nova não tem histórico atrás de si, então o voltar do navegador sai do site; o botão ✕ ou o Escape removem o parâmetro ali mesmo e você continua na página.
  • Um parâmetro que não nomeia grupo nem story — um favorito velho, um valor editado à mão, um story além do fim de um grupo — é retirado da URL, em vez de abrir um vizinho.

App com rotas — passe um adaptador. Escrever history.pushState por trás de um roteador deixa a localização dele desatualizada, e a navegação seguinte derruba o parâmetro:

tsx
import { useReactRouterUrlAdapter } from '@reelkit/react/react-router-url-adapter';

const adapter = useReactRouterUrlAdapter();
const stories = useOverlayUrlState({
  param: 'story',
  adapter,
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
  }),
});

<StoriesUrlOverlay controller={stories} groups={groups} />

Links estáveis. Por padrão o grupo é posicional, então um ?story=2.0 favoritado abre outra pessoa assim que o feed muda de ordem. Enderece o grupo por um id estável — outerCodec escreve o id na URL, outerLocator descobre 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 segue sendo um índice local simples.

tsx
const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
    // ?story=user_42.3
    outerCodec: { decode: (raw) => raw, encode: (id) => id },
    outerLocator: {
      locate: (id) => groups.findIndex((g) => g.author.id === id),
      identify: (index) => groups[index].author.id,
    },
    // Optional: ?story=user_42.story_7 — the story by id too
    innerCodec: { decode: (raw) => raw, encode: (id) => id },
    innerLocate: (outer, id) => {
      const index = groups[outer].stories.findIndex((s) => s.id === id);
      return index === -1 ? null : index;
    },
    innerIdentify: (outer, index) => groups[outer].stories[index].id,
  }),
});

Feeds infinitos. Paginar é assunto do outerLocator, independente do codec. locate é síncrono, então só responde pelos grupos já carregados — um link compartilhado para o grupo 400 de um feed que carregou 20 volta vazio. locateAsync é o plano B, chamado somente quando locate falha; o story é então limitado de novo pelo grupo em que a busca parar.

O mesmo locateAsync, no eixo externo

Este é o mesmo paginador locateAsync das chaves de eixo único — em uma chave de dois eixos ele viaja no outerLocator que você passa, então o eixo dos grupos pagina enquanto o story continua um índice local dentro do grupo resolvido.

tsx
const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
    outerLocator: {
      locate: (index) => (index < groups.length ? index : null),
      identify: (index) => index,
      locateAsync: async (index) => {
        const loaded = await loadUntilGroup(index); // page up to it
        if (!loaded) return null; // exhausted — link names no group
        setGroups(loaded); // commit — the overlay renders from this state
        return index;
      },
    },
  }),
});
  • Enquanto locateAsync está pendente, o player fica fechado e o parâmetro é deixado em paz, então o link direto sobrevive à busca. Um null ou uma rejeição derrubam o parâmetro.
  • Uma resposta que chega depois de a URL ter mudado, depois de um fechamento ou depois da desmontagem é descartada — uma busca lenta não pode abrir um story que ninguém pediu.
  • As opções completas de useOverlayUrlState estão na referência da API para React, e o passo a passo está no guia para React.

Lembrar o que já foi visto

Os anéis exibem o gradiente até o grupo ser assistido até o fim, e um grupo reabre no primeiro story que o leitor ainda não viu. As duas coisas vêm de um único createStoriesViewedStateController, criado uma vez onde o feed vive e passado para a lista de anéis e para o player como viewed: eles leem o repositório, o acompanham e registram o que é mostrado por conta própria. Uma entrada guardada nomeia o autor e o story por id, então sobrevive à reordenação do feed, e o mesmo controlador funciona em Vue e em Angular.

tsx
import { useRef, useState } from 'react';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/react-stories-player';

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

  // The controller reads the groups through a getter every time it needs
  // them, so keep the getter current: a ref for a plain prop, or a signal.
  const groupsRef = useRef(groups);
  groupsRef.current = groups;

  const [viewed] = useState(() =>
    createStoriesViewedStateController({
      storageKey: 'stories-seen',
      groups: () => groupsRef.current,
    }),
  );

  return (
    <>
      {/* The rings follow the controller by themselves. */}
      <StoriesRingList
        groups={groups}
        viewed={viewed}
        onSelect={(index) => {
          setGroup(index);
          setOpen(true);
        }}
      />

      {/* The player resumes, records and draws its card rings from it. */}
      <StoriesOverlay
        isOpen={open}
        onClose={() => setOpen(false)}
        groups={groups}
        initialGroupIndex={group}
        viewed={viewed}
      />
    </>
  );
}
  • O story de abertura conta. O story já na tela é registrado como visto na montagem, então abrir um grupo de um story só e fechar já o marca como assistido.
  • Deslizar adiante também retoma. resumeStoryIndex é consultado para todo grupo alcançado pela primeira vez na sessão, inclusive aquele em que o player abriu, a menos que initialStoryIndex nomeie um story diretamente; um grupo já percorrido reabre onde parou.
  • O link ainda vence. Com StoriesUrlOverlay, quem decide onde o player abre é o parâmetro, independentemente do que estiver guardado. Em todos os outros casos quem decide é o callback de retomada.
  • A contagem é uma posição, não um total. A entrada nomeia o story mais distante alcançado, então acrescentar um story a um grupo já assistido acende o anel de novo, e remover um do meio encurta a contagem. A mesma cura espontânea que um link compartilhado recebe.
  • O armazenamento é trocável. Passe storage: createSessionStorageAdapter() para esquecer ao fechar a aba, ou o seu próprio StorageAdapter. Duas abas abertas ficam em sintonia pelo evento de armazenamento do navegador.
  • Um player montado só na hora de abrir lê tarde demais. O player lê o repositório em um efeito que roda enquanto ainda está fechado. Com {open && <StoriesOverlay … />} esse momento não existe, então chame useAttachViewedState(viewed) no componente dono do controlador; ele se conecta ao montar e solta ao desmontar. As opções, e as peças de nível mais baixo que o compõem, estão na página do Stories Core.

Defina desktopLayout="carousel" para dispor o player como o Instagram na web: o story ativo fica no centro com o tamanho de sempre, e até dois grupos vizinhos ficam de cada lado como cards menores e escurecidos. Clicar em um card abre esse grupo, e os cards deslizam enquanto o grupo muda. Passe o mesmo controlador viewed que a lista de anéis recebe, e o card de um grupo já assistido ganha o anel apagado. Os cards o acompanham sozinhos, então só eles são redesenhados quando um story é marcado como visto e o player não é renderizado de novo.

tsx
// The same controller the ring list takes: the cards draw the same rings and
// repaint by themselves when a story is marked seen.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  initialGroupIndex={group}
  desktopLayout="carousel"
  viewed={viewed}
/>;
  • No celular o player continua simples. Até 768px de largura os cards não são renderizados e os gestos de deslizar usam groupTransition, como no layout padrão 'single'. Redimensionar a janela passando dessa largura troca o layout na hora.
  • Cada card mostra onde o grupo abre: o story em que ele foi deixado nesta sessão, ou então o que resumeStoryIndex retorna. O card exibe o pôster do vídeo ou a própria imagem. Um story sem nenhum dos dois, como um texto sobre um gradiente, é desenhado pelo seu renderSlide no tamanho do player e reduzido; um vídeo sem pôster mostra o fundo do card. Essa prévia chama renderSlide com isActive: false e é só uma imagem: nada dentro dela recebe foco ou cliques. Deixe atrás de isActive tudo o que um slide faz ao ser montado, como reprodução ou analytics.
  • O timer espera o deslize. O story não avança, e onStoryViewed não dispara para ele, até os cards pararem de se mover.
  • Toda forma de trocar de grupo desliza: clique em um card, StoriesApi.goToGroup, nextGroup e prevGroup e as setas do teclado depois do fim de um grupo. Um deslize com o dedo já move o próprio player, então não ganha outro 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 chromePlacement="group" para dar a cada grupo os seus, dentro do próprio slide, e os dois giram junto com o grupo, como no Instagram.

tsx
// Every group carries its own progress bar and header, so both turn with the
// group instead of switching above the player once it has changed.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  chromePlacement="group"
/>;
  • 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.
  • Renderizadores próprios são chamados para cada grupo na tela. renderProgressBar e renderHeader recebem groupIndex e isActive; para um grupo vizinho isActive é false e os sinais de progresso ficam parados.
  • Um cabeçalho próprio 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 cards deslizam, então a escolha aparece quando o grupo já está aberto.

Referência da API

StoriesOverlayProps

StoriesOverlayProps<T>

PropTipoPadrãoDescrição
isOpenbooleanobrigatórioControla a visibilidade do overlay. Com true, a rolagem do corpo fica travada.
groupsStoriesGroup<T>[]obrigatórioArray de grupos de stories a exibir
onClose() => voidobrigatórioCallback para fechar o overlay
ariaLabelstring'Stories player'Rótulo acessível da região do diálogo; anunciado pelos leitores de tela quando o overlay abre
initialGroupIndexnumber0Índice, começando em zero, do grupo visível no início
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), senão 0Índice, começando em zero, do story visível no início dentro do grupo. Informar um valor vence qualquer coisa lembrada; deixe de fora e o grupo de abertura retoma como todos os outros.
resumeStoryIndex(groupIndex: number) => number—Em qual story o grupo abre na primeira vez que é alcançado, para que o leitor continue de onde parou. Só é consultado para um grupo ainda não visitado nesta abertura.
groupTransitionTransitionTransformFncubeTransitionEfeito de transição do slider externo (o dos grupos)
defaultImageDurationnumber5000Duração padrão, em milissegundos, do avanço automático dos stories de imagem
tapZoneSplitnumber0.3Proporção que divide as zonas de toque (0–1). A parte esquerda volta, a direita avança.
hideUIOnPausebooleantrueEsconde a barra de progresso e o cabeçalho enquanto a pausa por toque longo dura
enableKeyboardbooleantrueLiga a navegação por teclado (setas esquerda e direita, Escape)
innerTransitionDurationnumber200Duração, em milissegundos, da animação de transição interna (entre stories)
minSegmentWidthnumber8Largura mínima, em pixels, de um segmento da barra de progresso
apiRefMutableRefObject<StoriesApi | null>-Ref para alcançar a StoriesApi imperativa
desktopLayout'single' | 'carousel''single'Layout no desktop. 'carousel' mostra os grupos vizinhos como cards de prévia ao lado do story ativo e desliza entre grupos. No celular o story sempre aparece sozinho. O tipo é exportado como DesktopLayout.
chromePlacement'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 as duas giram junto com o grupo. O tipo é exportado como ChromePlacement.
viewedStoriesViewedStateController-O que o leitor já viu, vindo de createStoriesViewedStateController(). Os grupos são retomados no primeiro story não visto, cada story mostrado é registrado e os cards do carrossel apagam o anel de um grupo assistido até o fim. Passe o mesmo controlador para StoriesRingList.
renderHeader(props: HeaderRenderProps<T>) => ReactNode-Renderizador próprio do cabeçalho. Recebe autor, story e os estados de pausa e de som. Com chromePlacement="group" o cabeçalho fica na área de swipe; veja Barra de progresso e cabeçalho em cada grupo.
renderFooter(props: FooterRenderProps<T>) => ReactNode-Renderizador próprio do rodapé. Recebe os dados do autor e do story.
renderSlide(props: SlideRenderProps<T>) => ReactNode-Renderizador próprio do slide, no lugar dos slides padrão de imagem e vídeo.
renderNavigation(props: NavigationRenderProps) => ReactNode-Navegação própria no desktop. Substitui os botões padrão de avançar e voltar.
renderProgressBar(props: ProgressBarRenderProps<T>) => ReactNode-Barra de progresso própria. Substitui a barra padrão desenhada em canvas.
renderLoading(props: LoadingRenderProps<T>) => ReactNode-Renderizador próprio do carregamento. Sem ele, aparece o girador padrão no cabeçalho.
renderError(props: ErrorRenderProps<T>) => ReactNode-Renderizador próprio do erro. Sem ele, aparece a camada padrão com o ícone de erro.
renderGroupPreview(props: GroupPreviewRenderProps<T>) => ReactNode-Card próprio do carrossel no desktop. Substitui o conteúdo padrão do card.

StoriesUrlOverlayProps

StoriesUrlOverlayProps<T>

Aceita todas as props do StoriesOverlay, menos o trio do estado aberto — isOpen, initialGroupIndex, initialStoryIndex —, que vem do controlador.

PropTipoPadrãoDescrição
controllerUrlStateController<TwoAxisPosition>obrigatórioControlador vindo de useOverlayUrlState, espalhado com urlIndexTwoAxisKey. A posição dele — 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.

Callbacks

PropTipoDescrição
onClose() => voidChamado quando o player fecha. Obrigatório no StoriesOverlay (o estado aberto é seu, então o fechamento também); opcional no StoriesUrlOverlay, onde quem fecha é a URL — passe apenas se quiser reagir depois do fechamento.
onStoryChange(groupIndex: number, storyIndex: number) => voidDisparado quando o story ativo muda
onGroupChange(groupIndex: number) => voidDisparado quando o grupo ativo muda
onStoryViewed(groupIndex: number, storyIndex: number) => voidDisparado quando um story fica visível
onStoryComplete(groupIndex: number, storyIndex: number) => voidDisparado quando o timer do story termina
onDoubleTap(groupIndex: number, storyIndex: number) => voidDisparado no gesto de toque duplo
onPause() => voidDisparado quando o player é pausado
onResume() => voidDisparado quando o player é retomado

Transições

A prop groupTransition controla o efeito 3D ao deslizar entre os grupos de pessoas. Importe as funções de transição de @reelkit/react:

tsx
import {
  cubeTransition,   // default — 3D cube rotation
  flipTransition,   // card flip
  fadeTransition,   // crossfade
  zoomTransition,   // zoom in/out
  slideTransition,  // horizontal slide
} from '@reelkit/react';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  groupTransition={flipTransition}
/>

Ciclo de vida do carregamento de conteúdo

Cada slide de story informa seu estado de carregamento pelos callbacks que chegam em SlideRenderProps:

CallbackQuando
onReadyO conteúdo está pronto (imagem carregada, vídeo tocando). O timer de progresso começa.
onWaitingO conteúdo travou (vídeo carregando no meio da reprodução). O girador aparece e o timer pausa.
onErrorO conteúdo não carregou. A camada de erro aparece.
onDurationReadyInforme a duração real da mídia (vinda dos metadados do vídeo, por exemplo) para reiniciar o timer com o tempo certo.
onEndedAvise que a mídia terminou (o vídeo acabou, por exemplo). Avança para o próximo story.
Cache do pré-carregador

Os componentes ImageStorySlide e VideoStorySlide que já vêm prontos pré-carregam o próximo story em segundo plano. Quando o leitor chega a um story pré-carregado, o conteúdo aparece na hora, sem girador.

Render Props

Todo elemento da interface pode ser substituído por uma render prop. Cada uma recebe props tipadas com o estado e os callbacks necessários.

renderHeader

Substitua o cabeçalho padrão (dados do autor, botões de pausa e som, botão de fechar):

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderHeader={({ author, story, isPaused, isMuted, isVideo, onToggleSound, onTogglePause, onClose }) => (
    <div style={{
      position: 'absolute',
      top: 0,
      left: 0,
      right: 0,
      padding: 16,
      display: 'flex',
      alignItems: 'center',
      gap: 8,
      zIndex: 10,
    }}>
      <img src={author.avatar} style={{ width: 32, height: 32, borderRadius: '50%' }} />
      <span style={{ color: '#fff', fontWeight: 600 }}>{author.name}</span>
      {isVideo && <button onClick={onToggleSound}>{isMuted ? 'Unmute' : 'Mute'}</button>}
      <button onClick={onClose} style={{ marginLeft: 'auto' }}>Close</button>
    </div>
  )}
/>

renderFooter

Acrescente um rodapé abaixo do conteúdo do story:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderFooter={({ author, story, storyIndex }) => (
    <div style={{
      position: 'absolute',
      bottom: 0,
      left: 0,
      right: 0,
      padding: 16,
      background: 'linear-gradient(transparent, rgba(0,0,0,0.6))',
      color: '#fff',
      zIndex: 10,
    }}>
      <span>{author.name} — Story {storyIndex + 1}</span>
    </div>
  )}
/>

renderSlide

Substitua por completo os slides padrão de imagem e vídeo. Use os subcomponentes ImageStorySlide e VideoStorySlide para aproveitar o tratamento de mídia que já vem pronto:

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

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderSlide={({ story, index, groupIndex, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }) => {
    const [w, h] = size;

    if (story.mediaType === 'video') {
      return (
        <div style={{ width: w, height: h, background: '#000' }}>
          <VideoStorySlide
            src={story.src}
            poster={story.poster}
            groupIndex={groupIndex}
            storyIndex={index}
            activeGroupIndex={activeGroupIndex}
            activeStoryIndex={activeStoryIndex}
            onDurationReady={onDurationReady}
            onPlaying={onReady}
            onWaiting={onWaiting}
            onEnded={onEnded}
            onError={onError}
          />
        </div>
      );
    }

    return (
      <div style={{ width: w, height: h, background: '#000' }}>
        <ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />
      </div>
    );
  }}
/>

renderNavigation

Substitua os botões de seta padrão do desktop:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderNavigation={({ onPrevStory, onNextStory, onPrevGroup, onNextGroup }) => (
    <div style={{ position: 'absolute', bottom: 16, left: 0, right: 0, display: 'flex', justifyContent: 'center', gap: 8, zIndex: 10 }}>
      <button onClick={onPrevGroup}>Prev Group</button>
      <button onClick={onPrevStory}>Prev</button>
      <button onClick={onNextStory}>Next</button>
      <button onClick={onNextGroup}>Next Group</button>
    </div>
  )}
/>

renderProgressBar

Substitua a barra de progresso padrão em canvas por uma implementação sua. O sinal progress emite valores de 0 a 1:

tsx
import { Observe } from '@reelkit/react';

<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderProgressBar={({ totalStories, activeIndex, progress }) => (
    <div style={{ display: 'flex', gap: 4, padding: '8px 16px' }}>
      {Array.from({ length: totalStories }, (_, i) => (
        <div key={i} style={{ flex: 1, height: 2, background: 'rgba(255,255,255,0.3)', borderRadius: 1, overflow: 'hidden' }}>
          <Observe signals={[activeIndex, progress]}>
            {() => {
              const fill = i < activeIndex.value ? 1 : i === activeIndex.value ? progress.value : 0;
              return <div style={{ width: `${fill * 100}%`, height: '100%', background: '#fff' }} />;
            }}
          </Observe>
        </div>
      ))}
    </div>
  )}
/>

renderLoading

Indicador de carregamento próprio enquanto o conteúdo é buscado:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderLoading={({ story, storyIndex, groupIndex }) => (
    <div style={{
      position: 'absolute',
      inset: 0,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      color: '#fff',
    }}>
      <span>Loading story {storyIndex + 1}...</span>
    </div>
  )}
/>

renderError

Camada de erro própria para quando o conteúdo não carrega:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderError={({ story, storyIndex, groupIndex }) => (
    <div style={{
      position: 'absolute',
      inset: 0,
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      flexDirection: 'column',
      color: '#fff',
      background: 'rgba(0,0,0,0.8)',
    }}>
      <span style={{ fontSize: 48 }}>!</span>
      <span>Failed to load story</span>
    </div>
  )}
/>

renderGroupPreview

Substitua o conteúdo de um card do carrossel no desktop. O player continua posicionando, escalando e deslizando o card; chame onOpen para abrir o grupo:

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  desktopLayout="carousel"
  renderGroupPreview={({ group, story, onOpen }) => (
    <button
      type="button"
      className="my-story-card"
      aria-label={`Open stories by ${group.author.name}`}
      onClick={onOpen}
    >
      {story?.poster && <img src={story.poster} alt="" />}
      <span>{group.author.name}</span>
    </button>
  )}
/>

StoriesApi

Use a prop apiRef para o controle imperativo:

tsx
import { useRef } from 'react';
import { StoriesOverlay, type StoriesApi } from '@reelkit/react-stories-player';

function App() {
  const apiRef = useRef<StoriesApi | null>(null);

  return (
    <>
      <button onClick={() => apiRef.current?.nextStory()}>Next Story</button>
      <button onClick={() => apiRef.current?.nextGroup()}>Next Group</button>
      <button onClick={() => apiRef.current?.pause()}>Pause</button>

      <StoriesOverlay
        isOpen={isOpen}
        onClose={handleClose}
        groups={groups}
        apiRef={apiRef}
      />
    </>
  );
}

Métodos

MétodoTipoDescrição
nextStory()() => voidAvança para o próximo story dentro do grupo atual
prevStory()() => voidVolta ao story anterior dentro do grupo atual
nextGroup()() => voidMuda para o próximo grupo
prevGroup()() => voidMuda para o grupo anterior
goToGroup(index)(index: number) => voidSalta direto para um grupo 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 já embutida toca no toque duplo, dando um retorno visual imediato. O callback onDoubleTap dispara com os índices do grupo e do story, então você guarda a curtida no seu próprio estado (chamada de API, armazenamento local, o que for). O player não guarda esse estado internamente.

tsx
<StoriesOverlay
  isOpen={isOpen}
  onClose={() => setIsOpen(false)}
  groups={groups}
  onDoubleTap={(groupIndex, storyIndex) => {
    // Built-in heart animation plays automatically.
    // Handle the like in your own state:
    const story = groups[groupIndex].stories[storyIndex];
    toggleLike(story.id);
  }}
/>

Personalizando a animação do coração

Ajuste a velocidade da animação pelo token --rk-stories-heart-duration (veja Temas). Para mudar cor, tamanho ou esconder o coração por completo, mire direto na classe .rk-stories-heart. O componente HeartAnimation também é exportado para uso isolado.

css
/* Speed up the pop via the token */
:root {
  --rk-stories-heart-duration: 1s;
}

/* Restyle color and size via the class */
.rk-stories-heart {
  color: #ff3b5c;
  font-size: 80px;
}

/* Or hide the built-in heart entirely */
.rk-stories-heart {
  display: none;
}

Por ora, a animação de coração embutida não pode ser trocada por uma render prop. Você pode reestilizá-la no CSS ou escondê-la com display: none e cuidar da sua própria animação no callback onDoubleTap. Se você precisa de uma render prop renderDoubleTap, conte para a gente nas issues do GitHub.

Subcomponentes

Blocos reutilizáveis exportados para você compor dentro das suas render props. O tipo de props de cada um é exportado junto — CanvasProgressBarProps, StoryHeaderProps, ImageStorySlideProps, VideoStorySlideProps, StoriesRingProps, StoriesRingListProps e HeartAnimationProps — para um componente que envolva um deles:

CanvasProgressBar

Barra de progresso segmentada em canvas, de alto desempenho. Desenha um segmento por story e anima o preenchimento do segmento ativo com requestAnimationFrame. Aceita uma janela deslizante para grupos com muitos stories. Passe live={false} para uma barra que só precisa mostrar onde um grupo parou: ela desenha quando os sinais mudam e depois de um redimensionamento, sem loop de animação.

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

<CanvasProgressBar
  totalStories={group.stories.length}
  activeIndex={activeIndexSignal}
  progress={progressSignal}
  minSegmentWidth={8}
  gap={2}
  barHeight={2}
  // A bar that is not live draws only when its signals change, with no
  // animation loop: enough for a group that is not playing.
  live={isActiveGroup}
/>

StoryHeader

Cabeçalho padrão, com avatar e nome do autor, selo de verificado, horário relativo, botão de pausa, botão de som, girador de carregamento e botão de fechar. Usado automaticamente quando renderHeader não é informado.

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

<StoryHeader
  author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg', verified: true }}
  createdAt={new Date(Date.now() - 3600_000)}
  onClose={handleClose}
  isPaused={false}
  onTogglePause={togglePause}
  isMuted={true}
  onToggleSound={toggleSound}
  isVideo={true}
  isLoading={false}
/>

ImageStorySlide

Slide de imagem de borda a borda, com object-fit: cover. Informa carregamento e erro pelos callbacks do ciclo de vida.

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

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

VideoStorySlide

Slide de vídeo que usa um elemento <video> compartilhado para a continuidade do som no iOS. Cuida da reprodução automática, dos quadros de pôster e da sincronia do som, e informa a duração e os eventos do ciclo de reprodução.

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

<VideoStorySlide
  src="/clip.mp4"
  poster="/clip-poster.jpg"
  groupIndex={0}
  storyIndex={2}
  activeGroupIndex={activeGroupSignal}
  activeStoryIndex={activeStorySignal}
  onDurationReady={(ms) => console.log('duration:', ms)}
  onPlaying={() => console.log('playing')}
  onWaiting={() => console.log('buffering')}
  onEnded={() => console.log('ended')}
  onError={() => console.log('error')}
/>

StoriesRing

Avatar circular com anel em gradiente no estilo Instagram. Dois estados: um grupo com stories por assistir ganha o gradiente giratório, um totalmente assistido ganha um anel discreto e parado.

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

<StoriesRing
  author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg' }}
  totalStories={5}
  viewedCount={2}
  onClick={() => openStories(0)}
/>

StoriesRingList

Fileira horizontal e rolável de componentes StoriesRing, com o nome de cada autor. Um anel por grupo.

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

// `viewed` is the same createStoriesViewedStateController the player takes; the
// rings follow it by themselves.
<StoriesRingList
  groups={groups}
  viewed={viewed}
  onSelect={(groupIndex) => openStories(groupIndex)}
/>;

HeartAnimation

Camada animada de coração, disparada no toque duplo. Cresce e some ao longo de 800ms. Personalize pelo CSS (veja a seção Toque duplo e curtidas).

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

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

Reexportações

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

OrigemReexportado
@reelkit/reactSoundProvider, 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

SoundProvider e useSoundState carregam o estado de som que o player compartilha com os seus slides. Um slide personalizado que toca a própria mídia lê o botão de mudo por useSoundState. Dentro do player já existe um provider; um VideoStorySlide ou slide personalizado renderizado em qualquer outro lugar precisa de um SoundProvider acima dele, senão useSoundState lança um erro.

Tipos

StoryItem

typescript
interface StoryItem {
  id: string;
  mediaType: 'image' | 'video';
  src: string;
  poster?: string;
  duration?: number;       // ms, images default to 5000, videos use natural duration
  createdAt?: string | Date;
  aspectRatio?: number;    // width / height
}

AuthorInfo

typescript
interface AuthorInfo {
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

StoriesGroup<T>

typescript
interface StoriesGroup<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  stories: T[];
}

HeaderRenderProps<T>

typescript
interface HeaderRenderProps<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
  isPaused: boolean;
  isMuted: boolean;
  isVideo: boolean;
  groupIndex: number;
  isActive: boolean; // false for a neighbouring group's header
  onToggleSound: () => void;
  onTogglePause: () => void;
  onClose: () => void;
}

FooterRenderProps<T>

typescript
interface FooterRenderProps<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
}

SlideRenderProps<T>

typescript
interface SlideRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  index: number;
  groupIndex: number;
  isActive: boolean;
  size: [number, number];
  activeGroupIndex: Signal<number>;
  activeStoryIndex: Signal<number>;
  onDurationReady: (durationMs: number) => void;
  onReady: () => void;
  onWaiting: () => void;
  onError: () => void;
  onEnded: () => void;
}
typescript
interface NavigationRenderProps {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

ProgressBarRenderProps<T>

typescript
interface ProgressBarRenderProps<T extends StoryItem = StoryItem> {
  totalStories: number;
  activeIndex: Signal<number>;
  progress: Signal<number>;
  group: StoriesGroup<T>;
  groupIndex: number;
  isActive: boolean; // false: the signals hold still for a neighbouring group
}

LoadingRenderProps<T>

typescript
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

ErrorRenderProps<T>

typescript
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

GroupPreviewRenderProps<T>

typescript
type DesktopLayout = 'single' | 'carousel';

interface GroupPreviewRenderProps<T extends StoryItem = StoryItem> {
  group: StoriesGroup<T>;
  groupIndex: number;
  story: T | undefined; // the story the group would open on
  offset: number; // negative on the left, positive on the right
  viewedCount: number; // from viewedState, 0 without it
  onOpen: () => void;
}

StoriesApi

typescript
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 campos seus e passe o parâmetro de tipo ao StoriesOverlay. Todas as render props vão receber o seu tipo estendido:

tsx
import {
  StoriesOverlay,
  ImageStorySlide,
  type StoryItem,
  type StoriesGroup,
  type SlideRenderProps,
} from '@reelkit/react-stories-player';

interface PromoStory extends StoryItem {
  title: string;
  subtitle?: string;
  bgGradient?: string;
  ctaText?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'brand', name: 'My Brand', avatar: '/brand.png', verified: true },
    stories: [
      {
        id: 'promo-1',
        mediaType: 'image',
        src: '/sale-banner.jpg',
        title: 'Flash Sale',
        subtitle: 'Up to 50% off',
        ctaText: 'Shop Now',
      },
      {
        id: 'promo-2',
        mediaType: 'image',
        src: '',
        title: 'Thank You',
        subtitle: '10K followers!',
        bgGradient: 'linear-gradient(135deg, #a18cd1, #fbc2eb)',
      },
    ],
  },
];

function CustomSlide({ story, size, onReady, onError }: SlideRenderProps<PromoStory>) {
  const [w, h] = size;
  const hasImage = story.src && story.mediaType === 'image';

  return (
    <div style={{ width: w, height: h, background: story.bgGradient ?? '#000', position: 'relative' }}>
      {hasImage && <ImageStorySlide src={story.src} onLoad={onReady} onError={onError} />}
      <div style={{ position: 'relative', zIndex: 1, textAlign: 'center', padding: 32, color: '#fff' }}>
        <h2>{story.title}</h2>
        {story.subtitle && <p>{story.subtitle}</p>}
        {story.ctaText && (
          <button style={{ marginTop: 16, padding: '8px 24px', borderRadius: 20, background: '#fff', color: '#000', border: 'none' }}>
            {story.ctaText}
          </button>
        )}
      </div>
    </div>
  );
}

<StoriesOverlay<PromoStory>
  isOpen={isOpen}
  onClose={handleClose}
  groups={groups}
  renderSlide={(props) => <CustomSlide {...props} />}
/>

Classes CSS

Todas as classes CSS são simples (não são CSS modules), então dá para alcançá-las com seletores de especificidade maior em uma folha de estilo carregada depois de @reelkit/react-stories-player/styles.css. Para mudar cor, tamanho e z-index, prefira as propriedades CSS documentadas na seção Temas, abaixo.

ClasseComponenteDescrição
.rk-stories-overlayOverlayFundo fixo em tela cheia (cor de fundo, z-index)
.rk-stories-swipe-wrapperOverlayInvólucro do deslizar para fechar (abriga os botões de navegação e o canvas)
.rk-stories-containerOverlayÁrea arredondada do story (posição, transbordo)
.rk-stories-ui-layerOverlayContêiner da interface sobreposta (cabeçalho, progresso, navegação). Dentro de cada slide de grupo com chromePlacement="group"
.rk-stories-ui-layer--hiddenOverlayEstado de interface escondida (alternado por hideUIOnPause)
.rk-stories-errorOverlayEstado de erro (ícone e texto centralizados)
.rk-stories-error-textOverlayTexto da mensagem de erro
.rk-stories-nav-btnNavigationSeta de avançar e voltar no desktop
.rk-stories-progress-barProgressBarInvólucro que posiciona a barra de progresso 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 de cabeçalho escondido (visible=false)
.rk-stories-header-avatarStoryHeaderImagem do avatar do autor
.rk-stories-header-nameStoryHeaderTexto do nome do autor
.rk-stories-header-verifiedStoryHeaderContêiner do selo de verificado
.rk-stories-header-timeStoryHeaderTexto do "há quanto tempo"
.rk-stories-header-actionsStoryHeaderAções do lado direito (fechar, som, pausa)
.rk-stories-header-btnStoryHeaderBotão de ação do cabeçalho
.rk-stories-header-btn--desktopStoryHeaderBotão de pausa, exibido só acima de 768px de largura
.rk-stories-header-spinnerStoryHeaderGirador do vídeo carregando
.rk-stories-imageImageStorySlideElemento do story de imagem
.rk-stories-videoVideoStorySlideContêiner do story de vídeo
.rk-stories-video-elementVideoStorySlideO elemento <video> compartilhado
.rk-stories-video-posterVideoStorySlideImagem de pôster do vídeo (some ao dar play)
.rk-stories-video-poster--visibleVideoStorySlideEstado de pôster visível (antes da reprodução)
.rk-stories-heartHeartAnimationAnimação do coração que salta no toque duplo
.rk-stories-ringStoriesRingAnel do story (avatar com borda em gradiente animado)
.rk-stories-ring--activeStoriesRingAnel com stories por assistir (anima)
.rk-stories-ring-avatarStoriesRingImagem do avatar dentro do anel
.rk-stories-ring-listStoriesRingListContêiner da fileira horizontal de anéis
.rk-stories-ring-list-itemStoriesRingListColuna com o anel e o nome
.rk-stories-ring-list-nameStoriesRingListNome do autor abaixo de cada anel
.rk-stories-overlay--carouselOverlayOverlay com o carrossel do desktop à mostra
.rk-stories-overlay--slidingOverlayDeslize do carrossel em andamento; a tela do player fica oculta
.rk-stories-carouselCarouselCamada de cards de prévia atrás do player
.rk-stories-carousel--instantCarouselPrimeiro quadro de um deslize, disposto sem transição
.rk-stories-cardCarouselUm card de prévia posicionado
.rk-stories-card--centerCarouselCard na posição central durante um deslize
.rk-stories-card--hiddenCarouselCard além dos dois mostrados de cada lado
.rk-stories-card-buttonCarouselConteúdo padrão do card (o botão que abre o grupo)
.rk-stories-card-imageCarouselQuadro de prévia (pôster ou imagem)
.rk-stories-card-frameCarouselPrévia reduzida, feita por renderSlide, de um story sem imagem nem pôster; inerte
.rk-stories-card-scrimCarouselCamada que escurece o quadro
.rk-stories-card-infoCarouselColuna com anel, nome do autor e horário
.rk-stories-card-nameCarouselNome do autor
.rk-stories-card-timeCarouselTexto de tempo relativo

Temas

Cada cor, tamanho, z-index e transição vive em uma propriedade CSS. Sobrescreva uma ou várias em :root (ou em qualquer ancestral do overlay) para trocar o tema sem mexer no código dos componentes.

TokenPadrãoO que controla
--rk-stories-overlay-bg#000Cor do fundo em tela cheia
--rk-stories-overlay-z9999z-index do overlay
--rk-stories-swipe-gap16pxEspaço entre os botões de navegação e a área do story
--rk-stories-container-radius12pxArredondamento dos cantos da área do story (desktop)
--rk-stories-container-radius-mobile0Cantos da área do story até 768px de largura
--rk-stories-ui-z15z-index da camada de interface (cabeçalho, barra de progresso)
--rk-stories-ui-transition200msDuração do esmaecimento quando hideUIOnPause age
--rk-stories-top-shade-height120pxAltura do gradiente atrás do cabeçalho
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Cor do gradiente do topo
--rk-stories-error-z5z-index do estado de erro
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)Gradiente 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-gap12pxEspaço entre o ícone de erro e o texto
--rk-stories-error-text-size13pxTamanho da fonte da mensagem de erro
--rk-stories-nav-z20z-index dos botões de navegação no desktop
--rk-stories-nav-size44pxTamanho dos botões de avançar e voltar no desktop
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)Fundo dos botões de navegação no desktop
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)Fundo desses botões ao passar o mouse
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)Cor do ícone desses botões
--rk-stories-nav-fg-hover#fffCor do ícone ao passar o mouse
--rk-stories-nav-transition150msTransição do fundo e da cor dos botões de navegação
--rk-stories-video-bg#000Cor das faixas em volta do <video>
--rk-stories-video-poster-transition200msDuração do esmaecimento do pôster quando o vídeo começa
--rk-stories-progress-bar-z10z-index da barra de progresso
--rk-stories-progress-bar-padding8px 8px 0Espaço em volta da barra de progresso
--rk-stories-header-z5z-index do cabeçalho
--rk-stories-header-top18pxDistância do cabeçalho até o topo do story
--rk-stories-header-padding12px 16pxRespiro interno da linha do cabeçalho
--rk-stories-header-gap8pxEspaço entre o avatar, o nome e o horário
--rk-stories-header-transition200msEsmaecimento do cabeçalho quando ele se esconde
--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 do "há quanto tempo"
--rk-stories-header-time-size12pxTamanho da fonte do "há quanto tempo"
--rk-stories-header-actions-gap8pxEspaço entre os botões de ação do cabeçalho
--rk-stories-header-btn-fg#fffCor dos ícones de ação do cabeçalho (fechar, som, pausa)
--rk-stories-header-btn-padding4pxRespiro interno dos botões de ação do cabeçalho
--rk-stories-header-spinner-size20pxTamanho do girador de carregamento
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Cor do trilho do girador de carregamento
--rk-stories-header-spinner-fg#fffCor do arco do girador de carregamento
--rk-stories-header-spinner-duration0.8sUma volta do girador de carregamento
--rk-stories-heart-z20z-index do coração do toque duplo
--rk-stories-heart-duration800msDuração da animação do coração, do salto ao desaparecimento
--rk-stories-ring-spin-duration4sDuração de uma volta do anel com stories por assistir
--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 fileira
--rk-stories-ring-list-padding12pxRespiro interno em volta da fileira 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 abaixo de cada anel
--rk-stories-card-bg#262626Fundo do card do carrossel, visível quando não há quadro de prévia
--rk-stories-card-radius8pxRaio dos cantos do card do carrossel
--rk-stories-card-scrimrgba(0, 0, 0, 0.45)Escurecimento sobre um card lateral
--rk-stories-card-fg#fffCor do texto do card
--rk-stories-card-name-size14pxTamanho da fonte do nome do autor no card
--rk-stories-card-time-fgrgba(255, 255, 255, 0.7)Cor do tempo relativo no card
--rk-stories-card-time-size13pxTamanho da fonte do tempo relativo no card
--rk-stories-card-gap6pxEspaço entre anel, nome e horário no card
--rk-stories-card-transition300msDuração do deslize do carrossel

Cole o trecho abaixo em uma folha de estilo carregada depois de @reelkit/react-stories-player/styles.css.

css
/* Brand the stories overlay */
:root {
  --rk-stories-overlay-bg: #0f172a;
  --rk-stories-container-radius: 24px;
  --rk-stories-nav-bg: rgba(99, 102, 241, 0.25);
  --rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.55);
  --rk-stories-top-shade-bg: linear-gradient(
    to bottom,
    rgba(99, 102, 241, 0.5) 0%,
    transparent 100%
  );
  --rk-stories-header-name-fg: #fef3c7;
  --rk-stories-ring-spin-duration: 2s;
}

/* 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 ariaLabel 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 percorrem os elementos focáveis lá dentro; um foco que escapa (clique fora, foco por código) é trazido de volta. Implementado com captureFocusForReturn e createFocusTrap, de @reelkit/core, reexportados por @reelkit/react.

No carrossel do desktop, cada card padrão é um botão com o rótulo "Open stories by" seguido do nome do autor. Os cards vêm depois dos controles do player na ordem do Tab e saem dela enquanto um deslize acontece.

Renderização no servidor

O overlay não renderiza nada enquanto está fechado e só faz o portal para document.body quando abre, o que acontece no cliente. StoriesUrlOverlay lê a barra de endereço em um efeito depois da montagem, então uma renderização no servidor nunca o abre. StoriesRingList renderiza todos os anéis como não assistidos no servidor: o armazenamento de vistos só é lido depois da montagem, então o markup do servidor e a primeira renderização no cliente coincidem. Veja o guia de SSR para o panorama completo.

Atalhos de teclado

TeclaAção
ArrowLeftStory anterior. No primeiro story de um grupo, vai para o grupo anterior, no story em que ele foi deixado (ou, senão, no story de retomada); no primeiro grupo, não faz nada
ArrowRightPróximo story. Depois do último story de um grupo, vai para o próximo grupo; depois do último grupo, o player fecha
EscapeFecha o player