video player css で組む カスタム動画プレーヤー 9選 — 動画ファイルなしで動く
video player css とは、mp4 を1本も置かずに、再生ボタン・シークバー・音量つまみといった操作まわりだけを HTML と CSS で組み立てるやり方です。ここに並ぶ9つは絵ではなく、押せばその場で状態が変わります。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 再生・一時停止モーフボタン
- 02 時間ツールチップ付きシークバー
- 03 音量スライダーポップオーバー
- 04 再生速度メニュー
- 05 字幕トグルと字幕スタイル
- 06 チャプターマーカーシークバー
- 07 ミニプレーヤー(PiP風)
- 08 フルスクリーントグル
- 09 キーボードショートカットのオーバーレイ
並びは人気順ではなく、動画を見る人が触る順番です。まず再生を押し(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つのアイコンへ rotate と scale を反対向きに当てているだけです。
.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 という数ひとつだけです。
@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フレームのうちに差し替えます。ポップオーバーは並びの中ではなく、ボタンの横に浮かせて置いています。
.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 で閉じるとフォーカスがボタンへ戻ります。
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 に残ります。
@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本です。
.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風)
ミニボタンを押すとプレーヤーが右下の小さなカードへ縮んで浮かび、元の場所には破線の枠と「ミニプレーヤーで再生中」の一行が残ります。閉じるボタンで元の大きさに戻ります。別ウィンドウは出さず、top も width も触らずに transform ひとつで移動と縮小をまとめています。
$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 を打つと戻り、戻った先でフォーカスはボタンに残ります。読み上げ向けのボタン名も、そのときの状態に合わせて入れ替えています。
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 だけで作っています。
.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つが必ず同じ瞬間を指します。ただしこの --p は MDN の @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
動画ファイルがないのに、どうして時計が進むのですか
時計の数字は動画の再生位置ではなく、setInterval か requestAnimationFrame で足していくただの秒数です。04 では選んだ倍率をその足し算に掛けているので、2倍を選ぶと数字も倍の速さで増えます。実ページでは、この秒数を <video> の currentTime に差し替えれば、見た目はそのままで本物の再生位置になります。
素の <video controls> ではだめですか
短い動画を1本置くだけならそのままで十分です。自分で組む理由は見た目の好みではなく、標準のコントロールでは出せないものが要るときに生まれます。チャプター名の読み上げ、章の目盛り、ミニプレーヤー、ショートカット一覧などがそれにあたり、その代わりロールもキー操作も自分で引き受けることになります。
スマートフォンの幅でも崩れませんか
崩れませんでした。9つとも幅 320px で測ってはみ出しは 0 です。幅が狭いときは間隔を詰めるのではなく、340px 以下で時刻表示を隠すというように、優先度の低いものから外しています。