デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
3 重の輪を、外側から少しずつずらしながら目標に対する割合まで満たします。割合が 1 周(100%)を超えると、明るさを上げた輪をさらに重ねて、2 周目が 1 周目の上に乗っているように見せます。
外側の輪から 0.3 秒ずつずらして満ち始め、割合が大きい輪ほど長い時間をかけて満ちます。値と色は表の行から読み、輪の半径は行の順番から自動で決まります。
行を増やすと、輪の太さとすき間の分だけ内側の輪の半径が足りなくなることがあります。表の行数を大きく変えるときは、輪の太さやすき間を詰めるか、SVG の大きさを広げてください。
画面に入った 1 回だけ満ち始め、値は表のセルと連動して数字も一緒に増えていきます。目標に届いた行には、達成の札が自動で現れます。
目標の達成率や KPI、フィットネスの記録、プロジェクトの進み具合など、複数の指標を重ねて見せたいときに向いています。
コード
<section class="rings" aria-labelledby="rings-title">
<h2 class="rings__title" id="rings-title">四半期の目標</h2>
<div class="rings__body">
<div class="rings__chart">
<!-- 輪は下の表の値から JS が描く -->
<svg class="rings__svg" viewBox="0 0 320 320" aria-hidden="true"></svg>
<div class="rings__center">
<p class="rings__count"><span class="rings__count-value">0</span><span class="rings__count-unit">件</span></p>
<p class="rings__count-label"><span class="rings__count-total"></span>件中の達成</p>
</div>
</div>
<!-- 値はこの表が正。輪の色(--color)と、100% を超えて重なる 2 周目の明るい色(--over)も、ここから読む。横に収まらない画面では、表だけを横にスクロールする -->
<div class="rings__table-wrap">
<table class="rings__table">
<thead>
<tr>
<th scope="col">指標</th>
<th scope="col">実績</th>
<th scope="col">達成率</th>
<th scope="col">状態</th>
</tr>
</thead>
<tbody>
<tr style="--color: #d9ba92; --over: #eadbc6">
<th scope="row">売上</th>
<td>1億2,800万円/目標1億円</td>
<td>128%</td>
<td><span class="rings__badge" hidden>目標達成</span></td>
</tr>
<tr style="--color: #7796ad; --over: #93b2c9">
<th scope="row">新規顧客</th>
<td>860人/目標1,000人</td>
<td>86%</td>
<td><span class="rings__badge" hidden>目標達成</span></td>
</tr>
<tr style="--color: #536e55; --over: #6F9770">
<th scope="row">成約件数</th>
<td>107件/目標100件</td>
<td>107%</td>
<td><span class="rings__badge" hidden>目標達成</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
.rings {
--bg: #111111;
--ink: #eeece6;
--mute: #a8a6a0;
--line: rgba(238, 236, 230, 0.14);
--met-bg: #202e25;
--met-ink: #6ba37f;
--stroke: 24; /* script.js の STROKE と同じ値にする */
padding: 64px clamp(16px, 6vw, 96px) 96px;
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.rings__title {
margin: 0 0 40px;
font-size: clamp(28px, 4vw, 44px);
font-weight: 800;
line-height: 1.1;
letter-spacing: -0.02em;
}
.rings__body {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 48px 64px;
}
.rings__chart { position: relative; flex: none; width: min(320px, 100%); }
.rings__svg { display: block; width: 100%; height: auto; }
.rings__track { fill: none; stroke-width: var(--stroke); opacity: 0.16; }
.rings__arc { fill: none; stroke-width: var(--stroke); stroke-linecap: round; stroke-dasharray: 0 100; }
.rings__center {
position: absolute;
inset: 0;
display: grid;
place-content: center;
gap: 4px;
text-align: center;
}
.rings__count { margin: 0; font-size: 40px; font-weight: 800; letter-spacing: -0.02em; }
.rings__count-unit { color: var(--mute); font-size: 20px; font-weight: 600; }
.rings__count-label { margin: 0; color: var(--mute); font-size: 12px; letter-spacing: 0.04em; }
.rings__table-wrap { flex: 1 1 320px; max-width: 100%; overflow-x: auto; }
.rings__table { border-spacing: 0; }
.rings__table th,
.rings__table td { padding: 16px 16px 16px 0; border-top: 1px solid var(--line); text-align: left; white-space: nowrap; }
.rings__table thead th { padding-block: 0 12px; border: 0; color: var(--mute); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; }
.rings__table tbody th { font-size: 16px; font-weight: 600; }
.rings__table td { color: var(--mute); font-size: 14px; }
.rings__table td:nth-child(3) { color: var(--ink); font-size: 22px; font-weight: 800; letter-spacing: -0.02em; }
/* 行の色の丸は、行に書いた --color から */
.rings__table tbody th::before {
content: "";
display: inline-block;
width: 12px;
height: 12px;
margin-right: 12px;
border-radius: 50%;
background: var(--color);
vertical-align: -1px;
}
.rings__badge {
padding: 2px 10px;
border-radius: 999px;
background: var(--met-bg);
color: var(--met-ink);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
import { gsap } from "gsap";
const SVG_NS = "http://www.w3.org/2000/svg";
const R = 120; // 外側の輪の半径
const STROKE = 24; // 輪の太さ(style.css の --stroke と同じ値。細くすると中央の数字の場所が広がる)
const GAP = 6; // 輪どうしのすき間
const FILL_AT = 0.2; // 外側の輪が満ち始める時刻
const EACH = 0.3; // 内側の輪へのずれ(秒)
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
function arc(parent, r, extraClass) {
const el = document.createElementNS(SVG_NS, "circle");
el.setAttribute("cx", 160);
el.setAttribute("cy", 160);
el.setAttribute("r", r);
el.setAttribute("pathLength", 100);
el.setAttribute("transform", "rotate(-90 160 160)");
if (extraClass) el.setAttribute("class", extraClass);
parent.append(el);
return el;
}
document.querySelectorAll(".rings").forEach((section) => {
const svg = section.querySelector(".rings__svg");
const countValue = section.querySelector(".rings__count-value");
const countTotal = section.querySelector(".rings__count-total");
// 値はこの表が正。輪は表の行の数と割合、色(--color)から描く。2 周目は明るい色(--over)で 1 周目と見分ける
const rows = [...section.querySelectorAll("tbody tr")].map((row, i) => {
const style = getComputedStyle(row);
const color = style.getPropertyValue("--color").trim();
const ratio = parseFloat(row.cells[2].textContent) / 100;
const r = R - i * (STROKE + GAP);
const track = arc(svg, r, "rings__track");
const ring = arc(svg, r, "rings__arc");
const over = arc(svg, r, "rings__arc rings__arc--over");
track.style.stroke = color;
ring.style.stroke = color;
over.style.stroke = style.getPropertyValue("--over").trim() || color;
return { row, ratio, ring, over, badge: row.querySelector(".rings__badge"), p: 0 };
});
countTotal.textContent = rows.length;
// 大きい輪ほど長く満たし、値が変わるたびに描き直す
const draw = () => {
let met = 0;
for (const r of rows) {
const lap = Math.min(r.p, 1);
r.ring.style.strokeDasharray = `${(lap * 100).toFixed(2)} 100`;
const spill = Math.max(0, Math.min(r.p - 1, 1));
r.over.style.strokeDasharray = `${(spill * 100).toFixed(2)} 100`;
r.row.cells[2].textContent = `${Math.round(r.p * 100)}%`;
r.badge.hidden = r.p < 1;
if (r.p >= 1) met++;
}
countValue.textContent = String(met);
};
const tl = gsap.timeline({ paused: true, onUpdate: draw });
rows.forEach((r, i) => {
tl.to(r, { p: r.ratio, duration: 1.1 + 0.5 * r.ratio, ease: "power2.inOut" }, FILL_AT + i * EACH);
});
// 動きを減らす設定では、満ちきった状態で見せる
if (reduceMotion) {
tl.progress(1);
return;
}
// 画面に入ったら 1 回だけ満たす
const io = new IntersectionObserver(
([entry], self) => {
if (!entry.isIntersecting) return;
self.unobserve(entry.target);
tl.play();
},
{ rootMargin: "0px 0px -20%" }
);
io.observe(section);
});