デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
カードを押すと、カードと同じ見た目の面が、カードの位置と大きさから画面いっぱいまで広がり、左半分に画像、右半分に説明が並ぶ詳細画面になります。広がる前の位置と大きさを測っておき、広がりきった見た目との間を補間する FLIP という型です。面の位置・幅・高さと角の丸みを動かし、中の画像も「カードの上側」から「画面の左半分」へ、別に大きさを補間します。閉じるときは同じタイムラインを逆にたどり、押したカードの位置へ縮んで戻ります。
広がるのは 0.9 秒の power3.inOut です。文字は補間せず、カードの文字は最初の 0.2 秒で消し、詳細の文字は 0.56 秒(広がりの 6 割ほど)から 0.06 秒ずつずらして 28px 下から出します。ほかのカードは 0.5 秒で濃さ 35%・大きさ 95% に下げ、押したカードだけが前へ出てくるように見せます。× は 0.63 秒から回りながら出ます。逆にたどるので、閉じるときは詳細の文字が消えてから面が縮み始めます。
transform の scale で広げる本来の FLIP は軽いのですが、中の画像と文字まで一緒に引き伸ばされ、縦長のカードから横長の画面へ比が変わる途中で歪みます。中身ごと広げるなら、幅と高さを補間します。位置は開くたびに測ります(スクロールや画面の大きさで変わるため)。広がりきったら位置と大きさの指定を外して CSS に任せ、閉じるときに測り直すと、開いているあいだに画面の大きさが変わっても、元のカードへぴったり戻ります。
詳細はダイアログの要素に入れて showModal で開くので、焦点の閉じ込めと後ろの締め出しはブラウザに任せられます。× を詳細の先頭に置くと、開いたとき焦点がまず × に入ります。Esc は cancel を止めて同じ動きで閉じ、閉じたら押したカードへ焦点を戻します。狭い画面では、画像を上に、文字をその下に並べます。
つまみは、広がる時間とイージング、詳細の文字が出始める時刻、ほかのカードを下げる濃さです。ページを移る遷移にするなら、閉じるボタンの代わりに詳細ページの URL へ進めます。ポートフォリオの一覧から作品の詳細へ、商品カードから詳しい説明へ、記事カードから本文へ、といった場面に向いています。
コード
<section class="works" aria-labelledby="works-title">
<div class="works__head">
<h2 class="works__title" id="works-title">制作実績</h2>
<p class="works__range">2024〜2026年</p>
</div>
<ul class="works__grid">
<li class="work">
<!-- カード。押すと、この位置と大きさから下の詳細が広がる。--tint は写真が届くまでの地の色 -->
<button class="work__card" type="button" aria-haspopup="dialog" style="--tint: #d9ba92">
<span class="work__art"><img src="/img/work-harbor.jpg" alt="" width="1280" height="1440"></span>
<span class="work__meta">
<span><span class="work__tag">ブランディング</span> <span class="work__name">しおさい荘</span></span>
<span class="work__year">2026年</span>
</span>
</button>
<!-- 詳細。showModal() で開くと、焦点の閉じ込めと Esc が付いてくる。× を先に置き、開いたら焦点がまず × に入る -->
<dialog class="work__detail" aria-labelledby="work-harbor">
<button class="work__close" type="button" aria-label="閉じる"></button>
<div class="work__text">
<p class="work__kicker">ブランディング・2026年</p>
<h3 class="work__title" id="work-harbor">しおさい荘</h3>
<p class="work__lead">港町の宿のリニューアル</p>
<p class="work__body">ここに説明文が入ります。</p>
<dl class="work__facts">
<div><dt>担当</dt><dd>ロゴ・印刷物</dd></div>
<div><dt>チーム</dt><dd>4名</dd></div>
<div><dt>公開</dt><dd>2026年</dd></div>
</dl>
<a class="work__link" href="/work/harbor/">事例を詳しく見る →</a>
</div>
</dialog>
</li>
<li class="work">
<button class="work__card" type="button" aria-haspopup="dialog" style="--tint: #475c6a">
<span class="work__art"><img src="/img/work-orbit.jpg" alt="" width="1280" height="1440"></span>
<span class="work__meta">
<span><span class="work__tag">プロダクトデザイン</span> <span class="work__name">ひより</span></span>
<span class="work__year">2026年</span>
</span>
</button>
<dialog class="work__detail" aria-labelledby="work-orbit">
<button class="work__close" type="button" aria-label="閉じる"></button>
<div class="work__text">
<p class="work__kicker">プロダクトデザイン・2026年</p>
<h3 class="work__title" id="work-orbit">ひより</h3>
<p class="work__lead">予定管理アプリの画面設計</p>
<p class="work__body">ここに説明文が入ります。</p>
<dl class="work__facts">
<div><dt>担当</dt><dd>アプリ・Web</dd></div>
<div><dt>チーム</dt><dd>6名</dd></div>
<div><dt>公開</dt><dd>2026年</dd></div>
</dl>
<a class="work__link" href="/work/orbit/">事例を詳しく見る →</a>
</div>
</dialog>
</li>
<li class="work">
<button class="work__card" type="button" aria-haspopup="dialog" style="--tint: #eddcd8">
<span class="work__art"><img src="/img/work-bloom.jpg" alt="" width="1280" height="1440"></span>
<span class="work__meta">
<span><span class="work__tag">キャンペーン</span> <span class="work__name">花めぐり</span></span>
<span class="work__year">2025年</span>
</span>
</button>
<dialog class="work__detail" aria-labelledby="work-bloom">
<button class="work__close" type="button" aria-label="閉じる"></button>
<div class="work__text">
<p class="work__kicker">キャンペーン・2025年</p>
<h3 class="work__title" id="work-bloom">花めぐり</h3>
<p class="work__lead">春の新作キャンペーン</p>
<p class="work__body">ここに説明文が入ります。</p>
<dl class="work__facts">
<div><dt>担当</dt><dd>広告・SNS</dd></div>
<div><dt>チーム</dt><dd>8名</dd></div>
<div><dt>公開</dt><dd>2025年</dd></div>
</dl>
<a class="work__link" href="/work/bloom/">事例を詳しく見る →</a>
</div>
</dialog>
</li>
</ul>
</section>
.works {
--ground: #f2f1ed; /* 地 */
--surface: #ffffff; /* カードと詳細の面 */
--ink: #262626; /* 文字と、× の線 */
--mute: #6b6b6b; /* 補足の文字 */
--line: rgba(38, 38, 38, 0.12); /* 区切り線 */
--radius: 4px; /* カードの角。広がると 0 になる */
--gutter: clamp(16px, 7.5vw, 96px);
padding: 64px var(--gutter);
background: var(--ground);
color: var(--ink);
}
/* 開いているあいだは後ろをスクロールさせない。バーの幅を先に空けておき、カードの位置がずれないようにする */
html { scrollbar-gutter: stable; }
html:has(.work__detail[open]) { overflow: hidden; }
.works__head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: last baseline; gap: 16px 32px; margin-bottom: 48px; }
.works__title { margin: 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.works__range { margin: 0; color: var(--mute); font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.works__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 32px; margin: 0; padding: 0; list-style: none; }
/* カード(白い面の上 7 割に画像、下に分類・作品名・年)。開くときは、この画像と文字を写した面(.work__panel)が広がる */
.work__card,
.work__panel { position: relative; overflow: hidden; background: var(--surface); color: var(--ink); }
.work__card { display: block; width: 100%; aspect-ratio: 341 / 448; padding: 0; border: 0; border-radius: var(--radius); font: inherit; text-align: left; cursor: pointer; }
.work__card:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.work__art { position: absolute; top: 0; left: 0; width: 100%; height: 70%; }
.work__art img { display: block; width: 100%; height: 100%; object-fit: cover; background: var(--tint); }
.work__meta { position: absolute; left: 24px; right: 24px; bottom: 24px; display: flex; justify-content: space-between; align-items: last baseline; gap: 16px; }
.work__tag { display: block; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
.work__name { display: block; margin-top: 8px; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.work__year { color: var(--mute); font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
/* 詳細。dialog を画面いっぱいに広げ、地は透明にする(広がる途中は、後ろの一覧が見える) */
.work__detail {
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: none;
color: var(--ink);
overflow: hidden;
}
.work__detail::backdrop { background: none; }
/* 広がりきったときの見た目。画像は左半分、文字は右半分(JS はここへ向けて補間する) */
.work__panel { position: absolute; inset: 0; }
.work__panel .work__art { width: 50%; height: 100%; }
.work__text { position: absolute; inset: 0 0 0 50%; overflow-y: auto; padding: clamp(96px, 20vh, 144px) var(--gutter) 64px 64px; }
.work__kicker { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
.work__title { margin: 16px 0 0; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.work__lead { margin: 32px 0 0; font-size: clamp(20px, 2.4vw, 24px); font-weight: 700; line-height: 1.4; letter-spacing: 0.04em; }
.work__body { margin: 16px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
/* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
.work__lead,
.work__body { word-break: auto-phrase; }
.work__facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 32px 0 0; padding-top: 16px; border-top: 1px solid var(--line); }
.work__facts dt { color: var(--mute); font-size: 13px; letter-spacing: 0.04em; }
.work__facts dd { margin: 4px 0 0; font-size: 16px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.work__link { display: inline-block; margin-top: 32px; color: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; text-underline-offset: 0.4em; }
/* 閉じるボタン(線の丸)。画像の上に来る狭い画面でも読めるよう、面の色で塗る。× は線 2 本で描く */
.work__close {
position: absolute;
top: 32px;
right: var(--gutter);
z-index: 1;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
box-shadow: inset 0 0 0 1px var(--ink);
background: var(--surface);
cursor: pointer;
}
.work__close::before,
.work__close::after { content: ""; position: absolute; inset: 23px 16px; border-radius: 1px; background: var(--ink); rotate: 45deg; }
.work__close::after { rotate: -45deg; }
.work__close:focus-visible,
.work__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 狭い画面では、画像を上 4 割に、文字をその下に置く */
@media (max-width: 767px) {
.work__panel .work__art { width: 100%; height: 40%; }
.work__text { inset: 40% 0 0; padding: 32px var(--gutter) 48px; }
.work__facts { grid-template-columns: repeat(3, auto); justify-content: start; column-gap: 24px; }
}
import { gsap } from "gsap";
const OPEN = 0.9; // 広がる時間(秒)。閉じるときは同じ動きを逆にたどる
const EASE = "power3.inOut";
// 動きを減らす設定では、ほぼ一瞬で切り替える
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 20 : 1;
document.querySelectorAll(".work").forEach((work) => {
const card = work.querySelector(".work__card");
const dialog = work.querySelector(".work__detail");
const close = dialog.querySelector(".work__close");
const parts = dialog.querySelectorAll(".work__text > *");
const others = [...work.parentElement.querySelectorAll(".work__card")].filter((other) => other !== card);
// 広がる面。カードの画像と文字を写して dialog のいちばん奥に敷く(同じ中身なので、読み上げには出さない)
const panel = document.createElement("div");
panel.className = "work__panel";
panel.setAttribute("aria-hidden", "true");
panel.append(card.querySelector(".work__art").cloneNode(true), card.querySelector(".work__meta").cloneNode(true));
dialog.prepend(panel);
const [art, meta] = panel.children;
const moved = "left,top,width,height,borderRadius,opacity,transform"; // GSAP が書く値(カードの --tint は残す)
let motion = null;
// FLIP: カードの位置と大きさ(First)と、CSS が決めた広がりきった見た目(Last)を測り、その間を補間する。
// scale ではなく left・top・width・height で動かすので、中の画像と文字が歪まない。位置は開くたびに測る
function build() {
gsap.set([panel, art], { clearProps: moved });
const from = card.getBoundingClientRect();
const fromArt = card.querySelector(".work__art").getBoundingClientRect();
const to = panel.getBoundingClientRect();
const toArt = art.getBoundingClientRect();
return gsap
.timeline({
paused: true,
// 広がりきったら CSS の見た目に任せる(画面の大きさが変わっても合う)。閉じきったら dialog を閉じる
onComplete: () => gsap.set([panel, art], { clearProps: "left,top,width,height,borderRadius" }),
onReverseComplete: () => dialog.close(),
})
.fromTo(panel, { left: from.left, top: from.top, width: from.width, height: from.height, borderRadius: getComputedStyle(card).borderRadius },
{ left: to.left, top: to.top, width: to.width, height: to.height, borderRadius: 0, duration: OPEN, ease: EASE }, 0)
// 画像はカードの上側から、詳細の左半分へ
.fromTo(art, { width: fromArt.width, height: fromArt.height }, { width: toArt.width, height: toArt.height, duration: OPEN, ease: EASE }, 0)
// 文字は補間しない。カードの文字はすぐ消し、詳細の文字は広がりきる少し前から下から出す
.fromTo(meta, { opacity: 1 }, { opacity: 0, duration: 0.2, ease: "none" }, 0)
.fromTo(others, { opacity: 1, scale: 1 }, { opacity: 0.35, scale: 0.95, duration: 0.5, ease: "power2.out" }, 0)
.fromTo(parts, { opacity: 0, y: 28 }, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out", stagger: 0.06 }, OPEN * 0.62)
.fromTo(close, { opacity: 0, scale: 0.6, rotation: -90 }, { opacity: 1, scale: 1, rotation: 0, duration: 0.5, ease: "back.out(1.8)" }, OPEN * 0.7)
.timeScale(speed);
}
card.addEventListener("click", () => {
dialog.showModal(); // 先に開いてから、広がりきった見た目を測る。焦点は × へ移る
motion?.kill();
motion = build().play();
});
// 同じ道をたどって、押したカードの位置へ戻す。広がりきったあとなら測り直す(画面の大きさが変わっていても合う)
function shut() {
if (!motion.isActive()) {
motion.kill();
motion = build().progress(1, true);
}
motion.reverse();
}
close.addEventListener("click", shut);
// Esc は既定だとその場で閉じるので、いったん止めて同じ動きで閉じる
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
shut();
});
// 閉じたら元に戻し、焦点を押したカードへ返す(Esc を続けて押すと、動きを待たずに閉じることもある)
dialog.addEventListener("close", () => {
motion?.kill();
gsap.set([panel, art, meta, close, ...parts, ...others], { clearProps: moved });
card.focus({ preventScroll: true });
});
});