本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
グラフ

ファネルのグラフの段が順に伸び、段ごとの転換率が現れる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
グラフ
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

訪問 → 会員登録 → 無料体験 → 購入の 4 段を、横棒の段として上から順に、中央から左右へ伸ばします。棒の幅は訪問の人数に対する割合で、左の人数も棒と一緒に 0 から数え上がります。段の間には上の棒と下の棒の幅をつなぐ台形を薄く敷き、その真ん中に前の段からの転換率の札を開きます。どこで人が減っているかを、幅の縮み方と札の数字の両方で読めます。

1 段は 0.7 秒の power3.out で伸び、次の段は 0.35 秒遅れて始めます(stagger)。ずれを伸びる時間の半分にすると、前の段が伸びきる前に次が動き出し、上から流れ落ちるように見えます。台形は下の段と一緒に現れ、上辺は上の棒の今の幅から取るので、棒とずれずにつながります。札は下の段が伸び始めて 0.45 秒後、ほぼ伸びきったころに開きます。先に札を出すと、まだ短い棒の上に数字だけが浮いて見えます。

期間のボタン(7日・30日・90日)を押すと、今の値から次の期間の値へ、上の段から 0.08 秒ずつずらして伸び縮みします。段ごとにずらすトゥイーンは、続けて押したときの後始末を overwrite の auto に任せないでください。待っている古い段のトゥイーンが後から始まり、新しい値を上書きしてしまいます。前のトゥイーンを覚えておき、押すたびに止めてから作ります。

人数は段の一覧としてページに書き、ほかの期間の人数は各段の data 属性に持たせます。棒の幅と転換率はスクリプトが人数から出すので、数字を直せば図も直ります。伸ばすのは画面に入った 1 回だけで、それまでは一覧の数字をそのまま残し、読み上げに届くようにします。期間のボタンは押した状態を aria-pressed で伝え、切り替えたことを見えない status に書きます。動きを減らす設定では、伸ばさずに出そろった状態で見せます。

つまみは、段のずれ・伸びる時間・棒の色と台形の濃さ(棒の色を地に 16% 混ぜた色)です。棒の幅を訪問に対する割合にしているので、最後の段がごく細くなるときは、段を減らすか、目盛りを対数に替えます。月ごとの差を並べる棒グラフと違い、段ごとの減り方と歩留まりを伝えるための形で、会員登録や購入までの流れを見せるマーケティングのレポート、EC の購入の手順、採用の選考の段階などに向いています。

コード

HTML
<figure class="funnel">
  <figcaption class="funnel__caption">
    <span class="funnel__eyebrow">Conversion funnel</span>
    <span class="funnel__title">購入までの流れ</span>
    <span class="funnel__note">過去<span class="funnel__days">30</span>日・単位:人</span>
  </figcaption>

  <!-- 期間のボタン。押すと、各段の人数が data-<日数> の値へ替わる -->
  <div class="funnel__periods" role="group" aria-label="期間">
    <button type="button" data-days="7" aria-pressed="false">7日</button>
    <button type="button" data-days="30" aria-pressed="true">30日</button>
    <button type="button" data-days="90" aria-pressed="false">90日</button>
  </div>

  <!-- 値はこの一覧が正。見えている人数は今の期間のもの。棒の幅と転換率は、JS が人数から出す -->
  <ol class="funnel__steps">
    <li class="funnel__step" data-7="5820" data-30="24000" data-90="68500">
      <span class="funnel__name">訪問</span>
      <b class="funnel__count">24,000</b>
      <span class="funnel__bar" aria-hidden="true"></span>
    </li>
    <li class="funnel__step" data-7="2212" data-30="9600" data-90="25345">
      <span class="funnel__name">会員登録</span>
      <b class="funnel__count">9,600</b>
      <span class="funnel__flow" aria-hidden="true"></span>
      <span class="funnel__bar" aria-hidden="true"></span>
      <span class="funnel__rate">転換率 <span>40.0</span>%</span>
    </li>
    <li class="funnel__step" data-7="1150" data-30="5280" data-90="14700">
      <span class="funnel__name">無料体験</span>
      <b class="funnel__count">5,280</b>
      <span class="funnel__flow" aria-hidden="true"></span>
      <span class="funnel__bar" aria-hidden="true"></span>
      <span class="funnel__rate">転換率 <span>55.0</span>%</span>
    </li>
    <li class="funnel__step" data-7="437" data-30="2112" data-90="6321">
      <span class="funnel__name">購入</span>
      <b class="funnel__count">2,112</b>
      <span class="funnel__flow" aria-hidden="true"></span>
      <span class="funnel__bar" aria-hidden="true"></span>
      <span class="funnel__rate">転換率 <span>40.0</span>%</span>
    </li>
  </ol>

  <p class="funnel__status" role="status"></p>
