デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- 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 の購入の手順、採用の選考の段階などに向いています。
コード
<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>
.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; }
}
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,
});
}));
});