Vue ストーリープレイヤー

@reelkit/vue-stories-player による、Vue 3 向けの Instagram 風ストーリープレイヤーのオーバーレイです。React 版と同じ @reelkit/stories-core エンジンを使うので、挙動も CSS クラスもテーマトークンも同じです。

ライブデモを見る →

Features

入れ子のナビゲーション
タップでストーリー、スワイプでユーザー
動画ストーリー
自動再生とサウンド切り替え
自動送り
ストーリーごとに設定できるタイマー
3D 転換
キューブ、フリップ、フェード、ズーム、スライド
プログレスバー
canvas による分割プログレス
画像と動画
どちらのメディアにも対応
仮想化
DOM 上は常に 3 スライドだけ
ダブルタップでいいね
ダブルタップでハートのアニメーション
デスクトップの操作
矢印ボタン
ストーリーリング
Instagram 風のアバターリング
ジェネリック型
StoryItem を独自のフィールドで拡張
スコープ付きスロット
UI のどの部分も差し替え可能
URL の状態
共有できる ?story=group.story リンク
既読状態
リングと再開位置がリロードをまたいで残る

インストール

bash
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-next

アプリのエントリーポイント(またはどこかのコンポーネント)で、スタイルシートを一度だけ読み込みます。

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

既定のヘッダーとナビゲーションの矢印は lucide-vue-next のアイコンを使います。別のアイコンライブラリを使いたい場合は、#header と #navigation のスコープ付きスロットで差し替えてください。

Vue 3.3 以降

オーバーレイはジェネリックなコンポーネントで、Vue は 3.3 からこれをサポートしています。古い Vue ではランタイムが props とイベントを捨ててしまうため、このパッケージは peer dependency として vue 3.3 以降を必要とします。

クイックスタート

StoriesOverlay が全画面のプレイヤーを描画します。Instagram 風の入口として StoriesRingList と組み合わせ、StoriesGroup の配列を渡して v-model:is-open をバインドしてください。

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import '@reelkit/vue-stories-player/styles.css';

const groups: StoriesGroup[] = [
  {
    author: { id: 'alice', name: 'Alice', avatar: '/alice.jpg', verified: true },
    stories: [
      { id: 's1', mediaType: 'image', src: '/story-1.jpg' },
      { id: 's2', mediaType: 'video', src: '/story-2.mp4', poster: '/poster-2.jpg' },
    ],
  },
  {
    author: { id: 'bob', name: 'Bob', avatar: '/bob.jpg' },
    stories: [{ id: 's3', mediaType: 'image', src: '/story-3.jpg' }],
  },
];

const isOpen = ref(false);
const groupIndex = ref(0);

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <StoriesRingList :groups="groups" @select="openGroup" />

  <!-- v-model:is-open keeps the ref in sync however the viewer closes it:
       the ✕ button, a swipe down, Escape, or the last story finishing. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :initial-group-index="groupIndex"
  />
</template>

URL の状態

ライブデモを見る →

StoriesUrlOverlay は、開いているかどうかをアドレスバーに置く別コンポーネントです。2 つの軸が 1 つのパラメーター ?story=<group>.<story> に乗るので、再生中のストーリーには共有・ブックマークでき、戻るボタンで閉じられるリンクが付きます。useOverlayUrlState と urlIndexTwoAxisKey でコントローラーを作り、controller として渡してください。

組み込みのキー

ストーリーは 2 軸なので、2 軸のキーをコントローラーに展開します。urlIndexTwoAxisKey(ユーザーとストーリーを位置で指定)か urlStableIdTwoAxisKey(ユーザーを安定した id で指定)のどちらかで、いずれも @reelkit/vue-stories-player から再エクスポートされています。URL 状態ガイドと Core API も参照してください。

vue
<script setup lang="ts">
import {
  StoriesUrlOverlay,
  useOverlayUrlState,
  urlIndexTwoAxisKey,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';
import { useVueRouterUrlAdapter } from '@reelkit/vue/vue-router-url-adapter';
import '@reelkit/vue-stories-player/styles.css';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The outer axis is the group, the inner one the story inside it.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => props.groups.length,
    innerCounts: () => props.groups.map((group) => group.stories.length),
  }),
});
</script>

<template>
  <!-- Opening is a link — the overlay reads the URL and opens itself. -->
  <RouterLink
    v-for="(group, index) in props.groups"
    :key="group.author.id"
    :to="`?story=${index}.0`"
  >
    {{ group.author.name }}
  </RouterLink>

  <StoriesUrlOverlay :controller="stories" :groups="props.groups" />
</template>
  • 開くと履歴が 1 件 積まれます。ストーリー間の移動もユーザーの切り替えも、その 1 件を 置き換える だけなので、何回操作しても履歴は増えず、戻る 1 回で必ずプレイヤーが閉じます。戻るは閉じる操作であって、ストーリーを 1 つ戻す操作ではありません。
  • 内側の移動も URL に乗ります。 同じユーザーのストーリーを進めると ?story=2.n が更新されるので、ディープリンクはそのストーリーちょうどに着きます。
  • 戻るで閉じるのは、アプリ内からプレイヤーを開いたときだけです。そのリンクが履歴を 1 件積んでいるからです。新しいタブで直接開いた共有リンクには背後に履歴がないので、ブラウザーの戻るはサイトの外へ出ます。✕ ボタンや Escape はその場でパラメーターを消し、ページにとどまります。
  • ユーザーもストーリーも指していないパラメーター(古いブックマーク、手で書き換えた値、グループの終わりを超えた番号)は、隣を開くのではなく URL から取り除かれます。
  • ルーターを使うアプリは、ルーター側のアダプターを渡してください。ナビゲーションの正となる場所をルーターに一本化するためです。@reelkit/vue/vue-router-url-adapter の useVueRouterUrlAdapter が Vue Router 用の既製アダプターです。ルーターの背後で履歴を書くと location が古いままになり、次のナビゲーションでパラメーターが落ちます。

