Stories Player
Оверлей плеєра історій у стилі Instagram для React на основі @reelkit/react-stories-player.
Features
Встановлення
npm i @reelkit/react-stories-player @reelkit/react lucide-reactНе забудьте імпортувати стилі:
import '@reelkit/react-stories-player/styles.css';Icons
Стандартна шапка використовує lucide-react для іконок. Якщо ви віддаєте перевагу іншій бібліотеці іконок, скористайтеся renderHeader та renderNavigation , щоб передати власні.
Швидкий старт
The StoriesOverlay рендерить повноекранний плеєр історій. Поєднуйте його з StoriesRingList для точок входу в стилі Instagram. Передайте масив об’єктів StoriesGroup і керуйте видимістю через 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>
);
}Ряд кілець, що памʼятає переглянуте навіть після перезавантаження, показує рецепт «Панель історій».
Стан в URL
Подивитися демо наживо →StoriesUrlOverlay — окремий компонент, стан відкриття якого живе в адресному рядку. Обидві осі їдуть в одному параметрі — ?story=<group>.<story> — тож історія, що грає, має посилання, яким можна поділитися, зберегти в закладки й закрити кнопкою «назад». Побудуйте контролер через useOverlayUrlState та urlIndexTwoAxisKey, а потім передайте його як controller.
Вбудовані клавіші
Історії двовісні, тож розгорніть у контролер двовісний ключ: urlIndexTwoAxisKey (група та історія за позицією) або urlStableIdTwoAxisKey (група за стабільним id) — обидва реекспортовані з @reelkit/react. See the посібник зі стану в URL та API ядра.
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} />- Відкриття додає один запис в історію. Свайп історій та і перемикання користувачів replace його, тож N переходів не додають записів, і один крок назад завжди закриває плеєр. «Назад» закриває, а не гортає історії.
- Внутрішня навігація теж передається. Індекс історії не застигає на рівні групи — перехід між історіями користувача оновлює
?story=2.n тож пряме посилання відкриває саме потрібну історію. - «Назад» закриває лише тоді, коли плеєр відкрили всередині застосунку — посилання додало запис. За надісланим посиланням у новій вкладці історії позаду немає, тож кнопка «назад» виведе із сайту; кнопка ✕ або Escape прибирає параметр на місці й лишає вас на сторінці.
- Параметр, який не називає ані групи, ані історії — застаріла закладка, змінене вручну значення, історія за межами групи — прибирається з URL, а не відкриває сусідню.
Застосунок із роутером — передайте адаптер. Writing history.pushState повз роутер лишає його місцеположення застарілим, і наступна навігація втрачає параметр:
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} />Стабільні посилання. Група типово адресується за позицією, тож збережений у закладках ?story=2.0 відкриє іншого користувача, щойно стрічку перевпорядкують. Адресуйте групу за стабільним id — outerCodec записує id в URL, outerLocator знаходить, де він лежить. Передайте innerCodec разом з innerLocate та innerIdentify, щоб так само адресувати історію всередині її групи; без них половина з історією лишається звичайним локальним індексом.
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,
}),
});Нескінченні стрічки. Гортання сторінками — задача outerLocator , незалежна від кодека. locate синхронний, тож відповідає лише за вже завантажені групи — надіслане посилання на групу 400 у стрічці, де завантажено 20, нічого не знайде. locateAsync — запасний варіант, що викликається лише коли locate не знаходить; історія заново обмежується за тією групою, на якій ус е зупинилося.
Той самий locateAsync, зовнішня вісь
Це той самий locateAsync пейджер, який приймають одновісні ключі — у двовісному ключі він їде разом із outerLocator , який ви передаєте, тож вісь груп гортається сторінками, а історія лишається локальним індексом усередині знайденої групи.
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;
},
},
}),
});- While
locateAsyncу процесі, плеєр лишається закритим, а параметр — недоторканим, тож пряме посилання переживає запит.nullабо відмова приби рає параметр. - Відповідь, що приходить після зміни URL, після закриття або після демонтажу, відкидається — повільний запит не відкриє історію, якої ніхто не просив.
- Full
useOverlayUrlStateопції — у довіднику API для React, а покроковий розбір — у посібнику для React.
Пам’ять про переглянуте
Кільця показують градієнт, доки групу не переглянуто до кінця, а група знову відкривається на першій історії, якої глядач ще не бачив. Обидва беруться з одного createStoriesViewedStateController, створеного один раз там, де живе стрічка, і переданого списку кілець та плеєру як viewed: вони самі читають сховище, стежать за ним і записують показане. Збережений запис називає автора та історію за id, тож переживає перевпорядкування стрічки, а той самий контролер працює у Vue та 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}
/>
</>
);
}- Початкова історія рахується. Історію, що вже на екрані, повідомлено переглянутою під час монтування, тож відкрити групу з однією історією й закрити — означає позначити її переглянутою.
- Свайп далі теж відновлює.
resumeStoryIndexбереться до уваги для кожної групи, досягнутої вперше в цій сесії, включно з тією, на якій відкрився плеєр, якщо тількиinitialStoryIndexне називає історію явно; група, яку вже прогорнули, знову відкривається там, де її залишили. - Посилання все одно переважає. Зі
StoriesUrlOverlayпараметр вирішує, де відкривається плеєр, що б не було збережено. Скрізь інде вирішує колбек відновлення. - Лічильник — це позиція, а не підрахунок. Запис називає найдальшу досягнуту історію, тож додавання історії до переглянутої групи знову засвічує її кільце, а вилучення із середини скорочує лічильник. Те саме самовиправлення, яке отримує спільне посилання.
- Сховище замінне. Передайте
storage: createSessionStorageAdapter(), щоб забувати при закритті вкладки, або власнийStorageAdapter. Дві відкриті вкладки тримаються в ногу через подію storage браузера. - Плеєр, змонтований лише в момент відкриття, читає запізно. Плеєр читає сховище в ефекті, який виконується, поки він ще закритий. З
{open && <StoriesOverlay … />}такого моменту немає, тож викличтеuseAttachViewedState(viewed)у компоненті, який володіє контролером; він під'єднується під час монтування й відпускає сховище під час демонтування. Параметри та низькорівневі частини, з яких це зібрано, описано на сторінці Stories Core.
Десктопна карусель
Задайте desktopLayout="carousel", щоб плеєр виглядав як вебверсія Instagram: активна історія лишається в центрі у звичному розмірі, а з кожного боку стоять до двох сусідніх груп у вигляді менших притемнених карток. Клік по картці відкриває цю групу, і картки ковзають убік, поки група змінюється. Передайте той самий контролер viewed, що й списку кілець, — і картка переглянутої групи отримає приглушене кільце. Картки стежать за ним самі, тож коли історію позначено переглянутою, перемальовуються лише вони, а плеєр повторно не рендериться.
// 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}
/>;- На телефонах плеєр звичайний. До 768px завширшки картки не рендеряться, а свайпи використовують
groupTransition, як і у стандартному макеті'single'. Зміна ширини вікна через цю межу перемикає макет одразу. - Картка показує, де відкриється група: історію, на якій її залишили в цій сесії, а інакше ту, яку повертає
resumeStoryIndex. На картці постер відео або саме зображення. І сторію без жодного з них, наприклад текст на градієнті, малює вашrenderSlideу розмірі плеєра зі зменшенням; відео без постера показує фон картки. Таке прев'ю викликаєrenderSlideзisActive: falseі є лише зображенням: нічого в ньому не отримує ні фокуса, ні кліків. Усе, що слайд робить під час монтування, як-от відтворення чи аналітику, тримайте за перевіркоюisActive. - Таймер чекає на ковзання. Історія не перемикається, і
onStoryViewedдля неї не спрацьовує, доки картки не зупиняться. - Ковзання вмикає будь-яка зміна групи: клік по картці,
StoriesApi.goToGroup,nextGroupіprevGroupта клавіші-стрілки за межею групи. Свайп пальцем рухає сам плеєр, тож ковзання поверх нього немає.
Смуга прогресу й шапка в кожній групі
Типово одна смуга прогресу й одна шапка лежать над плеєром і перемикаються на нову групу, щойно вона змінилася. Задайте chromePlacement="group", щоб кожна група мала власні всередині свого слайда: тоді обидві повертаються разом із групою, як в 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"
/>;- Сусідня група показує, де вона зупинилася. Поки плеєр повертається до неї, її смуга й шапка показують історію, з якої вона відкриється, ще без жодного перегляду.
- Група, яку покидають, лишається як була. Її смуга тримає прогрес до кінця повороту, тож досмотрена група лишається заповненою, а не порожніє посеред повороту.
- Власні рендерери викликаються для кожної групи на екрані.
renderProgressBarіrenderHeaderотримуютьgroupIndexтаisActive; для сусідньої групиisActiveдорівнюєfalse, а сигнали прогресу не змінюються. - Власна шапк а лежить в області свайпу. Дотик до неї перемикає історії, якщо він не влучив у
button, посилання чи елемент ізrole="button", тож усі елементи керування в ній мають бути саме такими. - Десктопна карусель посеред ковзання не змінюється. Поки картки ковзають, плеєр прихований, тож вибір видно, щойно група відкрилася.
Довідник API
StoriesOverlayProps
StoriesOverlayProps<T>
| Prop | Тип | Типове значення | Опис |
|---|---|---|---|
isOpen | boolean | required | Керує видимістю оверлея. Якщо true, прокручування body заблоковано. |
groups | StoriesGroup<T>[] | required | Масив груп історій для показу |
onClose | () => void | required | Колбек для закриття оверлея |
ariaLabel | string | 'Stories player' | Доступна назва області діалогу; екранні читачі оголошують її, коли оверлей відкривається |
initialGroupIndex | number | 0 | Індекс початково видимої групи, від нуля |
initialStoryIndex | number | resumeStoryIndex(initialGroupIndex), інакше 0 | Індекс початково видимої історії всередині групи, від нуля. Явно названий переважає над збереженим; пропустіть — і початкова група відновлюється, як і всі і нші. |
resumeStoryIndex | (groupIndex: number) => number | — | Історія, на якій група відкривається вперше, щоб глядач продовжив там, де зупинився. Береться до уваги лише для групи, ще не відвіданої під час цього відкриття. |
groupTransition | TransitionTransformFn | cubeTransition | Ефект переходу для зовнішнього слайдера груп |
defaultImageDuration | number | 5000 | Типова тривалість автопереходу для історій-зображень у мілісекундах |
tapZoneSplit | number | 0.3 | Співвідношення зон дотику (0–1). Ліва частина гортає назад, права — уперед. |
hideUIOnPause | boolean | true | Ховає смугу прогресу й заголовок, поки триває пауза від довгого натискання |
enableKeyboard | boolean | true | Вмикає навігацію з клавіатури (стрілки ліворуч і праворуч, Escape) |
innerTransitionDuration | number | 200 | Тривалість анімації внутрішнього пере ходу між історіями в мілісекундах |
minSegmentWidth | number | 8 | Мінімальна ширина сегмента смуги прогресу в пікселях |
apiRef | MutableRefObject<StoriesApi | null> | - | Ref для доступу до імперативного StoriesApi |
desktopLayout | 'single' | 'carousel' | 'single' | Макет на десктопі. 'carousel' показує сусідні групи картками поруч з активною історією й ковзає між групами. На телефонах історія завжди показується сама. Тип експортується як DesktopLayout. |
chromePlacement | 'overlay' | 'group' | 'overlay' | Де живуть смуга прогресу й шапка. 'overlay' малює одну копію над плеєром; 'group' малює по одній у кожному слайді групи, тож обидві повертаються разом із групою. Тип експортується як ChromePlacement. |
viewed | StoriesViewedStateController | - | Що глядач уже бачив, із createStoriesViewedStateController(). Групи відновлюються на першій непереглянутій історії, кожну показану історію записано, а картки каруселі приглушують кільце групи, переглянутої до кінця. Передайте той самий контролер у StoriesRingList. |
renderHeader | (props: HeaderRenderProps<T>) => ReactNode | - | Власний рендерер шапки. Отримує автора, історію, стан паузи та звуку. З chromePlacement="group" шапка лежить в області свайпу; див. Смуга прогресу й шапка в кожній групі. |
renderFooter | (props: FooterRenderProps<T>) => ReactNode | - | Власний рендерер підвала. Отримує дані автора та історії. |
renderSlide | (props: SlideRenderProps<T>) => ReactNode | - | Власний рендерер слайда, що замінює стандартні слайди із зображенням і відео. |
renderNavigation | (props: NavigationRenderProps) => ReactNode | - | Власна навігація для десктопа. Замінює стандартні кнопки-стрілки. |
renderProgressBar | (props: ProgressBarRenderProps<T>) => ReactNode | - | Власна смуга прогресу. Замінює стандартну смугу на canvas. |
renderLoading | (props: LoadingRenderProps<T>) => ReactNode | - | Власний рендерер стану завантаження. Якщо не задано, показується стандартний індикатор у шапці. |
renderError | (props: ErrorRenderProps<T>) => ReactNode | - | Власний рендерер стану помилки. Якщо не задано, показується стандартний значок помилки. |
renderGroupPreview | (props: GroupPreviewRenderProps<T>) => ReactNode | - | Власна картка десктопної каруселі. Замінює стандартний вміст картки. |
StoriesUrlOverlayProps
StoriesUrlOverlayProps<T>
Приймає всі пропси StoriesOverlay , крім трійки стану відкритт я — isOpen, initialGroupIndex, initialStoryIndex , яку натомість дає контролер.
| Prop | Тип | Типове значення | Опис |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | required | Контролер із useOverlayUrlState, розгорнутий разом із urlIndexTwoAxisKey. Його position — об’єкт { outer, inner } — вирішує, чи плеєр відкритий і де саме; оверлей записує назад на кожній навігації та на закритті. |
Колбеки
| Prop | Тип | Опис |
|---|---|---|
onClose | () => void | Викликається, коли плеєр закривається. Обов’язковий у StoriesOverlay (стан відкриття в аш, тож закриття обробляєте ви); необов’язковий у StoriesUrlOverlay, де закриттям керує URL — передавайте лише щоб зреагувати після закриття. |
onStoryChange | (groupIndex: number, storyIndex: number) => void | Спрацьовує, коли змінюється активна історія |
onGroupChange | (groupIndex: number) => void | Спрацьовує, коли змінюється активна група |
onStoryViewed | (groupIndex: number, storyIndex: number) => void | Спрацьовує, коли історія стає видимою |
onStoryComplete | (groupIndex: number, storyIndex: number) => void | Спрацьовує, коли завершується таймер історії |
onDoubleTap | (groupIndex: number, storyIndex: number) => void | Спрацьовує на подвійний дотик |
onPause | () => void | Спрацьовує, коли плеєр на паузі |
onResume | () => void | Спрацьовує, коли плеєр продовжує відтворення |
Переходи
The groupTransition керує 3D-ефектом переходу під час свайпу між групами користувачів. Імпортуйте функції переходів із @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}
/>Життєвий цикл завантаження вмісту
Кожен слайд історії повідомляє свій стан завантаження через колбеки, передані в SlideRenderProps:
| Колбек | Якщо |
|---|---|
onReady | Вміст готовий (зображення завантажено, відео грає). Таймер прогресу запускається. |
onWaiting | Вміст затримується (відео буферизується посеред відтворення). З’являється індикатор, таймер стає на паузу. |
onError | Вміст не завантажився. Показується оверлей помилки. |
onDurationReady | Повідомте справжню тривалість медіа (наприклад, із метаданих відео), щоб перезапустити таймер із правильною тривалістю. |
onEnded | Сигналізує, що медіа завершилося (наприклад, відео догралося). Відбувається перехід до наступної історії. |
Кешування попереднього завантаження
The built-in ImageStorySlide та VideoStorySlide заздалегідь вантажать наступну історію у фоні. Коли користувач переходить до вже завантаженої історії, вміст з’являється миттєво, без індикатора.
Render Props
Будь-який елемент інтерфейсу можна замінити через render props. Кожен отримує типізовані пропси з усім потрібним станом і колбеками.
renderHeader
Замініть стандартну шапку (дані автора, кнопки паузи та звуку, кнопка закриття):
<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
Додайте підвал під вмістом історії:
<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
Повністю замініть стандартні слайди із зображенням і відео. Скористайтеся підкомпонентами ImageStorySlide та VideoStorySlide для вбудованої роботи з медіа:
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
Замініть стандартні кнопки-стрілки для десктопа:
<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
Замініть стандартну смугу прогресу на canvas власною реалізацією. Сигнал progress видає значення від 0 до 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
Власний індикатор завантаження, поки вміст вантажиться:
<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
Власний оверлей помилки, коли вміст не завантажився:
<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
Замініть вміст картки десктопної каруселі. Плеєр і далі розміщує, масштабує й зсуває картку; викличте onOpen, щоб відкрити групу:
<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
Скористайтеся пропсом apiRef для імперативного керування:
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}
/>
</>
);
}Методи
| Метод | Тип | Опис |
|---|---|---|
nextStory() | () => void | Перехід до наступної історії в поточній групі |
prevStory() | () => void | Перехід до попередньої історії в поточній групі |
nextGroup() | () => void | Перемикає на наступну групу користувача |
prevGroup() | () => void | Перемикає на попередню гру пу користувача |
goToGroup(index) | (index: number) => void | Перехід до конкретної групи за індексом |
pause() | () => void | Призупиняє автоперехід і таймер прогресу |
resume() | () => void | Відновлює автоперехід і таймер прогресу |
Подвійний дотик і лайки
На подвійний дотик програється вбудована анімація сердечка — миттєвий візуальний відгук. Колбек onDoubleTap спрацьовує з індексами групи та історії, тож ви можете зберегти лайк у власному стані (запит до API, локальне сховище тощо). Сам плеєр стан лайків не веде.
<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);
}}
/>Налаштуванн я анімації сердечка
Змініть швидкість анімації через --rk-stories-heart-duration token (see Theming). Для кольору, розміру чи повного приховування сердечка звертайтеся до .rk-stories-heart напряму. Компонент HeartAnimation також експортовано для окремого використання.
/* 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;
}Вбудовану анімацію сердечка поки не можна замінити через render prop. Її можна перестилізувати засобами CSS або сховати через display: none і зробити власну анімацію в колбеку onDoubleTap . Якщо вам потрібен render prop renderDoubleTap , напишіть нам через GitHub Issues.
Sub-Components
Повторно використовувані блоки, експортовані для складання власних render props. Тип пропсів кожного експортовано поруч із ним — CanvasProgressBarProps, StoryHeaderProps, ImageStorySlideProps, VideoStorySlideProps, StoriesRingProps, StoriesRingListProps і HeartAnimationProps — для компонента, що обгортає один із них:
CanvasProgressBar
Швидка сегментована смуга прогресу на canvas. Рендерить сегмент для кожної історії й анімує заповнення активного через requestAnimationFrame. Підтримує рухоме вікно для груп із багатьма історіями. Передайте live={false} для смуги, яка лише показує, де зупинилася група: вона малюється, коли змінюються її сигнали, і після зміни розміру, без циклу анімації.
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
Стандартна шапка з аватаром автора, іменем, значком підтвердження, відносним часом, перемикачами паузи та звуку, індикатором завантаження й кнопкою закриття. Використовується автоматично, коли renderHeader не задано.
import { StoryHeader } from '@reelkit/react-stories-player';
<StoryHeader
author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg', verified: true }}
createdAt={new Date(Date.now() - 3600_000)}
onClose={handleClose}
isPaused={false}
onTogglePause={togglePause}
isMuted={true}
onToggleSound={toggleSound}
isVideo={true}
isLoading={false}
/>ImageStorySlide
Слайд-зображення на всю площу з object-fit: cover. Повідомляє про завантаження та помилки через колбеки для відстеження життєвого циклу.
import { ImageStorySlide } from '@reelkit/react-stories-player';
<ImageStorySlide
src="/photo.jpg"
aspectRatio={9 / 16}
onLoad={() => console.log('loaded')}
onError={() => console.log('failed')}
/>VideoStorySlide
Слайд-відео, що використовує спільний елемент <video> заради безперервності звуку на iOS. Дає раду автовідтворенню, постерам, синхронізації звуку й повідомляє тривалість і події життєвого циклу відтворення.
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
Круглий аватар із градієнтним кільцем у стилі Instagram. Сегменти показують переглянуті й непереглянуті історії — градієнт для непереглянутих, приглушений сірий для переглянутих.
import { StoriesRing } from '@reelkit/react-stories-player';
<StoriesRing
author={{ id: '1', name: 'Alice', avatar: '/avatar.jpg' }}
totalStories={5}
viewedCount={2}
onClick={() => openStories(0)}
/>StoriesRingList
Горизонтальний прокручуваний ряд компонентів StoriesRing з іменами авторів. Одне кільце на групу.
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
Анімований оверлей сердечка, що спрацьовує на подвійний дотик. Збільшується й згасає за 800 мс. Налаштовується засобами CSS (дивіться розділ про подвійний дотик і лайки).
import { HeartAnimation } from '@reelkit/react-stories-player';
<HeartAnimation onComplete={() => console.log('animation done')} />Реекспорти
Усе, що потрібно споживачеві, надходить із цього пакета, тож прямий імпорт з @reelkit/core ніколи не знадобиться.
| Звідки | Що реекспортовано |
|---|---|
@reelkit/react | SoundProvider, useSoundState, useOverlayUrlState, createViewedStateController, twoAxisViewedTracking, createLocalStorageAdapter, createSessionStorageAdapter, createMemoryStorageAdapter, urlIndexTwoAxisKey, urlStableIdTwoAxisKey, base64UrlCodec, а також типи UrlAdapter, UrlCodec, UrlLocator, UrlKey, UrlStateController, TwoAxisPosition, TwoAxisIdentity, UrlIndexTwoAxisKeyOptions, OverlayUrlStateOptions, ViewedStateController, ViewedStateOptions, StorageAdapter |
@reelkit/stories-core | createStoriesViewedStateController, StoriesViewedStateController, StoriesViewedStateControllerConfig, а також типи вмісту StoryItem, AuthorInfo, StoriesGroup, MediaType |
SoundProvider і useSoundState несуть стан звуку, який плеєр поділяє зі своїми слайдами. Власний слайд, що відтворює власне медіа, читає перемикач звуку через useSoundState. Усередині плеєра провайдер уже є; VideoStorySlide чи власному слайду, відрендереному деінде, потрібен SoundProvider вище за деревом, інакше useSoundState кидає помилку.
Types
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;
}Власні типи Story
Extend StoryItem власними п олями й передайте параметр типу в StoriesOverlay. Усі render props отримають ваш розширений тип:
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} />}
/>Класи CSS
Усі класи CSS звичайні (не CSS-модулі), тож їх можна перекрити селекторами вищої специфічності в таблиці стилів, підключеній після @reelkit/react-stories-player/styles.css. Для змін кольору, розміру та z-index краще беріть власні властивості CSS, описані в розділі Theming нижче.
| Class | Component | Опис |
|---|---|---|
.rk-stories-overlay | Overlay | Фіксоване повноекранне тло (фон, z-index) |
.rk-stories-swipe-wrapper | Overlay | Обгортка свайпу для закриття (містить кнопки навігації та canvas) |
.rk-stories-container | Overlay | Заокруглене полотно історії (позиція, переповнення) |
.rk-stories-ui-layer | Overlay | Контейнер інтерфейсу (шапка, прогрес, навігація). З chromePlacement="group" — у кожному слайді групи |
.rk-stories-ui-layer--hidden | Overlay | Стан прихованого інтерфейсу (перемикається через hideUIOnPause) |
.rk-stories-error | Overlay | Стан помилки (значок і текст по центру) |
.rk-stories-error-text | Overlay | Текст повідомлення про помилку |
.rk-stories-nav-btn | Навігація | Стрілка вперед або назад на десктопі |
.rk-stories-progress-bar | ProgressBar | Обгортка позиціювання смуги прогресу на canvas |
.rk-stories-slide-wrapper | Group | Одна група історій (зовнішній слайд) |
.rk-stories-story | Story | Одна історія (корінь внутрішнього слайда) |
.rk-stories-header | StoryHeader | Смуга шапки (аватар, ім’я, дії) |
.rk-stories-header--hidden | StoryHeader | Стан прихованої шапки (visible=false) |
.rk-stories-header-avatar | StoryHeader | Зображення аватара автора |
.rk-stories-header-name | StoryHeader | Текст імені автора |
.rk-stories-header-verified | StoryHeader | Контейнер значка підтвердження |
.rk-stories-header-time | StoryHeader | Текст «скільки часу тому» |
.rk-stories-header-actions | StoryHeader | Дії праворуч (закрити, вимкнути звук, пауза) |
.rk-stories-header-btn | StoryHeader | Кнопка дії в шапці |
.rk-stories-header-btn--desktop | StoryHeader | Кнопка паузи, видима лише на ширині понад 768px |
.rk-stories-header-spinner | StoryHeader | Індикатор буферизації відео |
.rk-stories-image | ImageStorySlide | Елемент історії-зображення |
.rk-stories-video | VideoStorySlide | Контейнер історії-відео |
.rk-stories-video-element | VideoStorySlide | Спільний елемент <video> |
.rk-stories-video-poster | VideoStorySlide | Постер відео (зникає з початком відтворення) |
.rk-stories-video-poster--visible | VideoStorySlide | Стан видимого постера (до відтворення) |
.rk-stories-heart | HeartAnimation | Анімація сердечка на подвійний дотик |
.rk-stories-ring | StoriesRing | Кільце історії (аватар з анімованою градієнтною рамкою) |
.rk-stories-ring--active | StoriesRing | Кільце з непереглянутими історіями (анімується) |
.rk-stories-ring-avatar | StoriesRing | Зображення аватара всередині кільця |
.rk-stories-ring-list | StoriesRingList | Контейнер горизонтального списку кілець |
.rk-stories-ring-list-item | StoriesRingList | Колонка з кільцем та іменем |
.rk-stories-ring-list-name | StoriesRingList | Ім’я автора під кожним кільцем |
.rk-stories-overlay--carousel | Overlay | Оверлей із показаною десктопною каруселлю |
.rk-stories-overlay--sliding | Overlay | Триває ковзання каруселі; полотно плеєра приховане |
.rk-stories-carousel | Carousel | Шар карток-прев’ю позаду плеєра |
.rk-stories-carousel--instant | Carousel | Перший кадр ковзання, розкладений без переходу |
.rk-stories-card | Carousel | Одна розміщена картка-прев’ю |
.rk-stories-card--center | Carousel | Картка в центральному слоті під час ковзання |
.rk-stories-card--hidden | Carousel | Картка за межами двох, що видно з боку |
.rk-stories-card-button | Carousel | Стандартний вміст картки (кнопка, що відкриває групу) |
.rk-stories-card-image | Carousel |