デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
一覧の動画のサムネイルを押すと、後ろが暗くなり、サムネイルがその位置と大きさのまま画面の中央へ広がっていきます。広がりきるころには中央の再生ボタンが消え、下に一時停止のボタンと時間、進みのバーが表示されて、バーが動き始めます。閉じるときは来た道をたどって、押したサムネイルへ縮んで戻ります。
広がるのは0.7秒、縮むのは0.6秒で、どちらも power3.inOut。後ろを暗くするオーバーレイも同じ長さとイージングにそろえたので、画面が広がりきるのと暗くなりきるのが同時です。動きは GSAP の Flip で組みました。サムネイルの位置と大きさを測ってから再生画面を開き、測った見た目から補間しています。
サムネイルも再生画面も16:9なので、切り抜く範囲は変わりません。それなら scale で拡大すれば済みそうですが、画面の中にある再生ボタンや操作の列まで一緒に大きくなってしまうので、幅と高さで補間しています。広がったあとの大きさは外側の要素で先に確保しておき、大きさが変わるのは再生画面の要素だけにしました。閉じるボタンや題の位置が動かないのはそのおかげです。
再生が始まるのは、広がりきってからです。動画が届くまでは読み込み済みのサムネイルの画像を poster として表示しておくので、広がる途中で画面が黒くなることはありません。進みのバーは timeupdate に任せると1秒に4回ほどしか更新されず、かくついて見えます。開いている間は毎フレーム currentTime を読んで描くようにしました。開くのは showModal に任せ、Esc でも同じ動きで閉じて、閉じたあとは押したサムネイルへフォーカスを戻します。
コード
<section class="movies" aria-labelledby="movies-heading">
<p class="movies__eyebrow">Movie</p>
<h2 class="movies__heading" id="movies-heading">制作事例</h2>
<!-- サムネイルは動画へのリンクにしておく(スクリプトが無くても動画は開ける)。data-date は再生画面の題の横に表示する -->
<ul class="movies__list">
<li class="movies__item">
<a class="movies__thumb" href="/movies/harbor-bakery.mp4" data-date="2026年2月公開" aria-label="港町のパン屋のブランドムービーを再生する(0:45)">
<img src="/img/harbor-bakery.jpg" alt="" width="640" height="360" />
<span class="movies__length" aria-hidden="true">0:45</span>
</a>
<h3 class="movies__title">港町のパン屋のブランドムービー</h3>
<p class="movies__text">ここに説明文が入ります。</p>
</li>
<li class="movies__item">
<a class="movies__thumb" href="/movies/seaside-hotel.mp4" data-date="2026年3月公開" aria-label="海辺のホテルの採用ムービーを再生する(0:30)">
<img src="/img/seaside-hotel.jpg" alt="" width="640" height="360" />
<span class="movies__length" aria-hidden="true">0:30</span>
</a>
<h3 class="movies__title">海辺のホテルの採用ムービー</h3>
<p class="movies__text">ここに説明文が入ります。</p>
</li>
<li class="movies__item">
<a class="movies__thumb" href="/movies/tea-town.mp4" data-date="2025年11月公開" aria-label="茶畑のある町の観光ムービーを再生する(1:20)">
<img src="/img/tea-town.jpg" alt="" width="640" height="360" />
<span class="movies__length" aria-hidden="true">1:20</span>
</a>
<h3 class="movies__title">茶畑のある町の観光ムービー</h3>
<p class="movies__text">ここに説明文が入ります。</p>
</li>
</ul>
</section>
<!-- showModal() で開く <dialog> には、フォーカスの閉じ込め・背面の無効化・Esc が付いてくる -->
<dialog class="player" aria-labelledby="player-title">
<div class="player__scrim"></div>
<div class="player__body">
<button class="player__close" type="button">閉じる</button>
<!-- player__screen は広がりきった大きさの場所を取っておく。その中で、サムネイルの位置と大きさから player__frame が広がる -->
<div class="player__screen">
<div class="player__frame" data-flip-id="player">
<video class="player__video" playsinline preload="none"></video>
<span class="player__play" aria-hidden="true"></span>
<div class="player__bar">
<button class="player__toggle" type="button" data-play="再生" data-pause="一時停止" aria-label="一時停止"></button>
<span class="player__time">0:00 / 0:00</span>
<span class="player__track" aria-hidden="true"><span class="player__fill"></span></span>
</div>
</div>
</div>
<p class="player__info"><strong class="player__title" id="player-title"></strong><span class="player__date"></span></p>
</div>
</dialog>
.movies,
.player {
--ink: #262626;
--mute-small: #6b6b6b;
--on-video: #ffffff; /* 動画の上の操作と、再生画面の文字 */
--mute-on-dark: #dedcd6;
--scrim: rgba(17, 17, 17, 0.9); /* 後ろを暗くするオーバーレイ */
--radius: 4px;
}
/* 開いているあいだは後ろをスクロールさせない。スクロールバーの幅を先に空けておき、消えたときにページが横へずれないようにする */
html { scrollbar-gutter: stable; }
html:has(.player[open]) { overflow: hidden; }
.movies { color: var(--ink); }
.movies__eyebrow { margin: 0; color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; line-height: 1; text-transform: uppercase; }
.movies__heading { margin: 16px 0 0; font-size: clamp(32px, 4vw, 44px); font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; }
.movies__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 32px; margin: 56px 0 0; padding: 0; list-style: none; }
.movies__title { margin: 20px 0 0; font-size: 20px; letter-spacing: -0.01em; line-height: 1.3; word-break: auto-phrase; } /* 和文を文節の切れ目で折り返す */
.movies__text { margin: 10px 0 0; font-size: 14px; letter-spacing: 0.04em; line-height: 1.7; }
.movies__thumb { position: relative; display: block; border-radius: var(--radius); }
.movies__thumb img { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; border-radius: var(--radius); object-fit: cover; }
.movies__thumb:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 長さのバッジ */
.movies__length { position: absolute; right: 12px; bottom: 12px; padding: 5px 8px; border-radius: 3px; background-color: rgba(17, 17, 17, 0.6); color: var(--on-video); font-size: 12px; font-weight: 600; line-height: 1; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
/* 再生ボタン(白い線の丸と三角)。サムネイルと再生画面の中央に、同じ大きさで置く */
.movies__thumb::before,
.player__play::before { content: ""; position: absolute; inset: calc(50% - 32px); border-radius: 50%; background-color: rgba(17, 17, 17, 0.45); box-shadow: inset 0 0 0 1.5px var(--on-video); }
.movies__thumb::after,
.player__play::after { content: ""; position: absolute; inset: calc(50% - 9px) calc(50% - 7px) calc(50% - 9px) calc(50% - 11px); background-color: var(--on-video); clip-path: polygon(0 0, 100% 50%, 0 100%); }
.movies__thumb::before,
.movies__thumb::after { z-index: 1; }
/* dialog を画面いっぱいに広げる(既定の余白・枠線・最大の大きさを外す) */
.player { width: 100%; max-width: none; height: 100%; max-height: none; margin: 0; padding: 0; border: 0; background: none; overflow: clip; color: var(--on-video); }
.player::backdrop { background: none; }
.player__scrim { position: absolute; inset: 0; background-color: var(--scrim); }
/* 閉じるボタン・画面・題を、画面の中央に縦に並べる */
.player__body { position: absolute; inset: 0; display: grid; grid-template-columns: min(896px, 100vw - 32px, (100dvh - 200px) * 16 / 9); place-content: center; justify-items: start; gap: 16px; pointer-events: none; }
.player__body > * { pointer-events: auto; }
.player__screen { position: relative; width: 100%; aspect-ratio: 16 / 9; }
.player__frame {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: var(--radius);
background-color: #000000;
}
.player__video { display: block; width: 100%; height: 100%; object-fit: cover; }
.player__play { position: absolute; inset: 0; pointer-events: none; }
/* 下の操作の列。暗いグラデーションの上に、一時停止・時間・進みのバー */
.player__bar { position: absolute; inset: auto 0 0; display: flex; align-items: center; gap: 16px; padding: 48px 24px 20px; background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.55)); }
.player__toggle { position: relative; flex: none; width: 20px; height: 20px; padding: 0; border: 0; background: none; color: var(--on-video); cursor: pointer; }
.player__toggle::before { content: ""; position: absolute; inset: 3px 4px; border-inline: 4px solid currentColor; }
.player[data-paused] .player__toggle::before { /* 止まっている間は再生の三角 */ inset: 3px 4px 3px 5px; border: 0; background-color: currentColor; clip-path: polygon(0 0, 100% 50%, 0 100%); }
.player__toggle:focus-visible { outline: 2px solid var(--on-video); outline-offset: 3px; }
.player__time { flex: none; min-width: 84px; font-size: 13px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.player__track { flex: 1; height: 4px; background-color: rgba(255, 255, 255, 0.3); }
.player__fill { display: block; height: 100%; background-color: var(--on-video); transform: scaleX(0); transform-origin: left; }
/* 閉じるボタン: 線のピル型に「閉じる」と × */
.player__close {
display: inline-flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 18px 0 20px;
justify-self: end;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--on-video);
background: none;
color: var(--on-video);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.player__close::after { content: ""; width: 14px; height: 14px; background: linear-gradient(45deg, transparent calc(50% - 1px), currentColor 0 calc(50% + 1px), transparent 0), linear-gradient(-45deg, transparent calc(50% - 1px), currentColor 0 calc(50% + 1px), transparent 0); }
.player__close:focus-visible { outline: 2px solid var(--on-video); outline-offset: 4px; }
.player__info { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; margin: 4px 0 0; }
.player__title { font-size: 18px; letter-spacing: 0.02em; }
.player__date { color: var(--mute-on-dark); font-size: 13px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
gsap.registerPlugin(Flip);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const OPEN = { duration: 0.7, ease: "power3.inOut" }; // 広がる動き。オーバーレイも同じ速さで暗くする
const CLOSE = { duration: 0.6, ease: "power3.inOut" }; // 縮んで戻る動き
const clock = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const dialog = document.querySelector(".player"), find = (name) => dialog.querySelector(`.player__${name}`);
const [scrim, frame, video, play, toggle, time, fill] = ["scrim", "frame", "video", "play", "toggle", "time", "fill"].map(find);
const chrome = ["bar", "info", "close"].map(find); // 操作の列・題・閉じるボタン
let opener = null; // 開いたサムネイルのリンク。閉じるときは、画面をここへ縮めて戻す
let motion = null;
const run = (timeline) => { motion?.kill(); motion = timeline.timeScale(SPEED); };
// 進みのバーと時間の表示。timeupdate は 1 秒に 4 回ほどしか来ないので、開いている間は毎フレーム描く
const update = () => {
const length = video.duration || 0;
gsap.set(fill, { scaleX: length ? video.currentTime / length : 0 });
time.textContent = `${clock(video.currentTime)} / ${clock(length)}`;
};
const syncToggle = () => {
dialog.toggleAttribute("data-paused", video.paused);
toggle.setAttribute("aria-label", video.paused ? toggle.dataset.play : toggle.dataset.pause);
};
function open(link) {
const thumb = link.querySelector("img");
opener = link;
// 届くまでは、読み込み済みのサムネイルの画像を表示しておく。同じ動画なら、続きから再生する
video.poster = thumb.currentSrc;
if (video.getAttribute("src") !== link.getAttribute("href")) video.src = link.href;
find("title").textContent = link.closest("li").querySelector(".movies__title").textContent;
find("date").textContent = link.dataset.date;
// 開く前のサムネイルの位置と大きさを測る。同じ flipId を付けた再生画面を、この見た目から広げる(FLIP)
thumb.dataset.flipId = frame.dataset.flipId;
const state = Flip.getState(thumb);
delete thumb.dataset.flipId;
dialog.showModal(); // フォーカスが中へ移り、背面は操作できなくなる
link.style.visibility = "hidden"; // 画面が一覧から抜け出したように見せる
update(); syncToggle();
gsap.ticker.add(update);
run(
gsap
.timeline({ onComplete: () => video.play().catch(syncToggle) }) // 広がりきってから再生する
// 大きさは width と height で補間する(scale にすると、中の再生ボタンまで拡大される)
.add(Flip.from(state, { targets: frame, ...OPEN }), 0)
.fromTo(scrim, { opacity: 0 }, { opacity: 1, ...OPEN }, 0)
.fromTo(play, { opacity: 1 }, { opacity: 0, duration: 0.3 }, 0.4)
.fromTo(chrome, { opacity: 0 }, { opacity: 1, duration: 0.3 }, 0.5)
);
}
// 同じ経路をたどって、サムネイルの位置と大きさへ縮める。縮みきってから close() を呼ぶ
function close() {
video.pause();
run(
gsap
.timeline({ onComplete: () => dialog.close() })
.to(chrome, { opacity: 0, duration: 0.15 }, 0)
.to(play, { opacity: 1, duration: 0.2 }, 0)
.add(Flip.fit(frame, opener.querySelector("img"), CLOSE), 0)
.to(scrim, { opacity: 0, ...CLOSE }, 0)
);
}
document.querySelectorAll(".movies__thumb").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
open(link);
});
});
toggle.addEventListener("click", () => (video.paused ? video.play() : video.pause()));
video.addEventListener("play", syncToggle);
video.addEventListener("pause", syncToggle);
video.addEventListener("click", () => toggle.click()); // 画面を押しても、再生と一時停止を切り替える
find("close").addEventListener("click", close);
scrim.addEventListener("click", close);
// Esc は既定だとその場で閉じるので、いったん止めて同じ動きで閉じる
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
close();
});
// 閉じたら元に戻す。Esc を続けて押すと、ブラウザが動きを待たずに閉じることもある
dialog.addEventListener("close", () => {
motion?.kill();
video.pause();
gsap.ticker.remove(update);
gsap.set([frame, scrim, play, ...chrome], { clearProps: "all" });
opener.style.visibility = "";
// Safari はリンクを押してもフォーカスを移さず、閉じたあとの戻し先が無くなるので、押したサムネイルへ戻す
opener.focus({ preventScroll: true });
});