安定したリンク。 ユーザーは既定では位置で指定されるため、ブックマークした ?story=2.0 はフィードの並びが変わると別の人を開きます。代わりに安定した id で指定してください。outerCodec が id を URL に書き、outerLocator がその位置を見つけます。innerCodec を innerLocate・innerIdentify とともに渡せば、グループ内のストーリーにも同じことができます。渡さなければ、ストーリー側は解決されたグループ内の普通のインデックスのままです。ストーリーの id には、2 つの部分の区切りである . を含めてはいけません。

typescript
// `?story=alice.0` — the group by the author's stable id, the story by its
// position inside whichever group that id resolves to.
const stories = useOverlayUrlState({
  param: 'story',
  adapter: useVueRouterUrlAdapter(),
  ...urlIndexTwoAxisKey({
    outerCount: () => groups.value.length,
    innerCounts: () => groups.value.map((group) => group.stories.length),
    outerCodec: { decode: (raw) => raw, encode: (id) => id },
    outerLocator: {
      locate: (id) =>
        groups.value.findIndex((group) => group.author.id === id),
      identify: (index) => groups.value[index].author.id,
    },
  }),
});

無限フィード。 ページングは codec とは独立した outerLocator の仕事です。locate は同期なので、すでに読み込まれたグループにしか答えられません。20 件しか読み込んでいないフィードで 400 番目のグループを指す共有リンクは空振りします。locateAsync はその控えで、locate が外したときだけ呼ばれます。ストーリーはその後、落ち着いた先のグループに合わせて範囲が取り直されます。

同じ locateAsync、外側の軸

これは 1 軸のキーが受け取るのと同じ locateAsync ページャーです。2 軸のキーでは渡した outerLocator に乗るので、ページングはユーザーの軸で起こり、ストーリーは解決されたグループ内のローカルなインデックスのままです。

typescript
// A feed that pages. `locate` answers for the groups already loaded;
// `locateAsync` runs only when it misses, so a shared link to a group past
// the window fetches up to it and then opens.
outerLocator: {
  locate: (index) => (index < loaded.value.length ? index : null),
  identify: (index) => index,
  locateAsync: async (index) => {
    if (index >= totalGroups) return null;
    loaded.value = await fetchGroupsUpTo(index);
    return index;
  },
},
  • locateAsync が待機している間、プレイヤーは閉じたままでパラメーターもそのままなので、ディープリンクは取得をまたいで生き残ります。null や拒否ならパラメーターは取り除かれます。
  • URL が変わったあと、閉じたあと、アンマウントしたあとに届いた答えは破棄されます。遅い取得が、もう誰も求めていないストーリーを開くことはありません。
  • useOverlayUrlState の全オプションは Vue API リファレンスにあります。

どこまで見たかを覚える

リングは、そのユーザーを最後まで見るまでグラデーションのままで、グループはまだ見ていない最初のストーリーから開き直します。どちらも 1 つの createStoriesViewedStateController から来ます。フィードのある場所で一度だけ作り、リング一覧とプレイヤーに viewed として渡せば、ストレージの読み取りも追従も、表示したストーリーの記録も自分でやります。保存される項目は作者とストーリーを id で記録するので、フィードの並べ替えにも耐え、同じコントローラーが React と Angular でも使えます。

vue
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import {
  StoriesOverlay,
  StoriesRingList,
  createStoriesViewedStateController,
  type StoriesGroup,
} from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

const isOpen = ref(false);
const groupIndex = shallowRef(0);

// One controller for the rings and the player. It reads the groups through
// the getter every time it needs them, so a feed that pages in more groups
// is counted too.
const viewed = createStoriesViewedStateController({
  storageKey: 'stories-seen',
  groups: () => props.groups,
});

const openGroup = (index: number) => {
  groupIndex.value = index;
  isOpen.value = true;
};
</script>

<template>
  <!-- The rings follow the controller by themselves. -->
  <StoriesRingList
    :groups="props.groups"
    :viewed="viewed"
    @select="openGroup"
  />

  <!-- The player resumes, records, and draws its card rings from it. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    :initial-group-index="groupIndex"
    :viewed="viewed"
  />
</template>
  • 最初に開いたストーリーも数に入ります。 すでに画面に出ているストーリーは、プレイヤーがマウントした時点で既読として報告されます。ストーリーが 1 つだけのグループを開いて閉じれば、それで見終わった扱いです。
  • 先へ進んだ分も再開します。 resumeStoryIndex は、このセッションで初めて到達したすべてのグループに対して呼ばれます。プレイヤーが最初に開くグループも含みます(initialStoryIndex でストーリーを明示した場合を除く)。すでに見送ったグループは、離れた場所から開き直します。
  • リンクのほうが強い。 StoriesUrlOverlay では、何が保存されていてもパラメーターが開く位置を決めます。それ以外の場面では再開コールバックが決めます。
  • カウントは合計ではなく位置です。 項目は到達した最も先のストーリーを指すので、見終わったグループにストーリーを足せばリングはまた点灯し、途中の 1 つを削ればカウントは縮みます。
  • ストレージは差し替えられます。 タブを閉じたら忘れるなら storage: createSessionStorageAdapter() を、独自の保存先なら自作の StorageAdapter を渡してください。開いている 2 つのタブは、ブラウザーの storage イベントで足並みをそろえます。
  • 開くときだけマウントするプレイヤーは読むのが遅すぎます。 オーバーレイは、まだ閉じた状態のマウント後にストレージを読みます。v-if="open" ではその瞬間がないので、コントローラーを持つコンポーネントで useAttachViewedState(() => viewed) を呼んでください。マウント後に接続し、アンマウント時に手放します。オプションと、これを組み立てている低レベルの部品は Stories Core のページにあります。

desktop-layout="carousel" を指定すると、Instagram のウェブ版のような配置になります。表示中のストーリーはいつもの大きさで中央に残り、その両側に最大 2 つずつ、隣のユーザーが小さく暗いカードとして並びます。カードをクリックするとそのユーザーが開き、グループが変わる間はカードがスライドします。リング一覧に渡したのと同じ viewed コントローラーを渡せば、見終わったグループのカードのリングは暗くなります。カードは自分でコントローラーを追うので、ストーリーが既読になったときに描き直されるのはカードだけで、プレイヤーは再描画されません。

