Stories Player

Оверлей плеєра історій у стилі Instagram для React на основі @reelkit/react-stories-player.

Подивитися демо наживо →

Features

Вкладена навігація
Дотик гортає історії, свайп перемикає групи
Історії-відео
Автовідтворення з перемикачем звуку
Auto-Advance
Налаштовний таймер для кожної історії
3D-переходи
Куб, переворот, затухання, масштаб, зсув
Смуга прогресу
Сегментований прогрес на canvas
Зображення та відео
Працює з обома типами медіа
Віртуалізований
Лише 3 слайди в DOM
Лайк подвійним дотиком
Анімація сердечка на подвійний дотик
Навігація на десктопі
Кнопки-стрілки на десктопі
Кільця історій
Кільця аватарів у стилі Instagram
Узагальнені типи
Розширюйте StoryItem власними даними
Render Props
Налаштовуйте кожен елемент інтерфейсу
Стан в URL
Посилання ?story=group.story, якими можна ділитися
Переглянуте
Кільця та відновлення переживають перезавантаження

Встановлення

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

Не забудьте імпортувати стилі:

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

Стандартна шапка використовує lucide-react для іконок. Якщо ви віддаєте перевагу іншій бібліотеці іконок, скористайтеся renderHeader та renderNavigation , щоб передати власні.

Швидкий старт

The StoriesOverlay рендерить повноекранний плеєр історій. Поєднуйте його з StoriesRingList для точок входу в стилі Instagram. Передайте масив об’єктів StoriesGroup і керуйте видимістю через isOpen.

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

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

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

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

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

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

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

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

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

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

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

Ряд кілець, що памʼятає переглянуте навіть після перезавантаження, показує рецепт «Панель історій».

Стан в URL

Подивитися демо наживо →

StoriesUrlOverlay — окремий компонент, стан відкриття якого живе в адресному рядку. Обидві осі їдуть в одному параметрі — ?story=<group>.<story> — тож історія, що грає, має посилання, яким можна поділитися, зберегти в закладки й закрити кнопкою «назад». Побудуйте контролер через useOverlayUrlState та urlIndexTwoAxisKey, а потім передайте його як controller.

Вбудовані клавіші

Історії двовісні, тож розгорніть у контролер двовісний ключ: urlIndexTwoAxisKey (група та історія за позицією) або urlStableIdTwoAxisKey (група за стабільним id) — обидва реекспортовані з @reelkit/react. See the посібник зі стану в URL та API ядра.

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

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

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

<StoriesUrlOverlay controller={stories} groups={groups} />
  • Відкриття додає один запис в історію. Свайп історій та і перемикання користувачів replace його, тож N переходів не додають записів, і один крок назад завжди закриває плеєр. «Назад» закриває, а не гортає історії.
  • Внутрішня навігація теж передається. Індекс історії не застигає на рівні групи — перехід між історіями користувача оновлює ?story=2.n тож пряме посилання відкриває саме потрібну історію.
  • «Назад» закриває лише тоді, коли плеєр відкрили всередині застосунку — посилання додало запис. За надісланим посиланням у новій вкладці історії позаду немає, тож кнопка «назад» виведе із сайту; кнопка ✕ або Escape прибирає параметр на місці й лишає вас на сторінці.
  • Параметр, який не називає ані групи, ані історії — застаріла закладка, змінене вручну значення, історія за межами групи — прибирається з URL, а не відкриває сусідню.

Застосунок із роутером — передайте адаптер. Writing history.pushState повз роутер лишає його місцеположення застарілим, і наступна навігація втрачає параметр:

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

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

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

Стабільні посилання. Група типово адресується за позицією, тож збережений у закладках ?story=2.0 відкриє іншого користувача, щойно стрічку перевпорядкують. Адресуйте групу за стабільним id — outerCodec записує id в URL, outerLocator знаходить, де він лежить. Передайте innerCodec разом з innerLocate та innerIdentify, щоб так само адресувати історію всередині її групи; без них половина з історією лишається звичайним локальним індексом.

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

