GODRICH

video player css で組む カスタム動画プレーヤー 9選 — 動画ファイルなしで動く

video player css とは、mp4 を1本も置かずに、再生ボタン・シークバー・音量つまみといった操作まわりだけを HTML と CSS で組み立てるやり方です。ここに並ぶ9つは絵ではなく、押せばその場で状態が変わります。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並びは人気順ではなく、動画を見る人が触る順番です。まず再生を押し(01)、どこまで進んだかを見て好きな地点へ飛び(02)、音を整えます(03)。次の3つは見方を変えるもので、速さ(04)・字幕(05)・章立て(06)と続きます。最後の3つは見る枠そのものを扱う操作で、小さく畳んで読みながら見る(07)、大きく広げる(08)、そしてマウスを使わない人のための一覧(09)です。9つとも動画ファイルは読み込まず、見えている動きはキーフレームと数十行の素の JavaScript だけで作っています。収録は 480×300 の枠で2秒・12fps、合計24フレームを撮り、隣り合うフレームの間で何%の画素が変わったかを測りました。

項目 変化面積 強度 動いたフレーム
01 再生・一時停止モーフ 31.23% 27.5 23/23
02 シークバー ツールチップ 30.39% 31.2 23/23
03 音量ポップオーバー 31.22% 33.3 23/23
04 再生速度メニュー 42.84% 39.1 23/23
05 字幕トグル 34.06% 34.5 23/23
06 チャプターマーカー 29.85% 33.6 23/23
07 ミニプレーヤー 46.53% 47.3 23/23
08 フルスクリーントグル 56.88% 29.9 23/23
09 ショートカット一覧 43.81% 87.5 21/23

幅 320px でも9つともはみ出しは 0 でした。

01再生・一時停止モーフボタン

ボタンを押すと再生の三角が回りながら一時停止の2本バーへ入れ替わり、横の時計が実際に進みます。自動再生では状態が切り替わるたびに白い円がひとつ外へ広がり、自分で押したときはボタン自体が少し縮んで反応を返します。入れ替えの正体は、同じ場所に重ねた2つのアイコンへ rotatescale を反対向きに当てているだけです。

transform-originaria-labelripple
.pp__ic {
  position: absolute; inset: 0;
  display: inline-flex; align-items: center; justify-content: center;
  transition: opacity $duration $easing, transform $duration $easing;
}
.pp__ic--pause { opacity: 0; transform: rotate(120deg) scale(.3); }
.pl.is-playing .pp__ic--play { opacity: 0; transform: rotate(-120deg) scale(.3); }
.pl.is-playing .pp__ic--pause { opacity: 1; transform: none; }

02時間ツールチップ付きシークバー

マウスを乗せた位置に時間のふきだしが追いかけてきて、再生済みの区間が青く塗られ、その先に読み込み済みのバッファが薄く敷かれます。ふきだしの中の小さなプレビュー枠も、乗せた位置に合わせて模様がずれます。動く部品は4つありますが、もとになる値は --p という数ひとつだけです。

role=slideraria-valuenowhover
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.18; }

.sk__buf { background: rgba(23, 20, 26, .16); transform: scaleX(min(1, calc(var(--p) + .18))); }
.sk__fill { background: $color; transform: scaleX(var(--p)); }
.sk__thumbwrap { transform: translateX(calc((100% - 14px) * var(--p))); }
.sk__tip { transform: translateX(calc((100% - 76px) * var(--h, var(--p)))); }
@keyframes sk-p {
  0% { --p: 0.18; }
  78% { --p: 0.92; }
  88% { --p: 0.92; }
  100% { --p: 0.18; }
}

03音量スライダーポップオーバー

スピーカーボタンを押すと、その右横からスライダーが飛び出し、つまみを動かすと音量が実際に上下します。音量が下がるとアイコンは小さいスピーカーへ、0 でミュートへ替わり、この交代はフェードさせず steps(1, end) で1フレームのうちに差し替えます。ポップオーバーは並びの中ではなく、ボタンの横に浮かせて置いています。