vue
<template>
  <!-- Neighbouring groups sit beside the active story as dimmed cards on a
       desktop screen. Phones keep the plain player whatever this says. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  />
</template>
  • スマートフォンでは普通のプレイヤーのままです。 幅 768px 以下ではカードは描画されず、スワイプは既定の 'single' レイアウトと同じく group-transition を使います。その幅をまたいでリサイズすると、その場で切り替わります。
  • 各カードはグループが開く場所を映します。 このセッションで離れたストーリー、なければ resumeStoryIndex が返すストーリーです。動画のポスターか画像を表示します。そのどちらも持たないストーリー(グラデーション上のテキストストーリーなど)は、あなたの #slide スロットがプレイヤーの大きさで描いたものを縮小して使います。ポスターのない動画はカードの背景を見せます。このプレビューには isActive: false が渡り、ただの絵として扱われます。フォーカスもクリックもできません。再生や計測など、スライドがマウント時に行う処理は isActive の内側に置いてください。
  • タイマーはスライドを待ちます。 カードが動き終わるまで、ストーリーは進まず、story-viewed もそのストーリーについては発行されません。
  • グループを変えるどの操作でもスライドします。 カードのクリック、StoriesApi.goToGroup、nextGroup と prevGroup、グループの端を越える矢印キーです。タッチのスワイプはプレイヤー自体を動かすので、その上にスライドは重なりません。

グループごとのプログレスバーとヘッダー

デフォルトでは、プログレスバーとヘッダーが 1 つずつプレーヤーの上にあり、グループが切り替わった後で新しいグループの表示に変わります。chrome-placement="group" を指定すると、各グループが自分のスライドの中に専用のプログレスバーとヘッダーを持ち、Instagram と同じようにグループと一緒に回転します。

vue
<template>
  <!-- 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
    v-model:is-open="isOpen"
    :groups="groups"
    chrome-placement="group"
  />
</template>
  • 隣のグループは、どこまで見たかを表示します。 プレーヤーがそのグループへ回転している間、プログレスバーとヘッダーは、そのグループが開くストーリーを、まだ何も再生していない状態で表示します。
  • 離れていくグループはそのままの状態を保ちます。 回転が終わるまでプログレスバーはそれまでの進み具合を保つので、最後まで見たグループは回転の途中で空にならず、満たされたままです。
  • スロットは画面上のグループそれぞれに対して描かれます。 #progressBar と #header のスコープには groupIndex と isActive が含まれます。隣のグループでは isActive が false になり、進捗のシグナルは変化しません。
  • #header スロットはスワイプ領域の中にあります。 ヘッダーをタップするとストーリーが切り替わります。ただし button、リンク、role="button" を持つ要素の上なら切り替わらないので、ヘッダー内の操作要素はすべてそのいずれかにしてください。
  • デスクトップのカルーセルでは、スライド中の見た目は変わりません。 カードがスライドしている間はプレーヤーが隠れるため、この設定はグループが開いてから反映されます。

API リファレンス

StoriesOverlay のプロパティ

StoriesOverlayProps<T>

Prop型既定値説明
aria-labelstring'Stories player'ダイアログ領域のアクセシブルな名前。オーバーレイが開くとスクリーンリーダーが読み上げます
chrome-placement'overlay' | 'group''overlay'プログレスバーとヘッダーを置く場所。'overlay' はプレーヤーの上に 1 つ描き、'group' はグループのスライドごとに 1 つ描くので、両方がグループと一緒に回転します。型は ChromePlacement として公開されています。
default-image-durationnumber5000画像ストーリーの自動送り時間(ミリ秒)
desktop-layout'single' | 'carousel''single'デスクトップでのレイアウト。'carousel' は隣のグループを表示中のストーリーの横にカードとして並べ、グループ間をスライドします。スマートフォンでは常にストーリーだけを表示します。型は DesktopLayout として公開されています。
enable-keyboardbooleantrueキーボード操作(左右の矢印、Escape)を有効にします
groupsStoriesGroup<T>[]required表示するストーリーグループ。プレイヤーを開いたままグループやストーリーを増やしても、配列を差し替えた場合も同じ配列に追加した場合も反映されます。
group-transitionTransitionTransformFncubeTransition外側(グループ)スライダーの転換。デスクトップのカルーセル表示中は無視され、代わりにスライドします。
hide-ui-on-pausebooleantrue長押しによる一時停止の間、プログレスバーとヘッダーを隠します
initial-group-indexnumber0最初に表示するグループの番号(0 始まり)
initial-story-indexnumber再開位置、なければ 0最初に表示するストーリーの番号(0 始まり)。明示した値は記憶よりも優先されます。省略すると、そのグループも他と同じように再開します。
inner-transition-durationnumber200ストーリー間のクロスフェード時間(ミリ秒)
is-openbooleanrequired表示を制御し、開いている間はページのスクロールを固定します。v-model:is-open でバインドしてください。
min-segment-widthnumber8スライディングウィンドウに切り替わる前の、プログレス 1 区切りの最小幅(ピクセル)
resume-story-index(groupIndex: number) => number—グループに初めて到達したとき、どのストーリーから開くか。この表示中にまだ訪れていないグループにだけ問い合わせ、viewed よりも優先されます。
tap-zone-splitnumber0.3タップ領域の分割比(0–1)。左側が戻る、残りが進むです。
viewedStoriesViewedStateController—視聴者がすでに見た内容で、createStoriesViewedStateController() から得ます。グループは未読の最初のストーリーから再開し、表示したストーリーはすべて記録され、カルーセルのカードは見終わったグループのリングを暗くします。同じコントローラーを StoriesRingList にも渡してください。

StoriesUrlOverlay のプロパティ

StoriesUrlOverlayProps<T>

上記のプロパティをすべて受け取りますが、開閉位置の 3 つ(is-open、initial-group-index、initial-story-index)は例外で、これらはコントローラーから与えられます。

