デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
最初に見えるのは、中央で少しずつ傾いて重なった4枚のカードの束です。画面に入ると、扇を開くようにカードが横一列へ広がり、その途中から番号と矢印、続いて見出しと説明が下から現れて、すべての工程を一度に見比べられる並びになります。回転の軸はカードの下の辺の中央に置いてあるので、束のときは上へいくほど開いた形になり、並ぶにつれて傾きが消えていきます。
カードは0.1秒ずつずらして動かし、行き過ぎのある back.out で、所定の位置を少し越えてから収まります。束のときの位置は、中央の座標ではなく、並んだ位置からのずれと傾きで与えています。座標を直接入れると、画面の幅が変わるたびに置き直しが要りますが、差で持っておけば、どの幅でも並んだ位置を基準にして束から開けます。
束になっている間、後ろのカードには背景と同じ色を半透明で重ね、1枚奥へいくごとに14%ずつ濃くしています。暗いカードをただ重ねるだけでは何枚あるのか読み取れず、影を足すと角が濁ります。背景の色で薄めれば、奥のカードほど背景に溶け込んで、枚数が伝わります。
文字の入ったカードを回すので、force3D はオフにして平面のまま動かし、位置のずれも整数の px にそろえました。既定のままだと、傾いている途中だけカードが別のレイヤーに載り、見出しの文字の輪郭が揺れて見えます。カーソルを重ねると、そのカードだけが少し持ち上がります。
コード
<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());
});