role=slidersteps(1,end)popover
.vol { position: absolute; left: $sp-2; bottom: $sp-2; display: flex; align-items: flex-end; gap: $sp-1; }
.vol__pop {
  position: absolute; left: calc(100% + #{$sp-1}); bottom: 0;
  display: flex; align-items: center; gap: $sp-2;
  width: 152px; height: 36px;
  border-radius: $r-control;
  background: rgba(23, 20, 26, .88);
  transform: translateX(-6px) scale(.6);
  transform-origin: left bottom;
  opacity: 0;
  pointer-events: none;
  transition: transform $duration $easing, opacity $duration $easing;
}
.pl.is-open .vol__pop { transform: none; opacity: 1; pointer-events: auto; }

04再生速度メニュー

速度ボタンを押すと 0.5倍から2倍までの一覧が開き、選んだ倍率の分だけ画面の時計が本当に速く、または遅く進みます。チェックが付くのは選んだ行だけです。↑↓ で移動して Enter で決め、Escape で閉じるとフォーカスがボタンへ戻ります。

aria-haspopupcheckEscape
function pick(n) {
  i = n;
  rate = parseFloat(items[n].getAttribute('data-r'));
  items.forEach(function (b, k) { b.setAttribute('aria-checked', String(k === n)); });
  menu.style.setProperty('--i', String(n));
  val.textContent = items[n].querySelector('.spd__lab').textContent;
}
function toggle(on) {
  root.classList.remove('is-demo');
  open = on;
  root.classList.toggle('is-open', on);
  btn.setAttribute('aria-expanded', String(on));
  if (on) items[i].focus({ preventScroll: true }); else btn.focus({ preventScroll: true });
}

05字幕トグルと字幕スタイル

CC ボタンで字幕のオンとオフを切り替えられ、オンのあいだは文が下から浮かび上がって入れ替わり、スタイルは「地チップ」と「輪郭」の2択です。4つの文は同じマスに重ねて置き、一度に1つだけを見せる作りです。押下の状態は aria-pressed に残ります。

aria-pressedtranslateYopacity
@keyframes cap-1 {
  0%, 21.99% { opacity: 1; transform: none; }
  22%, 87.99% { opacity: 0; transform: translateY(10px); }
  88% { opacity: 1; transform: translateY(10px); }
  93%, 100% { opacity: 1; transform: none; }
}
@keyframes cap-2 {
  0%, 21.99% { opacity: 0; transform: translateY(10px); }
  22% { opacity: 1; transform: translateY(10px); }
  27%, 43.99% { opacity: 1; transform: none; }
  44%, 100% { opacity: 0; transform: translateY(10px); }
}

06チャプターマーカーシークバー

シークバーがチャプターごとの目盛りに分かれ、再生ヘッドが境界を越えると上のチャプター名が1行分巻き上がります。目盛りを押せばその章の先頭へ実際に飛びます。区間の塗りは「(現在地 − 区間の始まり) × 区間の逆数」を 0 から 1 のあいだに収めて出しているので、章が何本あっても計算式は1本です。

segmentaria-valuetextclick
.ch__seg {
  position: absolute; top: 0; bottom: 0;
  left: calc(var(--s) * 100%);
  right: calc(100% - var(--e) * 100%);
  margin-right: $sp-1;
  border-radius: $r-pill;
  background: rgba(23, 20, 26, .16);
  overflow: hidden;
}
.ch__fill {
  position: absolute; inset: 0;
  border-radius: $r-pill;
  background: $color;
  transform-origin: left center;
  transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}

07ミニプレーヤー(PiP風)

ミニボタンを押すとプレーヤーが右下の小さなカードへ縮んで浮かび、元の場所には破線の枠と「ミニプレーヤーで再生中」の一行が残ります。閉じるボタンで元の大きさに戻ります。別ウィンドウは出さず、topwidth も触らずに transform ひとつで移動と縮小をまとめています。

scalearia-labelfocus
$mini-scale: .44;
$mini-x: -4px;
$mini-y: 40px;

.pip.is-mini .pl { transform: translate($mini-x, $mini-y) scale($mini-scale); }
.pip.is-mini .pip__ph { opacity: 1; }
.pip.is-mini .pl__close { opacity: 1; pointer-events: auto; }
.pip.is-mini .pl__mini { opacity: 0; pointer-events: none; }
@keyframes pip-shrink {
  0%, 26% { transform: none; }
  38%, 70% { transform: translate($mini-x, $mini-y) scale($mini-scale); }
  86%, 100% { transform: none; }
}

08フルスクリーントグル

拡大ボタンを押すとプレーヤーが一回り大きく広がり、角の丸みが取れて隅のアイコンが縮小マークへ替わります。もう一度押すか Escape を打つと戻り、戻った先でフォーカスはボタンに残ります。読み上げ向けのボタン名も、そのときの状態に合わせて入れ替えています。

transformEscapearia-expanded
function full(on) {
  root.classList.remove('is-demo');
  root.classList.toggle('is-full', on);
  btn.setAttribute('aria-expanded', String(on));
  btn.setAttribute('aria-label', on ? 'フルスクリーン解除' : 'フルスクリーン');
}
btn.addEventListener('click', function () { full(!root.classList.contains('is-full')); });
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && root.classList.contains('is-full')) {
    full(false);
    btn.focus({ preventScroll: true });
  }
});