Prop型既定値説明
controllerUrlStateController<TwoAxisPosition>requiredurlIndexTwoAxisKey を展開した useOverlayUrlState のコントローラー。その position({ outer, inner } のオブジェクト)が、プレイヤーを開くかどうかとどこから開くかを決めます。オーバーレイは移動のたびと閉じるときに書き戻します。

イベント

イベントペイロード説明
@api-readyStoriesApiプレイヤーがマウントされた時点で、命令的な API とともに発行されます
@closevoidプレイヤーが閉じたときに発行されます。StoriesUrlOverlay では、この時点でパラメーターはすでに消えています。
@double-tapgroupIndex: number, storyIndex: numberダブルタップ時に、ハートのアニメーションと合わせて発行されます
@group-changegroupIndex: number表示中のグループが変わったあとに発行されます
@pausevoid長押し、またはヘッダーの一時停止ボタンで発行されます
@resumevoid指を離したとき、またはヘッダーの再生ボタンで発行されます
@story-changegroupIndex: number, storyIndex: number表示中のストーリーが変わったあとに発行されます
@story-completegroupIndex: number, storyIndex: numberストーリーのタイマーが切れたときに発行されます
@story-viewedgroupIndex: number, storyIndex: numberストーリーが表示されたときに発行されます。最初に開いたものも含みます
@update:is-openboolean閉じるときに発行され、`v-model:is-open` を可能にします

v-model:is-open

v-model:is-open を使えば、1 つのバインディングでオーバーレイを操作できます。イベントそのものが必要なら、:is-open と @close の組み合わせもそのまま使えます。

vue
<script setup lang="ts">
import { ref } from 'vue';
import { StoriesOverlay } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
</script>

<template>
  <button type="button" @click="isOpen = true">Open stories</button>

  <!-- One binding, in both directions. -->
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups" />

  <!-- The explicit pair still works when you need the event itself. -->
  <StoriesOverlay
    :is-open="isOpen"
    :groups="groups"
    @close="isOpen = false"
  />
</template>

スコープ付きスロット

8 つのスコープ付きスロットで、プレイヤーのどの部分も差し替えられます。それぞれ型の付いたスコープオブジェクトを受け取ります。渡さなかったスロットは既定のままで、何も描かなかったスロットも同じ扱いです。したがって false になった v-if は、穴を残すのではなく既定に戻ります。

スロットスコープ説明
#error{ story, storyIndex, groupIndex }表示中のストーリーの上に描かれる組み込みの「Content unavailable」パネルを差し替えます
#footer{ author, story, storyIndex }表示中のグループのストーリーの下にフッターを足します
#groupPreview{ group, groupIndex, story, offset, viewedCount, onOpen }デスクトップのカルーセルのカードの中身を描きます。配置・拡縮・スライドは引き続きプレイヤーが行います
#header{ author, story, storyIndex, isPaused, isMuted, isVideo, groupIndex, isActive, onToggleSound, onTogglePause, onClose }既定のヘッダー(作者、一時停止、サウンド、閉じる)を差し替えます。chrome-placement="group" ではスワイプ領域の中に入ります。グループごとのプログレスバーとヘッダーを参照してください。
#loading{ story, storyIndex, groupIndex }表示中のストーリーの読み込み中に出る、既定のヘッダーのスピナーを差し替えます
#navigation{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }デスクトップの左右の矢印をまとめて差し替えます
#progressBar{ totalStories, activeIndex, progress, group, groupIndex, isActive }canvas のプログレスバーを差し替えます。activeIndex と progress は core のシグナルなので、toVueRef で橋渡ししてください。
#slide{ story, index, groupIndex, isActive, size, activeGroupIndex, activeStoryIndex, onDurationReady, onReady, onWaiting, onError, onEnded }既定の画像・動画スライドを差し替えます
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Every slot is optional; one you leave out keeps the default, and so
         does one that renders nothing. -->
    <template #header="{ author, isPaused, onTogglePause, onClose }">
      <header class="my-header">
        <img :src="author.avatar" :alt="author.name" />
        <span>{{ author.name }}</span>
        <button type="button" @click="onTogglePause">
          {{ isPaused ? 'Play' : 'Pause' }}
        </button>
        <button type="button" @click="onClose">Close</button>
      </header>
    </template>

    <template #footer="{ author, story }">
      <form class="my-reply" @submit.prevent="reply(author, story)">
        <input v-model="draft" :placeholder="`Reply to ${author.name}…`" />
      </form>
    </template>
  </StoriesOverlay>
</template>

スライドをカスタマイズする

#slide の中で ImageStorySlide と VideoStorySlide を組み合わせれば、自動再生・ポスターの取得・サウンドの同期を保ったまま、自分のレイアウトを描けます。スコープのライフサイクルコールバックを呼んでください。呼ばないとタイマーは永遠に始まりません。

コールバック呼ぶタイミング
onReady画像を読み込んだ、または動画が再生を始めた。読み込み状態を解除してタイマーを開始します。
onWaiting再生の途中で動画がバッファリング中。スピナーを出してタイマーを止めます。
onErrorコンテンツの読み込みに失敗した。エラー表示にし、その URL は壊れたものとして覚えるので、開き直しても再試行しません。
onDurationReadyメディアの実際の長さをミリ秒で伝えます。タイマーが動画と同じだけ動くようになります。
onEndedメディアが終わった。次のストーリーへ進みます。
vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Compose ImageStorySlide and VideoStorySlide to keep autoplay, poster
         capture and sound sync while drawing your own layout around them.
         Pass the callbacks through, or the timer never starts. -->
    <template #slide="scope">
      <div class="my-slide">
        <VideoStorySlide
          v-if="scope.story.mediaType === 'video'"
          :src="scope.story.src"
          :poster="scope.story.poster"
          :group-index="scope.groupIndex"
          :story-index="scope.index"
          :active-group-index="scope.activeGroupIndex"
          :active-story-index="scope.activeStoryIndex"
          :on-duration-ready="scope.onDurationReady"
          :on-playing="scope.onReady"
          :on-waiting="scope.onWaiting"
          :on-ended="scope.onEnded"
          :on-error="scope.onError"
        />
        <ImageStorySlide
          v-else
          :src="scope.story.src"
          :on-load="scope.onReady"
          :on-error="scope.onError"
        />

        <!-- A carousel card draws this same slot with isActive: false, and
             nothing in it can be clicked. Keep playback or analytics behind
             the flag. -->
        <span v-if="scope.isActive" class="my-badge">{{ scope.story.id }}</span>
      </div>
    </template>
  </StoriesOverlay>
