Stories Player

@reelkit/react-stories-player を使った、React 用の Instagram 風のストーリーのオーバーレイです。

ライブデモを見る →

機能

ネストしたナビゲーション
タップでストーリーを送り、スワイプでグループを切り替え
動画のストーリー
サウンド切り替え付きの自動再生
自動送り
ストーリーごとに設定できるタイマー
3D トランジション
キューブ、フリップ、フェード、ズーム、スライド
進捗バー
canvas で描く分割された進捗
画像と動画
両方のメディアに対応
仮想化
DOM にはスライド 3 枚だけ
ダブルタップでいいね
ダブルタップでハートのアニメーション
デスクトップの操作
デスクトップでは矢印ボタン
ストーリーのリング
Instagram 風のアバターのリング
ジェネリック型
独自のデータで StoryItem を拡張
レンダープロップ
あらゆる UI 要素をカスタマイズ
URL の状態
共有できる ?story=group.story のリンク
既読の状態
既読のリングと続きからの再開がリロード後も残ります

インストール

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

スタイルのインポートを忘れないでください:

typescript
import '@reelkit/react-stories-player/styles.css';
アイコン

デフォルトのヘッダーはアイコンに lucide-react を使います。別のアイコンライブラリを使いたい場合は、renderHeader と renderNavigation で独自のものを渡してください。

クイックスタート

StoriesOverlay コンポーネントはフルスクリーンのストーリーのプレイヤーを描画します。Instagram 風の入り口として StoriesRingList と組み合わせてください。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 は、開閉状態をアドレスバーに持つ別のコンポーネントです。2 つの軸が 1 つのパラメーター(?story=<group>.<story>)に乗るので、再生中のストーリーに、共有でき、ブックマークでき、戻るボタンで閉じられるリンクが付きます。useOverlayUrlState と urlIndexTwoAxisKey でコントローラーを作り、controller として渡します。

組み込みのキー

ストーリーは二軸なので、二軸のキーをコントローラーに展開します。urlIndexTwoAxisKey(グループとストーリーを位置で指す)か urlStableIdTwoAxisKey(グループを安定した id で指す)で、どちらも @reelkit/react から再エクスポートされています。URL の状態のガイド と Core 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} />
  • 開くと履歴エントリーを 1 つ 積みます。ストーリーのスワイプ も ユーザーの切り替えもそれを 置き換え るので、N 回移動してもエントリーは増えず、1 回戻れば必ずプレイヤーが閉じます。戻るとプレイヤーが閉じ、ストーリーは戻りません。
  • 内側の移動も運ばれます。 ストーリーのインデックスはグループ単位で固定されず、ユーザーのストーリー内で進むと ?story=2.n が更新されるので、ディープリンクは正確なストーリーに着きます。
  • 戻るで閉じるのは、アプリ内から開いた場合だけです(リンクがエントリーを積んだ場合)。新しいタブで直接開いた共有リンクには前の履歴がないので、ブラウザーの戻るではサイトを離れます。✕ ボタンか Escape なら、その場でパラメーターを取り除いてページにとどまります。
  • どのグループやストーリーも指さないパラメーター(古いブックマーク、手で書き換えた値、グループの末尾を超えたストーリー)は、隣を開くのではなく URL から取り除かれます。

ルーターを使うアプリでは、アダプターを渡します。 ルーターの裏で 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,
  }),
});

無限フィード。 ページングは codec とは関係なく、outerLocator の役目です。locate は同期なので、読み込み済みのグループにしか答えません。20 件を読み込んだフィードの 400 番目のグループへの共有リンクは見つかりません。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;
      },
    },
  }),
});
  • locateAsync が保留中のあいだ、プレイヤーは閉じたままで、パラメーターはそのまま残るので、ディープリンクは取得のあいだも失われません。null か reject でパラメーターは取り除かれます。
  • URL が変わったあと、閉じたあと、アンマウントしたあとに届いた答えは破棄されます。遅い取得が、誰も求めていないストーリーを開くことはありません。
  • useOverlayUrlState のすべてのオプションは React API リファレンス に、手順は React ガイド にあります。

見たところを覚えておく