Нескінченні стрічки. Гортання сторінками — задача outerLocator , незалежна від кодека. locate синхронний, тож відповідає лише за вже завантажені групи — надіслане посилання на групу 400 у стрічці, де завантажено 20, нічого не знайде. locateAsync — запасний варіант, що викликається лише коли locate не знаходить; історія заново обмежується за тією групою, на якій усе зупинилося.

Той самий locateAsync, зовнішня вісь

Це той самий locateAsync пейджер, який приймають одновісні ключі — у двовісному ключі він їде разом із outerLocator , який ви передаєте, тож вісь груп гортається сторінками, а історія лишається локальним індексом усередині знайденої групи.

tsx
const stories = useOverlayUrlState({
  param: 'story',
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.length,
    innerCounts: () => groups.map((g) => g.stories.length),
    outerLocator: {
      locate: (index) => (index < groups.length ? index : null),
      identify: (index) => index,
      locateAsync: async (index) => {
        const loaded = await loadUntilGroup(index); // page up to it
        if (!loaded) return null; // exhausted — link names no group
        setGroups(loaded); // commit — the overlay renders from this state
        return index;
      },
    },
  }),
});
  • While locateAsync у процесі, плеєр лишається закритим, а параметр — недоторканим, тож пряме посилання переживає запит. null або відмова прибирає параметр.
  • Відповідь, що приходить після зміни URL, після закриття або після демонтажу, відкидається — повільний запит не відкриє історію, якої ніхто не просив.
  • Full useOverlayUrlState опції — у довіднику API для React, а покроковий розбір — у посібнику для React.

Пам’ять про переглянуте

Кільця показують градієнт, доки групу не переглянуто до кінця, а група знову відкривається на першій історії, якої глядач ще не бачив. Обидва беруться з одного createStoriesViewedStateController, створеного один раз там, де живе стрічка, і переданого списку кілець та плеєру як viewed: вони самі читають сховище, стежать за ним і записують показане. Збережений запис називає автора та історію за id, тож переживає перевпорядкування стрічки, а той самий контролер працює у Vue та Angular.

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

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

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

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

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

      {/* The player resumes, records and draws its card rings from it. */}
      <StoriesOverlay
        isOpen={open}
        onClose={() => setOpen(false)}
        groups={groups}
        initialGroupIndex={group}
        viewed={viewed}
      />
    </>
  );
}
  • Початкова історія рахується. Історію, що вже на екрані, повідомлено переглянутою під час монтування, тож відкрити групу з однією історією й закрити — означає позначити її переглянутою.
  • Свайп далі теж відновлює. resumeStoryIndex береться до уваги для кожної групи, досягнутої вперше в цій сесії, включно з тією, на якій відкрився плеєр, якщо тільки initialStoryIndex не називає історію явно; група, яку вже прогорнули, знову відкривається там, де її залишили.
  • Посилання все одно переважає. Зі StoriesUrlOverlay параметр вирішує, де відкривається плеєр, що б не було збережено. Скрізь інде вирішує колбек відновлення.
  • Лічильник — це позиція, а не підрахунок. Запис називає найдальшу досягнуту історію, тож додавання історії до переглянутої групи знову засвічує її кільце, а вилучення із середини скорочує лічильник. Те саме самовиправлення, яке отримує спільне посилання.
  • Сховище замінне. Передайте storage: createSessionStorageAdapter(), щоб забувати при закритті вкладки, або власний StorageAdapter. Дві відкриті вкладки тримаються в ногу через подію storage браузера.
  • Плеєр, змонтований лише в момент відкриття, читає запізно. Плеєр читає сховище в ефекті, який виконується, поки він ще закритий. З {open && <StoriesOverlay … />} такого моменту немає, тож викличте useAttachViewedState(viewed) у компоненті, який володіє контролером; він під'єднується під час монтування й відпускає сховище під час демонтування. Параметри та низькорівневі частини, з яких це зібрано, описано на сторінці Stories Core.