</figure>

CSS
.funnel {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #898989;
  --mute-small: #6b6b6b; /* 16px 未満の文字 */
  --line: rgb(38 38 38 / 0.12);
  --bar: #62839d; /* スレートの彩度を 1.25 倍にした値 */
  --flow: color-mix(in srgb, var(--bar) 16%, var(--bg)); /* 段の間の台形 */
  --bar-height: 64px;
  --gap: 48px; /* 段の間。台形と転換率の札が入る */
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: start;
  gap: 40px 24px;
  margin: 0;
  padding: 64px clamp(16px, 5vw, 96px);
  background: var(--bg);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.funnel__caption { display: grid; gap: 12px; }
.funnel__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.funnel__title { font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.funnel__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }

/* 期間のボタン。押している期間だけ、明暗を反転する */
.funnel__periods { display: flex; padding: 4px; border: 1px solid var(--line); border-radius: 999px; }
.funnel__periods button {
  min-width: 72px;
  height: 32px;
  padding: 0 16px;
  border: 0;
  border-radius: 999px;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}
.funnel__periods [aria-pressed="true"] { background: var(--ink); color: #ffffff; }
.funnel__periods button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* 1 段 = 左に名前と人数、右に棒。棒の列の幅を 100% として、棒の幅を --w(%)で決める */
.funnel__steps { grid-column: 1 / -1; display: grid; gap: var(--gap); margin: 0; padding: 0; list-style: none; }
.funnel__step { display: grid; grid-template-columns: clamp(88px, 18vw, 200px) 1fr; align-items: center; column-gap: 24px; }
.funnel__name { grid-area: 1 / 1; align-self: end; color: var(--mute-small); font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }
.funnel__count { grid-area: 2 / 1; align-self: start; font-size: clamp(22px, 4vw, 28px); font-weight: 800; letter-spacing: -0.01em; }
.funnel__bar { grid-area: 1 / 2 / 3; justify-self: center; width: calc(var(--w, 0) * 1%); height: var(--bar-height); border-radius: 4px; background: var(--bar); }

/* 段の間の台形。上辺は上の段の幅(--prev)、下辺はこの段の幅(--w) */
.funnel__flow {
  grid-area: 1 / 2;
  align-self: start;
  width: 100%;
  height: var(--gap);
  margin-top: calc(var(--gap) * -1);
  background: var(--flow);
  opacity: var(--flow-in, 1);
  clip-path: polygon(
    calc(50% - var(--prev, 0) * 0.5%) 0, calc(50% + var(--prev, 0) * 0.5%) 0,
    calc(50% + var(--w, 0) * 0.5%) 100%, calc(50% - var(--w, 0) * 0.5%) 100%
  );
}

/* 転換率の札。台形の真ん中に置き、--open(0〜1)で中央から左右へ開く */
.funnel__rate {
  grid-area: 1 / 2;
  align-self: start;
  justify-self: center;
  z-index: 1;
  margin-top: calc(var(--gap) / -2 - 14px);
  padding: 0 14px;
  border-radius: 999px;
  background: var(--ink);
  color: #ffffff;
  font-size: 13px;
  font-weight: 600;
  line-height: 28px;
  letter-spacing: 0.04em;
  white-space: nowrap;
}
.funnel.is-ready .funnel__rate { clip-path: inset(0 calc(50% - var(--open, 0) * 50%) round 999px); }

/* 読み上げだけに届ける */
.funnel__status { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* 狭い画面では、期間のボタンを見出しの下へ回し、段の名前を小さくする */
@media (max-width: 600px) {
  .funnel { grid-template-columns: 1fr; }
  .funnel__periods { justify-self: start; }
  .funnel__step { column-gap: 16px; }
  .funnel__name { font-size: 13px; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、その場で切り替える
const format = (v) => Math.round(v).toLocaleString("ja-JP");

document.querySelectorAll(".funnel").forEach((chart) => {
  const buttons = [...chart.querySelectorAll(".funnel__periods button")];
  const days = chart.querySelector(".funnel__days");
  const status = chart.querySelector(".funnel__status");
  const steps = [...chart.querySelectorAll(".funnel__step")].map((el) => ({ el, count: el.querySelector(".funnel__count"), rate: el.querySelector(".funnel__rate span") }));
  let current = buttons.find((b) => b.getAttribute("aria-pressed") === "true").dataset.days;

  // 期間の人数から、棒の幅(訪問の人数に対する %)と、前の段からの転換率(%)を出す
  const targets = (period) => {
    const values = steps.map((s) => Number(s.el.dataset[period]));
    return values.map((n, i) => ({ n, w: (n / values[0]) * 100, r: i ? (n / values[i - 1]) * 100 : 0 }));
  };
  const end = targets(current);
  // 描く値。w は棒の幅、n は人数、r は転換率、flow は段の間の台形の濃さ、open は札の開き
  const state = end.map((t) => ({ w: 0, n: 0, r: t.r, flow: 0, open: 0 }));
  const draw = () => state.forEach((s, i) => {
    const { el, count, rate } = steps[i];
    el.style.setProperty("--w", s.w);
    el.style.setProperty("--prev", i ? state[i - 1].w : 0); // 台形の上辺は、上の段の今の幅
    el.style.setProperty("--flow-in", s.flow);
    el.style.setProperty("--open", s.open);
    count.textContent = format(s.n);
    if (rate) rate.textContent = s.r.toFixed(1);
  });
  chart.classList.add("is-ready");

  // 上の段から順に、中央から左右へ伸ばす。台形は下の段と一緒に現れ、札は下の段が伸びきるころに開く
  const lower = state.slice(1);
  const grow = gsap.timeline({ paused: true, onUpdate: draw })
    .fromTo(state, { w: 0, n: 0 }, { w: (i) => end[i].w, n: (i) => end[i].n, duration: 0.7, ease: "power3.out", stagger: 0.35 })
    .fromTo(lower, { flow: 0 }, { flow: 1, duration: 0.3, ease: "power1.out", stagger: 0.35 }, 0.35)
    .fromTo(lower, { open: 0 }, { open: 1, duration: 0.35, ease: "power3.out", stagger: 0.35 }, 0.8);

  // 動きを減らす設定では、出そろった状態で見せる。ほかは画面に入ったら 1 回だけ伸ばす
  if (reduceMotion) grow.progress(1);
  else {
    const io = new IntersectionObserver(([entry]) => {
      if (!entry.isIntersecting) return;
      io.disconnect();
      grow.play();
    }, { rootMargin: "0px 0px -20%" }); // 下から 2 割入ったら始める
    io.observe(chart.querySelector(".funnel__steps"));
  }

  // 期間を替えると、今の値から次の期間の値へ、上の段から順に伸び縮みする
  let switching;
  buttons.forEach((button) => button.addEventListener("click", () => {
    if (button.dataset.days === current) return;
    current = button.dataset.days;
    buttons.forEach((b) => b.setAttribute("aria-pressed", String(b === button)));
    days.textContent = current;
    status.textContent = `過去${current}日の人数に切り替えました`;
    const to = targets(current);
    grow.kill();
    // 段ごとにずらすトゥイーンは、前のものを覚えて止める(overwrite に任せると、待っている古い段が後から上書きする)
    switching?.kill();
    switching = gsap.to(state, {
      w: (i) => to[i].w,
      n: (i) => to[i].n,
      r: (i) => to[i].r,
      flow: 1,
      open: 1,
      duration: time(0.7),
      ease: "power3.out",
      stagger: time(0.08),
      onUpdate: draw,
    });
  }));
});

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

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