リングは、グループを最後まで見るまでグラデーションを表示し、グループは閲覧者がまだ見ていない最初のストーリーから開きます。どちらも 1 つの 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}
      />
    </>
  );
}
  • 最初のストーリーも数えます。 画面にすでに表示されているストーリーはマウント時に既読として報告されるので、ストーリーが 1 つだけのグループは開いて閉じれば見たことになります。
  • スワイプで進んでも続きから再開します。 resumeStoryIndex は、このセッションで初めて到達したすべてのグループ(プレイヤーが開いたグループも含む)で参照されます。ただし initialStoryIndex がストーリーを直接指定している場合は別です。すでにスワイプで通過したグループは、離れたところから再び開きます。
  • リンクが優先されます。 StoriesUrlOverlay では、何が保存されていても、プレイヤーがどこで開くかはパラメーターが決めます。それ以外では再開のコールバックが決めます。
  • 件数は合計ではなく位置です。 エントリーが表すのは到達したいちばん先のストーリーなので、見終えたグループにストーリーを追加するとリングがまた点灯し、途中から削除すると件数が減ります。共有リンクと同じ自己修復です。
  • ストレージは差し替えられます。 タブを閉じたら忘れるには storage: createSessionStorageAdapter() を、ほかには独自の StorageAdapter を渡してください。開いている 2 つのタブは、ブラウザーの storage イベントで同期します。
  • 開く瞬間にだけマウントされるプレイヤーは、読み取りが遅すぎます。 プレイヤーは、まだ閉じている間に動くエフェクトでストアを読み取ります。{open && <StoriesOverlay … />} ではその機会がないので、コントローラーを持つコンポーネントで useAttachViewedState(viewed) を呼んでください。マウント時に接続し、アンマウント時に手放します。オプションと、土台になっている低レベルの部品は Stories Core のページにあります。

desktopLayout="carousel" を指定すると、プレーヤーが Instagram のウェブ版のようなレイアウトになります。アクティブなストーリーはいつもの大きさのまま中央に置かれ、その左右に隣のグループが最大 2 つずつ、小さく暗めのカードとして並びます。カードをクリックするとそのグループが開き、グループが切り替わる間カードが横へスライドします。リング一覧に渡すのと同じ 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 までならカードは描画されず、スワイプにはデフォルトの 'single' レイアウトと同じく groupTransition が使われます。ウィンドウの幅がこの境目をまたぐと、その場でレイアウトが切り替わります。
  • カードにはグループが開く位置が表示されます:このセッションで離れたときのストーリー、なければ resumeStoryIndex が返すストーリーです。動画のポスターか画像そのものを表示します。どちらもないストーリー(グラデーション上のテキストのストーリーなど)は、renderSlide がプレーヤーの大きさで描き、それを縮小して表示します。ポスターのない動画ではカードの背景が見えます。このプレビューは renderSlide を isActive: false で呼び出す、ただの画像です。中の要素にフォーカスもクリックも届きません。再生やアナリティクスなど、スライドがマウント時に行う処理は isActive のときだけ実行してください。
  • タイマーはスライドを待ちます。 カードの動きが止まるまで、ストーリーは進まず、そのストーリーの onStoryViewed も呼ばれません。
  • グループを切り替えるどの方法でもスライドします:カードのクリック、StoriesApi.goToGroup、nextGroup と prevGroup、そしてグループの最後を越える矢印キーです。タッチのスワイプはプレーヤー自体を動かすので、その上にスライドは重なりません。

グループごとの進捗バーとヘッダー

デフォルトでは、進捗バーとヘッダーが 1 つずつプレーヤーの上にあり、グループが切り替わった後で新しいグループの表示に変わります。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型デフォルト説明
isOpenboolean必須オーバーレイの表示を制御します。true のとき body のスクロールが止まります。
groupsStoriesGroup<T>[]必須表示するストーリーのグループの配列
onClose() => void必須オーバーレイを閉じるコールバック
ariaLabelstring'Stories player'ダイアログ領域のアクセシブルなラベル。オーバーレイが開くとスクリーンリーダーが読み上げます
initialGroupIndexnumber0最初に表示するグループの 0 始まりのインデックス
initialStoryIndexnumberresumeStoryIndex(initialGroupIndex)、なければ 0グループ内で最初に表示するストーリーの 0 始まりのインデックス。指定すると覚えている位置より優先されます。省略すると、開いたグループもほかのグループと同じように続きから始まります。
resumeStoryIndex(groupIndex: number) => number—グループに初めて到達したときに開くストーリー。閲覧者が前回の続きから見られます。今回開いてからまだ訪れていないグループでだけ参照されます。
groupTransitionTransitionTransformFncubeTransition外側(グループ)のスライダーのトランジション効果
defaultImageDurationnumber5000画像のストーリーを自動で送るまでのデフォルトの時間(ミリ秒)
tapZoneSplitnumber0.3タップゾーンの分割比(0–1)。左の部分で前へ、右で次へ移動します。
hideUIOnPausebooleantrue長押しによる一時停止の間、プログレスバーとヘッダーを隠します
enableKeyboardbooleantrueキーボード操作(左右の矢印、Escape)を有効にします
innerTransitionDurationnumber200内側(ストーリー)のトランジションのアニメーションの長さ(ミリ秒)
minSegmentWidthnumber8進捗バーのセグメントの最小幅(ピクセル)
apiRefMutableRefObject<StoriesApi | null>-命令的な StoriesApi にアクセスするための ref
desktopLayout'single' | 'carousel''single'デスクトップでのレイアウト。'carousel' ではアクティブなストーリーの横に隣のグループをプレビューカードで表示し、グループ間をスライドで切り替えます。スマートフォンでは常にストーリーだけを表示します。型は DesktopLayout として公開されています。
chromePlacement'overlay' | 'group''overlay'進捗バーとヘッダーを置く場所。'overlay' はプレーヤーの上に 1 つ描き、'group' はグループのスライドごとに 1 つ描くので、両方がグループと一緒に回転します。型は 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-独自の読み込み UI のレンダラー。指定しなければヘッダーのデフォルトのスピナーを表示します。
renderError(props: ErrorRenderProps<T>) => ReactNode-独自のエラー UI のレンダラー。指定しなければデフォルトのエラーアイコンのオーバーレイを表示します。
renderGroupPreview(props: GroupPreviewRenderProps<T>) => ReactNode-デスクトップのカルーセル用の独自のカード。デフォルトのカードの中身を置き換えます。

