デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
サムネイルを押すと、その写真がサムネイルの位置と大きさから画面の中央へ広がり、後ろが暗くなります。サムネイルは正方形に切り抜いてありますが、広がるにつれて切り抜きがほどけ、3:2 の全体が見えてきます。閉じるときは同じ道をたどって、押したサムネイルの位置へ縮んで戻ります。ページを移らずに一覧の上へ重ねて開くので、見終わると元の場所から続けて眺められます。広がる前の位置と大きさを測っておき、行き先の枠をその見た目から動かす FLIP という型で、コードの節では GSAP の Flip プラグインで組んでいます。
広がるのは 0.7 秒、縮むのは 0.6 秒で、どちらも power3.inOut です。幕も写真と同じ長さと同じ曲線で暗くし、広がりきったときにちょうど暗くなりきるようにします。写真より先に暗くなりきると、真っ暗な画面の中を小さな写真だけが動いて見える時間ができます。題と × は広がりきる少し前から 0.3 秒で出し、閉じるときは最初の 0.15 秒で消します。
大きさは scale ではなく、幅と高さで補間します。scale で縮めると、正方形から横長へ比が変わる途中で、写真が横に引き伸ばされて見えます。枠の中の写真を object-fit の cover にしておけば、枠が変わるたびに写真が枠を覆い直すので、歪まずに見える範囲だけが広がります。
ライトボックスはダイアログの要素に入れて showModal で開くので、焦点の閉じ込めと後ろの締め出しはブラウザに任せられます。Esc は cancel を止めて同じ動きで閉じ、閉じたら押したサムネイルへ焦点を戻します。Safari はリンクを押しても焦点を移さないので、戻し先を自分で指定しておきます。サムネイルを大きい画像へのリンクにしておくと、スクリプトが動かない環境でも写真を開けます。
大きい画像が届くまでは、読み込み済みのサムネイルの画像を先に見せておくと、広がる途中の枠が空になりません。縦長の写真も、寸法を書いておけば同じ式で画面に収まります。前後の写真へ送る形にするなら、閉じるときに、そのとき見ている写真のサムネイルへ縮めます。作品集やフォトギャラリー、商品の画像の拡大に向いています。
コード
<!-- サムネイルは大きい画像へのリンクにしておく(スクリプトが無くても画像は開ける)。
data-width と data-height に大きい画像の寸法、alt に写真の説明を入れる -->
<ul class="gallery">
<li>
<a class="gallery__item" href="/images/salt-flats.jpg" data-width="1500" data-height="1000" data-caption="塩湖の朝・2025年3月">
<img src="/images/salt-flats-thumb.jpg" alt="白い塩の平原に、人が一人小さく立っている" width="400" height="400" />
</a>
</li>
<li>
<a class="gallery__item" href="/images/harbor.jpg" data-width="1500" data-height="1000" data-caption="夕暮れの港・2025年9月">
<img src="/images/harbor-thumb.jpg" alt="夕暮れの港。水平線に太陽が沈んでいく" width="400" height="400" />
</a>
</li>
<li>
<a class="gallery__item" href="/images/first-snow.jpg" data-width="1000" data-height="1500" data-caption="初雪・2026年1月">
<img src="/images/first-snow-thumb.jpg" alt="雪の積もった野原に、針葉樹が並んでいる" width="400" height="400" />
</a>
</li>
</ul>
<!-- showModal() で開く <dialog> には、焦点の閉じ込め・背面の締め出し・Esc が付いてくる。
dialog は画面いっぱいに広げ、幕・写真・× を中に置く(幕も写真と一緒に GSAP で動かすため) -->
<dialog class="lightbox" aria-label="写真の拡大表示">
<div class="lightbox__scrim"></div>
<figure class="lightbox__figure">
<div class="lightbox__frame" data-flip-id="lightbox"><img class="lightbox__image" alt="" /></div>
<figcaption class="lightbox__caption"></figcaption>
</figure>
<button class="lightbox__close" type="button" aria-label="閉じる"></button>
</dialog>
.gallery,
.lightbox {
--ink: #262626; /* 一覧の焦点の枠 */
--on-dark: #eeece6; /* ライトボックスの文字と線 */
--scrim: rgba(17, 17, 17, 0.92); /* 後ろを暗くする幕 */
--radius: 4px; /* 写真の角 */
}
/* 開いているあいだは後ろをスクロールさせない。バーの幅を先に空けておき、消えたときにページが横へずれないようにする */
html { scrollbar-gutter: stable; }
html:has(.lightbox[open]) { overflow: hidden; }
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 24px;
margin: 0;
padding: 0;
list-style: none;
}
.gallery__item { display: block; border-radius: var(--radius); cursor: zoom-in; }
/* サムネイルは正方形に切り抜く。開くと、写真の全体が見えてくる */
.gallery__item img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 1;
border-radius: var(--radius);
object-fit: cover;
}
.gallery__item:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* dialog を画面いっぱいに広げる(既定の余白・枠・最大の大きさを外す) */
.lightbox {
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: none;
overflow: hidden;
cursor: zoom-out;
}
.lightbox::backdrop { background: none; }
.lightbox__scrim { position: absolute; inset: 0; background-color: var(--scrim); }
/* 写真と題を画面の中央に置く */
.lightbox__figure {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 16px;
margin: 0;
}
/* 枠は写真の縦横比(--ratio)のまま、画面の幅と高さの両方に収まる大きさにする。中の写真は枠を覆う */
.lightbox__frame {
width: min(1080px, 100vw - 48px, (100dvh - 176px) * var(--ratio, 1.5));
aspect-ratio: var(--ratio, 1.5);
overflow: hidden;
border-radius: var(--radius);
}
.lightbox__image { display: block; width: 100%; height: 100%; object-fit: cover; }
.lightbox__caption { color: var(--on-dark); font-size: 14px; letter-spacing: 0.04em; }
/* アイコンだけのボタンは線の丸。× は線 2 本で描く */
.lightbox__close {
position: absolute;
top: 24px;
right: 24px;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--on-dark);
background: none;
cursor: pointer;
}
.lightbox__close::before,
.lightbox__close::after { content: ""; position: absolute; inset: 19px 12px; border-radius: 1px; background-color: var(--on-dark); rotate: 45deg; }
.lightbox__close::after { rotate: -45deg; }
.lightbox__close:focus-visible { outline: 2px solid var(--on-dark); outline-offset: 4px; }
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 dialog = document.querySelector(".lightbox");
const scrim = dialog.querySelector(".lightbox__scrim");
const frame = dialog.querySelector(".lightbox__frame");
const image = frame.querySelector(".lightbox__image");
const caption = dialog.querySelector(".lightbox__caption");
const chrome = [caption, dialog.querySelector(".lightbox__close")];
let opener = null; // 開いたサムネイルのリンク。閉じるときは、写真をここへ縮めて戻す
let motion = null;
const run = (timeline) => {
motion?.kill();
motion = timeline.timeScale(SPEED);
};
function open(link) {
const thumb = link.querySelector("img");
opener = link;
// 大きい画像が届くまでは、読み込み済みのサムネイルの画像を見せておく
image.src = thumb.currentSrc;
image.src = link.href;
image.alt = thumb.alt;
caption.textContent = link.dataset.caption;
frame.style.setProperty("--ratio", link.dataset.width / link.dataset.height);
// 開く前のサムネイルの位置と大きさを測る。同じ flipId を付けた枠を、この見た目から広げる(FLIP)
thumb.dataset.flipId = frame.dataset.flipId;
const state = Flip.getState(thumb);
delete thumb.dataset.flipId;
dialog.showModal(); // 焦点が中へ移り、背面は操作できなくなる
thumb.style.visibility = "hidden"; // 写真が一覧から抜け出したように見せる
run(
gsap
.timeline()
// 大きさは width と height で補間する(scale にすると写真が歪む)。枠が広がるにつれ、切り抜きがほどけていく
.add(Flip.from(state, { targets: frame, absolute: true, ...OPEN }), 0)
.fromTo(scrim, { opacity: 0 }, { opacity: 1, ...OPEN }, 0)
.fromTo(chrome, { opacity: 0 }, { opacity: 1, duration: 0.3 }, 0.5)
);
}
// 同じ道をたどって、サムネイルの位置と大きさへ縮める。縮みきってから close() を呼ぶ
function close() {
run(
gsap
.timeline({ onComplete: () => dialog.close() })
.to(chrome, { opacity: 0, duration: 0.15 }, 0)
.add(Flip.fit(frame, opener.querySelector("img"), { absolute: true, ...CLOSE }), 0)
.to(scrim, { opacity: 0, ...CLOSE }, 0)
);
}
document.querySelectorAll(".gallery__item").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
open(link);
});
});
// 幕・写真・× のどこを押しても閉じる
dialog.addEventListener("click", close);
// Esc は既定だとその場で閉じるので、いったん止めて同じ動きで閉じる
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
close();
});
// 閉じたら元に戻す。Esc を続けて押すと、ブラウザが動きを待たずに閉じることもある
dialog.addEventListener("close", () => {
motion?.kill();
gsap.set([frame, scrim, ...chrome], { clearProps: "all" });
opener.querySelector("img").style.visibility = "";
// Safari はリンクを押しても焦点を移さず、閉じたあとの戻し先が無くなるので、押したサムネイルへ戻す
opener.focus({ preventScroll: true });
});