</template>
プリローダーのキャッシュ

組み込みの ImageStorySlide と VideoStorySlide は、次のストーリーをバックグラウンドで先読みします。先読み済みのストーリーに進むと、読み込み表示なしで即座に中身が出ます。一度失敗した URL も覚えているので、壊れたストーリーを開き直すと、再試行せずにすぐエラーを出します。

ナビゲーションをカスタマイズする

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Replaces both desktop arrows at once. The first node sits left of
         the story, the rest to its right. -->
    <template #navigation="{ onPrevStory, onNextStory, onPrevGroup, onNextGroup }">
      <div class="my-nav">
        <button type="button" @click="onPrevGroup">Previous user</button>
        <button type="button" @click="onPrevStory">Back</button>
      </div>
      <div class="my-nav">
        <button type="button" @click="onNextGroup">Next user</button>
        <button type="button" @click="onNextStory">Forward</button>
      </div>
    </template>
  </StoriesOverlay>
</template>

プログレスバーをカスタマイズする

スコープが運ぶのはただの数値ではなく core のシグナルなので、タイマーはプレイヤーを再描画せずにバーを動かせます。@reelkit/vue の toVueRef で橋渡ししてください。

vue
<script setup lang="ts">
import { toVueRef, type Signal } from '@reelkit/vue';

const props = defineProps<{
  totalStories: number;
  activeIndex: Signal<number>;
  progress: Signal<number>;
}>();

// Core signals do not re-render a Vue component on their own: bridge them.
const active = toVueRef(props.activeIndex);
const progress = toVueRef(props.progress);
</script>

<template>
  <div class="my-progress">
    <span
      v-for="index in totalStories"
      :key="index"
      class="my-segment"
      :style="{
        '--fill':
          index - 1 < active
            ? '100%'
            : index - 1 === active
              ? `${progress * 100}%`
              : '0%',
      }"
    />
  </div>
</template>

読み込みとエラーをカスタマイズする

vue
<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Both are drawn over the active story only. Without the loading slot
         the default is the header spinner; without the error slot it is the
         built-in "Content unavailable" panel. -->
    <template #loading="{ story }">
      <p class="my-loading">Loading {{ story.mediaType }}…</p>
    </template>

    <template #error="{ story, groupIndex }">
      <div class="my-error">
        <p>That {{ story.mediaType }} would not load.</p>
        <button type="button" @click="report(groupIndex, story)">Report</button>
      </div>
    </template>
  </StoriesOverlay>
</template>
vue
<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :viewed="viewed"
    desktop-layout="carousel"
  >
    <!-- The player still positions, scales and slides the card; this fills
         it. `offset` is negative on the left, positive on the right, and 0
         for the card passing through the center during a slide. -->
    <template #groupPreview="{ group, story, viewedCount, offset, onOpen }">
      <button type="button" class="my-card" @click="onOpen">
        <img v-if="story?.poster" :src="story.poster" alt="" />
        <span>{{ group.author.name }}</span>
        <small>{{ viewedCount }} / {{ group.stories.length }} seen</small>
        <em v-if="offset === 0">opening…</em>
      </button>
    </template>
  </StoriesOverlay>
</template>

転換

group-transition は、ユーザーを切り替えるときの 3D 効果を決めます。転換の関数は @reelkit/vue からインポートします。

vue
<script setup lang="ts">
import { StoriesOverlay } from '@reelkit/vue-stories-player';
import {
  cubeTransition,
  flipTransition,
  fadeTransition,
  zoomTransition,
  slideTransition,
} from '@reelkit/vue';
</script>

<template>
  <!-- cubeTransition is the default; flip, fade, zoom and slide ship too. -->
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="groups"
    :group-transition="flipTransition"
  />
</template>

StoriesApi

命令的に操作するには、オーバーレイにテンプレート参照を置くか、@api-ready イベントから API を受け取ります。プレイヤーが閉じている間の呼び出しは何もしません。

vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { StoriesOverlay, type StoriesApi } from '@reelkit/vue-stories-player';

const isOpen = ref(false);
const player = useTemplateRef<StoriesApi>('player');

// Calls while the player is closed are safely ignored.
const skipUser = () => player.value?.nextGroup();
</script>

<template>
  <button type="button" @click="skipUser">Skip this user</button>

  <StoriesOverlay
    ref="player"
    v-model:is-open="isOpen"
    :groups="groups"
    @api-ready="(api) => api.pause()"
  />
</template>

メソッド

メソッド型説明
nextStory()() => void現在のグループ内で次のストーリーへ進みます
prevStory()() => void現在のグループ内で前のストーリーへ戻ります
nextGroup()() => void次のユーザーのグループへ切り替えます
prevGroup()() => void前のユーザーのグループへ切り替えます
goToGroup(index)(index: number) => void番号で特定のグループへ移動します
pause()() => void自動送りとプログレスのタイマーを止めます
resume()() => void自動送りとプログレスのタイマーを再開します

ダブルタップといいね

ダブルタップでハートのアニメーションが再生され、その場で手応えが返ります。@double-tap イベントはグループとストーリーの番号とともに発行されるので、いいねは自分の状態として保存できます。プレイヤー自身はいいねの状態を持ちません。

vue
<script setup lang="ts">
import { StoriesOverlay, type StoriesGroup } from '@reelkit/vue-stories-player';

const props = defineProps<{ groups: StoriesGroup[] }>();

// The heart animation is built in; the like itself is yours to keep.
const like = (groupIndex: number, storyIndex: number) => {
  const story = props.groups[groupIndex].stories[storyIndex];
  void fetch(`/api/stories/${story.id}/like`, { method: 'POST' });
};
</script>