StoriesUrlOverlayProps

StoriesUrlOverlayProps<T>

StoriesOverlay の props をすべて受け取りますが、開閉状態の 3 つ(isOpen、initialGroupIndex、initialStoryIndex)は代わりにコントローラーから与えられます。

Prop型デフォルト説明
controllerUrlStateController<TwoAxisPosition>必須urlIndexTwoAxisKey を展開した useOverlayUrlState のコントローラー。その位置({ 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プレイヤーが再開したときに呼ばれます

トランジション

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メディアが終わった(たとえば動画が最後まで再生された)ことを伝えます。次のストーリーへ進みます。
プリローダーのキャッシュ

組み込みの ImageStorySlide と VideoStorySlide コンポーネントは、次のストーリーをバックグラウンドで先読みします。先読み済みのストーリーへ移動すると、読み込みのスピナーなしで内容がすぐに表示されます。

レンダープロップ

UI 要素はすべてレンダープロップで置き換えられます。それぞれが、必要な状態とコールバックをすべて持つ型付きの 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 トークンで調整します(テーマ設定 を参照)。色、大きさ、またはハートを完全に隠すには、.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;
}

組み込みのハートのアニメーションは、今のところレンダープロップでは置き換えられません。CSS でスタイルを変えるか、display: none で隠して onDoubleTap コールバックの中で独自のアニメーションを扱ってください。renderDoubleTap のレンダープロップが必要なら、GitHub Issues で知らせてください。

サブコンポーネント

独自のレンダープロップで組み合わせるために公開されている、再利用できる部品です。それぞれの 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

iOS で音声を途切れさせないために共有の <video> 要素を使う動画スライドです。自動再生、ポスターのフレーム、サウンドの同期を処理し、長さと再生のライフサイクルのイベントを報告します。

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 風のグラデーションのリングが付いた円形のアバターです。状態は 2 つで、まだ見るストーリーが残っているグループは回転するグラデーションに、すべて見たグループは落ち着いた平坦なリングになります。

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 コンポーネントが横に並ぶ、スクロールできる行です。グループごとに 1 つのリングです。

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

ダブルタップで表示されるハートのアニメーションのオーバーレイです。800ms かけて拡大しながらフェードアウトします。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 が例外を投げます。

型

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 型

StoryItem を独自のフィールドで拡張し、型パラメーターを StoriesOverlay に渡します。すべてのレンダープロップが拡張した型を受け取ります:

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 Modules ではありません)なので、@reelkit/react-stories-player/styles.css のあとに読み込むスタイルシートで、詳細度の高いセレクターを使って指定できます。色、サイズ、z-index を変えるなら、下の テーマ設定 で説明している CSS カスタムプロパティを使ってください。

