デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
質問ごとに、「そう思う・ややそう思う」が中央の線から右へ、「そう思わない・あまりそう思わない」が左へ伸びます。「どちらでもない」は中央の線をまたいで淡い灰で置くので、賛成と反対の長さを同じ線から比べられます。中央の線が上から引かれたあと、質問の行が上から順に伸び、両端に賛成と反対の合計の割合が数え上がります。下の軸から棒を立てるグラフや、順位を入れ替える横棒のグラフと違い、1 本の棒を中央で割って左右へ同時に伸ばします。
行ごとに伸びの進みを 1 つだけ持ち、左右それぞれの長さを掛けて両端を決めます。左右が同時に伸びきるので、短い側だけが先に止まることがありません。1 行は 0.9 秒の power3.out で伸び、次の行は 0.28 秒遅れて始めます。区分の境目は中央からの累計で決め、伸びた先で切るので、隣の区分とすき間なくつながったまま伸びます。
区分の色は、強い答えを濃く、弱い答えを淡く、どちらでもないをいちばん淡い灰にして、明るさで見分けます。賛成はスレート、反対はくすみローズの系統に分けていますが、色の違いだけに頼らず、左右と濃さでも読めるようにしています。どちらでもないを中央に置くときは、その割合の半分ずつを左右の長さに足してください。足し忘れると、中央の線がどちらでもないの真ん中からずれ、賛否の長さを比べる基準が狂います。
値は質問ごとの表としてページに書き、スクリプトはその表を読んで行を組みます。組んだあとの表は見えなくするだけにして、読み上げに残します。行に重ねると、ほかの行が薄くなり、その行の区分ごとの割合が棒の中に出ます。幅の足りない区分には出しません。枠は焦点を受け、上下の矢印キーで質問を移ると、区分ごとの割合を見えない status に書きます。動きを減らす設定では、伸びきった状態で置きます。
つまみは、目盛りの端(--max)、行が伸びる時間と間、区分の色です。目盛りの端を超える長い棒と合計の数字のために、棒の列の右に余白の列を残しています。狭い画面では目盛りの端を広げて、棒と合計が列に収まるようにします。満足度調査や社内アンケートの結果、施策の前後で賛否がどう動いたかを見せる節に向いています。
コード
<figure class="likert">
<figcaption class="likert__caption">
<span class="likert__eyebrow">Survey</span>
<span class="likert__title">サービスの満足度アンケート</span>
<span class="likert__note"><span>2026年9月・</span><span>回答者1,240人・</span><span>単位:%</span></span>
</figcaption>
<ul class="likert__legend" aria-hidden="true">
<li>そう思わない</li>
<li>あまりそう思わない</li>
<li>どちらでもない</li>
<li>ややそう思う</li>
<li>そう思う</li>
</ul>
<!-- 行はスクリプトが表から組む。枠は焦点を受け、上下の矢印キーで質問を移れる -->
<div class="likert__chart" tabindex="0" role="group" aria-label="質問ごとの回答の割合のグラフ。上下の矢印キーで質問を移れます">
<ol class="likert__rows" aria-hidden="true"></ol>
<span class="likert__center" aria-hidden="true"></span>
<ul class="likert__ticks" aria-hidden="true">
<li style="--p: -80">80%</li>
<li style="--p: -40">40%</li>
<li style="--p: 0">0</li>
<li style="--p: 40">40%</li>
<li style="--p: 80">80%</li>
</ul>
</div>
<p class="likert__status" role="status"></p>
<!-- 値はこの表が正。列は左の区分から順に(反対 2 つ・どちらでもない・賛成 2 つ) -->
<table class="likert__table">
<caption>質問ごとの回答の割合(%)</caption>
<thead>
<tr><th scope="col">質問</th><th scope="col">そう思わない</th><th scope="col">あまりそう思わない</th><th scope="col">どちらでもない</th><th scope="col">ややそう思う</th><th scope="col">そう思う</th></tr>
</thead>
<tbody>
<tr><th scope="row">使いやすさ</th><td>4</td><td>8</td><td>14</td><td>38</td><td>36</td></tr>
<tr><th scope="row">価格</th><td>12</td><td>22</td><td>24</td><td>28</td><td>14</td></tr>
<tr><th scope="row">サポート</th><td>6</td><td>12</td><td>20</td><td>34</td><td>28</td></tr>
<tr><th scope="row">デザイン</th><td>2</td><td>6</td><td>12</td><td>36</td><td>44</td></tr>
<tr><th scope="row">機能の充実</th><td>8</td><td>16</td><td>26</td><td>32</td><td>18</td></tr>
</tbody>
</table>
</figure>
.likert {
--bg: #f2f1ed;
--surface: #ffffff;
--ink: #262626;
--mute: #898989;
--mute-small: #6b6b6b; /* 16px 未満の文字 */
--line: rgb(38 38 38 / 0.12);
/* 区分の色(左から)。強い答えは濃く、弱い答えは淡く、どちらでもないはいちばん淡い灰 */
--no: #8E5955;
--slightly-no: #dab2ab;
--neutral: #dedcd6;
--slightly-yes: #a9bac9;
--yes: #475c6a;
--max: 80; /* 目盛りの左右の端(%) */
--name: 192px; /* 質問の列 */
--end: 96px; /* 目盛りの端を越える棒と合計のための右の余白 */
--row: 64px;
--bar: 32px;
position: relative;
display: grid;
gap: 24px;
overflow: clip; /* 読み上げ用に隠した表が、狭い画面で横にはみ出さないように */
margin: 0;
padding: 48px clamp(16px, 5vw, 96px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.likert__caption { display: grid; gap: 10px; }
.likert__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.likert__title { font-size: clamp(22px, 6.4vw, 44px); /* 狭い画面でも 1 行に収める */ font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.likert__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }
.likert__note span { display: inline-block; } /* 狭い画面でも、語の途中で折り返さない */
.likert__legend { display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 0; padding: 0; color: var(--mute-small); font-size: 13px; letter-spacing: 0.04em; list-style: none; }
.likert__legend li::before { content: ""; display: inline-block; width: 14px; height: 14px; margin-right: 8px; border-radius: 2px; background: var(--c); vertical-align: -2px; }
.likert__legend li:nth-child(1), .likert__seg:nth-child(1) { --c: var(--no); --t: var(--surface); }
.likert__legend li:nth-child(2), .likert__seg:nth-child(2) { --c: var(--slightly-no); }
.likert__legend li:nth-child(3), .likert__seg:nth-child(3) { --c: var(--neutral); }
.likert__legend li:nth-child(4), .likert__seg:nth-child(4) { --c: var(--slightly-yes); }
.likert__legend li:nth-child(5), .likert__seg:nth-child(5) { --c: var(--yes); --t: var(--surface); }
/* 質問と棒の 2 列。棒の列の真ん中が中央の線 */
.likert__chart {
--x: calc(var(--name) + (100% - var(--name) - var(--end)) / 2); /* 中央の線の位置 */
--k: calc((100% - var(--name) - var(--end)) / var(--max) / 2); /* 1% の長さ */
position: relative;
display: grid;
grid-template-columns: var(--name) minmax(0, 1fr) var(--end);
/* 目盛りの縦線(±40%・±80%。HTML の目盛りとそろえる)。行の地より奥に引く */
background: linear-gradient(to right, transparent calc(var(--x) - 80 * var(--k)), var(--line) 0 calc(var(--x) - 80 * var(--k) + 1px), transparent 0 calc(var(--x) - 40 * var(--k)), var(--line) 0 calc(var(--x) - 40 * var(--k) + 1px), transparent 0 calc(var(--x) + 40 * var(--k)), var(--line) 0 calc(var(--x) + 40 * var(--k) + 1px), transparent 0 calc(var(--x) + 80 * var(--k)), var(--line) 0 calc(var(--x) + 80 * var(--k) + 1px), transparent 0) 0 0 / 100% calc(100% - 32px) no-repeat;
}
.likert__chart:focus-visible { outline: 2px solid var(--ink); outline-offset: 8px; }
.likert__rows { grid-area: 1 / 1 / 2 / -1; display: grid; /* 中央の線と同じ 1 行目に置く(行を書かないと、線に 1 行目を取られて下へ押し出される) */ grid-template-columns: subgrid; margin: 0; padding: 0; list-style: none; }
.likert__row { position: relative; grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; height: var(--row); transition: opacity 0.25s; }
.likert__row::before { content: ""; position: absolute; inset: 4px -8px; border-radius: 4px; background: var(--surface); opacity: 0; transition: opacity 0.25s; }
.likert.is-hovering .likert__row:not(.is-active) { opacity: 0.3; } /* 重ねていない行を薄くする */
.likert__row.is-active::before { opacity: 1; }
.likert__name { position: relative; padding-left: 16px; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap; }
/* 棒の列。真ん中が中央の線 */
.likert__track { position: relative; height: 100%; }
.likert__seg { position: absolute; top: calc((var(--row) - var(--bar)) / 2); display: grid; place-items: center; height: var(--bar); background: var(--c); }
.likert__value { z-index: 1; padding: 0 4px; background: var(--c); color: var(--t, var(--ink)); font-size: 13px; font-weight: 700; opacity: 0; transition: opacity 0.25s; }
.likert__row.is-active .likert__seg:not(.is-narrow) .likert__value { opacity: 1; }
.likert__total { position: absolute; top: 50%; font-size: 16px; font-weight: 700; transform: translateY(-50%); }
.likert__total--no { color: var(--no); translate: calc(-100% - 12px); }
.likert__total--yes { color: var(--yes); translate: 12px; }
/* 中央の線と目盛り */
.likert__center { grid-column: 2; grid-row: 1; position: relative; pointer-events: none; }
.likert__center::before { content: ""; position: absolute; top: 0; left: calc(50% - 1px); width: 2px; height: calc(var(--drop, 0) * 100%); background: var(--ink); }
.likert__ticks { grid-column: 2; position: relative; height: 32px; margin: 0; padding: 0; color: var(--mute-small); font-size: 13px; list-style: none; }
.likert__ticks li { position: absolute; bottom: 0; left: calc(50% + var(--p) / var(--max) * 50%); transform: translateX(-50%); }
/* 狭い画面では質問の列を詰め、目盛りの端を広げて、長い棒と合計が列に収まるようにする */
@media (max-width: 640px) {
.likert { --max: 120; --name: 88px; --end: 0px; --row: 56px; --bar: 24px; }
.likert__name { padding-left: 8px; font-size: 13px; }
.likert__total { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.likert__row, .likert__row::before, .likert__value { transition: none; }
}
/* 読み上げだけに届ける。表は、スクリプトが行を組むまで見えたまま残す */
.likert__status,
.likert.is-live .likert__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.likert__table { width: 100%; border-spacing: 0; }
.likert__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.likert__table th, .likert__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; }
.likert__table th:first-child { text-align: left; }
.likert__table thead th { border: 0; color: var(--mute-small); font-size: 13px; font-weight: 600; }
import { gsap } from "gsap";
// 動きを減らす設定では、伸びきった絵で置く
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const make = (tag, className, parent) => parent.appendChild(Object.assign(document.createElement(tag), { className }));
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
document.querySelectorAll(".likert").forEach((chart) => {
const grid = chart.querySelector(".likert__chart");
const list = chart.querySelector(".likert__rows");
const status = chart.querySelector(".likert__status");
const labels = [...chart.querySelectorAll("thead th")].slice(1).map((th) => th.textContent);
// 値は表が正。列は左から、反対 2 つ・どちらでもない・賛成 2 つ
const rows = [...chart.querySelectorAll("tbody tr")].map((tr) => {
const v = [...tr.cells].slice(1).map((td) => Number(td.textContent));
const row = { v, name: tr.cells[0].textContent, e: 0 };
// 中央からの境目(%)。左は反対の 2 区分とどちらでもないの半分、右は賛成の 2 区分とどちらでもないの半分
row.edge = [-(v[0] + v[1] + v[2] / 2), -(v[1] + v[2] / 2), -v[2] / 2, v[2] / 2, v[2] / 2 + v[3], v[2] / 2 + v[3] + v[4]];
row.li = make("li", "likert__row", list);
make("span", "likert__name", row.li).textContent = row.name;
const track = make("span", "likert__track", row.li);
row.segs = v.map((value) => {
const seg = make("span", "likert__seg", track);
make("span", "likert__value", seg).textContent = `${value}%`;
return seg;
});
row.no = make("span", "likert__total likert__total--no", track);
row.yes = make("span", "likert__total likert__total--yes", track);
return row;
});
chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す
let max = 80;
let width = 0;
const pos = (p) => `${50 + (p / max) * 50}%`; // 中央から p% の位置(棒の列に対する %)
const draw = () => {
for (const row of rows) {
// 左右とも、伸びの進み e に長さを掛けた所まで出す(左右が同時に伸びきる)
const [reachL, reachR] = [-row.edge[0] * row.e, row.edge[5] * row.e];
const x = row.edge.map((p) => clamp(p, -reachL, reachR));
row.segs.forEach((seg, k) => {
seg.style.left = pos(x[k]);
seg.style.width = `${((x[k + 1] - x[k]) / max) * 50}%`;
seg.classList.toggle("is-narrow", ((x[k + 1] - x[k]) / max) * 0.5 * width < 34); // 幅の足りない区分には割合を出さない
});
row.no.style.left = pos(x[0]);
row.yes.style.left = pos(x[5]);
row.no.textContent = `${Math.round((row.v[0] + row.v[1]) * row.e)}%`;
row.yes.textContent = `${Math.round((row.v[3] + row.v[4]) * row.e)}%`;
row.no.style.opacity = row.yes.style.opacity = Math.min(1, row.e * 2);
}
};
// 目盛りの端は CSS の値。狭い画面で変わるので、大きさが変わるたびに読み直す
new ResizeObserver(() => {
max = parseFloat(getComputedStyle(chart).getPropertyValue("--max"));
width = rows[0].segs[0].parentElement.offsetWidth;
draw();
}).observe(list);
// 画面に入ったら 1 回だけ。中央の線が引かれ、質問の行が上から順に左右へ伸びる
const line = { drop: 0 };
const play = () => {
const tl = gsap.timeline({ onUpdate: draw });
tl.to(line, { drop: 1, duration: 0.4, ease: "power2.out", onUpdate: () => chart.style.setProperty("--drop", line.drop) }, 0);
rows.forEach((row, i) => tl.to(row, { e: 1, duration: 0.9, ease: "power3.out" }, 0.4 + 0.28 * i));
if (reduceMotion) tl.progress(1);
};
const io = new IntersectionObserver(([entry]) => entry.isIntersecting && (io.disconnect(), play()), { rootMargin: "0px 0px -20%" });
io.observe(chart);
// 重ねた行の地を明るくし、ほかの行を薄くして、区分ごとの割合を出す
let active = -1;
const show = (index) => {
if (index === active) return;
active = index;
chart.classList.toggle("is-hovering", index >= 0);
rows.forEach((row, i) => row.li.classList.toggle("is-active", i === index));
const row = rows[index];
status.textContent = row ? `${row.name} ${row.v.map((v, k) => `${labels[k]} ${v}%`).join("、")}` : "";
};
rows.forEach((row, i) => {
row.li.addEventListener("pointerenter", (e) => e.pointerType !== "touch" && show(i));
row.li.addEventListener("pointerdown", () => show(i)); // 指で触れたときも、その行を出す
});
list.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && show(-1));
// キーボードでは最初の質問から出し、上下の矢印キーで移る。Esc で閉じる
grid.addEventListener("focus", () => grid.matches(":focus-visible") && show(Math.max(active, 0)));
grid.addEventListener("blur", () => show(-1));
grid.addEventListener("keydown", (e) => {
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
if (!step) return e.key === "Escape" ? show(-1) : undefined;
e.preventDefault();
show(active < 0 ? 0 : (active + step + rows.length) % rows.length);
});
});