CSSで、動画のサムネイルの上に再生ボタンを重ねる方法

​​CSS​HTML,JavaScript,Playwright

動画のサムネイルの上に再生ボタンを重ねるには、サムネイルの画像とボタンを同じ親に入れ、親に position: relative、ボタンに position: absolute; inset: 0; margin: auto; と幅・高さを付けます。サムネイルの幅が変わっても、ボタンは常に真ん中に来ます。ボタンの中の三角は、そのまま真ん中に置くと左に寄って見えるので、幅の6分の1だけ右へずらします。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML・CSS・JavaScript のページを Playwright で開き、要素の位置や計算後のスタイル、クリックしたときの動きを記録しました。動画とサムネイルは、検証のために ffmpeg で作った WebM と JPEG です。Firefox と Safari、スマートフォンの実機では試していません。

サムネイルのリンクに再生ボタンを重ねる

動画のページへ移るリンクの中に、サムネイルの img と、再生ボタンの span を入れます。例では、検証のページに合わせて、リンク先を #video-1 にしています。

html
<a class="thumb" href="#video-1">
  <img src="thumb.jpg" alt="動画:四角が右へ動くサンプル" width="640" height="360">
  <span class="play" aria-hidden="true"></span>
</a>
css
.thumb {
  position: relative;
  display: block;
  width: 320px;
}

.thumb img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

.play {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background: rgb(0 0 0 / 0.6);
  display: grid;
  place-items: center;
}

/* 白い三角 */
.play::before {
  content: "";
  width: 24px;
  height: 28px;
  background: #fff;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
  translate: 4px 0;
}

inset は top・right・bottom・left をまとめて書くプロパティです。inset: 0 で、ボタンを置く範囲をリンクの上下左右の端まで広げ、幅と高さを 64px に決めて margin: auto を付けます。

検証環境で、リンクの幅を 200px、320px、480px に変え、ボタンの中心とサムネイルの中心を比べました。どの幅でも、ずれは縦横とも 0px で、ボタンは真ん中に来ました。

ボタンの width と height を書かずに auto にすると、ボタンはリンクと同じ 320×180px に広がりました。この書き方では、ボタンの大きさを必ず決めておきます。

画像には aspect-ratio: 16 / 9 と object-fit: cover を付けています。MDN によると、cover では画像が縦横比を保ったまま枠全体を埋めるように拡大・縮小され、画像と枠の縦横比が合わないときは、はみ出した部分が切り取られます。

真ん中に置く書き方はほかに2つある

前の節の .play の position・inset・margin を、次のどちらかに置き換えても、上の3つの幅で、ボタンの中心とサムネイルの中心のずれは 0px でした。border-radius や background など、見た目の指定は前の節と同じです。

1つ目は、ボタンの左上の角をサムネイルの真ん中に置いてから、ボタンの大きさの半分だけ左上へ戻す書き方です。

css
.play {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  width: 64px;
  height: 64px;
}

translate プロパティの % は、translate() 関数と同じく、transform-box で決まる参照ボックスの大きさが基準です。検証では、ボタン自身の幅と高さの半分だけ戻りました。

2つ目は、リンクを grid にして、画像とボタンを同じマス目に重ねる書き方です。ボタンに position を付けません。

css
.thumb {
  position: relative;
  display: grid;
  width: 320px;
}

.thumb > * {
  grid-area: 1 / 1;
}

.thumb .play {
  margin: auto;
  width: 64px;
  height: 64px;
}

この2つは、ボタンの width と height を書かなくても真ん中に来ます。検証環境で、幅と高さを auto にして padding: 20px を付けると、ボタンは中の三角に合わせた 64×68px になり、どちらも中心のずれは 0px でした。

三角は、幅の6分の1だけ右へずらす

三角は clip-path の polygon(0 0, 100% 50%, 0 100%) で、四角を右向きの三角に切り抜いて作っています。

::before の箱を丸の真ん中に置いても、三角の白い部分は左に寄ります。右へとがった三角は、面積の多くが左側にあるからです。頂点が (0, 0)、(幅, 高さの半分)、(0, 高さ) の三角の重心は、3つの頂点の平均なので、左の辺から幅の3分の1の位置にあります。箱の真ん中(幅の2分の1)との差は、幅の6分の1です。

検証環境で、直径 64px の丸の部分のスクリーンショットを撮り、白い画素の位置の平均を求めました。丸の中心は x=31.5 です。

三角の置き方 白い画素の位置の平均(x)
箱を真ん中に置いただけ 27.22
translate で右へ 4px ずらす 31.22

幅 24px の三角なら、6分の1は 4px です。4px ずらすと、白い部分の重心が、丸の中心とほぼ同じ位置に来ました。

直径 64px の丸い再生ボタンを2つ縦に並べ、丸の中心にピンクの縦線を引いた画面。上の「ずらさない」は、白い三角の左の辺が縦線から大きく離れ、三角の大部分が線の左側にある。下の「右に 4px ずらす」は、三角が右へ移り、線の左右に白い部分が分かれている
三角を右へずらす前と後(表示を拡大。中心の縦線と説明の文字は画像用に足したもの)

リンクの外にボタンを置くと、クリックがリンクに届かない

上の例では、ボタンの span がリンクの中にあるので、ボタンの上をクリックしても、リンク先へ移りました。

レイアウトの都合で、ボタンをリンクの外(リンクの兄弟要素)に置くこともあります。

