本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
セクション

工程のセクションでカードが重なった束から扇状に広がって並ぶ

デモ

操作:カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

工程の紹介を、重なったカードの束から始めます。画面に入ると、中央で少し傾いて重なっていた 4 枚が、扇を開くように横一列へ広がります。回す軸をカードの下の辺の中央に置いているので、束のときは上へいくほど開いた形になり、広がるにつれて傾きが 0 に戻ります。カードが着いたあと、番号と矢印、続いて見出しと説明が下から現れます。

1 枚ずつ 0.1 秒ずらし、行き過ぎる back.out で所定の位置を少し越えてから収まります。束の位置は、並んだ位置からの差(横のずれと傾き)で与えています。中央の座標を直接入れると、画面の幅が変わるたびに置き直すことになるためです。

束のあいだ、後ろのカードには地と同じ色の霞を、1 枚奥へいくごとに 14% ずつかけています。濃い面を重ねるだけでは何枚あるか読めず、影を足すと角が濁ります。地の色で薄めると、奥のカードが背景に沈んで枚数が伝わります。

文字の入ったカードを回すので、force3D を切って平面のまま動かし、横のずれは整数 px にそろえています。ここを既定のままにすると、傾いている途中だけ別の層に載り、見出しの字の縁が揺れます。カードに重ねると 1 枚だけ 12px 持ち上がりますが、束のうちは重なっていて選べないので、広がってから効くようにしています。

つまみは、束の傾き、ずらす間隔、行き過ぎの強さ、霞の濃さです。スクロールに合わせて重なりながら積み上がる作りや、指で横にはじいて送る山と違い、こちらは一度で 4 枚すべてを見せて比べさせる形です。制作の流れ、申し込みの手順、サービスの段階の紹介に向いています。

コード

HTML
<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>

CSS
.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; }
}

JavaScript
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());
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。