03一区画ずつ満ちるドーナツグラフ
チャネル比率をarcコマンドで計算し、各区画がstroke-dasharrayの長さだけ順に満ちる
使いどころ流入チャネル比率カード、サマリーレポートの構成比
stroke-dasharraystroke-dashoffsetanimation-delay

<main class="stage">
<div class="cn is-demo" id="cn">
<div class="cn__card">
<div class="cn__head">
<p class="cn__title">流入チャネル</p>
<span class="cn__chip">直近7日</span>
</div>
<div class="cn__body">
<div class="cn__plot" id="plot">
<svg class="cn__svg" viewBox="0 0 180 180" role="img" aria-label="流入チャネルのドーナツグラフ。検索が46%で最多">
<!-- 조각은 실제 원호 path — JS 가 arcPath() 로 d 를 계산해 심는다 -->
<g id="slices"></g>
<!-- 중앙 合計 라벨 -->
<text class="cn__sumLabel" x="90" y="82">合計</text>
<text class="cn__sumVal" x="90" y="104">100%</text>
</svg>
<!-- 툴팁 — plot 안에서 절대 배치, 조각 위 커서를 따라온다 -->
<div class="cn__tip" id="tip" aria-hidden="true"><span class="cn__tipCh" id="tipCh">検索</span><strong class="cn__tipVal" id="tipVal">46%</strong></div>
<!-- 스크린리더용 데이터 표 — 화면에 안 보인다 -->
<table class="cn__sr">
<caption>流入チャネル</caption>
<thead><tr><th>チャネル</th><th>比率</th></tr></thead>
<tbody id="srBody"></tbody>
</table>
</div>
<!-- 범례 — 색점과 채널명과 비율을 한 줄에. 색만으로 구분하지 않는다 -->
<ul class="cn__legend" id="legend"></ul>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 한 조각씩 그려지는 도넛 그래프 — 실제 사용에선 JS 가 arcPath() 로 완성된 원호 네 개를
// 심어 완성된 도넛이 곧 뜨고 조각에 올라가면 툴팁이 채널과 비율을 말해 준다.
// 미리보기(.is-demo)는 같은 path 에 pathLength=100 + stroke-dashoffset 키프레임을 얹어
// 붓질하듯 조각을 한 개씩 그려 채우는 모양을 2초 루프로 보여 준다.
$duration: $dur-loop;
$easing: linear; // 붓은 등속으로 긋는다 — 드로잉에 ease-out 을 주면 끝에서 늘어진다
@include stage($stage-ink);
.cn { display: flex; justify-content: center; width: 100%; padding-bottom: $sp-2; }
.cn__card {
width: min(100%, 400px); background: $subject-cream; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3 $sp-4;
}
.cn__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; margin-bottom: $sp-2; }
.cn__title { margin: 0; font-weight: 800; font-size: clamp(12px, 3vw, 14px); color: $subject-ink; }
.cn__chip {
font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); color: rgba(23, 20, 26, .66);
border: 1px solid rgba(23, 20, 26, .28); border-radius: $r-pill; padding: $sp-1 $sp-2;
}
.cn__body { display: flex; align-items: center; gap: $sp-4; }
.cn__plot { position: relative; flex: none; }
.cn__svg { display: block; width: 168px; height: auto; touch-action: none; }
// 조각 — 실제 원호 path 를 굵은 stroke 로 그린다. 계열색은 고정 순서대로만 (dataviz 검증 팔레트)
.cn__slice { fill: none; stroke-width: 26; }
.cn__slice:nth-child(1) { stroke: $chart-1; }
.cn__slice:nth-child(2) { stroke: $chart-2; }
.cn__slice:nth-child(3) { stroke: $chart-3; }
.cn__slice:nth-child(4) { stroke: $chart-4; }
// 조각 사이 2px 간격 — path 를 gap 각도만큼 줄여 그렸으므로 크림 배경이 자연 분할선으로 보인다
// 중앙 합계 라벨 — 텍스트는 잉크 토큰
.cn__sumLabel { fill: rgba(23, 20, 26, .72); font-weight: 700; font-size: 10px; text-anchor: middle; }
.cn__sumVal { fill: $subject-ink; font-weight: 800; font-size: 21px; text-anchor: middle; }
// 범례 — 색점과 채널명과 비율을 한 줄에. 색만으로 구분하지 않는다
.cn__legend { list-style: none; margin: 0; padding: 0; display: grid; gap: $sp-2; flex: 1; min-width: 0; }
.cn__item { display: flex; align-items: center; gap: $sp-2; }
.cn__dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.cn__item:nth-child(1) .cn__dot { background: $chart-1; }
.cn__item:nth-child(2) .cn__dot { background: $chart-2; }
.cn__item:nth-child(3) .cn__dot { background: $chart-3; }
.cn__item:nth-child(4) .cn__dot { background: $chart-4; }
.cn__name { font-weight: 700; font-size: clamp(11px, 3vw, 12.5px); color: $subject-ink; }
.cn__pct {
margin-left: auto; font-weight: 800; font-size: clamp(11px, 3vw, 12.5px);
color: rgba(23, 20, 26, .66); font-variant-numeric: tabular-nums;
}
// 툴팁 — 절대 배치라 루트에 padding-bottom 이 있어야 높이가 무대를 넘기지 않는다
.cn__tip {
position: absolute; transform: translate(-50%, -130%);
display: flex; align-items: baseline; gap: $sp-1; pointer-events: none;
background: $subject-ink; color: $subject-cream; border-radius: $r-xs;
padding: $sp-1 $sp-2; font-weight: 700; font-size: 10px; white-space: nowrap;
opacity: 0;
}
.cn__tip.is-on { opacity: 1; transition: opacity $dur-quick $ease-out; }
.cn__tipCh { opacity: .72; }
.cn__tipVal { font-size: 12px; }
// 스크린리더용 표 — absolute+clip 은 레이아웃 크기가 그대로 scrollHeight 에 잡혀 넘침(BD8)이 뜨므로
// fixed 로 캔버스 밖에 둔다. 스크린리더는 그대로 읽는다
.cn__sr {
position: fixed; top: 0; left: -640px; width: 320px; margin: 0;
}
// ── 미리보기 루프: 조각이 --d(0.24s) 간격으로 하나씩 그려진다 — 이전 조각이 끝나는 순간
// 다음 조각의 붓질이 시작된다 (드로잉 0.24s = 간격 0.24s). 마지막 조각은 52% 에 완성.
// pathLength=100 이라 dashoffset 이 원호 길이와 무관하게 0~100 으로 돈다.
// animation-delay 는 낱개 속성으로 — 단축을 쓰면 delay 가 리셋된다. backwards 로 대기 구간 채움.
// 되돌림은 98% — 24프레임의 마지막 장이 95.8% 지점이라 그 뒤는 샘플이 없다. 루프 안에서
// 지우면 "다 지워지는 장" 이 최대 변화가 돼 poster 로 뽑힌다 (255-03 과 같은 계열).
.cn.is-demo .cn__slice { stroke-dasharray: 100; }
.cn.is-demo .cn__slice {
animation-name: cnDraw;
animation-duration: $duration;
animation-timing-function: $easing;
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cnDraw {
0%, 4% { stroke-dashoffset: 100; }
16%, 96% { stroke-dashoffset: 0; }
98%, 100% { stroke-dashoffset: 100; }
}
// 범례 넷과 중앙 합계는 도넛이 다 그려진 54% 에 **한 번에** 컷으로 붙는다 — 조각마다 따로 붙이면
// 최대 변화 프레임이 첫 조각의 붓질에 잡혀 poster 가 "파란 조각 하나" 가 된다 (실측 2026-09-12)
.cn.is-demo .cn__item,
.cn.is-demo .cn__sumLabel,
.cn.is-demo .cn__sumVal {
animation-name: cnReveal;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cnReveal {
0%, 52% { opacity: 0; }
54%, 96% { opacity: 1; }
98%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
// 320px 에서 도넛을 줄여 카드 전체가 세로 200px 안에 들어온다
.cn__svg { width: 122px; }
.cn__card { padding: $sp-2 $sp-3; }
.cn__body { gap: $sp-3; }
.cn__legend { gap: $sp-1; }
.cn__head { margin-bottom: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cn.is-demo .cn__slice,
.cn.is-demo .cn__item,
.cn.is-demo .cn__sumLabel,
.cn.is-demo .cn__sumVal { animation: none !important; }
// 움직임 없이 다 그려진 최종 상태로 점프한다
.cn.is-demo .cn__slice { stroke-dashoffset: 0 !important; }
.cn.is-demo .cn__item,
.cn.is-demo .cn__sumLabel,
.cn.is-demo .cn__sumVal { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cn {
display: flex;
justify-content: center;
width: 100%;
padding-bottom: 8px;
}
.cn__card {
width: min(100%, 400px);
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px;
}
.cn__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.cn__title {
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3vw, 14px);
color: #17141a;
}
.cn__chip {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
color: rgba(23, 20, 26, 0.66);
border: 1px solid rgba(23, 20, 26, 0.28);
border-radius: 999px;
padding: 4px 8px;
}
.cn__body {
display: flex;
align-items: center;
gap: 16px;
}
.cn__plot {
position: relative;
flex: none;
}
.cn__svg {
display: block;
width: 168px;
height: auto;
touch-action: none;
}
.cn__slice {
fill: none;
stroke-width: 26;
}
.cn__slice:nth-child(1) {
stroke: #2f6df6;
}
.cn__slice:nth-child(2) {
stroke: #c2361a;
}
.cn__slice:nth-child(3) {
stroke: #2b9d74;
}
.cn__slice:nth-child(4) {
stroke: #8a5cd6;
}
.cn__sumLabel {
fill: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 10px;
text-anchor: middle;
}
.cn__sumVal {
fill: #17141a;
font-weight: 800;
font-size: 21px;
text-anchor: middle;
}
.cn__legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
flex: 1;
min-width: 0;
}
.cn__item {
display: flex;
align-items: center;
gap: 8px;
}
.cn__dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex: none;
}
.cn__item:nth-child(1) .cn__dot {
background: #2f6df6;
}
.cn__item:nth-child(2) .cn__dot {
background: #c2361a;
}
.cn__item:nth-child(3) .cn__dot {
background: #2b9d74;
}
.cn__item:nth-child(4) .cn__dot {
background: #8a5cd6;
}
.cn__name {
font-weight: 700;
font-size: clamp(11px, 3vw, 12.5px);
color: #17141a;
}
.cn__pct {
margin-left: auto;
font-weight: 800;
font-size: clamp(11px, 3vw, 12.5px);
color: rgba(23, 20, 26, 0.66);
font-variant-numeric: tabular-nums;
}
.cn__tip {
position: absolute;
transform: translate(-50%, -130%);
display: flex;
align-items: baseline;
gap: 4px;
pointer-events: none;
background: #17141a;
color: #fff7e6;
border-radius: 4px;
padding: 4px 8px;
font-weight: 700;
font-size: 10px;
white-space: nowrap;
opacity: 0;
}
.cn__tip.is-on {
opacity: 1;
transition: opacity 220ms ease-out;
}
.cn__tipCh {
opacity: 0.72;
}
.cn__tipVal {
font-size: 12px;
}
.cn__sr {
position: fixed;
top: 0;
left: -640px;
width: 320px;
margin: 0;
}
.cn.is-demo .cn__slice {
stroke-dasharray: 100;
}
.cn.is-demo .cn__slice {
animation-name: cnDraw;
animation-duration: 2s;
animation-timing-function: linear;
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cnDraw {
0%, 4% {
stroke-dashoffset: 100;
}
16%, 96% {
stroke-dashoffset: 0;
}
98%, 100% {
stroke-dashoffset: 100;
}
}
.cn.is-demo .cn__item,
.cn.is-demo .cn__sumLabel,
.cn.is-demo .cn__sumVal {
animation-name: cnReveal;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cnReveal {
0%, 52% {
opacity: 0;
}
54%, 96% {
opacity: 1;
}
98%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cn__svg {
width: 122px;
}
.cn__card {
padding: 8px 12px;
}
.cn__body {
gap: 12px;
}
.cn__legend {
gap: 4px;
}
.cn__head {
margin-bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cn.is-demo .cn__slice,
.cn.is-demo .cn__item,
.cn.is-demo .cn__sumLabel,
.cn.is-demo .cn__sumVal {
animation: none !important;
}
.cn.is-demo .cn__slice {
stroke-dashoffset: 0 !important;
}
.cn.is-demo .cn__item,
.cn.is-demo .cn__sumLabel,
.cn.is-demo .cn__sumVal {
opacity: 1 !important;
}
}var DATA = [['検索', 46], ['直接', 27], ['ソーシャル', 17], ['その他', 10]];
var CX = 90, CY = 90, R = 60; // 도넛 중심과 반지름 (viewBox 좌표)
var GAP = 2 / R; // 조각 사이 2px 간격을 각도로 환산 — 크림 배경이 이 틈으로 보인다
var SVGNS = 'http://www.w3.org/2000/svg';
var cn = document.getElementById('cn');
var plot = document.getElementById('plot');
var tip = document.getElementById('tip');
var tipCh = document.getElementById('tipCh');
var tipVal = document.getElementById('tipVal');
var slices = document.getElementById('slices');
var legend = document.getElementById('legend');
// 시작·끝 각도(라디안)로 원호 path 한 조각 — M 으로 붓을 대고 A 로 호를 긋는다.
// rotateY 3D 뒤집기 같은 효과는 쓰지 않는다 — |cos θ| 함정 없이 평면 원호만으로 그린다
function arcPath(a0, a1) {
var x0 = (CX + R * Math.cos(a0)).toFixed(2), y0 = (CY + R * Math.sin(a0)).toFixed(2);
var x1 = (CX + R * Math.cos(a1)).toFixed(2), y1 = (CY + R * Math.sin(a1)).toFixed(2);
var large = (a1 - a0) > Math.PI ? 1 : 0; // 180도 넘는 호에만 큰 호 플래그
return 'M' + x0 + ',' + y0 + ' A' + R + ',' + R + ' 0 ' + large + ' 1 ' + x1 + ',' + y1;
}
// 툴팁을 커서 위에 놓는다
function moveTip(e) {
var r = plot.getBoundingClientRect();
tip.style.left = (e.clientX - r.left) + 'px';
tip.style.top = (e.clientY - r.top) + 'px';
}
// 12시에서 시작해 시계 방향으로 チャネル 비율만큼씩 각도를 쓴다
var angle = -Math.PI / 2;
DATA.forEach(function (d, i) {
var span = d[1] / 100 * Math.PI * 2;
var p = document.createElementNS(SVGNS, 'path');
p.setAttribute('class', 'cn__slice');
p.setAttribute('d', arcPath(angle + GAP / 2, angle + span - GAP / 2));
p.setAttribute('pathLength', '100');
p.style.setProperty('--d', (i * .24).toFixed(2) + 's');
p.addEventListener('pointerenter', function (e) {
if (cn.classList.contains('is-demo')) { cn.classList.remove('is-demo'); }
tipCh.textContent = d[0]; tipVal.textContent = d[1] + '%';
moveTip(e);
tip.classList.add('is-on');
});
p.addEventListener('pointermove', moveTip);
p.addEventListener('pointerleave', function () { tip.classList.remove('is-on'); });
slices.appendChild(p);
angle += span;
});
// 범례 — 도넛이 다 그려진 뒤 넷이 한 번에 나타난다 (CSS 가 시점을 잡는다)
DATA.forEach(function (d) {
var li = document.createElement('li');
li.className = 'cn__item';
li.innerHTML = '<span class="cn__dot"></span><span class="cn__name">' + d[0] +
'</span><span class="cn__pct">' + d[1] + '%</span>';
legend.appendChild(li);
});
// 스크린리더용 표 채우기
var srBody = document.getElementById('srBody');
DATA.forEach(function (d) {
var tr = document.createElement('tr');
var th = document.createElement('th'); th.setAttribute('scope', 'row'); th.textContent = d[0];
var td = document.createElement('td'); td.textContent = d[1] + '%';
tr.appendChild(th); tr.appendChild(td); srBody.appendChild(tr);
});// ChartDonutArc — vanilla 판(run/289/demo/03-chart-draw-donut-arc/index.html) 의 React 포트.
// 유입 채널 도넛 — 조각마다 실제 원호 path(M + A)를 굵은 stroke 로 그리고
// 순차 delay 로 한 조각씩 붓질한다. 조각에 올라가면 미리보기 루프가 떨어지고
// 커서를 따라다니는 툴팁이 채널과 비율을 말해 준다.
import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ChartDonutArc.module.scss";
export type ChartDonutArcProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 조각에 올라가면 꺼진다 */
demo?: boolean;
};
type Vars = CSSProperties & { "--d"?: string };
type PtrEvt = { clientX: number; clientY: number };
type Box<T> = { current: T | null };
const DATA: Array<[string, number]> = [["검색", 46], ["직접", 27], ["소셜", 17], ["기타", 10]];
const CX = 90, CY = 90, R = 60;
const GAP = 2 / R; // 조각 사이 2px 간격을 각도로 환산 — 크림 배경이 이 틈으로 보인다
/** 시작·끝 각도(라디안)로 원호 path 한 조각 — M 으로 붓을 대고 A 로 호를 긋는다 */
function arcPath(a0: number, a1: number) {
const x0 = (CX + R * Math.cos(a0)).toFixed(2), y0 = (CY + R * Math.sin(a0)).toFixed(2);
const x1 = (CX + R * Math.cos(a1)).toFixed(2), y1 = (CY + R * Math.sin(a1)).toFixed(2);
const large = a1 - a0 > Math.PI ? 1 : 0; // 180도 넘는 호에만 큰 호 플래그
return `M${x0},${y0} A${R},${R} 0 ${large} 1 ${x1},${y1}`;
}
/** 12시에서 시작해 시계 방향으로 채널 비율만큼씩 각도를 쓴다 */
function sliceAngles(): Array<[number, number]> {
const out: Array<[number, number]> = [];
let angle = -Math.PI / 2;
for (const [, pct] of DATA) {
const span = pct / 100 * Math.PI * 2;
out.push([angle, angle + span]);
angle += span;
}
return out;
}
const ANGLES = sliceAngles();
type Hover = { i: number; x: number; y: number } | null;
export default function ChartDonutArc({ demo = true }: ChartDonutArcProps) {
const plotRef: Box<HTMLDivElement> = useRef(null);
const [live, setLive] = useState(!demo);
const [hover, setHover] = useState<Hover>(null);
/** 조각 위 포인터 — 툴팁 내용은 채널, 위치는 plot 기준 커서 좌표.
* live 를 켜야 .isDemo 루프가 떨어진다 (live=false 가 미리보기 상태다) */
function onOver(i: number, e: PtrEvt) {
setLive(true);
const r = plotRef.current?.getBoundingClientRect();
if (r) setHover({ i, x: e.clientX - r.left, y: e.clientY - r.top });
}
const rootCls = [styles.root, live ? "" : styles.isDemo].filter(Boolean).join(" ");
return (
<div className={rootCls}>
<div className={styles.card}>
<div className={styles.head}>
<p className={styles.title}>유입 채널</p>
<span className={styles.chip}>최근 7일</span>
</div>
<div className={styles.body}>
<div className={styles.plot} ref={plotRef}>
<svg className={styles.svg} viewBox="0 0 180 180" role="img"
aria-label="유입 채널 도넛 그래프, 검색 46% 가장 많음">
{DATA.map(([name], i) => {
const [a0, a1] = ANGLES[i];
return (
<path
key={name}
className={styles.slice}
d={arcPath(a0 + GAP / 2, a1 - GAP / 2)}
pathLength={100}
style={{ "--d": `${(i * .24).toFixed(2)}s` } as Vars}
onPointerEnter={(e: ReactPointerEvent<SVGPathElement>) => onOver(i, e)}
onPointerMove={(e: ReactPointerEvent<SVGPathElement>) => onOver(i, e)}
onPointerLeave={() => setHover(null)}
/>
);
})}
{/* 중앙 합계 라벨 */}
<text className={styles.sumLabel} x={90} y={82}>합계</text>
<text className={styles.sumVal} x={90} y={104}>100%</text>
</svg>
{/* 툴팁 — 조각 위 커서를 따라온다 */}
<div
className={[styles.tip, live && hover ? styles.tipIsOn : ""].filter(Boolean).join(" ")}
aria-hidden="true"
style={hover ? { left: `${hover.x}px`, top: `${hover.y}px` } : undefined}
>
<span className={styles.tipCh}>{hover ? DATA[hover.i][0] : "검색"}</span>
<strong className={styles.tipVal}>{hover ? `${DATA[hover.i][1]}%` : "46%"}</strong>
</div>
{/* 스크린리더용 데이터 표 — 화면에 안 보인다 */}
<table className={styles.sr}>
<caption>유입 채널</caption>
<thead><tr><th scope="col">채널</th><th scope="col">비율</th></tr></thead>
<tbody>
{DATA.map(([name, pct]) => (
<tr key={name}><th scope="row">{name}</th><td>{pct}%</td></tr>
))}
</tbody>
</table>
</div>
{/* 범례 — 색점과 채널명과 비율을 한 줄에. 색만으로 구분하지 않는다 */}
<ul className={styles.legend}>
{DATA.map(([name, pct], i) => (
<li key={name}
className={[styles.item, styles[`item${i + 1}` as const]].join(" ")}
style={{ "--d": `${(i * .24).toFixed(2)}s` } as Vars}>
<span className={styles.dot}></span>
<span className={styles.name}>{name}</span>
<span className={styles.pct}>{pct}%</span>
</li>
))}
</ul>
</div>
</div>
</div>
);
}03 一区画ずつ満ちるドーナツグラフ — 計測 累積面積 7.771% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · css グラフ をSVGで描く9種 — ライブラリなし
このデモが属する記事
css グラフ をSVGで描く9種 — ライブラリなしcss グラフとは、数値を線・棒・面という目に見えるかたちに変える、いちばん軽い方法のことです。ここにある9種(折れ線・縦棒・ドーナツ・レーダー・ヒートマップ・スパークライン・積み上げ面・ツリーマップ・ゲージ)は、ライブラリなしでSVGの座 この記事のデモをすべて見る