html
<div class="wrap">
  <a href="#video-5"><img src="thumb.jpg" alt="動画:四角が右へ動くサンプル" width="640" height="360"></a>
  <span class="play" aria-hidden="true"></span>
</div>
css
.wrap {
  position: relative;
  width: 320px;
}

.wrap a,
.wrap img {
  display: block;
}

.wrap img {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

.play は、最初の節と同じものです。

この形で、ボタンの真ん中をクリックしても、リンク先へは移りませんでした。document.elementFromPoint() で調べると、その位置にある要素はボタンの span で、クリックはリンクではなく span が受け取っています。

ボタンに pointer-events: none を付けると、同じ位置の要素は下の img になり、クリックでリンク先へ移りました。

css
.wrap .play {
  pointer-events: none;
}

pointer-events の none を付けた要素は、ポインターイベントの対象になりません。飾りとして重ねる要素に付けておくと、クリックは下の要素に届きます。

マウスを重ねたときと、キーボードで選んだときに目立たせる

リンクにマウスを重ねたときと、キーボードの Tab キーでリンクを選んだときに、ボタンを大きくして色を変えます。

css
.play {
  transition: scale 0.15s, background-color 0.15s;
}

.thumb:hover .play,
.thumb:focus-visible .play {
  scale: 1.1;
  background-color: #d85878;
}

検証環境では、サムネイルにマウスを重ねたときも、ページの先頭から Tab キーを1回押してリンクを選んだときも、ボタンの scale は 1.1、背景はピンクになりました。マウスでクリックしたあと、リンクにフォーカスが残ったままマウスを外へ動かしたときは、元の黒っぽい丸に戻りました。

:focus-visible は、フォーカスを見せるべきだとブラウザが判断したときだけ当てはまる疑似クラスで、マウスでクリックしたときは当てはまりませんでした。

再生ボタンの span には aria-hidden="true" を付けています。Playwright でアクセシビリティツリーを見ると、リンクの名前は img の alt から付き、ボタンは出てきませんでした。

text
- link "動画:四角が右へ動くサンプル":
  - /url: "#video-1"
  - img "動画:四角が右へ動くサンプル"

video の上に再生ボタンを置き、押したら再生する

ページを移らずに、その場で再生するときは、video の poster 属性にサムネイルを指定し、その上に button を重ねます。ボタンを押したら play() を呼び、再生が始まったらボタンを隠して、ブラウザの操作パネル(controls)を出します。

html
<div class="player">
  <video src="sample.webm" poster="thumb.jpg" preload="metadata" playsinline></video>
  <button class="play-btn" type="button" aria-label="動画を再生"></button>
</div>
css
.player {
  position: relative;
  width: 480px;
}

.player video {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  background: #000;
}

.play-btn {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 72px;
  height: 72px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgb(0 0 0 / 0.6);
  cursor: pointer;
  display: grid;
  place-items: center;
}

.play-btn::before {
  content: "";
  width: 26px;
  height: 30px;
  background: #fff;
  clip-path: polygon(0 0, 100% 50%, 0 100%);
  translate: 4px 0;
}

.play-btn:hover,
.play-btn:focus-visible {
  background-color: #d85878;
}

/* display: grid のままだと、hidden を付けても消えない */
.play-btn[hidden] {
  display: none;
}
js
for (const player of document.querySelectorAll('.player')) {
  const video = player.querySelector('video');
  const button = player.querySelector('.play-btn');
  button.addEventListener('click', () => video.play());
  video.addEventListener('play', () => {
    button.hidden = true;
    video.controls = true;
  });
  video.addEventListener('pause', () => {
    button.hidden = false;
  });
}

ボタンは video の兄弟要素ですが、ボタン自身がクリックを受け取って play() を呼ぶので、pointer-events: none は付けません。ボタンの名前は aria-label で付けていて、アクセシビリティツリーでは button "動画を再生" になりました。

video の上の再生ボタンを3回操作する。1回目はマウスを丸い再生ボタンに重ね、ボタンがピンクに変わる。2回目はボタンをクリックし、ボタンが消えて動画が動き出し、下に操作パネルが出て、表示が「再生中」に変わる。3回目は動画の左上寄りをクリックし、動画が止まって再生ボタンが真ん中に戻り、表示がピンクの「一時停止」に変わる
video の上の再生ボタンを、マウスで3回操作したときの動き(マウスの絵、クリックの輪、下の表示は GIF 用に足したもの)

controls を出したあと、再生中の動画をクリックすると、Chromium では動画が一時停止し、pause イベントでボタンの hidden が false に戻りました。最後まで再生したときも、pause、ended の順にイベントが届き、ボタンが戻りました。

display を指定したボタンは、hidden を付けても消えない

CSS の最後の .play-btn[hidden] の行を書かずに試すと、ボタンを押して再生が始まっても、ボタンが動画の真ん中に残りました。button.hidden は true になっていましたが、計算後の display は grid のままでした。.play-btn[hidden] { display: none; } を足すと、再生中の display は none になり、ボタンは消えました。

MDN の hidden 属性のページによると、ブラウザは hidden の状態を display: none で実現でき、要素の display を別の値にすると、その状態が上書きされて、hidden 属性があっても表示されます。三角を真ん中に置くために display: grid や display: flex をボタンに書いたときは、[hidden] の行も一緒に書いておきます。

​Posted by 86