Задайте desktopLayout="carousel", щоб плеєр виглядав як вебверсія Instagram: активна історія лишається в центрі у звичному розмірі, а з кожного боку стоять до двох сусідніх груп у вигляді менших притемнених карток. Клік по картці відкриває цю групу, і картки ковзають убік, поки група змінюється. Передайте той самий контролер viewed, що й списку кілець, — і картка переглянутої групи отримає приглушене кільце. Картки стежать за ним самі, тож коли історію позначено переглянутою, перемальовуються лише вони, а плеєр повторно не рендериться.

tsx
// The same controller the ring list takes: the cards draw the same rings and
// repaint by themselves when a story is marked seen.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  initialGroupIndex={group}
  desktopLayout="carousel"
  viewed={viewed}
/>;
  • На телефонах плеєр звичайний. До 768px завширшки картки не рендеряться, а свайпи використовують groupTransition, як і у стандартному макеті 'single'. Зміна ширини вікна через цю межу перемикає макет одразу.
  • Картка показує, де відкриється група: історію, на якій її залишили в цій сесії, а інакше ту, яку повертає resumeStoryIndex. На картці постер відео або саме зображення. Історію без жодного з них, наприклад текст на градієнті, малює ваш renderSlide у розмірі плеєра зі зменшенням; відео без постера показує фон картки. Таке прев'ю викликає renderSlide з isActive: false і є лише зображенням: нічого в ньому не отримує ні фокуса, ні кліків. Усе, що слайд робить під час монтування, як-от відтворення чи аналітику, тримайте за перевіркою isActive.
  • Таймер чекає на ковзання. Історія не перемикається, і onStoryViewed для неї не спрацьовує, доки картки не зупиняться.
  • Ковзання вмикає будь-яка зміна групи: клік по картці, StoriesApi.goToGroup, nextGroup і prevGroup та клавіші-стрілки за межею групи. Свайп пальцем рухає сам плеєр, тож ковзання поверх нього немає.

Смуга прогресу й шапка в кожній групі

Типово одна смуга прогресу й одна шапка лежать над плеєром і перемикаються на нову групу, щойно вона змінилася. Задайте chromePlacement="group", щоб кожна група мала власні всередині свого слайда: тоді обидві повертаються разом із групою, як в Instagram.

tsx
// Every group carries its own progress bar and header, so both turn with the
// group instead of switching above the player once it has changed.
<StoriesOverlay
  isOpen={open}
  onClose={() => setOpen(false)}
  groups={groups}
  chromePlacement="group"
/>;
  • Сусідня група показує, де вона зупинилася. Поки плеєр повертається до неї, її смуга й шапка показують історію, з якої вона відкриється, ще без жодного перегляду.
  • Група, яку покидають, лишається як була. Її смуга тримає прогрес до кінця повороту, тож досмотрена група лишається заповненою, а не порожніє посеред повороту.
  • Власні рендерери викликаються для кожної групи на екрані. renderProgressBar і renderHeader отримують groupIndex та isActive; для сусідньої групи isActive дорівнює false, а сигнали прогресу не змінюються.
  • Власна шапка лежить в області свайпу. Дотик до неї перемикає історії, якщо він не влучив у button, посилання чи елемент із role="button", тож усі елементи керування в ній мають бути саме такими.
  • Десктопна карусель посеред ковзання не змінюється. Поки картки ковзають, плеєр прихований, тож вибір видно, щойно група відкрилася.

Довідник API

StoriesOverlayProps

StoriesOverlayProps<T>

