02Bar chart grow
Five revenue bars grow from the floor with staggered starts that ripple like a wave; once each bar lands, its value label counts up.
Best forA channel or location comparison dashboard
staggereaseOutCubicfillRect

<main class="stage">
<div class="bg" id="bg">
<p class="bg__hint">
<svg class="bg__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 40v168h-56V40Z" opacity=".2"/><path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></g></svg>
<span>Five channel bars grow in sequence and become a wave — <code>stagger</code>·<code>easeOutCubic</code>·<code>fillRect</code></span>
</p>
<div class="bg__field" id="field" role="application" aria-label="Bar chart growth test board" tabindex="0">
<canvas class="bg__cv" id="cv" role="img" aria-label="Five-channel revenue bar chart — bars growing from the floor to their values"></canvas>
</div>
<div class="bg__bar">
<button class="bg__btn" id="replay" type="button">
<svg class="bg__bico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
<span>Redraw</span>
</button>
<table class="sr-only">
<caption>Quarterly revenue by channel (unit: 10K KRW)</caption>
<thead><tr><th scope="col">Channel</th><th scope="col">Revenue</th></tr></thead>
<tbody>
<tr><th scope="row">Search</th><td>420</td></tr>
<tr><th scope="row">Social</th><td>310</td></tr>
<tr><th scope="row">Email</th><td>265</td></tr>
<tr><th scope="row">Direct</th><td>195</td></tr>
<tr><th scope="row">Partner</th><td>140</td></tr>
</tbody>
</table>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
// 막대 그래프 성장 — 움직임은 전부 캔버스 rAF 루프 안(막대 stagger 성장 + 값 카운트업).
// CSS 애니메이션 0개 — 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 막대 여러 개는 차트 토큰 고정 순서대로. 2·3·4번 계열은 파스텔 카운터파트(한 단 밝게)로 밝은 무대에 맞춘다.
$duration: $dur-loop;
$easing: $ease-out; // easeOutCubic 을 JS 에서 계산하므로 CSS 쪽 이징 토큰은 감속 역할명으로
$color: $chart-1;
@include stage($stage-paper);
// 화면 낭독기용 표 — 캔버스를 읽지 못하므로 같은 데이터를 표로 붙인다
.sr-only {
// fixed + top/left 명시 — absolute 였을 때 표의 실제 콘텐츠 높이(행 수만큼 ≒ 390px)가
// scrollHeight 에 잡혀 데스크톱·폰 전부 세로 넘침(BD8/BD9)이 났다. top 을 주지 않으면
// static 자리 기준으로 배치돼 여전히 scrollHeight 에 들어간다 — 좌상단 고정으로 뺀다.
position: fixed; top: 0; left: 0; width: 1px; height: 1px; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.bg {
// 캔버스가 읽는 색 — JS 가 getComputedStyle 로 꺼내 쓴다
--chart-1: #{$chart-1};
--chart-2: #{$chart-2};
--chart-3: #{$chart-3};
--chart-4: #{$chart-4};
// 파스텔 카운터파트 — 어두운 무대 기준 계열색을 흰 무대 위 가시에 맞춘 리프트
--chart-2-lift: #{color.mix(#ffffff, $chart-2, 18%)};
--chart-3-lift: #{color.mix(#ffffff, $chart-3, 14%)};
--chart-4-lift: #{color.mix(#ffffff, $chart-4, 16%)};
--ink: #{$subject-ink};
--mute: #{rgba($subject-ink, .62)};
--grid: #{rgba($subject-ink, .10)};
--track: #{rgba($subject-ink, .06)};
--paper: #{$stage-paper};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.bg__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.bg__hint code {
padding: 0 $sp-1; border-radius: $r-xs;
background: rgba($chart-1, .10);
color: $subject-ink;
font-weight: 700; font-size: .92em; line-height: 1.3;
}
.bg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.bg__field {
position: relative; height: 176px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.bg__cv { display: block; width: 100%; height: 100%; }
.bg__bar {
display: flex; align-items: center; gap: $sp-2;
}
.bg__btn {
display: inline-flex; align-items: center; gap: $sp-1;
box-sizing: border-box; padding: $sp-2 $sp-4; border: 0;
border-radius: $r-pill; background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 12px; line-height: 1.2;
cursor: pointer;
}
.bg__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.bg__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.bg { gap: $sp-1; }
.bg__field { height: 112px; }
.bg__hint { font-size: 10px; }
.bg__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끌 애니메이션이 없다. 완성 상태 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.bg__btn { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr-only {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.bg {
--chart-1: #2f6df6;
--chart-2: #c2361a;
--chart-3: #2b9d74;
--chart-4: #8a5cd6;
--chart-2-lift: rgb(80.3843137255%, 35.3647058824%, 26.3607843137%);
--chart-3-lift: rgb(28.5019607843%, 66.9490196078%, 53.1215686275%);
--chart-4-lift: rgb(61.4588235294%, 46.3058823529%, 86.4941176471%);
--ink: #17141a;
--mute: rgba(23, 20, 26, 0.62);
--grid: rgba(23, 20, 26, 0.1);
--track: rgba(23, 20, 26, 0.06);
--paper: #ffffff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.bg__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.bg__hint code {
padding: 0 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.1);
color: #17141a;
font-weight: 700;
font-size: 0.92em;
line-height: 1.3;
}
.bg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.bg__field {
position: relative;
height: 176px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.bg__cv {
display: block;
width: 100%;
height: 100%;
}
.bg__bar {
display: flex;
align-items: center;
gap: 8px;
}
.bg__btn {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.2;
cursor: pointer;
}
.bg__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.bg__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.bg {
gap: 4px;
}
.bg__field {
height: 112px;
}
.bg__hint {
font-size: 10px;
}
.bg__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.bg__btn {
cursor: default;
}
}// 막대 그래프 성장 — 다섯 Channel 매출이 바닥에서 값까지 자란다.
// 각 막대의 시작 시각이 오른쪽으로 갈수록 늦춰져(stagger) 파도처럼 번지고,
// 성장 곡선은 easeOutCubic(처음 빠르고 끝에서 뭉툭해지는 감속) — 막대가 "콩" 하고 착지하는 맛을 만든다.
// 막대가 다 자란 뒤(각자 자기 시각에) Value 레이블이 0 에서 값까지 카운트업된다.
var root = document.getElementById('bg');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var replayBtn = document.getElementById('replay');
var css = getComputedStyle(root);
var C_1 = css.getPropertyValue('--chart-1').trim();
var C_2 = css.getPropertyValue('--chart-2').trim();
var C_3 = css.getPropertyValue('--chart-3').trim();
var C_4 = css.getPropertyValue('--chart-4').trim();
var C_2L = css.getPropertyValue('--chart-2-lift').trim();
var C_3L = css.getPropertyValue('--chart-3-lift').trim();
var C_4L = css.getPropertyValue('--chart-4-lift').trim();
var C_INK = css.getPropertyValue('--ink').trim();
var C_MUTE = css.getPropertyValue('--mute').trim();
var C_GRID = css.getPropertyValue('--grid').trim();
var C_TRACK = css.getPropertyValue('--track').trim();
var C_PAPER = css.getPropertyValue('--paper').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 고정 시드 데이터 — 다섯 Channel 분기 Revenue(만원). 파스텔 카운터파트: 첫 계열은 그대로, 나머지는 한 단계 밝게
var ROWS = [
{ name: 'Search', v: 420, c: C_1, lift: C_1 },
{ name: 'Social', v: 310, c: C_2, lift: C_2L },
{ name: 'Email', v: 265, c: C_3, lift: C_3L },
{ name: 'Direct', v: 195, c: C_4, lift: C_4L },
{ name: 'Partner', v: 140, c: C_2, lift: C_2L }
];
var PAD_L = 52, PAD_R = 14, PAD_T = 20, PAD_B = 26;
var LOOP = 2; // 한 사이클 2초
var GROW = 0.46; // 막대 하나가 자라는 데 쓰는 구간(전체의 46%)
var STAGGER = 0.075; // 오른쪽 막대가 늦게 시작하는 간격 — 5개면 마지막은 0.30 에 시작
var COUNT_AT = 0.62; // Value 레이블 카운트업 시작(마지막 막대 착지 직후)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var t0 = 0, raf = 0;
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 감속 이징 — 1 - (1-t)^3. 처음에 빨랐다가 목표에서 미끄러지듯 멈춘다
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function draw(p) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
var plotW = W - PAD_L - PAD_R;
var plotH = H - PAD_T - PAD_B;
var baseY = PAD_T + plotH;
var n = ROWS.length;
var slot = plotW / n;
var barW = Math.min(44, Math.max(18, slot * 0.62));
// 가로 그리드 — 100/200/300/400 네 줄. 첫 10% 에 아래에서 뜬다
var axisP = Math.min(1, p / 0.10);
ctx.lineWidth = 1;
ctx.font = '600 10px ' + FONT;
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
for (var g = 100; g <= 400; g += 100) {
var gy = baseY - plotH * (g / 450) + (1 - axisP) * 12;
ctx.strokeStyle = C_GRID;
ctx.beginPath();
ctx.moveTo(PAD_L, Math.round(gy) + 0.5);
ctx.lineTo(W - PAD_R, Math.round(gy) + 0.5);
ctx.stroke();
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText(String(g), PAD_L - 6, gy);
ctx.globalAlpha = 1;
}
// 막대 다섯 개 — i 번째는 i*STAGGER 에 시작해 GROW 동안 자란다(stagger 파도)
for (var i = 0; i < n; i++) {
var row = ROWS[i];
var cx = PAD_L + slot * (i + 0.5);
var fullH = plotH * (row.v / 450);
// 바닥 트랙 — 다 자랐을 때의 자리를 옅게 미리 깔아 둔다(착지 목표가 보인다)
ctx.fillStyle = C_TRACK;
ctx.fillRect(cx - barW / 2, baseY - fullH, barW, fullH);
var local = Math.max(0, Math.min(1, (p - i * STAGGER) / GROW));
var eased = easeOutCubic(local);
var h = fullH * eased;
var y = baseY - h;
// 막대 — fillRect. 위 4px 를 같은 색의 밝은 단(lift)으로 덮어 둥근 데이터 끝처럼 보이게 한다
if (h > 0) {
ctx.fillStyle = row.c;
ctx.fillRect(cx - barW / 2, y, barW, h);
if (h > 5) {
ctx.fillStyle = row.lift;
ctx.fillRect(cx - barW / 2, y, barW, Math.min(4, h));
}
}
// 착지 순간의 잔물결 — 자라는 동안 막대 꼭대기에 작은 캡이 붙었다가 착지하며 사라진다
if (local > 0 && local < 1) {
ctx.beginPath();
ctx.arc(cx, y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = row.c;
ctx.fill();
}
// Channel 이름 — Axis 아래. 왼쪽부터 차례로 나타난다
var lp = Math.max(0, Math.min(1, (p - 0.06 - i * STAGGER) / 0.12));
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = lp;
ctx.font = '600 10px ' + FONT;
ctx.fillText(row.name, cx, baseY + 14);
ctx.globalAlpha = 1;
// Value 레이블 — 막대 착지 뒤 자기 차례에 카운트업. 착지 전엔 그리지 않는다
if (local >= 1) {
var cp = Math.max(0, Math.min(1, (p - (i * STAGGER + GROW)) / 0.22));
var shown = Math.round(row.v * easeOutCubic(cp));
ctx.font = '700 11px ' + FONT;
ctx.fillStyle = C_INK;
ctx.fillText(String(shown), cx, y - 7);
}
}
}
function frame(now) {
if (!t0) t0 = now;
var p = ((now - t0) / 1000 % LOOP) / LOOP; // 0..1, 2초 루프
draw(p);
raf = requestAnimationFrame(frame);
}
replayBtn.addEventListener('click', function () { t0 = 0; });
window.addEventListener('resize', function () { fit(); if (reduce) draw(1); });
fit();
if (reduce) {
// reduce — 루프 없이 완성 상태(다 자란 막대와 카운트업 끝난 Value) 한 장만 그린다
draw(1);
} else {
raf = requestAnimationFrame(frame);
}// BarGrowChart — vanilla 판(run/305/demo/02-chart-canvas-bar-grow/index.html) 의 React 포트.
// 다섯 채널 막대가 stagger 로 시간차를 두고 바닥에서 값까지 easeOutCubic 으로 자라며,
// 착지한 막대부터 값 레이블이 카운트업된다. @keyframes 없음 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BarGrowChart.module.scss";
export type BarGrowChartProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Row = { name: string; v: number; cvar: string; liftvar: string };
// 고정 시드 데이터 — 다섯 채널 분기 매출(만원). 색은 CSS 변수 이름으로 달아둔다(그릴 때 읽는다)
const ROWS: Row[] = [
{ name: "검색", v: 420, cvar: "--chart-1", liftvar: "--chart-1" },
{ name: "소셜", v: 310, cvar: "--chart-2", liftvar: "--chart-2-lift" },
{ name: "이메일", v: 265, cvar: "--chart-3", liftvar: "--chart-3-lift" },
{ name: "직접", v: 195, cvar: "--chart-4", liftvar: "--chart-4-lift" },
{ name: "파트너", v: 140, cvar: "--chart-2", liftvar: "--chart-2-lift" }
];
const PAD_L = 52, PAD_R = 14, PAD_T = 20, PAD_B = 26;
const LOOP = 2; // 한 사이클 2초
const GROW = 0.46; // 막대 하나가 자라는 구간
const STAGGER = 0.075; // 오른쪽 막대가 늦게 시작하는 간격
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "막대 그래프 성장 실험판";
const CV_LABEL = "다섯 채널 매출 막대 그래프 — 막대가 바닥에서 값까지 자라는 모습";
export default function BarGrowChart({
duration = "2s",
easing = "ease-out",
color = "#2f6df6",
}: BarGrowChartProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function cssv(n: string): string {
const root = rootRef.current;
return root ? getComputedStyle(root).getPropertyValue(n).trim() : "";
}
// 고DPI — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
function fit(): void {
const cv = cvRef.current;
const s = sizeRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
s.w = r.width; s.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 감속 이징 — 1 - (1-t)^3
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function draw(p: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const C_INK = cssv("--ink");
const C_MUTE = cssv("--mute");
const C_GRID = cssv("--grid");
const C_TRACK = cssv("--track");
const C_PAPER = cssv("--paper");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
const plotW = W - PAD_L - PAD_R;
const plotH = H - PAD_T - PAD_B;
const baseY = PAD_T + plotH;
const n = ROWS.length;
const slot = plotW / n;
const barW = Math.min(30, Math.max(14, slot * 0.42));
// 가로 그리드 — 첫 10% 에 아래에서 뜬다
const axisP = Math.min(1, p / 0.10);
ctx.lineWidth = 1;
ctx.font = "600 10px " + FONT;
ctx.textBaseline = "middle";
ctx.textAlign = "right";
for (let g = 100; g <= 400; g += 100) {
const gy = baseY - plotH * (g / 450) + (1 - axisP) * 12;
ctx.strokeStyle = C_GRID;
ctx.beginPath();
ctx.moveTo(PAD_L, Math.round(gy) + 0.5);
ctx.lineTo(W - PAD_R, Math.round(gy) + 0.5);
ctx.stroke();
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText(String(g), PAD_L - 6, gy);
ctx.globalAlpha = 1;
}
// 막대 — i 번째는 i*STAGGER 에 시작해 GROW 동안 자란다
for (let i = 0; i < n; i++) {
const row = ROWS[i];
const cx = PAD_L + slot * (i + 0.5);
const fullH = plotH * (row.v / 450);
const rowC = row.cvar === "--chart-1" ? (cssv(row.cvar) || color) : cssv(row.cvar);
// 바닥 트랙 — 착지 목표 자리를 옅게 깐다
ctx.fillStyle = C_TRACK;
ctx.fillRect(cx - barW / 2, baseY - fullH, barW, fullH);
const local = Math.max(0, Math.min(1, (p - i * STAGGER) / GROW));
const eased = easeOutCubic(local);
const h = fullH * eased;
const y = baseY - h;
// 막대 — fillRect. 위 4px 를 밝은 단(lift)으로 덮어 둥근 데이터 끝처럼 보이게 한다
if (h > 0) {
ctx.fillStyle = rowC;
ctx.fillRect(cx - barW / 2, y, barW, h);
if (h > 5) {
ctx.fillStyle = cssv(row.liftvar);
ctx.fillRect(cx - barW / 2, y, barW, Math.min(4, h));
}
}
// 자라는 동안 막대 꼭대기 캡
if (local > 0 && local < 1) {
ctx.beginPath();
ctx.arc(cx, y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = rowC;
ctx.fill();
}
// 채널 이름 — 왼쪽부터 차례로
const lp = Math.max(0, Math.min(1, (p - 0.06 - i * STAGGER) / 0.12));
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = lp;
ctx.font = "600 10px " + FONT;
ctx.fillText(row.name, cx, baseY + 14);
ctx.globalAlpha = 1;
// 값 레이블 — 착지 뒤 자기 차례에 카운트업
if (local >= 1) {
const cp = Math.max(0, Math.min(1, (p - (i * STAGGER + GROW)) / 0.22));
const shown = Math.round(row.v * easeOutCubic(cp));
ctx.font = "700 11px " + FONT;
ctx.fillStyle = C_INK;
ctx.fillText(String(shown), cx, y - 7);
}
}
}
function frame(now: number): void {
if (!t0Ref.current) t0Ref.current = now;
const p = ((now - t0Ref.current) / 1000 % LOOP) / LOOP;
draw(p);
rafRef.current = requestAnimationFrame(frame);
}
function replay(): void {
t0Ref.current = 0;
}
function onResize(): void {
fit();
if (reduced()) draw(1);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프 없이 완성 상태 한 장만 그린다
draw(1);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 40v168h-56V40Z" opacity=".2"/><path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></g></svg>
<span>다섯 채널 막대가 시간차로 자라 파도가 됩니다 — <code>stagger</code>·<code>easeOutCubic</code>·<code>fillRect</code></span>
</p>
<div className={styles.field} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef} role="img" aria-label={CV_LABEL}></canvas>
</div>
<div className={styles.bar}>
<button className={styles.btn} type="button" onClick={replay}>
<svg className={styles.bico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
<span>다시 그리기</span>
</button>
<table className={styles.srOnly}>
<caption>채널별 분기 매출(단위: 만원)</caption>
<thead><tr><th scope="col">채널</th><th scope="col">매출</th></tr></thead>
<tbody>
{ROWS.map((r, i) => (
<tr key={i}><th scope="row">{r.name}</th><td>{r.v}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
}02 Bar chart grow — Measured area 14.631% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · JavaScript Chart Drawing, 9 Ways — No Library
The post this demo lives in
JavaScript Chart Drawing, 9 Ways — No LibraryA javascript chart is a data array turned into x·y coordinates painted onto a canvas as lines, bars, and arcs. These nin See all demos in this post
If you like this effect - similar demos
Dashboard UI01 Bars That Grow on a Spring, Overshooting Once Before SettlingBar Chart Animation: 9 Demos From Spring to Race
Dashboard UI04 A month bar splits into days where it standsChart UI Design: 9 Interactions You Can Drag
Dashboard UI02 Column bars growing from the baselineCSS Chart Design: 9 SVG Graphs, No Library