クラスコンポーネント説明
.rk-stories-overlayOverlay固定配置のフルスクリーンの背景(背景色、z-index)
.rk-stories-swipe-wrapperOverlayスワイプで閉じるためのラッパー(ナビゲーションボタンと canvas を含みます)
.rk-stories-containerOverlay角の丸いストーリーの表示領域(配置、はみ出し)
.rk-stories-ui-layerOverlayUI オーバーレイのコンテナー(ヘッダー、進捗、ナビゲーション)。chromePlacement="group" では各グループのスライドの中にあります
.rk-stories-ui-layer--hiddenOverlayUI を隠した状態(hideUIOnPause で切り替わります)
.rk-stories-errorOverlayエラー状態(中央のアイコンとテキスト)
.rk-stories-error-textOverlayエラーメッセージのテキスト
.rk-stories-nav-btnNavigationデスクトップの前後の矢印
.rk-stories-progress-barProgressBarcanvas の進捗バーを配置するラッパー
.rk-stories-slide-wrapperGroupストーリーの 1 グループ(外側のスライド)
.rk-stories-storyStory1 つのストーリー(内側のスライドのルート)
.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配置済みのプレビューカード 1 枚
.rk-stories-card--centerCarouselスライド中に中央の位置にあるカード
.rk-stories-card--hiddenCarousel片側に表示する 2 枚より外側のカード
.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経過時間のテキスト

テーマ設定

色、サイズ、z-index、トランジションはすべて CSS カスタムプロパティにあります。:root(またはオーバーレイの任意の祖先)で 1 つでも複数でも上書きすれば、コンポーネントのソースに触れずにテーマを変えられます。

トークンデフォルト制御するもの
--rk-stories-overlay-bg#000フルスクリーンの背景色
--rk-stories-overlay-z9999オーバーレイの z-index
--rk-stories-swipe-gap16pxナビゲーションボタンとストーリーの表示領域の間隔
--rk-stories-container-radius12pxストーリーの表示領域の角丸(デスクトップ)
--rk-stories-container-radius-mobile0幅 768px までのストーリーの表示領域の角
--rk-stories-ui-z15UI レイヤー(ヘッダー、進捗バー)の z-index
--rk-stories-ui-transition200mshideUIOnPause が切り替わるときのフェードの長さ
--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-z5エラー状態の z-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-z20デスクトップのナビゲーションボタンの z-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-z10進捗バーの z-index
--rk-stories-progress-bar-padding8px 8px 0進捗バーの周りの余白
--rk-stories-header-z5ヘッダーの z-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読み込み中のスピナーが 1 回転する長さ
--rk-stories-heart-z20ダブルタップのハートの z-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 の画像か色を指定できます。明るいページでは :root ではなく .rk-stories-ring-list に設定してください。プレーヤーのカルーセルの色が保たれます
--rk-stories-ring-viewed-colorrgba(255, 255, 255, 0.25)すべて見終えたリングの表示で、暗いページ向けの値です。明るいページでは :root ではなく .rk-stories-ring-list に設定してください
--rk-stories-ring-gradientnone内部用:リングが描画のたびに書き込み、リングに gradientColors や viewedColor が渡されない限り上の 2 つのトークンを参照します
--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;
}

アクセシビリティ

オーバーレイのルートはモーダルダイアログ(role="dialog"、aria-modal="true")です。ariaLabel を指定すると、スクリーンリーダーの読み上げが変わります。デフォルトは「Stories player」です。

オーバーレイは開くとフォーカスを取り込み、閉じるとトリガーに戻します。Tab と Shift+Tab は中のフォーカス可能な要素を循環し、外へ出たフォーカス(外側のクリック、プログラムからのフォーカス)は引き戻されます。@reelkit/core の captureFocusForReturn と createFocusTrap(@reelkit/react が再エクスポート)で実装されています。

デスクトップのカルーセルでは、デフォルトのカードはそれぞれ「Open stories by」と作成者名のラベルを持つボタンです。カードは Tab の順序でプレーヤーの操作ボタンのあとに来て、スライドの間はその順序から外れます。

サーバーサイドレンダリング

オーバーレイは閉じている間は何も描画せず、開いたときに初めて document.body にポータルします。これはクライアントで起こります。StoriesUrlOverlay はマウント後のエフェクトでアドレスバーを読むので、サーバーでの描画で開くことはありません。StoriesRingList はサーバーではすべてのリングを未視聴として描画します。視聴済みのストアはマウント後にしか読まれないため、サーバーのマークアップとクライアントの最初の描画が一致します。全体像は SSR ガイド を参照してください。

キーボードショートカット

キー動作
ArrowLeft前のストーリー。グループの最初のストーリーでは前のグループへ移り、離れたときのストーリー(なければ再開位置のストーリー)を開きます。最初のグループでは何もしません
ArrowRight次のストーリー。グループの最後のストーリーを過ぎると次のグループへ移り、最後のグループを過ぎるとプレイヤーが閉じます
Escapeプレイヤーを閉じる