PropТипТипове значенняОпис
isOpenbooleanrequiredКерує видимістю оверлея. Якщо true, прокручування body заблоковано.
groupsStoriesGroup<T>[]requiredМасив груп історій для показу
onClose() => voidrequiredКолбек для закриття оверлея
ariaLabelstring'Stories player'Доступна назва області діалогу; екранні читачі оголошують її, коли оверлей відкривається
initialGroupIndexnumber0Індекс початково видимої групи, від нуля
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex), інакше 0Індекс початково видимої історії всередині групи, від нуля. Явно названий переважає над збереженим; пропустіть — і початкова група відновлюється, як і всі інші.
resumeStoryIndex(groupIndex: number) => number—Історія, на якій група відкривається вперше, щоб глядач продовжив там, де зупинився. Береться до уваги лише для групи, ще не відвіданої під час цього відкриття.
groupTransitionTransitionTransformFncubeTransitionЕфект переходу для зовнішнього слайдера груп
defaultImageDurationnumber5000Типова тривалість автопереходу для історій-зображень у мілісекундах
tapZoneSplitnumber0.3Співвідношення зон дотику (0–1). Ліва частина гортає назад, права — уперед.
hideUIOnPausebooleantrueХоває смугу прогресу й заголовок, поки триває пауза від довгого натискання
enableKeyboardbooleantrueВмикає навігацію з клавіатури (стрілки ліворуч і праворуч, Escape)
innerTransitionDurationnumber200Тривалість анімації внутрішнього переходу між історіями в мілісекундах
minSegmentWidthnumber8Мінімальна ширина сегмента смуги прогресу в пікселях
apiRefMutableRefObject<StoriesApi | null>-Ref для доступу до імперативного StoriesApi
desktopLayout'single' | 'carousel''single'Макет на десктопі. 'carousel' показує сусідні групи картками поруч з активною історією й ковзає між групами. На телефонах історія завжди показується сама. Тип експортується як DesktopLayout.
chromePlacement'overlay' | 'group''overlay'Де живуть смуга прогресу й шапка. 'overlay' малює одну копію над плеєром; 'group' малює по одній у кожному слайді групи, тож обидві повертаються разом із групою. Тип експортується як ChromePlacement.
viewedStoriesViewedStateController-Що глядач уже бачив, із 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ТипТипове значенняОпис
controllerUrlStateController<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:

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

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

Життєвий цикл завантаження вмісту

Кожен слайд історії повідомляє свій стан завантаження через колбеки, передані в SlideRenderProps:

КолбекЯкщо
onReadyВміст готовий (зображення завантажено, відео грає). Таймер прогресу запускається.
onWaitingВміст затримується (відео буферизується посеред відтворення). З’являється індикатор, таймер стає на паузу.
onErrorВміст не завантажився. Показується оверлей помилки.
onDurationReadyПовідомте справжню тривалість медіа (наприклад, із метаданих відео), щоб перезапустити таймер із правильною тривалістю.
onEndedСигналізує, що медіа завершилося (наприклад, відео догралося). Відбувається перехід до наступної історії.
Кешування попереднього завантаження

The built-in ImageStorySlide та VideoStorySlide заздалегідь вантажать наступну історію у фоні. Коли користувач переходить до вже завантаженої історії, вміст з’являється миттєво, без індикатора.

Render Props

Будь-який елемент інтерфейсу можна замінити через render props. Кожен отримує типізовані пропси з усім потрібним станом і колбеками.

renderHeader

Замініть стандартну шапку (дані автора, кнопки паузи та звуку, кнопка закриття):

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

renderFooter

Додайте підвал під вмістом історії:

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

renderSlide

Повністю замініть стандартні слайди із зображенням і відео. Скористайтеся підкомпонентами ImageStorySlide та VideoStorySlide для вбудованої роботи з медіа:

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

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

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

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

renderNavigation

Замініть стандартні кнопки-стрілки для десктопа:

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

renderProgressBar

Замініть стандартну смугу прогресу на canvas власною реалізацією. Сигнал progress видає значення від 0 до 1:

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

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

renderLoading

Власний індикатор завантаження, поки вміст вантажиться:

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

renderError

Власний оверлей помилки, коли вміст не завантажився:

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

renderGroupPreview

Замініть вміст картки десктопної каруселі. Плеєр і далі розміщує, масштабує й зсуває картку; викличте onOpen, щоб відкрити групу:

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

StoriesApi

Скористайтеся пропсом apiRef для імперативного керування:

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

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

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

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

Методи

МетодТипОпис
nextStory()() => voidПерехід до наступної історії в поточній групі
prevStory()() => voidПерехід до попередньої історії в поточній групі
nextGroup()() => voidПеремикає на наступну групу користувача
prevGroup()() => voidПеремикає на попередню групу користувача
goToGroup(index)(index: number) => voidПерехід до конкретної групи за індексом
pause()() => voidПризупиняє автоперехід і таймер прогресу
resume()() => voidВідновлює автоперехід і таймер прогресу