<template>
  <StoriesOverlay
    v-model:is-open="isOpen"
    :groups="props.groups"
    @double-tap="like"
  />
</template>

ハートのアニメーションを調整する

速さは --rk-stories-heart-duration トークンで調整します(テーマを参照)。色や大きさ、あるいはハートを完全に隠したい場合は .rk-stories-heart クラスを直接指定してください。HeartAnimation コンポーネントは単体でも使えるように公開されています。

css
:root {
  /* Pop-in and fade-out duration. */
  --rk-stories-heart-duration: 500ms;
}

/* Colour and size are plain CSS on the element itself. */
.rk-stories-heart svg {
  width: 72px;
  height: 72px;
  fill: #ff4d6d;
}

/* Or hide it and animate your own from the double-tap event. */
.rk-stories-heart {
  display: none;
}

組み込みのハートのアニメーションは、まだスロットでは差し替えられません。CSS でスタイルを変えるか、display: none で隠したうえで @double-tap イベントから独自のアニメーションを動かしてください。heart スロットが必要なら、GitHub Issues でお知らせください。

サブコンポーネント

スロットの中で組み合わせても、単体で使ってもよい公開部品です。StoriesRing、StoriesRingList、StoryHeader、CanvasProgressBar、ImageStorySlide、VideoStorySlide、HeartAnimation。

コンポーネント説明
CanvasProgressBarタイマーのシグナルから直接描く、canvas の分割プログレスバー。ストーリーが多いグループではスライディングウィンドウを使います。:live="false" にすると、アニメーションのループを持たず、シグナルが変わったときとサイズが変わった後にだけ描画します。
HeartAnimationダブルタップのハート。アニメーションが終わると complete を発行するので、親が取り除けます。
ImageStorySlideobject-fit: cover の全面画像スライド。読み込みとエラーをコールバックで知らせます。
StoriesRingグラデーションのリングが付いた円形アバター。状態は 2 つで、未読が残っていれば回転し、見終わると平坦になります。click を発行します。
StoriesRingList作者名付きのリングを横に並べたもので、グループごとに 1 つ。グループ番号とともに select を発行します。
StoryHeader既定のヘッダー。アバター、名前、認証バッジ、相対時刻、一時停止、サウンド、閉じる、そして読み込み表示。
VideoStorySlide共有された 1 つの <video> 要素に支えられた動画スライドで、iOS でのサウンド継続のためのものです。自動再生、ポスターのフレーム、長さの報告を扱います。
vue
<script setup lang="ts">
import {
  CanvasProgressBar,
  HeartAnimation,
  ImageStorySlide,
  StoriesRing,
  StoriesRingList,
  StoryHeader,
  VideoStorySlide,
} from '@reelkit/vue-stories-player';
</script>

<template>
  <!-- One ring, and the horizontal row of them. -->
  <StoriesRing
    :author="group.author"
    :total-stories="group.stories.length"
    :viewed-count="2"
    :size="72"
    @click="open(group)"
  />
  <StoriesRingList :groups="groups" :viewed="viewed" @select="open" />

  <!-- Inside a #header slot: the default header, with your own additions. -->
  <StoryHeader
    :author="group.author"
    :created-at="story.createdAt"
    :is-paused="isPaused"
    :is-video="story.mediaType === 'video'"
    :is-muted="isMuted"
    :on-toggle-pause="onTogglePause"
    :on-toggle-sound="onToggleSound"
    :on-close="onClose"
  />

  <!-- Inside a #progressBar slot: the canvas bar, driven by the signals. -->
  <CanvasProgressBar
    :total-stories="group.stories.length"
    :active-index="activeIndex"
    :progress="progress"
    :min-segment-width="12"
  />

  <!-- Inside a #slide slot; see the slide slot example above. -->
  <ImageStorySlide :src="story.src" :on-load="onReady" :on-error="onError" />
  <VideoStorySlide
    :src="story.src"
    :group-index="groupIndex"
    :story-index="index"
    :active-group-index="activeGroupIndex"
    :active-story-index="activeStoryIndex"
  />

  <!-- Standalone, outside the player. -->
  <HeartAnimation @complete="remove(heart.id)" />
</template>

再エクスポート

利用側に必要なものはすべてこのパッケージから来るので、@reelkit/core を直接インポートする必要はありません。

取得元再エクスポートされるもの
@reelkit/vueObserve、SoundProvider、useSoundState、useOverlayUrlState、createViewedStateController、twoAxisViewedTracking、createLocalStorageAdapter、createSessionStorageAdapter、createMemoryStorageAdapter、urlIndexTwoAxisKey、urlStableIdTwoAxisKey、base64UrlCodec、および型 UrlAdapter、UrlCodec、UrlLocator、UrlKey、UrlStateController、TwoAxisPosition、TwoAxisIdentity、UrlIndexTwoAxisKeyOptions、OverlayUrlStateOptions、ViewedStateController、ViewedStateOptions、StorageAdapter
@reelkit/stories-corecreateStoriesViewedStateController、StoriesViewedStateController、StoriesViewedStateControllerConfig、およびコンテンツの型 StoryItem、AuthorInfo、StoriesGroup、MediaType

Observe は、プレイヤーが内部で使っている描画の境界です。core のシグナルが変わったときに自分のスロットだけを描き直すので、ストーリーが変わってもヘッダーとプログレスバーだけが更新され、2 つのスライダーは描き直されません。toVueRef でコンポーネント全体がシグナルを追うのでは粗すぎるとき、自作のスロットの中で使ってください。詳しくは Vue API リファレンスへ。

各コンポーネントのプロパティ型も公開されています。StoriesOverlayProps、StoriesUrlOverlayProps、StoriesRingProps、StoriesRingListProps、StoryHeaderProps、CanvasProgressBarProps、ImageStorySlideProps、VideoStorySlideProps。

型

StoryItem、AuthorInfo、StoriesGroup

