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

スクロールで比較表のセクションの行が順に立ち上がり、丸とチェックが線で描かれる

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

動作
DOM
種類
レシピ
使うもの
ScrollTrigger
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

プランを比べる表を、スクロールの量に合わせて組み立てていきます。はじめにおすすめの列の地が上から降り、続いて見出しの行、そのあと項目の行が1行ずつ24px下から立ち上がります。行が着くころに、その行の丸・チェック・ばつの印が左の列から順に描かれます。スクロールに追従させているので、指を戻せば逆にたどって消えていきます。

印は、SVG の pathLength を 1 にして、破線の送りを 1 から 0 へ動かすだけで描けます。線の長さを測らなくてよく、折れたチェックでも、丸でも、2 本に分かれたばつでも同じ書き方で済みます。同じ行の印は 0.06 秒ずつずらすので、左の列から順に入っていきます。

おすすめの列の地は、表の中ではなく表の後ろに置いた 1 枚の面です。セルに地を塗ると、行が 1 行ずつ現れるあいだ、地も行の切れ目ごとに現れて縞に見えてしまいます。面は見出しの行の 40px 上から最後の行の 24px 下まで伸ばし、clip-path を上から下へ開いて出しています。

行の区切り線は、border ではなく内側に落とす影で引いています。border だと行の高さが線の分だけ変わり、立ち上がる量とそろわなくなります。行は文字を含むので、濃さだけを先に短く上げ、位置は整数 px にそろえて動かしています。

つまみは、行ごとのずれ、立ち上がる量、印を描く時間です。料金表の列そのものを起こして見せる作りと違い、こちらは行ごとに印が描かれていくところが見どころなので、項目の多い比べにも向きます。プランの比較、機能の対応表、他社との比べに使えます。

コード

HTML
<section class="cmp" aria-labelledby="cmp-title">
  <p class="cmp__eyebrow">Comparison</p>
  <h2 class="cmp__title" id="cmp-title">プランの違いを、<wbr>ひと目で。</h2>
  <div class="cmp__frame">
    <!-- おすすめの列の地。セルに塗ると、行が 1 行ずつ出るあいだ縞に見えるので、表の後ろに 1 枚だけ置く -->
    <span class="cmp__panel" aria-hidden="true"></span>
    <table class="cmp__table">
      <caption class="cmp__sr">プランごとの対応内容</caption>
      <thead>
        <tr class="cmp__row">
          <th scope="col">サポート内容</th>
          <th scope="col">ライト</th>
          <th scope="col">スタンダード</th>
          <th scope="col">プレミアム</th>
        </tr>
      </thead>
      <tbody>
        <tr class="cmp__row">
          <th scope="row">サーバーの監視と障害対応</th>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
        </tr>
        <tr class="cmp__row">
          <th scope="row">月次のバックアップ</th>
          <td data-mark="partial"><span class="cmp__sr">一部対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
        </tr>
        <tr class="cmp__row">
          <th scope="row">アクセスの月次レポート</th>
          <td data-mark="none"><span class="cmp__sr">非対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
        </tr>
        <tr class="cmp__row">
          <th scope="row">記事や写真の差し替え</th>
          <td data-mark="none"><span class="cmp__sr">非対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
        </tr>
        <tr class="cmp__row">
          <th scope="row">新しいページの制作</th>
          <td data-mark="none"><span class="cmp__sr">非対応</span></td>
          <td data-mark="partial"><span class="cmp__sr">一部対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
        </tr>
        <tr class="cmp__row">
          <th scope="row">専任の担当者</th>
          <td data-mark="none"><span class="cmp__sr">非対応</span></td>
          <td data-mark="none"><span class="cmp__sr">非対応</span></td>
          <td data-mark="check"><span class="cmp__sr">対応</span></td>
        </tr>
      </tbody>
    </table>
  </div>
  <p class="cmp__legend">
    <span data-mark="check">対応</span>
    <span data-mark="partial">一部対応</span>
    <span data-mark="none">非対応</span>
  </p>
</section>