Подвійний дотик і лайки

На подвійний дотик програється вбудована анімація сердечка — миттєвий візуальний відгук. Колбек onDoubleTap спрацьовує з індексами групи та історії, тож ви можете зберегти лайк у власному стані (запит до API, локальне сховище тощо). Сам плеєр стан лайків не веде.

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

Налаштування анімації сердечка

Змініть швидкість анімації через --rk-stories-heart-duration token (see Theming). Для кольору, розміру чи повного приховування сердечка звертайтеся до .rk-stories-heart напряму. Компонент HeartAnimation також експортовано для окремого використання.

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

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

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

Вбудовану анімацію сердечка поки не можна замінити через 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} для смуги, яка лише показує, де зупинилася група: вона малюється, коли змінюються її сигнали, і після зміни розміру, без циклу анімації.

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

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

StoryHeader

Стандартна шапка з аватаром автора, іменем, значком підтвердження, відносним часом, перемикачами паузи та звуку, індикатором завантаження й кнопкою закриття. Використовується автоматично, коли renderHeader не задано.

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

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

ImageStorySlide

Слайд-зображення на всю площу з object-fit: cover. Повідомляє про завантаження та помилки через колбеки для відстеження життєвого циклу.

tsx
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. Дає раду автовідтворенню, постерам, синхронізації звуку й повідомляє тривалість і події життєвого циклу відтворення.

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

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

StoriesRing

Круглий аватар із градієнтним кільцем у стилі Instagram. Сегменти показують переглянуті й непереглянуті історії — градієнт для непереглянутих, приглушений сірий для переглянутих.

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

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

StoriesRingList

Горизонтальний прокручуваний ряд компонентів StoriesRing з іменами авторів. Одне кільце на групу.

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

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

HeartAnimation

Анімований оверлей сердечка, що спрацьовує на подвійний дотик. Збільшується й згасає за 800 мс. Налаштовується засобами CSS (дивіться розділ про подвійний дотик і лайки).

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

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

Реекспорти

Усе, що потрібно споживачеві, надходить із цього пакета, тож прямий імпорт з @reelkit/core ніколи не знадобиться.

ЗвідкиЩо реекспортовано
@reelkit/reactSoundProvider, useSoundState, useOverlayUrlState, createViewedStateController, twoAxisViewedTracking, createLocalStorageAdapter, createSessionStorageAdapter, createMemoryStorageAdapter, urlIndexTwoAxisKey, urlStableIdTwoAxisKey, base64UrlCodec, а також типи UrlAdapter, UrlCodec, UrlLocator, UrlKey, UrlStateController, TwoAxisPosition, TwoAxisIdentity, UrlIndexTwoAxisKeyOptions, OverlayUrlStateOptions, ViewedStateController, ViewedStateOptions, StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController, StoriesViewedStateController, StoriesViewedStateControllerConfig, а також типи вмісту StoryItem, AuthorInfo, StoriesGroup, MediaType

SoundProvider і useSoundState несуть стан звуку, який плеєр поділяє зі своїми слайдами. Власний слайд, що відтворює власне медіа, читає перемикач звуку через useSoundState. Усередині плеєра провайдер уже є; VideoStorySlide чи власному слайду, відрендереному деінде, потрібен SoundProvider вище за деревом, інакше useSoundState кидає помилку.

Types

StoryItem

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

AuthorInfo

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

StoriesGroup<T>

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

HeaderRenderProps<T>

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

FooterRenderProps<T>

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

SlideRenderProps<T>

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

ProgressBarRenderProps<T>

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

LoadingRenderProps<T>

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

ErrorRenderProps<T>

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

GroupPreviewRenderProps<T>

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

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

StoriesApi

typescript
interface StoriesApi {
  nextStory: () => void;
  prevStory: () => void;
  nextGroup: () => void;
  prevGroup: () => void;
  goToGroup: (index: number) => void;
  pause: () => void;
  resume: () => void;
}

Власні типи Story

Extend StoryItem власними полями й передайте параметр типу в StoriesOverlay. Усі render props отримають ваш розширений тип:

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

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

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

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

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

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