typescript
interface StoryItem {
  /** Unique within its group. Stored viewed state keys on it. */
  id: string;
  mediaType: 'image' | 'video';
  src: string;
  /** Poster for a video, also used as the carousel card preview. */
  poster?: string;
  /** Overrides defaultImageDuration for this story, in milliseconds. */
  duration?: number;
  createdAt?: string | Date;
  /** Media aspect ratio (width / height). */
  aspectRatio?: number;
}

interface AuthorInfo {
  /** Stable id. Viewed state and stable-id URLs key on it. */
  id: string;
  name: string;
  avatar: string;
  verified?: boolean;
}

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

スロットのスコープ

スコープの型はすべて公開されているので、コンパイラーが推論できない場所ではスロットに型を付けられます。HeaderSlotScope、FooterSlotScope、SlideSlotScope、NavigationSlotScope、ProgressBarSlotScope、LoadingSlotScope、ErrorSlotScope、GroupPreviewSlotScope、そして StoriesApi です。

typescript
interface HeaderSlotScope<T extends StoryItem = StoryItem> {
  author: AuthorInfo;
  story: T;
  storyIndex: number;
  isPaused: boolean;
  isMuted: boolean;
  /** True while the active story is a video — gate your sound button on it. */
  isVideo: boolean;
  groupIndex: number;
  /** False for a neighbouring group's header with chrome-placement="group". */
  isActive: boolean;
  onToggleSound: () => void;
  onTogglePause: () => void;
  onClose: () => void;
}

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

interface SlideSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  index: number;
  groupIndex: number;
  /** False for the copy a carousel card draws. */
  isActive: boolean;
  size: [number, number];
  activeGroupIndex: Signal<number>;
  activeStoryIndex: Signal<number>;
  /** Report the real media length, in milliseconds. */
  onDurationReady: (durationMs: number) => void;
  onReady: () => void;
  onWaiting: () => void;
  onError: () => void;
  onEnded: () => void;
}

interface NavigationSlotScope {
  onPrevStory: () => void;
  onNextStory: () => void;
  onPrevGroup: () => void;
  onNextGroup: () => void;
}

interface ProgressBarSlotScope<T extends StoryItem = StoryItem> {
  totalStories: number;
  activeIndex: Signal<number>;
  /** 0 to 1 for the active story. Bridge it with toVueRef. */
  progress: Signal<number>;
  group: StoriesGroup<T>;
  groupIndex: number;
  /** False: the signals hold still for a neighbouring group. */
  isActive: boolean;
}

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

/** Same shape as LoadingSlotScope. */
interface ErrorSlotScope<T extends StoryItem = StoryItem> {
  story: T;
  storyIndex: number;
  groupIndex: number;
}

interface GroupPreviewSlotScope<T extends StoryItem = StoryItem> {
  group: StoriesGroup<T>;
  groupIndex: number;
  /** The story the group would open on, if it has one. */
  story: T | undefined;
  /** Negative left, positive right, 0 while sliding through the center. */
  offset: number;
  viewedCount: number;
  onOpen: () => void;
}

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

独自のストーリー型

StoryItem を自分のフィールドで拡張し、その型でグループを型付けします。StoriesOverlay と StoriesUrlOverlay は React のプレーヤーと同じくジェネリックです。ストーリー型は groups から推論されるため、ストーリーが StoryItem に合わないグループは型チェックで弾かれます。Vue ではスロットがこの型を運べないので、スロットのスコープは型を付けるまで型なしのままです。スロットのスコープに型を付ければ、テンプレートの中でも追加フィールドが見えたままになります。この型注釈は groups と照合されないため、両者を揃えておいてください。

vue
<script setup lang="ts">
import { ref } from 'vue';
import {
  StoriesOverlay,
  type SlideSlotScope,
  type StoriesGroup,
  type StoryItem,
} from '@reelkit/vue-stories-player';

const isOpen = ref(false);

interface PromoStory extends StoryItem {
  title?: string;
  ctaHref?: string;
}

const groups: StoriesGroup<PromoStory>[] = [
  {
    author: { id: 'shop', name: 'Shop', avatar: '/shop.jpg' },
    stories: [
      {
        id: 'sale',
        mediaType: 'image',
        src: '/sale.jpg',
        title: 'Spring sale',
        ctaHref: '/sale',
      },
    ],
  },
];
</script>

<template>
  <StoriesOverlay v-model:is-open="isOpen" :groups="groups">
    <!-- Annotate the scope with your own item type to keep the slot typed. -->
    <template #slide="scope: SlideSlotScope<PromoStory>">
      <article class="promo">
        <h2>{{ scope.story.title }}</h2>
        <a v-if="scope.story.ctaHref" :href="scope.story.ctaHref">Shop now</a>
      </article>
    </template>
  </StoriesOverlay>
</template>

CSS クラス

クラス名は素のもの(scoped ではありません)なので、@reelkit/vue-stories-player/styles.css のあとに読み込むスタイルシートでどれでも上書きできます。@reelkit/react-stories-player と同一なので、一方のバインディング向けに書いたスタイルシートがもう一方でもそのまま使えます。色・大きさ・z-index については、テーマのカスタムプロパティを優先してください。