CSS
.cmp {
  --bg: #111111;
  --surface: #262626;
  --ink: #eeece6;
  --mute: #a8a6a0;
  --line: rgba(238, 236, 230, 0.14);
  --ok: #6ba37f; /* 成功の色。Night の小さな文字でも 4.5:1 に届く側 */
  --label-w: 37%;
  --col-w: 21%;
  --step: 40px; /* おすすめの列が、見出しの行の上へ伸びる高さ */
  padding: 96px 24px;
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.cmp__eyebrow,
.cmp__title,
.cmp__frame,
.cmp__legend { max-width: 1088px; margin-inline: auto; }

.cmp__eyebrow { margin: 0 auto 12px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.cmp__title { margin: 0 auto; font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; } /* 折り返すのは HTML の <wbr> の所だけ */

/* 地の面は表の後ろ。上の余白ぶんだけ、おすすめの列が一段高く見える */
.cmp__frame { position: relative; margin-top: 40px; padding: var(--step) 0 24px; }
.cmp__panel {
  position: absolute;
  inset-block: 0;
  left: calc(var(--label-w) + var(--col-w));
  width: var(--col-w);
  box-sizing: border-box;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--surface);
  clip-path: inset(0 0 calc(100% - var(--panel, 1) * 100%) 0 round 4px);
}

.cmp__table { position: relative; width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; }
.cmp__table th:first-child { width: var(--label-w); }
.cmp__table th:not(:first-child) { width: var(--col-w); }

.cmp__table th,
.cmp__table td { padding: 16px 8px; box-shadow: inset 0 -1px 0 var(--line); }

.cmp__table thead th { padding: 24px 8px 16px; vertical-align: bottom; text-align: center; font-size: 20px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.cmp__table thead th:first-child { text-align: left; font-size: 13px; letter-spacing: 0.06em; color: var(--mute); }
.cmp__table tbody th { text-align: left; font-size: 15px; font-weight: 400; line-height: 1.6; letter-spacing: 0.04em; word-break: auto-phrase; }

/* 印。線の長さを測らずに描けるよう、pathLength は JS が 1 にする */
.cmp__mark { display: block; width: 24px; height: 24px; margin: 0 auto; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
[data-mark="check"] .cmp__mark { stroke: var(--ok); }
[data-mark="partial"] .cmp__mark { stroke: var(--ink); }
[data-mark="none"] .cmp__mark { stroke: var(--mute); }

.cmp__legend { display: flex; flex-wrap: wrap; gap: 8px 24px; margin: 24px auto 0; font-size: 13px; line-height: 1; letter-spacing: 0.04em; color: var(--mute); }
.cmp__legend span { display: flex; align-items: center; gap: 6px; }
.cmp__legend .cmp__mark { width: 16px; height: 16px; margin: 0; stroke-width: 3; }

/* 読み上げ用。行が濃さで出入りしても、値は読み上げとタブ移動に残る */
.cmp__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (max-width: 720px) {
  .cmp { --label-w: 28%; --col-w: 24%; --step: 28px; padding: 64px 16px; } /* プラン名(6 字)が 1 行に収まる列の幅 */
  .cmp__frame { margin-top: 24px; }
  .cmp__table thead th { padding: 16px 2px 12px; font-size: 12px; }
  .cmp__table th,
  .cmp__table td { padding: 12px 4px; }
  .cmp__table tbody th { font-size: 13px; line-height: 1.5; }
  .cmp__mark { width: 20px; height: 20px; stroke-width: 2.4; }
}

@media (prefers-reduced-motion: reduce) {
  .cmp__panel { clip-path: none; }
}

JavaScript
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const NS = "http://www.w3.org/2000/svg";
// 印の形。viewBox は 24。ばつは 2 本を 1 つの path にまとめ、送りだけで 2 画に描く
const SHAPES = {
  check: ["path", { d: "M5 12.5l4.5 4.5L19 7.5" }],
  partial: ["circle", { cx: 12, cy: 12, r: 6.5 }],
  none: ["path", { d: "M7.5 7.5l9 9M16.5 7.5l-9 9" }],
};

// セルに印の SVG を足し、描く線を返す
function addMark(cell) {
  const [name, attrs] = SHAPES[cell.dataset.mark];
  const svg = document.createElementNS(NS, "svg");
  svg.setAttribute("viewBox", "0 0 24 24");
  svg.setAttribute("aria-hidden", "true");
  svg.setAttribute("class", "cmp__mark");
  const line = document.createElementNS(NS, name);
  for (const [key, value] of Object.entries(attrs)) line.setAttribute(key, value);
  line.setAttribute("pathLength", "1"); // 長さを 1 と決めると、破線も送りも割合で書ける
  svg.append(line);
  cell.prepend(svg);
  return line;
}

document.querySelectorAll(".cmp").forEach((section) => {
  const lines = new Map();
  for (const cell of section.querySelectorAll("[data-mark]")) lines.set(cell, addMark(cell));
  // 動きを減らす設定では、出そろった表をそのまま見せる(印も描いたまま)
  if (reduceMotion) return;
  for (const line of lines.values()) line.style.strokeDasharray = "1 1";

  const tl = gsap.timeline({ paused: true }).fromTo(section.querySelector(".cmp__panel"), { "--panel": 0 }, { "--panel": 1, duration: 0.5, ease: "power2.out" }, 0);
  section.querySelectorAll(".cmp__row").forEach((row, i) => {
    const at = i === 0 ? 0.25 : 0.7 + (i - 1) * 0.35; // 見出しの行がいちばん先、あとは 1 行ずつ
    // 文字を含む行なので、濃さだけ先に短く上げ、位置は平面のまま整数 px で動かす
    tl.fromTo(row, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "none" }, at)
      .fromTo(row, { y: 24 }, { y: 0, duration: 0.6, ease: "power3.out", force3D: false, snap: { y: 1 } }, at);
    const marks = row.querySelectorAll(".cmp__mark > *");
    if (marks.length) tl.fromTo(marks, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: 0.45, ease: "power2.out", stagger: 0.06 }, at + 0.1);
  });

  // スクロールの量にそのまま結ぶ。指を戻せば逆にたどる。
  // end を clamp() で包むと、表がページのいちばん下にあって終わりの位置まで送れないときも、送りきった所で全部の行が出る
  ScrollTrigger.create({ animation: tl, trigger: section.querySelector(".cmp__frame"), start: "top 80%", end: "clamp(bottom 60%)", scrub: true });
});

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

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