Класи CSS

Усі класи CSS звичайні (не CSS-модулі), тож їх можна перекрити селекторами вищої специфічності в таблиці стилів, підключеній після @reelkit/react-stories-player/styles.css. Для змін кольору, розміру та z-index краще беріть власні властивості CSS, описані в розділі Theming нижче.

ClassComponentОпис
.rk-stories-overlayOverlayФіксоване повноекранне тло (фон, z-index)
.rk-stories-swipe-wrapperOverlayОбгортка свайпу для закриття (містить кнопки навігації та canvas)
.rk-stories-containerOverlayЗаокруглене полотно історії (позиція, переповнення)
.rk-stories-ui-layerOverlayКонтейнер інтерфейсу (шапка, прогрес, навігація). З chromePlacement="group" — у кожному слайді групи
.rk-stories-ui-layer--hiddenOverlayСтан прихованого інтерфейсу (перемикається через hideUIOnPause)
.rk-stories-errorOverlayСтан помилки (значок і текст по центру)
.rk-stories-error-textOverlayТекст повідомлення про помилку
.rk-stories-nav-btnНавігаціяСтрілка вперед або назад на десктопі
.rk-stories-progress-barProgressBarОбгортка позиціювання смуги прогресу на canvas
.rk-stories-slide-wrapperGroupОдна група історій (зовнішній слайд)
.rk-stories-storyStoryОдна історія (корінь внутрішнього слайда)
.rk-stories-headerStoryHeaderСмуга шапки (аватар, ім’я, дії)
.rk-stories-header--hiddenStoryHeaderСтан прихованої шапки (visible=false)
.rk-stories-header-avatarStoryHeaderЗображення аватара автора
.rk-stories-header-nameStoryHeaderТекст імені автора
.rk-stories-header-verifiedStoryHeaderКонтейнер значка підтвердження
.rk-stories-header-timeStoryHeaderТекст «скільки часу тому»
.rk-stories-header-actionsStoryHeaderДії праворуч (закрити, вимкнути звук, пауза)
.rk-stories-header-btnStoryHeaderКнопка дії в шапці
.rk-stories-header-btn--desktopStoryHeaderКнопка паузи, видима лише на ширині понад 768px
.rk-stories-header-spinnerStoryHeaderІндикатор буферизації відео
.rk-stories-imageImageStorySlideЕлемент історії-зображення
.rk-stories-videoVideoStorySlideКонтейнер історії-відео
.rk-stories-video-elementVideoStorySlideСпільний елемент <video>
.rk-stories-video-posterVideoStorySlideПостер відео (зникає з початком відтворення)
.rk-stories-video-poster--visibleVideoStorySlideСтан видимого постера (до відтворення)
.rk-stories-heartHeartAnimationАнімація сердечка на подвійний дотик
.rk-stories-ringStoriesRingКільце історії (аватар з анімованою градієнтною рамкою)
.rk-stories-ring--activeStoriesRingКільце з непереглянутими історіями (анімується)
.rk-stories-ring-avatarStoriesRingЗображення аватара всередині кільця
.rk-stories-ring-listStoriesRingListКонтейнер горизонтального списку кілець
.rk-stories-ring-list-itemStoriesRingListКолонка з кільцем та іменем
.rk-stories-ring-list-nameStoriesRingListІм’я автора під кожним кільцем
.rk-stories-overlay--carouselOverlayОверлей із показаною десктопною каруселлю
.rk-stories-overlay--slidingOverlayТриває ковзання каруселі; полотно плеєра приховане
.rk-stories-carouselCarouselШар карток-прев’ю позаду плеєра
.rk-stories-carousel--instantCarouselПерший кадр ковзання, розкладений без переходу
.rk-stories-cardCarouselОдна розміщена картка-прев’ю
.rk-stories-card--centerCarouselКартка в центральному слоті під час ковзання
.rk-stories-card--hiddenCarouselКартка за межами двох, що видно з боку
.rk-stories-card-buttonCarouselСтандартний вміст картки (кнопка, що відкриває групу)
.rk-stories-card-imageCarouselКадр прев’ю (постер або зображення)
.rk-stories-card-frameCarouselЗменшене прев’ю renderSlide для історії без зображення чи постера; неінтерактивне
.rk-stories-card-scrimCarouselЗатемнення поверх кадру
.rk-stories-card-infoCarouselКолонка з кільцем, ім’ям автора й часом
.rk-stories-card-nameCarouselІм’я автора
.rk-stories-card-timeCarouselТекст «скільки часу тому»