クラスコンポーネント説明
.rk-stories-overlayOverlay固定配置の全画面背景(背景色、z-index)
.rk-stories-overlay--carouselOverlayデスクトップのカルーセルを表示中のオーバーレイ
.rk-stories-overlay--slidingOverlayカルーセルのスライド中。プレイヤーの画面は隠れます
.rk-stories-swipe-wrapperOverlayスワイプで閉じるためのラッパー(ナビゲーションボタンと画面を収めます)
.rk-stories-containerOverlay角の丸いストーリーの画面(位置、overflow)
.rk-stories-ui-layerOverlayUI のコンテナー(ヘッダー、プログレス)。chrome-placement="group" では各グループのスライドの中にあります
.rk-stories-ui-layer--hiddenOverlayUI を隠した状態(hide-ui-on-pause が切り替えます)
.rk-stories-errorOverlayエラー状態(中央のアイコンとテキスト)
.rk-stories-error-textOverlayエラーメッセージのテキスト
.rk-stories-nav-btnNavigationデスクトップの前後の矢印
.rk-stories-progress-barCanvasProgressBarcanvas プログレスバーの配置用ラッパー
.rk-stories-slide-wrapperGroup1 つのストーリーグループ(外側のスライド)
.rk-stories-storyStory1 つのストーリー(内側のスライドのルート)
.rk-stories-headerStoryHeaderヘッダーの帯(アバター、名前、操作)
.rk-stories-header--hiddenStoryHeaderヘッダーを隠した状態
.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リングと名前の 1 列
.rk-stories-ring-list-nameStoriesRingList各リングの下の作者名
.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ポスターも画像もないストーリー。#slide スロットがプレイヤーの大きさで描き、縮小したもの
.rk-stories-card-scrimCarousel絵の上に重なる暗さの層
.rk-stories-card-infoCarouselリング・作者名・時刻の列
.rk-stories-card-nameCarousel作者名
.rk-stories-card-timeCarousel相対時刻のテキスト

テーマ

色・大きさ・z-index・転換はすべて CSS カスタムプロパティにあり、既定値は :root に置かれています。:root、またはオーバーレイの祖先のどこかで 1 つでも複数でも上書きすれば、コンポーネントのソースに触れずにテーマを変えられます。トークンは @reelkit/react-stories-player と一致するので、テーマはバインディング間で持ち運べます。

トークン既定値対象
--rk-stories-overlay-bg#000全画面背景の色
--rk-stories-overlay-z9999オーバーレイの z-index
--rk-stories-container-radius12pxストーリー画面の角の丸み(デスクトップ)
--rk-stories-container-radius-mobile0幅 768px までのスマートフォンでのストーリー画面の角
--rk-stories-swipe-gap16pxナビゲーションボタンとストーリー画面の間隔
--rk-stories-top-shade-height120pxヘッダー背後の上部グラデーションの高さ
--rk-stories-top-shade-bglinear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%)上部グラデーションの色
--rk-stories-ui-transition200mshide-ui-on-pause が切り替わるときのフェード時間
--rk-stories-ui-z15UI レイヤー(進捗バーとヘッダー)の 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-nav-z20デスクトップのナビゲーションボタンの 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-text-size13pxエラーメッセージの文字サイズ
--rk-stories-error-gap12pxエラーのアイコンとテキストの間隔
--rk-stories-error-z5エラー状態の z-index
--rk-stories-video-bg#000<video> の背後の余白の色
--rk-stories-video-poster-transition200ms再生開始時のポスターのフェード時間
--rk-stories-progress-bar-padding8px 8px 0進捗バーの周りの余白
--rk-stories-progress-bar-z10進捗バーの z-index
--rk-stories-header-top18pxストーリー上端からのヘッダーの位置
--rk-stories-header-padding12px 16pxヘッダー行の内側の余白
--rk-stories-header-z5ヘッダーの z-index
--rk-stories-header-transition200msヘッダーが隠れるときのフェード時間
--rk-stories-header-gap8pxアバター・名前・時刻の間隔
--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-btn-fg#fffヘッダーの操作アイコンの色(閉じる、ミュート、一時停止)
--rk-stories-header-btn-padding4pxヘッダーの操作ボタンの内側の余白
--rk-stories-header-actions-gap8pxヘッダーの操作ボタン同士の間隔
--rk-stories-header-spinner-size20px読み込みスピナーの直径
--rk-stories-header-spinner-fg#fff読み込みスピナーの弧の色
--rk-stories-header-spinner-trackrgba(255, 255, 255, 0.3)読み込みスピナーのトラックの色
--rk-stories-header-spinner-duration0.8s読み込みスピナーの回転時間
--rk-stories-heart-duration800msハートが出て消えるまでの時間
--rk-stories-heart-z20ダブルタップのハートの z-index
--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カルーセルのスライド時間
css
/* Loaded after @reelkit/vue-stories-player/styles.css. Every token has a
   default on :root, so override only what you want to change. The names
   match @reelkit/react-stories-player, so a theme ports between bindings. */
:root {
  --rk-stories-overlay-bg: #0f172a;
  --rk-stories-container-radius: 24px;

  --rk-stories-nav-bg: rgba(99, 102, 241, 0.35);
  --rk-stories-nav-bg-hover: rgba(168, 85, 247, 0.65);
  --rk-stories-nav-fg: #fff;

  --rk-stories-header-name-fg: #fef3c7;
  --rk-stories-header-time-fg: rgba(254, 243, 199, 0.7);

  --rk-stories-ring-spin-duration: 2s;
  --rk-stories-heart-duration: 600ms;

  --rk-stories-card-bg: #1e293b;
  --rk-stories-card-transition: 260ms;
}

/* 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")。スクリーンリーダーの読み上げを変えるには aria-label を設定してください。既定は「Stories player」です。

オーバーレイは開くときにフォーカスを預かり、閉じるときに元の要素へ返します。Tab と Shift+Tab は内側のフォーカス可能な要素を巡り、外へ逃げたフォーカスは引き戻されます。@reelkit/core の captureFocusForReturn と createFocusTrap(@reelkit/vue が再エクスポート)で実装しています。

デスクトップのカルーセルでは、既定のカードはそれぞれ「Open stories by」と作者名のラベルを持つボタンです。カードはタブ順でプレイヤーの操作のあとに並び、スライド中はタブ順から外れます。自分のグループを開いたカードは、フォーカスをページに落とさずダイアログへ返します。

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

閉じている間、オーバーレイは何も描画しません。StoriesRingList はサーバー側ではすべてのリングを未読として描画します。既読のストレージはマウント後にしか読まないので、サーバーのマークアップと最初のクライアント描画が一致します。全体像は SSR ガイドにあります。

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

キー動作
ArrowLeft前のストーリー。グループの最初のストーリーでは前のグループを、離れたところから開きます。最初のグループでは何もしません。
ArrowRight次のストーリー。グループの最後のストーリーを過ぎると次のグループを開きます。最後のグループではプレイヤーを閉じます。
Escapeプレイヤーを閉じる