09キーボードショートカットのオーバーレイ

「?」ボタンを押すと画面が暗く沈んでショートカット一覧が浮かび、Space・←・→・M・F が紙の上の説明ではなくその場で効きます。自動再生では5つのキーが順に光りますが、その順番は JavaScript ではなく負の animation-delay だけで作っています。

kbdrole=dialogfocus
.pl.is-demo .ovl kbd {
  animation-name: kbd-cycle;
  animation-duration: $dur-loop;
  animation-timing-function: $ease-out;
  animation-iteration-count: infinite;
}
.pl.is-demo .ovl .k-space { animation-delay: -1980ms; }
.pl.is-demo .ovl .k-left  { animation-delay: -1700ms; }
.pl.is-demo .ovl .k-right { animation-delay: -1420ms; }
.pl.is-demo .ovl .k-m     { animation-delay: -1180ms; }
.pl.is-demo .ovl .k-f     { animation-delay: -940ms; }

どこで壊れるか — 落とし穴

いちばん高くついたのは 03 の音量ポップオーバーでした。最初の版は .vol を flex の並びにして、その子としてポップオーバーを入れていました。すると閉じているあいだも幅 152px の箱がそこに居座り、スピーカーボタンは左端ではなく枠の真ん中あたりまで押し出され、下にあった「1:25 / 3:00」の時刻表示と重なって読めなくなりました。直したのは、ポップオーバーを position: absolute; left: calc(100% + 4px) でボタンの真横へ浮かせ、時刻を右端へ逃がしたことです。開く箱を並びの中に置くと、閉じているあいだもずっとその場所の家賃を払い続けます。

2つ目は 02 のシークバーです。最初の版は塗りだけ transform: scaleX(.18) の固定値で、再生ヘッドが 54% まで進んでも青い区間は 20% のところで止まったままでした。塗り・バッファ・つまみ・ふきだしの4つを --p という数ひとつから計算し直すと、4つが必ず同じ瞬間を指します。ただしこの --pMDN の @property の解説にあるとおり syntax: "<number>" で登録しておかないと、キーフレームのあいだで補間されず途中で飛びます。数値として登録された変数だけが、アニメーションの対象になります。