Theming

Кожен колір, розмір, z-index і перехід живе у власній властивості CSS. Перевизначайте одну чи кілька на :root (або на будь-якому предку оверлея), щоб змінити тему, не чіпаючи код компонентів.

TokenТипове значенняControls
--rk-stories-overlay-bg#000Колір повноекранного фону
--rk-stories-overlay-z9999z-index оверлею
--rk-stories-swipe-gap16pxВідстань між кнопками навігації та полотном історії
--rk-stories-container-radius12pxЗаокруглені кути полотна історії (десктоп)
--rk-stories-container-radius-mobile0Кути полотна історії на ширині до 768px
--rk-stories-ui-z15z-index шару інтерфейсу (шапка, смуга прогресу)
--rk-stories-ui-transition200msТривалість згасання, коли перемикається hideUIOnPause
--rk-stories-top-shade-height120pxВисота верхнього градієнтного затемнення під шапкою
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)Колір верхнього градієнтного затемнення
--rk-stories-error-z5z-index стану помилки
--rk-stories-error-bglinear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)Градієнт фону в стані помилки
--rk-stories-error-fgrgba(255, 255, 255, 0.5)Колір іконки й тексту помилки
--rk-stories-error-gap12pxВідстань між іконкою помилки та її текстом
--rk-stories-error-text-size13pxРозмір шрифту повідомлення про помилку
--rk-stories-nav-z20z-index кнопок навігації на десктопі
--rk-stories-nav-size44pxРозмір кнопок «назад» і «далі» на десктопі
--rk-stories-nav-bgrgba(255, 255, 255, 0.1)Фон кнопки навігації на десктопі
--rk-stories-nav-bg-hoverrgba(255, 255, 255, 0.2)Фон кнопки навігації на десктопі під час наведення
--rk-stories-nav-fgrgba(255, 255, 255, 0.7)Колір іконки кнопки навігації на десктопі
--rk-stories-nav-fg-hover#fffКолір іконки кнопки навігації на десктопі під час наведення
--rk-stories-nav-transition150msПерехід фону й кольору кнопки навігації
--rk-stories-video-bg#000Фон смуг навколо <video>
--rk-stories-video-poster-transition200msТривалість згасання постера, коли починається відтворення відео
--rk-stories-progress-bar-z10z-index смуги прогресу
--rk-stories-progress-bar-padding8px 8px 0Відступ навколо смуги прогресу
--rk-stories-header-z5z-index шапки
--rk-stories-header-top18pxВертикальний відступ шапки від верху історії
--rk-stories-header-padding12px 16pxВнутрішній відступ рядка шапки
--rk-stories-header-gap8pxВідстань між аватаром, іменем і часом
--rk-stories-header-transition200msЗгасання шапки, коли її приховано
--rk-stories-header-avatar-size32pxШирина й висота аватара
--rk-stories-header-name-fg#fffКолір імені автора
--rk-stories-header-name-size14pxРозмір шрифту імені автора
--rk-stories-header-name-weight600Товщина шрифту імені автора
--rk-stories-header-time-fgrgba(255, 255, 255, 0.6)Колір тексту часу публікації
--rk-stories-header-time-size12pxРозмір шрифту часу публікації
--rk-stories-header-actions-gap8pxВідстань між кнопками дій у шапці
--rk-stories-header-btn-fg#fffКолір іконок дій у шапці (закриття, звук, пауза)
--rk-stories-header-btn-padding4pxВнутрішній відступ кнопки дії в шапці
--rk-stories-header-spinner-size20pxРозмір індикатора буферизації
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)Колір доріжки індикатора буферизації
--rk-stories-header-spinner-fg#fffКолір дуги індикатора буферизації
--rk-stories-header-spinner-duration0.8sОдин оберт індикатора буферизації
--rk-stories-heart-z20z-index сердечка на подвійний дотик
--rk-stories-heart-duration800msТривалість анімації появи та згасання
--rk-stories-ring-spin-duration4sТривалість обертання градієнта активного кільця
--rk-stories-ring-active-scale0.95Масштаб кільця під час натискання
--rk-stories-ring-active-gradientconic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433)Що показує кільце, коли лишилися історії: будь-яке CSS-зображення чи колір. На світлій сторінці задавайте його на .rk-stories-ring-list, а не на :root, щоб карусель плеєра зберегла свій
--rk-stories-ring-viewed-colorrgba(255, 255, 255, 0.25)Що показує повністю переглянуте кільце; розраховано на темну сторінку. На світлій задавайте його на .rk-stories-ring-list, а не на :root
--rk-stories-ring-gradientnoneВнутрішній: кільце записує його під час кожного рендеру й посилається на два токени вище, якщо кільцю не передали gradientColors чи viewedColor
--rk-stories-ring-list-gap12pxВідстань між кільцями в списку
--rk-stories-ring-list-padding12pxВнутрішній відступ навколо списку кілець
--rk-stories-ring-list-item-gap4pxВідстань між кільцем та іменем під ним
--rk-stories-ring-list-name-size12pxРозмір шрифту імені автора під кожним кільцем
--rk-stories-card-bg#262626Фон картки каруселі, видимий, коли кадру прев’ю немає
--rk-stories-card-radius8pxРадіус кутів картки каруселі
--rk-stories-card-scrimrgba(0, 0, 0, 0.45)Затемнення бічної картки
--rk-stories-card-fg#fffКолір тексту картки
--rk-stories-card-name-size14pxРозмір шрифту імені автора на картці
--rk-stories-card-time-fgrgba(255, 255, 255, 0.7)Колір тексту часу на картці
--rk-stories-card-time-size13pxРозмір шрифту часу на картці
--rk-stories-card-gap6pxВідступ між кільцем, ім’ям і часом на картці
--rk-stories-card-transition300msТривалість ковзання каруселі

