Stories Player
Um player de stories em tela cheia no estilo Instagram para React, com @reelkit/react-stories-player.
Recursos
Instalação
npm i @reelkit/react-stories-player @reelkit/react lucide-reactNão esqueça de importar os estilos:
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.
import { useState } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';
const groups: StoriesGroup[] = [
{
author: {
id: 'user-1',
name: 'Alice',
avatar: '/cdn/samples/avatars/avatar-06.jpg',
verified: true,
},
stories: [
{
id: 's1-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-001.jpg',
},
{
id: 's1-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-002.jpg',
},
{
id: 's1-3',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-003.jpg',
},
],
},
{
author: {
id: 'user-2',
name: 'Bob',
avatar: '/cdn/samples/avatars/avatar-07.jpg',
},
stories: [
{
id: 's2-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-004.jpg',
},
{
id: 's2-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-005.jpg',
},
],
},
{
author: {
id: 'user-3',
name: 'Charlie',
avatar: '/cdn/samples/avatars/avatar-08.jpg',
verified: true,
},
stories: [
{
id: 's3-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-006.jpg',
},
],
},
];
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(0);
// What was seen: rings, resume and recording from one controller, kept in
// localStorage across reloads.
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groups,
}),
);
const openStories = (groupIndex: number) => {
setSelectedGroup(groupIndex);
setIsOpen(true);
};
return (
<div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
<StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />
<StoriesOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
groups={groups}
initialGroupIndex={selectedGroup}
viewed={viewed}
/>
</div>
);
}import { useState } from 'react';
import {
StoriesOverlay,
StoriesRingList,
createStoriesViewedStateController,
type StoriesGroup,
} from '@reelkit/react-stories-player';
import '@reelkit/react-stories-player/styles.css';
const groups: StoriesGroup[] = [
{
author: {
id: 'user-1',
name: 'Alice',
avatar: '/cdn/samples/avatars/avatar-06.jpg',
verified: true,
},
stories: [
{
id: 's1-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-001.jpg',
},
{
id: 's1-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-002.jpg',
},
{
id: 's1-3',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-003.jpg',
},
],
},
{
author: {
id: 'user-2',
name: 'Bob',
avatar: '/cdn/samples/avatars/avatar-07.jpg',
},
stories: [
{
id: 's2-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-004.jpg',
},
{
id: 's2-2',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-005.jpg',
},
],
},
{
author: {
id: 'user-3',
name: 'Charlie',
avatar: '/cdn/samples/avatars/avatar-08.jpg',
verified: true,
},
stories: [
{
id: 's3-1',
mediaType: 'image',
src: '/cdn/samples/images/stories/story-006.jpg',
},
],
},
];
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(0);
// What was seen: rings, resume and recording from one controller, kept in
// localStorage across reloads.
const [viewed] = useState(() =>
createStoriesViewedStateController({
storageKey: 'stories-seen',
groups: () => groups,
}),
);
const openStories = (groupIndex: number) => {
setSelectedGroup(groupIndex);
setIsOpen(true);
};
return (
<div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
<StoriesRingList groups={groups} viewed={viewed} onSelect={openStories} />
<StoriesOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
groups={groups}
initialGroupIndex={selectedGroup}
viewed={viewed}
/>
</div>
);
}Para 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.
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:
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.
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.
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
locateAsyncestá pendente, o player fica fechado e o parâmetro é deixado em paz, então o link direto sobrevive à busca. Umnullou 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
useOverlayUrlStateestã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.
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 queinitialStoryIndexnomeie 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óprioStorageAdapter. 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 chameuseAttachViewedState(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.
Carrossel no desktop
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.
// 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
resumeStoryIndexretorna. 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 seurenderSlideno tamanho do player e reduzido; um vídeo sem pôster mostra o fundo do card. Essa prévia chamarenderSlidecomisActive: falsee é só uma imagem: nada dentro dela recebe foco ou cliques. Deixe atrás deisActivetudo 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
onStoryViewednão dispara para ele, até os cards pararem de se mover. - Toda forma de trocar de grupo desliza: clique em um card,
StoriesApi.goToGroup,nextGroupeprevGroupe 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.
// 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.
renderProgressBarerenderHeaderrecebemgroupIndexeisActive; para um grupo vizinhoisActiveéfalsee 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 comrole="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>
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
isOpen | boolean | obrigatório | Controla a visibilidade do overlay. Com true, a rolagem do corpo fica travada. |
groups | StoriesGroup<T>[] | obrigatório | Array de grupos de stories a exibir |
onClose | () => void | obrigatório | Callback para fechar o overlay |
ariaLabel | string | 'Stories player' | Rótulo acessível da região do diálogo; anunciado pelos leitores de tela quando o overlay abre |
initialGroupIndex | number | 0 | Índice, começando em zero, do grupo visível no início |
initialStoryIndex | number | resumeStoryIndex(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. |
groupTransition | TransitionTransformFn | cubeTransition | Efeito de transição do slider externo (o dos grupos) |
defaultImageDuration | number | 5000 | Duração padrão, em milissegundos, do avanço automático dos stories de imagem |
tapZoneSplit | number | 0.3 | Proporção que divide as zonas de toque (0–1). A parte esquerda volta, a direita avança. |
hideUIOnPause | boolean | true | Esconde a barra de progresso e o cabeçalho enquanto a pausa por toque longo dura |
enableKeyboard | boolean | true | Liga a navegação por teclado (setas esquerda e direita, Escape) |
innerTransitionDuration | number | 200 | Duração, em milissegundos, da animação de transição interna (entre stories) |
minSegmentWidth | number | 8 | Largura mínima, em pixels, de um segmento da barra de progresso |
apiRef | MutableRefObject<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. |
viewed | StoriesViewedStateController | - | 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.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | obrigatório | Controlador 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
| Prop | Tipo | Descrição |
|---|---|---|
onClose | () => void | Chamado 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) => void | Disparado quando o story ativo muda |
onGroupChange | (groupIndex: number) => void | Disparado quando o grupo ativo muda |
onStoryViewed | (groupIndex: number, storyIndex: number) => void | Disparado quando um story fica visível |
onStoryComplete | (groupIndex: number, storyIndex: number) => void | Disparado quando o timer do story termina |
onDoubleTap | (groupIndex: number, storyIndex: number) => void | Disparado no gesto de toque duplo |
onPause | () => void | Disparado quando o player é pausado |
onResume | () => void | Disparado 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:
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:
| Callback | Quando |
|---|---|
onReady | O conteúdo está pronto (imagem carregada, vídeo tocando). O timer de progresso começa. |
onWaiting | O conteúdo travou (vídeo carregando no meio da reprodução). O girador aparece e o timer pausa. |
onError | O conteúdo não carregou. A camada de erro aparece. |
onDurationReady | Informe a duração real da mídia (vinda dos metadados do vídeo, por exemplo) para reiniciar o timer com o tempo certo. |
onEnded | Avise 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):
<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:
<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:
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:
<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:
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:
<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:
<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:
<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:
import { useRef } from 'react';
import { StoriesOverlay, type StoriesApi } from '@reelkit/react-stories-player';
function App() {
const apiRef = useRef<StoriesApi | null>(null);
return (
<>
<button onClick={() => apiRef.current?.nextStory()}>Next Story</button>
<button onClick={() => apiRef.current?.nextGroup()}>Next Group</button>
<button onClick={() => apiRef.current?.pause()}>Pause</button>
<StoriesOverlay
isOpen={isOpen}
onClose={handleClose}
groups={groups}
apiRef={apiRef}
/>
</>
);
}Métodos
| Método | Tipo | Descrição |
|---|---|---|
nextStory() | () => void | Avança para o próximo story dentro do grupo atual |
prevStory() | () => void | Volta ao story anterior dentro do grupo atual |
nextGroup() | () => void | Muda para o próximo grupo |
prevGroup() | () => void | Muda para o grupo anterior |
goToGroup(index) | (index: number) => void | Salta direto para um grupo pelo índice |
pause() | () => void | Pausa o avanço automático e o timer de progresso |
resume() | () => void | Retoma 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.
<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.
/* 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.
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.
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.
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.
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.
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.
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).
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.
| Origem | Reexportado |
|---|---|
@reelkit/react | 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-core | createStoriesViewedStateController, 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
interface StoryItem {
id: string;
mediaType: 'image' | 'video';
src: string;
poster?: string;
duration?: number; // ms, images default to 5000, videos use natural duration
createdAt?: string | Date;
aspectRatio?: number; // width / height
}AuthorInfo
interface AuthorInfo {
id: string;
name: string;
avatar: string;
verified?: boolean;
}StoriesGroup<T>
interface StoriesGroup<T extends StoryItem = StoryItem> {
author: AuthorInfo;
stories: T[];
}HeaderRenderProps<T>
interface HeaderRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
isPaused: boolean;
isMuted: boolean;
isVideo: boolean;
groupIndex: number;
isActive: boolean; // false for a neighbouring group's header
onToggleSound: () => void;
onTogglePause: () => void;
onClose: () => void;
}FooterRenderProps<T>
interface FooterRenderProps<T extends StoryItem = StoryItem> {
author: AuthorInfo;
story: T;
storyIndex: number;
}SlideRenderProps<T>
interface SlideRenderProps<T extends StoryItem = StoryItem> {
story: T;
index: number;
groupIndex: number;
isActive: boolean;
size: [number, number];
activeGroupIndex: Signal<number>;
activeStoryIndex: Signal<number>;
onDurationReady: (durationMs: number) => void;
onReady: () => void;
onWaiting: () => void;
onError: () => void;
onEnded: () => void;
}NavigationRenderProps
interface NavigationRenderProps {
onPrevStory: () => void;
onNextStory: () => void;
onPrevGroup: () => void;
onNextGroup: () => void;
}ProgressBarRenderProps<T>
interface ProgressBarRenderProps<T extends StoryItem = StoryItem> {
totalStories: number;
activeIndex: Signal<number>;
progress: Signal<number>;
group: StoriesGroup<T>;
groupIndex: number;
isActive: boolean; // false: the signals hold still for a neighbouring group
}LoadingRenderProps<T>
interface LoadingRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}ErrorRenderProps<T>
interface ErrorRenderProps<T extends StoryItem = StoryItem> {
story: T;
storyIndex: number;
groupIndex: number;
}GroupPreviewRenderProps<T>
type DesktopLayout = 'single' | 'carousel';
interface GroupPreviewRenderProps<T extends StoryItem = StoryItem> {
group: StoriesGroup<T>;
groupIndex: number;
story: T | undefined; // the story the group would open on
offset: number; // negative on the left, positive on the right
viewedCount: number; // from viewedState, 0 without it
onOpen: () => void;
}StoriesApi
interface StoriesApi {
nextStory: () => void;
prevStory: () => void;
nextGroup: () => void;
prevGroup: () => void;
goToGroup: (index: number) => void;
pause: () => void;
resume: () => void;
}Tipos de Story personalizados
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:
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.
| Classe | Componente | Descrição |
|---|---|---|
.rk-stories-overlay | Overlay | Fundo fixo em tela cheia (cor de fundo, z-index) |
.rk-stories-swipe-wrapper | Overlay | Invólucro do deslizar para fechar (abriga os botões de navegação e o canvas) |
.rk-stories-container | Overlay | Área arredondada do story (posição, transbordo) |
.rk-stories-ui-layer | Overlay | Contêiner da interface sobreposta (cabeçalho, progresso, navegação). Dentro de cada slide de grupo com chromePlacement="group" |
.rk-stories-ui-layer--hidden | Overlay | Estado de interface escondida (alternado por hideUIOnPause) |
.rk-stories-error | Overlay | Estado de erro (ícone e texto centralizados) |
.rk-stories-error-text | Overlay | Texto da mensagem de erro |
.rk-stories-nav-btn | Navigation | Seta de avançar e voltar no desktop |
.rk-stories-progress-bar | ProgressBar | Invólucro que posiciona a barra de progresso em canvas |
.rk-stories-slide-wrapper | Group | Um grupo de stories (slide externo) |
.rk-stories-story | Story | Um story (raiz do slide interno) |
.rk-stories-header | StoryHeader | Barra do cabeçalho (avatar, nome, ações) |
.rk-stories-header--hidden | StoryHeader | Estado de cabeçalho escondido (visible=false) |
.rk-stories-header-avatar | StoryHeader | Imagem do avatar do autor |
.rk-stories-header-name | StoryHeader | Texto do nome do autor |
.rk-stories-header-verified | StoryHeader | Contêiner do selo de verificado |
.rk-stories-header-time | StoryHeader | Texto do "há quanto tempo" |
.rk-stories-header-actions | StoryHeader | Ações do lado direito (fechar, som, pausa) |
.rk-stories-header-btn | StoryHeader | Botão de ação do cabeçalho |
.rk-stories-header-btn--desktop | StoryHeader | Botão de pausa, exibido só acima de 768px de largura |
.rk-stories-header-spinner | StoryHeader | Girador do vídeo carregando |
.rk-stories-image | ImageStorySlide | Elemento do story de imagem |
.rk-stories-video | VideoStorySlide | Contêiner do story de vídeo |
.rk-stories-video-element | VideoStorySlide | O elemento <video> compartilhado |
.rk-stories-video-poster | VideoStorySlide | Imagem de pôster do vídeo (some ao dar play) |
.rk-stories-video-poster--visible | VideoStorySlide | Estado de pôster visível (antes da reprodução) |
.rk-stories-heart | HeartAnimation | Animação do coração que salta no toque duplo |
.rk-stories-ring | StoriesRing | Anel do story (avatar com borda em gradiente animado) |
.rk-stories-ring--active | StoriesRing | Anel com stories por assistir (anima) |
.rk-stories-ring-avatar | StoriesRing | Imagem do avatar dentro do anel |
.rk-stories-ring-list | StoriesRingList | Contêiner da fileira horizontal de anéis |
.rk-stories-ring-list-item | StoriesRingList | Coluna com o anel e o nome |
.rk-stories-ring-list-name | StoriesRingList | Nome do autor abaixo de cada anel |
.rk-stories-overlay--carousel | Overlay | Overlay com o carrossel do desktop à mostra |
.rk-stories-overlay--sliding | Overlay | Deslize do carrossel em andamento; a tela do player fica oculta |
.rk-stories-carousel | Carousel | Camada de cards de prévia atrás do player |
.rk-stories-carousel--instant | Carousel | Primeiro quadro de um deslize, disposto sem transição |
.rk-stories-card | Carousel | Um card de prévia posicionado |
.rk-stories-card--center | Carousel | Card na posição central durante um deslize |
.rk-stories-card--hidden | Carousel | Card além dos dois mostrados de cada lado |
.rk-stories-card-button | Carousel | Conteúdo padrão do card (o botão que abre o grupo) |
.rk-stories-card-image | Carousel | Quadro de prévia (pôster ou imagem) |
.rk-stories-card-frame | Carousel | Prévia reduzida, feita por renderSlide, de um story sem imagem nem pôster; inerte |
.rk-stories-card-scrim | Carousel | Camada que escurece o quadro |
.rk-stories-card-info | Carousel | Coluna com anel, nome do autor e horário |
.rk-stories-card-name | Carousel | Nome do autor |
.rk-stories-card-time | Carousel | Texto 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.
| Token | Padrão | O que controla |
|---|---|---|
--rk-stories-overlay-bg | #000 | Cor do fundo em tela cheia |
--rk-stories-overlay-z | 9999 | z-index do overlay |
--rk-stories-swipe-gap | 16px | Espaço entre os botões de navegação e a área do story |
--rk-stories-container-radius | 12px | Arredondamento dos cantos da área do story (desktop) |
--rk-stories-container-radius-mobile | 0 | Cantos da área do story até 768px de largura |
--rk-stories-ui-z | 15 | z-index da camada de interface (cabeçalho, barra de progresso) |
--rk-stories-ui-transition | 200ms | Duração do esmaecimento quando hideUIOnPause age |
--rk-stories-top-shade-height | 120px | Altura do gradiente atrás do cabeçalho |
--rk-stories-top-shade-bg | linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%) | Cor do gradiente do topo |
--rk-stories-error-z | 5 | z-index do estado de erro |
--rk-stories-error-bg | linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) | Gradiente de fundo do estado de erro |
--rk-stories-error-fg | rgba(255, 255, 255, 0.5) | Cor do ícone e do texto de erro |
--rk-stories-error-gap | 12px | Espaço entre o ícone de erro e o texto |
--rk-stories-error-text-size | 13px | Tamanho da fonte da mensagem de erro |
--rk-stories-nav-z | 20 | z-index dos botões de navegação no desktop |
--rk-stories-nav-size | 44px | Tamanho dos botões de avançar e voltar no desktop |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | Fundo dos botões de navegação no desktop |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | Fundo desses botões ao passar o mouse |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | Cor do ícone desses botões |
--rk-stories-nav-fg-hover | #fff | Cor do ícone ao passar o mouse |
--rk-stories-nav-transition | 150ms | Transição do fundo e da cor dos botões de navegação |
--rk-stories-video-bg | #000 | Cor das faixas em volta do <video> |
--rk-stories-video-poster-transition | 200ms | Duração do esmaecimento do pôster quando o vídeo começa |
--rk-stories-progress-bar-z | 10 | z-index da barra de progresso |
--rk-stories-progress-bar-padding | 8px 8px 0 | Espaço em volta da barra de progresso |
--rk-stories-header-z | 5 | z-index do cabeçalho |
--rk-stories-header-top | 18px | Distância do cabeçalho até o topo do story |
--rk-stories-header-padding | 12px 16px | Respiro interno da linha do cabeçalho |
--rk-stories-header-gap | 8px | Espaço entre o avatar, o nome e o horário |
--rk-stories-header-transition | 200ms | Esmaecimento do cabeçalho quando ele se esconde |
--rk-stories-header-avatar-size | 32px | Largura e altura do avatar |
--rk-stories-header-name-fg | #fff | Cor do nome do autor |
--rk-stories-header-name-size | 14px | Tamanho da fonte do nome do autor |
--rk-stories-header-name-weight | 600 | Peso da fonte do nome do autor |
--rk-stories-header-time-fg | rgba(255, 255, 255, 0.6) | Cor do texto do "há quanto tempo" |
--rk-stories-header-time-size | 12px | Tamanho da fonte do "há quanto tempo" |
--rk-stories-header-actions-gap | 8px | Espaço entre os botões de ação do cabeçalho |
--rk-stories-header-btn-fg | #fff | Cor dos ícones de ação do cabeçalho (fechar, som, pausa) |
--rk-stories-header-btn-padding | 4px | Respiro interno dos botões de ação do cabeçalho |
--rk-stories-header-spinner-size | 20px | Tamanho do girador de carregamento |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | Cor do trilho do girador de carregamento |
--rk-stories-header-spinner-fg | #fff | Cor do arco do girador de carregamento |
--rk-stories-header-spinner-duration | 0.8s | Uma volta do girador de carregamento |
--rk-stories-heart-z | 20 | z-index do coração do toque duplo |
--rk-stories-heart-duration | 800ms | Duração da animação do coração, do salto ao desaparecimento |
--rk-stories-ring-spin-duration | 4s | Duração de uma volta do anel com stories por assistir |
--rk-stories-ring-active-scale | 0.95 | Escala do anel enquanto é pressionado |
--rk-stories-ring-active-gradient | conic-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-color | rgba(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-gradient | none | Interno: 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-gap | 12px | Espaço entre os anéis da fileira |
--rk-stories-ring-list-padding | 12px | Respiro interno em volta da fileira de anéis |
--rk-stories-ring-list-item-gap | 4px | Espaço entre um anel e o nome |
--rk-stories-ring-list-name-size | 12px | Tamanho da fonte do nome abaixo de cada anel |
--rk-stories-card-bg | #262626 | Fundo do card do carrossel, visível quando não há quadro de prévia |
--rk-stories-card-radius | 8px | Raio dos cantos do card do carrossel |
--rk-stories-card-scrim | rgba(0, 0, 0, 0.45) | Escurecimento sobre um card lateral |
--rk-stories-card-fg | #fff | Cor do texto do card |
--rk-stories-card-name-size | 14px | Tamanho da fonte do nome do autor no card |
--rk-stories-card-time-fg | rgba(255, 255, 255, 0.7) | Cor do tempo relativo no card |
--rk-stories-card-time-size | 13px | Tamanho da fonte do tempo relativo no card |
--rk-stories-card-gap | 6px | Espaço entre anel, nome e horário no card |
--rk-stories-card-transition | 300ms | Duração do deslize do carrossel |
Cole o trecho abaixo em uma folha de estilo carregada depois de @reelkit/react-stories-player/styles.css.
/* Brand the stories overlay */
:root {
--rk-stories-overlay-bg: #0f172a;
--rk-stories-container-radius: 24px;
--rk-stories-nav-bg: rgba(99, 102, 241, 0.25);
--rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.55);
--rk-stories-top-shade-bg: linear-gradient(
to bottom,
rgba(99, 102, 241, 0.5) 0%,
transparent 100%
);
--rk-stories-header-name-fg: #fef3c7;
--rk-stories-ring-spin-duration: 2s;
}
/* Rings on a light page. Set on the ring list, not :root, so the rings of the
player's own carousel keep the colours made for its dark backdrop. */
.rk-stories-ring-list {
--rk-stories-ring-viewed-color: #d4d4d8;
}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
| Tecla | Ação |
|---|---|
ArrowLeft | Story 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 |
ArrowRight | Próximo story. Depois do último story de um grupo, vai para o próximo grupo; depois do último grupo, o player fecha |
Escape | Fecha o player |