Vue ストーリープレイヤー
@reelkit/vue-stories-player による、Vue 3 向けの Instagram 風ストーリープレイヤーのオーバーレイです。React 版と同じ @reelkit/stories-core エンジンを使うので、挙動も CSS クラスもテーマトークンも同じです。
Features
インストール
npm install @reelkit/vue-stories-player @reelkit/vue lucide-vue-nextアプリのエントリーポイント(またはどこかのコンポーネント)で、スタイルシートを一度だけ読み込みます。
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 をバインドしてください。
<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 として渡してください。
<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 つの部分の区切りである . を含めてはいけません。
// `?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 に乗るので、ページングはユーザーの軸で起こり、ストーリーは解決されたグループ内のローカルなインデックスのままです。
// 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 でも使えます。
<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 コントローラーを渡せば、見終わったグループのカードのリングは暗くなります。カードは自分でコントローラーを追うので、ストーリーが既読になったときに描き直されるのはカードだけで、プレイヤーは再描画されません。
<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 と同じようにグループと一緒に回転します。
<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-label | string | 'Stories player' | ダイアログ領域のアクセシブルな名前。オーバーレイが開くとスクリーンリーダーが読み上げます |
chrome-placement | 'overlay' | 'group' | 'overlay' | プログレスバーとヘッダーを置く場所。'overlay' はプレーヤーの上に 1 つ描き、'group' はグループのスライドごとに 1 つ描くので、両方がグループと一緒に回転します。型は ChromePlacement として公開されています。 |
default-image-duration | number | 5000 | 画像ストーリーの自動送り時間(ミリ秒) |
desktop-layout | 'single' | 'carousel' | 'single' | デスクトップでのレイアウト。'carousel' は隣のグループを表示中のストーリーの横にカードとして並べ、グループ間をスライドします。スマートフォンでは常にストーリーだけを表示します。型は DesktopLayout として公開されています。 |
enable-keyboard | boolean | true | キーボード操作(左右の矢印、Escape)を有効にします |
groups | StoriesGroup<T>[] | required | 表示するストーリーグループ。プレイヤーを開いたままグループやストーリーを増やしても、配列を差し替えた場合も同じ配列に追加した場合も反映されます。 |
group-transition | TransitionTransformFn | cubeTransition | 外側(グループ)スライダーの転換。デスクトップのカルーセル表示中は無視され、代わりにスライドします。 |
hide-ui-on-pause | boolean | true | 長押しによる一時停止の間、プログレスバーとヘッダーを隠します |
initial-group-index | number | 0 | 最初に表示するグループの番号(0 始まり) |
initial-story-index | number | 再開位置、なければ 0 | 最初に表示するストーリーの番号(0 始まり)。明示した値は記憶よりも優先されます。省略すると、そのグループも他と同じように再開します。 |
inner-transition-duration | number | 200 | ストーリー間のクロスフェード時間(ミリ秒) |
is-open | boolean | required | 表示を制御し、開いている間はページのスクロールを固定します。v-model:is-open でバインドしてください。 |
min-segment-width | number | 8 | スライディングウィンドウに切り替わる前の、プログレス 1 区切りの最小幅(ピクセル) |
resume-story-index | (groupIndex: number) => number | — | グループに初めて到達したとき、どのストーリーから開くか。この表示中にまだ訪れていないグループにだけ問い合わせ、viewed よりも優先されます。 |
tap-zone-split | number | 0.3 | タップ領域の分割比(0–1)。左側が戻る、残りが進むです。 |
viewed | StoriesViewedStateController | — | 視聴者がすでに見た内容で、createStoriesViewedStateController() から得ます。グループは未読の最初のストーリーから再開し、表示したストーリーはすべて記録され、カルーセルのカードは見終わったグループのリングを暗くします。同じコントローラーを StoriesRingList にも渡してください。 |
StoriesUrlOverlay のプロパティ
StoriesUrlOverlayProps<T>
上記のプロパティをすべて受け取りますが、開閉位置の 3 つ(is-open、initial-group-index、initial-story-index)は例外で、これらはコントローラーから与えられます。
| Prop | 型 | 既定値 | 説明 |
|---|---|---|---|
controller | UrlStateController<TwoAxisPosition> | required | urlIndexTwoAxisKey を展開した useOverlayUrlState のコントローラー。その position({ outer, inner } のオブジェクト)が、プレイヤーを開くかどうかとどこから開くかを決めます。オーバーレイは移動のたびと閉じるときに書き戻します。 |
イベント
| イベント | ペイロード | 説明 |
|---|---|---|
| @api-ready | StoriesApi | プレイヤーがマウントされた時点で、命令的な API とともに発行されます |
| @close | void | プレイヤーが閉じたときに発行されます。StoriesUrlOverlay では、この時点でパラメーターはすでに消えています。 |
| @double-tap | groupIndex: number, storyIndex: number | ダブルタップ時に、ハートのアニメーションと合わせて発行されます |
| @group-change | groupIndex: number | 表示中のグループが変わったあとに発行されます |
| @pause | void | 長押し、またはヘッダーの一時停止ボタンで発行されます |
| @resume | void | 指を離したとき、またはヘッダーの再生ボタンで発行されます |
| @story-change | groupIndex: number, storyIndex: number | 表示中のストーリーが変わったあとに発行されます |
| @story-complete | groupIndex: number, storyIndex: number | ストーリーのタイマーが切れたときに発行されます |
| @story-viewed | groupIndex: number, storyIndex: number | ストーリーが表示されたときに発行されます。最初に開いたものも含みます |
| @update:is-open | boolean | 閉じるときに発行され、`v-model:is-open` を可能にします |
v-model:is-open
v-model:is-open を使えば、1 つのバ インディングでオーバーレイを操作できます。イベントそのものが必要なら、:is-open と @close の組み合わせもそのまま使えます。
<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 } | 既定の画像・動画スライドを差し替えます |
<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 | メディアが終わった。次のストーリーへ進みます。 |
<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 も覚えているので、壊れたストーリーを開き直すと、再試行せずにすぐエラーを出します。
ナビゲーションをカスタマイズする
<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 で橋渡ししてください。
<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>読み込みとエラーをカスタマイズする
<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>カルーセルのカードをカスタマイズする
<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 からインポートします。
<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 を受け取ります。プレイヤーが閉じている間の呼び出しは何もしません。
<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 イベントはグループとストーリーの番号とともに発行されるので、いいねは自分の状態として保存できます。プレイヤー自身はいいねの状態を持ちません。
<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 コンポーネントは単体でも使えるように公開されています。
: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 を発行するので、親が取り除けます。 |
ImageStorySlide | object-fit: cover の全面画像スライド。読み込みとエラーをコールバックで知らせます。 |
StoriesRing | グラデーションのリングが付いた円形アバター。状態は 2 つで、未読が残っていれば回転し、見終わると平坦になります。click を発行します。 |
StoriesRingList | 作者名付きのリングを横に並べたもので、グループごとに 1 つ。グループ番号とともに select を発行します。 |
StoryHeader | 既定のヘッダー。アバター、名前、認証バッジ、相対時刻、一時停止、サウンド、閉じる、そして読み込み表示。 |
VideoStorySlide | 共有された 1 つの <video> 要素に支えられた動画スライドで、iOS でのサウンド継続のためのものです。自動再生、ポスターのフレーム、長さの報告を扱います。 |
<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/vue | Observe、SoundProvider、useSoundState、useOverlayUrlState、createViewedStateController、twoAxisViewedTracking、createLocalStorageAdapter、createSessionStorageAdapter、createMemoryStorageAdapter、urlIndexTwoAxisKey、urlStableIdTwoAxisKey、base64UrlCodec、および型 UrlAdapter、UrlCodec、UrlLocator、UrlKey、UrlStateController、TwoAxisPosition、TwoAxisIdentity、UrlIndexTwoAxisKeyOptions、OverlayUrlStateOptions、ViewedStateController、ViewedStateOptions、StorageAdapter |
@reelkit/stories-core | createStoriesViewedStateController、StoriesViewedStateController、StoriesViewedStateControllerConfig、およびコンテンツの型 StoryItem、AuthorInfo、StoriesGroup、MediaType |
Observe は、プレイヤーが内部で使っている描画の境界です。core のシグナルが変わったときに自分のスロットだけを描き直すので、ストーリーが変わってもヘッダーとプログレスバーだけが更新され、2 つのスライダーは描き直されません。toVueRef でコンポーネント全体がシグナルを追うのでは粗すぎるとき、自作のスロットの中で使ってください。詳しくは Vue API リファレンスへ。
各コンポーネントのプロパティ型も公開されています。StoriesOverlayProps、StoriesUrlOverlayProps、StoriesRingProps、StoriesRingListProps、StoryHeaderProps、CanvasProgressBarProps、ImageStorySlideProps、VideoStorySlideProps。
型
StoryItem、AuthorInfo、StoriesGroup
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 です。
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 と照合されないため、両者を揃えておいてください。
<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-overlay | Overlay | 固定配置の全画面背景(背景色、z-index) |
.rk-stories-overlay--carousel | Overlay | デスクトップのカルーセルを表示中のオーバーレイ |
.rk-stories-overlay--sliding | Overlay | カルーセルのスライド中。プレイヤーの画面は隠れます |
.rk-stories-swipe-wrapper | Overlay | スワイプで閉じるためのラッパー(ナビゲーションボタンと画面を収めます) |
.rk-stories-container | Overlay | 角の丸いストーリーの画面(位置、overflow) |
.rk-stories-ui-layer | Overlay | UI のコンテナー(ヘッダー、プログレス)。chrome-placement="group" では各グループのスライドの中にあります |
.rk-stories-ui-layer--hidden | Overlay | UI を隠した状態(hide-ui-on-pause が切り替えます) |
.rk-stories-error | Overlay | エラー状態(中央のアイコンとテキスト) |
.rk-stories-error-text | Overlay | エラーメッセージのテキスト |
.rk-stories-nav-btn | Navigation | デスクトップの前後の矢印 |
.rk-stories-progress-bar | CanvasProgressBar | canvas プログレスバーの配置用ラッパー |
.rk-stories-slide-wrapper | Group | 1 つのストーリーグループ(外側のスライド) |
.rk-stories-story | Story | 1 つのストーリー(内側のスライドのルート) |
.rk-stories-header | StoryHeader | ヘッダーの帯(アバター、名前、操作) |
.rk-stories-header--hidden | StoryHeader | ヘッダーを隠した状態 |
.rk-stories-header-avatar | StoryHeader | 作者のアバター画像 |
.rk-stories-header-name | StoryHeader | 作者名のテキスト |
.rk-stories-header-verified | StoryHeader | 認証バッジ |
.rk-stories-header-time | StoryHeader | 相対時刻のテキスト |
.rk-stories-header-actions | StoryHeader | 右側の操作(サウンド、一時停止、閉じる) |
.rk-stories-header-btn | StoryHeader | ヘッダーの操作ボタン |
.rk-stories-header-btn--desktop | StoryHeader | 一時停止ボタン。幅 768px を超える画面でだけ表示されます |
.rk-stories-header-spinner | StoryHeader | 読み込み表示 |
.rk-stories-image | ImageStorySlide | 画像ストーリーの要素 |
.rk-stories-video | VideoStorySlide | 動画ストーリーのコンテナー |
.rk-stories-video-element | VideoStorySlide | 共有される <video> 要素 |
.rk-stories-video-poster | VideoStorySlide | 動画のポスター画像(再生開始でフェードアウト) |
.rk-stories-video-poster--visible | VideoStorySlide | ポスターが見えている状態(再生前) |
.rk-stories-heart | HeartAnimation | ダブルタップのハートが弾むアニメーション |
.rk-stories-ring | StoriesRing | ストーリーのリング(アニメーションするグラデーション枠のアバター) |
.rk-stories-ring--active | StoriesRing | 未読が残っているリング(動きます) |
.rk-stories-ring-avatar | StoriesRing | リングの内側のアバター画像 |
.rk-stories-ring-list | StoriesRingList | 横並びリング一覧のコンテナー |
.rk-stories-ring-list-item | StoriesRingList | リングと名前の 1 列 |
.rk-stories-ring-list-name | StoriesRingList | 各リングの下の作者名 |
.rk-stories-carousel | Carousel | プレイヤーの背後にあるカードの層 |
.rk-stories-carousel--instant | Carousel | スライドの最初のフレーム。転換なしで配置されます |
.rk-stories-card | Carousel | 配置された 1 枚のカード |
.rk-stories-card--center | Carousel | スライド中に中央の位置にあるカード |
.rk-stories-card--hidden | Carousel | 片側に表示される 2 枚より外側のカード |
.rk-stories-card-button | Carousel | カードの既定の中身(そのグループを開くボタン) |
.rk-stories-card-image | Carousel | プレビューの絵(ポスターまたは画像) |
.rk-stories-card-frame | Carousel | ポスターも画像もないストーリー。#slide スロットがプレイヤーの大きさで描き、縮小したもの |
.rk-stories-card-scrim | Carousel | 絵の上に重なる暗さの層 |
.rk-stories-card-info | Carousel | リング・作者名・時刻の列 |
.rk-stories-card-name | Carousel | 作者名 |
.rk-stories-card-time | Carousel | 相対時刻のテキスト |
テーマ
色・大きさ・z-index・転換はすべて CSS カスタムプロパティにあり、既定値は :root に置かれています。:root、またはオーバーレイの祖先のどこかで 1 つでも複数でも上書きすれば、コンポーネントのソースに触れずにテーマを変えられます。トークンは @reelkit/react-stories-player と一致するので、テーマはバインディング間で持ち運べます。
| トークン | 既定値 | 対象 |
|---|---|---|
--rk-stories-overlay-bg | #000 | 全画面背景の色 |
--rk-stories-overlay-z | 9999 | オーバーレイの z-index |
--rk-stories-container-radius | 12px | ストーリー画面の角の丸み(デスクトップ) |
--rk-stories-container-radius-mobile | 0 | 幅 768px までのスマートフォンでのストーリー画面の角 |
--rk-stories-swipe-gap | 16px | ナビゲーションボタンとストーリー画面の間隔 |
--rk-stories-top-shade-height | 120px | ヘッダー背後の上部グラデーションの高さ |
--rk-stories-top-shade-bg | linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 100%) | 上部グラデーションの色 |
--rk-stories-ui-transition | 200ms | hide-ui-on-pause が切り替わるときのフェード時間 |
--rk-stories-ui-z | 15 | UI レイヤー(進捗バーとヘッダー)の z-index |
--rk-stories-nav-size | 44px | デスクトップの前後ボタンの大きさ |
--rk-stories-nav-bg | rgba(255, 255, 255, 0.1) | デスクトップのナビゲーションボタンの背景 |
--rk-stories-nav-bg-hover | rgba(255, 255, 255, 0.2) | ホバー時のナビゲーションボタンの背景 |
--rk-stories-nav-fg | rgba(255, 255, 255, 0.7) | ナビゲーションボタンのアイコンの色 |
--rk-stories-nav-fg-hover | #fff | ホバー時のアイコンの色 |
--rk-stories-nav-transition | 150ms | デスクトップのナビゲーションボタンのホバー時のトランジション |
--rk-stories-nav-z | 20 | デスクトップのナビゲーションボタンの z-index |
--rk-stories-error-bg | linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%) | エラー状態の背景グラデーション |
--rk-stories-error-fg | rgba(255, 255, 255, 0.5) | エラーのアイコンとテキストの色 |
--rk-stories-error-text-size | 13px | エラーメッセージの文字サイズ |
--rk-stories-error-gap | 12px | エラーのアイコンとテキストの間隔 |
--rk-stories-error-z | 5 | エラー状態の z-index |
--rk-stories-video-bg | #000 | <video> の背後の余白の色 |
--rk-stories-video-poster-transition | 200ms | 再生開始時のポスターのフェード時間 |
--rk-stories-progress-bar-padding | 8px 8px 0 | 進捗バーの周りの余白 |
--rk-stories-progress-bar-z | 10 | 進捗バーの z-index |
--rk-stories-header-top | 18px | ストーリー上端からのヘッダーの位置 |
--rk-stories-header-padding | 12px 16px | ヘッダー行の内側の余白 |
--rk-stories-header-z | 5 | ヘッダーの z-index |
--rk-stories-header-transition | 200ms | ヘッダーが隠れるときのフェード時間 |
--rk-stories-header-gap | 8px | アバター・名前・時刻の間隔 |
--rk-stories-header-avatar-size | 32px | アバターの幅と高さ |
--rk-stories-header-name-fg | #fff | 作者名の色 |
--rk-stories-header-name-size | 14px | 作者名の文字サイズ |
--rk-stories-header-name-weight | 600 | 作者名の文字の太さ |
--rk-stories-header-time-fg | rgba(255, 255, 255, 0.6) | 相対時刻の文字色 |
--rk-stories-header-time-size | 12px | 相対時刻の文字サイズ |
--rk-stories-header-btn-fg | #fff | ヘッダーの操作アイコンの色(閉じる、ミュート、一時停止) |
--rk-stories-header-btn-padding | 4px | ヘッダーの操作ボタンの内側の余白 |
--rk-stories-header-actions-gap | 8px | ヘッダーの操作ボタン同士の間隔 |
--rk-stories-header-spinner-size | 20px | 読み込みスピナーの直径 |
--rk-stories-header-spinner-fg | #fff | 読み込みスピナーの弧の色 |
--rk-stories-header-spinner-track | rgba(255, 255, 255, 0.3) | 読み込みスピナーのトラックの色 |
--rk-stories-header-spinner-duration | 0.8s | 読み込みスピナーの回転時間 |
--rk-stories-heart-duration | 800ms | ハートが出て消えるまでの時間 |
--rk-stories-heart-z | 20 | ダブルタップのハートの z-index |
--rk-stories-ring-spin-duration | 4s | 未読が残るリングの回転時間 |
--rk-stories-ring-active-scale | 0.95 | 押している間のリングの拡大率 |
--rk-stories-ring-active-gradient | conic-gradient(from 180deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888, #f09433) | 未読のストーリーが残っているリングの表示。CSS の画像か色を指定できます。明るいページでは :root ではなく .rk-stories-ring-list に設定してください。プレーヤーのカルーセルの色が保たれます |
--rk-stories-ring-viewed-color | rgba(255, 255, 255, 0.25) | すべて見終えたリングの表示で、暗いページ向けの値です。明るいページでは :root ではなく .rk-stories-ring-list に設定してください |
--rk-stories-ring-gradient | none | 内部用:リングが描画のたびに書き込み、リングに gradientColors や viewedColor が渡されない限り上の 2 つのトークンを参照します |
--rk-stories-ring-list-gap | 12px | 一覧のリング同士の間隔 |
--rk-stories-ring-list-padding | 12px | リング一覧の周りの余白 |
--rk-stories-ring-list-item-gap | 4px | リングとその名前の間隔 |
--rk-stories-ring-list-name-size | 12px | 各リングの下の作者名の文字サイズ |
--rk-stories-card-bg | #262626 | カードの背景。プレビューの絵がないときに見えます |
--rk-stories-card-radius | 8px | カードの角の丸み |
--rk-stories-card-scrim | rgba(0, 0, 0, 0.45) | 横のカードにかかる暗さ |
--rk-stories-card-fg | #fff | カードの文字色 |
--rk-stories-card-name-size | 14px | カードの作者名の文字サイズ |
--rk-stories-card-time-fg | rgba(255, 255, 255, 0.7) | カードの時刻の文字色 |
--rk-stories-card-time-size | 13px | カードの時刻の 文字サイズ |
--rk-stories-card-gap | 6px | カード上のリング・名前・時刻の間隔 |
--rk-stories-card-transition | 300ms | カルーセルのスライド時間 |
/* 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 | プレイヤーを閉じる |