Вставте фрагмент нижче в таблицю стилів, підключену після @reelkit/react-stories-player/styles.css.

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

/* Rings on a light page. Set on the ring list, not :root, so the rings of the
   player's own carousel keep the colours made for its dark backdrop. */
.rk-stories-ring-list {
  --rk-stories-ring-viewed-color: #d4d4d8;
}

Accessibility

Корінь оверлея — модальний діалог (role="dialog", aria-modal="true"). Set ariaLabel щоб змінити оголошення для екранного читача; типове значення — «Stories player».

Оверлей захоплює фокус під час відкриття й повертає його на елемент-тригер після закриття. Tab і Shift+Tab циклічно проходять фокусовані елементи всередині; фокус, що вислизнув (клік поза оверлеєм, програмна установка), повертається назад. Реалізовано через captureFocusForReturn та createFocusTrap з @reelkit/core, які реекспортує @reelkit/react.

У десктопній каруселі кожна стандартна картка — кнопка з підписом «Open stories by» та ім’ям автора. Картки йдуть у порядку Tab після елементів керування плеєра і випадають із нього, поки триває ковзання.

Рендеринг на сервері

Закритий оверлей не малює нічого й монтується порталом у document.body лише після відкриття, а це відбувається на клієнті. StoriesUrlOverlay читає адресний рядок в ефекті після монтування, тож серверний рендер ніколи його не відкриває. StoriesRingList малює на сервері всі кільця непереглянутими: сховище переглянутого читають лише після монтування, тож серверна розмітка збігається з першим клієнтським рендером. Ширший контекст — у посібнику з SSR.

Клавіатурні скорочення

KeyAction
ArrowLeftПопередня історія. На першій історії групи — попередня група, на тій історії, де її залишили (інакше на історії для продовження); на першій групі нічого не відбувається
ArrowRightНаступна історія. Після останньої історії групи — наступна група; після останньої групи плеєр закривається
EscapeЗакрити плеєр