デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
工程の紹介を、重なったカードの束から始めます。画面に入ると、中央で少し傾いて重なっていた 4 枚が、扇を開くように横一列へ広がります。回す軸をカードの下の辺の中央に置いているので、束のときは上へいくほど開いた形になり、広がるにつれて傾きが 0 に戻ります。カードが着いたあと、番号と矢印、続いて見出しと説明が下から現れます。
1 枚ずつ 0.1 秒ずらし、行き過ぎる back.out で所定の位置を少し越えてから収まります。束の位置は、並んだ位置からの差(横のずれと傾き)で与えています。中央の座標を直接入れると、画面の幅が変わるたびに置き直すことになるためです。
束のあいだ、後ろのカードには地と同じ色の霞を、1 枚奥へいくごとに 14% ずつかけています。濃い面を重ねるだけでは何枚あるか読めず、影を足すと角が濁ります。地の色で薄めると、奥のカードが背景に沈んで枚数が伝わります。
文字の入ったカードを回すので、force3D を切って平面のまま動かし、横のずれは整数 px にそろえています。ここを既定のままにすると、傾いている途中だけ別の層に載り、見出しの字の縁が揺れます。カードに重ねると 1 枚だけ 12px 持ち上がりますが、束のうちは重なっていて選べないので、広がってから効くようにしています。
つまみは、束の傾き、ずらす間隔、行き過ぎの強さ、霞の濃さです。スクロールに合わせて重なりながら積み上がる作りや、指で横にはじいて送る山と違い、こちらは一度で 4 枚すべてを見せて比べさせる形です。制作の流れ、申し込みの手順、サービスの段階の紹介に向いています。
コード
<section class="fan" aria-labelledby="fan-title">
<p class="fan__eyebrow">Process</p>
<h2 class="fan__title" id="fan-title">公開までの4つの工程</h2>
<p class="fan__lead">ここに説明文が入ります。</p>
<ol class="fan__list">
<li class="fan__item">
<a class="fan__card" href="/process/#research">
<div class="fan__row">
<span class="fan__num">01</span>
<svg class="fan__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 17L17 7M9 7h8v8" /></svg>
</div>
<div class="fan__copy">
<h3 class="fan__name">調査</h3>
<p class="fan__text">ここに説明文が入ります。</p>
</div>
</a>
</li>
<li class="fan__item">
<a class="fan__card" href="/process/#design">
<div class="fan__row">
<span class="fan__num">02</span>
<svg class="fan__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 17L17 7M9 7h8v8" /></svg>
</div>
<div class="fan__copy">
<h3 class="fan__name">設計</h3>
<p class="fan__text">ここに説明文が入ります。</p>
</div>
</a>
</li>
<li class="fan__item">
<a class="fan__card" href="/process/#build">
<div class="fan__row">
<span class="fan__num">03</span>
<svg class="fan__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 17L17 7M9 7h8v8" /></svg>
</div>
<div class="fan__copy">
<h3 class="fan__name">制作</h3>
<p class="fan__text">ここに説明文が入ります。</p>
</div>
</a>
</li>
<li class="fan__item">
<a class="fan__card" href="/process/#launch">
<div class="fan__row">
<span class="fan__num">04</span>
<svg class="fan__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 17L17 7M9 7h8v8" /></svg>
</div>
<div class="fan__copy">
<h3 class="fan__name">公開</h3>
<p class="fan__text">ここに説明文が入ります。</p>
</div>
</a>
</li>
</ol>
<p class="fan__note">各工程の終わりに、成果物をまとめてお渡しします。</p>
</section>
.fan {
--bg: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b;
--card: #262626; /* 明暗を反転したカード */
--card-ink: #eeece6;
--card-mute: #a8a6a0;
--card-line: rgba(238, 236, 230, 0.14);
--lift: 12px; /* 重ねたときに持ち上がる量 */
padding: 96px 24px;
background: var(--bg);
color: var(--ink);
}
.fan__eyebrow,
.fan__title,
.fan__lead,
.fan__note { max-width: 1088px; margin-inline: auto; }
.fan__eyebrow { margin: 0 auto 12px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.fan__title { margin: 0 auto; font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.fan__lead { margin: 16px auto 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--mute); }
.fan__note { margin: 32px auto 0; font-size: 13px; line-height: 1.6; letter-spacing: 0.04em; text-align: center; color: var(--mute); }
.fan__list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; max-width: 1000px; margin: 40px auto 0; padding: 0; list-style: none; }
/* 束のとき、後ろのカードほど奥に重なる */
.fan__item { position: relative; transition: translate 0.3s; }
.fan__item:nth-child(2) { z-index: 1; }
.fan__item:nth-child(3) { z-index: 2; }
.fan__item:nth-child(4) { z-index: 3; }
.fan__item:hover,
.fan__item:focus-within { translate: 0 calc(-1 * var(--lift)); }
.fan__card {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
box-sizing: border-box;
min-height: 296px;
padding: 24px;
border-radius: 4px;
background: var(--card);
color: var(--card-ink);
text-decoration: none;
}
/* 束のあいだ、後ろのカードにかける地の色の霞(奥行き) */
.fan__card::after { content: ""; position: absolute; inset: 0; border-radius: 4px; background: rgb(242 241 237 / var(--haze, 0)); pointer-events: none; }
.fan__card:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.fan__row { display: flex; align-items: center; justify-content: space-between; }
.fan__num { font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--card-mute); font-variant-numeric: tabular-nums; }
.fan__arrow { box-sizing: border-box; width: 32px; height: 32px; padding: 9px; border: 1px solid var(--card-line); border-radius: 50%; fill: none; stroke: var(--card-ink); stroke-width: 3.43; stroke-linecap: round; stroke-linejoin: round; }
.fan__copy { margin-top: 32px; }
.fan__name { margin: 0; font-size: 22px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.fan__text { margin: 8px 0 0; font-size: 13px; line-height: 1.7; letter-spacing: 0.04em; color: var(--card-mute); }
@media (max-width: 900px) {
.fan__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.fan__card { min-height: 240px; }
}
@media (max-width: 560px) {
.fan { padding: 64px 16px; }
.fan__list { grid-template-columns: minmax(0, 1fr); }
.fan__card { min-height: 0; }
.fan__copy { margin-top: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.fan__item { transition: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const FAN = 6; // 束のときの 1 枚あたりの傾き(度)
const NUDGE = 10; // 束のときに、1 枚ずつずらす量(px)
const HAZE = 0.14; // 1 枚奥へいくごとに濃くなる霞
document.querySelectorAll(".fan").forEach((section) => {
// 動きを減らす設定では、広がった並びをそのまま見せる
if (reduceMotion) return;
const list = section.querySelector(".fan__list");
const cards = [...section.querySelectorAll(".fan__card")];
const mid = (cards.length - 1) / 2;
const box = list.getBoundingClientRect();
const tl = gsap.timeline({ paused: true });
cards.forEach((card, i) => {
// 束の位置は、並んだ位置からの差で与える。中央の座標を直に入れると、幅が変わるたびに置き直しになる。
// 縦の差も取るので、1 列に折り返す狭い画面でも同じように束から開く
const rect = card.getBoundingClientRect();
const dx = Math.round(box.left + box.width / 2 + (i - mid) * NUDGE - (rect.left + rect.width / 2));
const dy = Math.round(box.top + box.height / 2 - (rect.top + rect.height / 2));
const at = i * 0.1;
// 文字の入ったカードを回すので、平面のまま動かし、横のずれは整数 px にそろえる
tl.fromTo(
card,
{ x: dx, y: dy, rotation: (i - mid) * FAN, "--haze": (cards.length - 1 - i) * HAZE },
{ x: 0, y: 0, rotation: 0, "--haze": 0, transformOrigin: "50% 100%", duration: 0.8, ease: "back.out(1.1)", force3D: false, snap: { x: 1, y: 1 } },
at
).from(card.querySelectorAll(".fan__row, .fan__copy"), { opacity: 0, y: 10, duration: 0.4, ease: "power3.out", force3D: false, stagger: 0.08 }, at + 0.15);
});
// 一覧の上端が画面の下から 20% の線を越えたら、1 回だけ開く
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
tl.play();
},
{ rootMargin: "0px 0px -20% 0px" }
);
io.observe(list);
// Tab でカードに来たら、線を越える前でも開ききる(束のまま焦点が入らないように)
section.addEventListener("focusin", () => tl.play());
});