3つ目は 05 の字幕です。出ていく文が 25% で消え、入ってくる文が 25% から 30% にかけて浮かび上がる作りにしたところ、字幕のない画面が1枚だけ記録されました。24フレームの収録は 4.1667% 刻みなので、ちょうど 25.0% の位置にサンプルが1枚あり、そこが入れ替えの空白と重なったからです。切り替えを 21.99% から 22% という 0.01% 幅のカットに詰め、なめらかさは translateY の立ち上がりだけに残すと、どのフレームにも字幕が1行あります。ここまでの3つを直したあとのソースは、解凍パスワード 3ekbtjj6 で開く zip に9つ分そろえてあります。

残りは短く書きます。06 は CSS が --p を動かしているのに文字は setInterval の自前の時計で回っていて、再生ヘッドがイントロにいるのに時計は 2:49 を指していました。requestAnimationFrame の中で getComputedStyle から --p を読み、同じフレームに合わせて直しています。08 は requestFullscreen() を呼びません。iframe は allow="fullscreen" が付いていないと要求を黙って断るので、舞台の中で transform: scale(1.3) に置き換えてあり、実ページへ移すときに Fullscreen API をつなげば本物になります。07 と 08 は、scale() で大きくなった箱が overflow: hidden の中でも文書の高さに数えられる点にも引っかかりました。09 で5つのキーを負の delay で順に光らせるときは animation ショートハンドを使えません。ショートハンドは animation-delay を 0 に戻すので、5つが同時に光ります。

アクセシビリティ — reduced-motion で何が残るか

@media (prefers-reduced-motion: reduce) の指定は9つ全部に入れてありますが、そこで止めるのは飾りの繰り返しだけです。背景の縞が流れる動きと自動デモのループは消え、押下・展開・音量・チャプターといった状態の値はそのまま残ります。動きを減らす設定は、情報まで減らしてよいという意味ではないからです。値を持つ 02・03・06 には role="slider"aria-valuemin aria-valuemax aria-valuenow が付き、MDN の slider ロールの解説が勧めるとおり3つとも aria-valuetext を添えていて、06 はそこへ章の名前まで入れて「導入 チャプター、0:24」のように読み上げます。オンとオフで意味が変わる 05・07 は aria-pressed、再生と一時停止で名前が変わる 01 は aria-label の入れ替え、04 は aria-haspopup="menu"aria-expanded に加えて各行が role="menuitemradio"aria-checked、08 は aria-expanded、09 は role="dialog" aria-modal="true" です。

フォーカスを移したり戻したりする 03・04・06・07・08・09 では focus({ preventScroll: true }) を使います。この部品は一覧ページに iframe で埋め込まれるので、素の focus() だと読んでいる人の画面が勝手に下へ引っ張られるからです。マウスなしで完結するのは 02・03・04・06・09 の5つで、↑↓・←→・Home・End・Enter・Space・Escape だけで操作しきれます。同じ場面で使う部品はメディアカテゴリに、押すと返事をする部品はクリックカテゴリにまとめてあります。

FAQ

動画ファイルがないのに、どうして時計が進むのですか

時計の数字は動画の再生位置ではなく、setIntervalrequestAnimationFrame で足していくただの秒数です。04 では選んだ倍率をその足し算に掛けているので、2倍を選ぶと数字も倍の速さで増えます。実ページでは、この秒数を <video>currentTime に差し替えれば、見た目はそのままで本物の再生位置になります。

素の <video controls> ではだめですか

短い動画を1本置くだけならそのままで十分です。自分で組む理由は見た目の好みではなく、標準のコントロールでは出せないものが要るときに生まれます。チャプター名の読み上げ、章の目盛り、ミニプレーヤー、ショートカット一覧などがそれにあたり、その代わりロールもキー操作も自分で引き受けることになります。

スマートフォンの幅でも崩れませんか

崩れませんでした。9つとも幅 320px で測ってはみ出しは 0 です。幅が狭いときは間隔を詰めるのではなく、340px 以下で時刻表示を隠すというように、優先度の低いものから外しています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。