02밑변에서 자라는 세로 막대 그래프
주간 매출을 viewBox 좌표로 바꿔 막대가 밑변에서 scaleY 로 자라고 값 라벨이 0부터 올라간다
쓰임주간 매출 요약 카드, 이번 주 최고치 강조
transform-originscaleYanimation-delay

<main class="stage">
<div class="cb is-demo" id="cb">
<div class="cb__card">
<div class="cb__head">
<p class="cb__title">주간 매출</p>
<span class="cb__chip">단위 만원</span>
</div>
<div class="cb__plot" id="plot">
<svg class="cb__svg" id="chart" viewBox="0 0 440 200" role="img" aria-label="주간 매출 막대 그래프, 토요일 620만원 최고">
<!-- 가로 격자 3단 — y 스케일 눈금(200/400/600)과 같은 간격 -->
<g class="cb__grid">
<line x1="34" y1="34.9" x2="432" y2="34.9"></line>
<line x1="34" y1="76.6" x2="432" y2="76.6"></line>
<line x1="34" y1="118.3" x2="432" y2="118.3"></line>
</g>
<g class="cb__ticks">
<text x="28" y="34.9">600</text>
<text x="28" y="76.6">400</text>
<text x="28" y="118.3">200</text>
</g>
<!-- 밑변 축 — 막대의 baseline -->
<line class="cb__axis" x1="34" y1="160" x2="432" y2="160"></line>
<!-- 막대·값 라벨·요일 라벨은 JS 가 viewBox 좌표로 심는다 -->
<g class="cb__days" id="days"></g>
<g class="cb__cells" id="cells"></g>
</svg>
<!-- 툴팁 — plot 안에서 절대 배치, 막대 위에 뜬다. 미리보기에선 최고 막대 위 -->
<div class="cb__tip" id="tip" aria-hidden="true"><span class="cb__tipDay" id="tipDay">토</span><strong class="cb__tipVal" id="tipVal">620만원</strong></div>
<!-- 스크린리더용 데이터 표 — 화면에 안 보인다 -->
<table class="cb__sr">
<caption>주간 매출</caption>
<thead><tr><th>요일</th><th>매출(만원)</th></tr></thead>
<tbody id="srBody"></tbody>
</table>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 자라나는 세로 막대 그래프 — 실제 사용에선 JS 가 scaleY() 로 막대 일곱 개를 심어
// 완성된 그래프가 곧 뜨고 pointermove 로 툴팁이 막대를 따라온다. 미리보기(.is-demo)는
// 같은 rect 에 scaleY 키프레임을 얹어 막대가 밑변에서부터 순서대로 자라는 모양을
// 2초 루프로 보여 준다. 값 라벨은 막대가 자라는 동안 반값을 보이다 최종값으로 컷 교체된다.
$duration: $dur-loop;
$easing: $ease-spring;
@include stage($stage-yellow);
.cb { display: flex; justify-content: center; width: 100%; padding-bottom: $sp-2; }
.cb__card {
width: min(100%, 420px); background: $subject-cream; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3 $sp-4 $sp-2;
}
.cb__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; margin-bottom: $sp-2; }
.cb__title { margin: 0; font-weight: 800; font-size: clamp(12px, 3vw, 14px); color: $subject-ink; }
.cb__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;
}
.cb__plot { position: relative; }
.cb__svg { display: block; width: 100%; height: auto; touch-action: none; cursor: crosshair; }
.cb__grid line { stroke: rgba(23, 20, 26, .12); stroke-width: 1; }
.cb__ticks text {
fill: rgba(23, 20, 26, .72); font-weight: 700; font-size: 9px;
text-anchor: end; dominant-baseline: middle;
}
.cb__day { fill: rgba(23, 20, 26, .72); font-weight: 700; font-size: 10px; text-anchor: middle; }
.cb__axis { stroke: rgba(23, 20, 26, .32); stroke-width: 1.5; }
// 막대 — 잉크. 최고 막대(토)만 계열색 블루로 강조해 "어디가 정점인가"가 먼저 읽힌다
.cb__bar { fill: $subject-ink; }
.cb__bar--peak { fill: $chart-1; }
// 원래 transform-origin 은 요소 중심이 맞지만, 막대의 scaleY 는 밑변 축에 붙은 채
// 위로 자라야 막대 그래프답다 — baseline 에 닿는 아래쪽이 원점이므로 bottom 이 정당하다.
// transform-box: fill-box 로 SVG rect 자체를 기준 박스로 지정한다
.cb__bar { transform-box: fill-box; transform-origin: 50% 100%; }
// 값 라벨 — 텍스트는 텍스트 토큰(잉크)을 쓰고 막대 색을 물려받지 않는다
.cb__val {
fill: rgba(23, 20, 26, .72); font-weight: 700; font-size: 10px;
text-anchor: middle; dominant-baseline: auto;
}
// 최고 막대 위 라벨만 계열색으로 더 굵게 — 정점의 값이 먼저 읽힌다
.cb__vPeak { fill: $subject-blue-deep; font-weight: 800; font-size: 11px; } // 크림 면 위 $chart-1 은 4.25:1 — deep 5.98:1 (막대 면색은 그대로)
// 툴팁 — 절대 배치라 루트에 padding-bottom 이 있어야 높이가 무대를 넘기지 않는다
.cb__tip {
position: absolute; transform: translate(-50%, 4px);
display: flex; align-items: baseline; gap: $sp-1; pointer-events: none;
background: $subject-ink; color: $stage-yellow; border-radius: $r-xs;
padding: $sp-1 $sp-2; font-weight: 700; font-size: 10px; white-space: nowrap;
opacity: 0;
}
.cb__tip.is-on { opacity: 1; transform: translate(-50%, 0); transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-out; }
.cb__tipDay { opacity: .72; }
.cb__tipVal { font-size: 12px; }
// 스크린리더용 표 — absolute+clip 은 레이아웃 크기가 그대로 scrollHeight 에 잡혀 넘침(BD8)이 뜨므로
// fixed 로 캔버스 밖에 둔다. 스크린리더는 그대로 읽는다
.cb__sr {
position: fixed; top: 0; left: -640px; width: 320px; margin: 0;
}
// 실사용(정적)에서는 최종값 라벨만 — 카운트업 없이 곧바로 완성 상태
.cb:not(.is-demo) .cb__vHalf { display: none; }
// ── 미리보기 루프: 막대가 --d 간격으로 밑변에서 위로 자란다 (0.08s 씩, 마지막 막대는
// 24% 지점에 시작해 54% 에 완성). animation-delay 는 낱개 속성으로 적는다 —
// animation: 단축을 쓰면 delay 가 0s 로 리셋된다. backwards 로 대기 구간에도 0% 프레임을 채운다.
// 완성 유지 뒤 86~96% 에 걸쳐 되감아 빈 상태로 돌아가고 96~100% 는 빈 대 빈이라
// 루프 이어붙음 지점이 튀지 않는다 (되감기를 한 프레임에 몰면 poster 가 소멸 순간을 잡는다).
.cb.is-demo .cb__bar {
animation-name: cbGrow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cbGrow {
0% { transform: scaleY(0); }
30%, 86% { transform: scaleY(1); }
96%, 100% { transform: scaleY(0); }
}
// 반값 라벨 — 자기 막대가 자라는 동안만 보이고 26→28% 사이에 최종 라벨과 컷 교체
.cb.is-demo .cb__vHalf {
animation-name: cbHalf;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cbHalf {
0%, 26% { opacity: 1; }
28%, 100% { opacity: 0; }
}
.cb.is-demo .cb__vFull {
animation-name: cbFull;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cbFull {
0%, 26% { opacity: 0; }
28%, 94% { opacity: 1; }
96%, 100% { opacity: 0; }
}
// 미리보기 툴팁은 최고 막대(토) 위에 미리 놓아 둔다 (JS 가 세팅) — 전 막대 완성 뒤 컷 등장
.cb.is-demo .cb__tip {
animation-name: cbTip;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cbTip {
0%, 62% { opacity: 0; }
64%, 90% { opacity: 1; }
92%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
// 요일 라벨은 한 글자라 320px 에서도 겹치지 않는다 — 카드 여백만 줄인다
.cb__card { padding: $sp-2 $sp-3 $sp-1; }
.cb__val { font-size: 9px; }
.cb__vPeak { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__bar,
.cb.is-demo .cb__vHalf,
.cb.is-demo .cb__vFull,
.cb.is-demo .cb__tip { animation: none !important; }
// 움직임 없이 다 자란 최종 상태로 점프한다
.cb.is-demo .cb__bar { transform: scaleY(1) !important; }
.cb.is-demo .cb__vHalf { opacity: 0 !important; }
.cb.is-demo .cb__vFull, .cb.is-demo .cb__tip { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
display: flex;
justify-content: center;
width: 100%;
padding-bottom: 8px;
}
.cb__card {
width: min(100%, 420px);
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px 16px 8px;
}
.cb__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.cb__title {
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3vw, 14px);
color: #17141a;
}
.cb__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;
}
.cb__plot {
position: relative;
}
.cb__svg {
display: block;
width: 100%;
height: auto;
touch-action: none;
cursor: crosshair;
}
.cb__grid line {
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 1;
}
.cb__ticks text {
fill: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 9px;
text-anchor: end;
dominant-baseline: middle;
}
.cb__day {
fill: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 10px;
text-anchor: middle;
}
.cb__axis {
stroke: rgba(23, 20, 26, 0.32);
stroke-width: 1.5;
}
.cb__bar {
fill: #17141a;
}
.cb__bar--peak {
fill: #2f6df6;
}
.cb__bar {
transform-box: fill-box;
transform-origin: 50% 100%;
}
.cb__val {
fill: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 10px;
text-anchor: middle;
dominant-baseline: auto;
}
.cb__vPeak {
fill: #0a4fe8;
font-weight: 800;
font-size: 11px;
}
.cb__tip {
position: absolute;
transform: translate(-50%, 4px);
display: flex;
align-items: baseline;
gap: 4px;
pointer-events: none;
background: #17141a;
color: #ffd23f;
border-radius: 4px;
padding: 4px 8px;
font-weight: 700;
font-size: 10px;
white-space: nowrap;
opacity: 0;
}
.cb__tip.is-on {
opacity: 1;
transform: translate(-50%, 0);
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.cb__tipDay {
opacity: 0.72;
}
.cb__tipVal {
font-size: 12px;
}
.cb__sr {
position: fixed;
top: 0;
left: -640px;
width: 320px;
margin: 0;
}
.cb:not(.is-demo) .cb__vHalf {
display: none;
}
.cb.is-demo .cb__bar {
animation-name: cbGrow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cbGrow {
0% {
transform: scaleY(0);
}
30%, 86% {
transform: scaleY(1);
}
96%, 100% {
transform: scaleY(0);
}
}
.cb.is-demo .cb__vHalf {
animation-name: cbHalf;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cbHalf {
0%, 26% {
opacity: 1;
}
28%, 100% {
opacity: 0;
}
}
.cb.is-demo .cb__vFull {
animation-name: cbFull;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-delay: var(--d, 0s);
animation-fill-mode: backwards;
animation-iteration-count: infinite;
}
@keyframes cbFull {
0%, 26% {
opacity: 0;
}
28%, 94% {
opacity: 1;
}
96%, 100% {
opacity: 0;
}
}
.cb.is-demo .cb__tip {
animation-name: cbTip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cbTip {
0%, 62% {
opacity: 0;
}
64%, 90% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cb__card {
padding: 8px 12px 4px;
}
.cb__val {
font-size: 9px;
}
.cb__vPeak {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb.is-demo .cb__bar,
.cb.is-demo .cb__vHalf,
.cb.is-demo .cb__vFull,
.cb.is-demo .cb__tip {
animation: none !important;
}
.cb.is-demo .cb__bar {
transform: scaleY(1) !important;
}
.cb.is-demo .cb__vHalf {
opacity: 0 !important;
}
.cb.is-demo .cb__vFull, .cb.is-demo .cb__tip {
opacity: 1 !important;
}
}var DATA = [320, 410, 380, 505, 460, 620, 545];
var DAY = ['월', '화', '수', '목', '금', '토', '일'];
var VW = 440, VH = 200; // viewBox 크기
var PAD_L = 34, PAD_R = 432, TOP = 14, BASE = 160; // 그림 영역 좌표 (BASE 가 밑변 축)
var MAX_Y = 700, BAR_W = 30; // y 상한 — 눈금 200 간격과 맞춘 값
var SVGNS = 'http://www.w3.org/2000/svg';
var cb = document.getElementById('cb');
var svg = document.getElementById('chart');
var tip = document.getElementById('tip');
var tipDay = document.getElementById('tipDay');
var tipVal = document.getElementById('tipVal');
// 데이터 값 → viewBox 좌표. 막대 그래프의 핵심은 이 두 함수다
function scaleY(v) { return BASE - (BASE - TOP) * v / MAX_Y; }
function band() { return (PAD_R - PAD_L) / DATA.length; } // 막대 하나가 차지하는 폭
function centerX(i) { return PAD_L + band() * (i + .5); } // 막대 중심 = 라벨 위치
// SVG 자식을 만드는 헬퍼 — 속성만 채워 부모에 붙인다
function el(name, attrs, parent) {
var n = document.createElementNS(SVGNS, name);
for (var k in attrs) { n.setAttribute(k, attrs[k]); }
parent.appendChild(n);
return n;
}
var peakIdx = DATA.indexOf(Math.max.apply(null, DATA));
var cells = document.getElementById('cells');
var days = document.getElementById('days');
DATA.forEach(function (v, i) {
var cx = centerX(i), top = scaleY(v);
// 막대 하나에 셀 하나 — 막대와 두 값 라벨이 같은 --d 를 공유해 순차 등장이 맞물린다
var g = el('g', { 'class': 'cb__cell', 'style': '--d:' + (i * .08).toFixed(2) + 's' }, cells);
el('rect', { 'class': 'cb__bar' + (i === peakIdx ? ' cb__bar--peak' : ''),
x: (cx - BAR_W / 2).toFixed(1), y: top.toFixed(1), width: BAR_W, height: (BASE - top).toFixed(1) }, g);
// 값 라벨 두 장 — 미리보기에서 반값과 최종값을 컷으로 바꾼다. 실사용은 최종값만 보인다
el('text', { 'class': 'cb__val cb__vHalf' + (i === peakIdx ? ' cb__vPeak' : ''), x: cx.toFixed(1), y: (top - 7).toFixed(1) }, g).textContent = Math.round(v / 2);
el('text', { 'class': 'cb__val cb__vFull' + (i === peakIdx ? ' cb__vPeak' : ''), x: cx.toFixed(1), y: (top - 7).toFixed(1) }, g).textContent = v;
el('text', { 'class': 'cb__day', x: cx.toFixed(1), y: 176 }, days).textContent = DAY[i];
});
// 스크린리더용 표 채우기 — 그림이 값을 다 말해 주지는 않는다
var srBody = document.getElementById('srBody');
DATA.forEach(function (v, i) {
var tr = document.createElement('tr');
var th = document.createElement('th'); th.setAttribute('scope', 'row'); th.textContent = DAY[i];
var td = document.createElement('td'); td.textContent = v;
tr.appendChild(th); tr.appendChild(td); srBody.appendChild(tr);
});
// 툴팁을 i번 막대 위에 놓는다 — viewBox 좌표를 plot 비율로 바꿔 % 로
function placeTip(i) {
tip.style.left = (centerX(i) / VW * 100) + '%';
tip.style.bottom = ((VH - scaleY(DATA[i]) + 8) / VH * 100) + '%';
tipDay.textContent = DAY[i];
tipVal.textContent = DATA[i] + '만원';
}
placeTip(peakIdx); // 미리보기 툴팁은 최고 막대 위에 떠 있다
// 사람이 조작하면 미리보기 루프를 끄고 진짜 툴팁이 따라온다
svg.addEventListener('pointermove', function (e) {
if (cb.classList.contains('is-demo')) { cb.classList.remove('is-demo'); }
var r = svg.getBoundingClientRect();
var vx = (e.clientX - r.left) / r.width * VW; // 화면 좌표 → viewBox 좌표
var i = Math.floor((vx - PAD_L) / band());
i = Math.max(0, Math.min(DATA.length - 1, i));
placeTip(i);
tip.classList.add('is-on');
});
svg.addEventListener('pointerleave', function () {
tip.classList.remove('is-on');
placeTip(peakIdx); // 최고 막대로 돌아간다
});// ChartBarColumn — vanilla 판(run/289/demo/02-chart-draw-bar-column/index.html) 의 React 포트.
// 세로 막대 일곱 개가 baseline 에서 순서대로 자라는 주간 매출 그래프.
// 사람이 포인터를 올리면 미리보기 루프(.isDemo)가 떨어지고 막대 위 툴팁이 따라온다.
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChartBarColumn.module.scss";
export type ChartBarColumnProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 포인터를 올리면 꺼진다 */
demo?: boolean;
};
type Vars = CSSProperties & { "--d"?: string };
type PtrEvt = { clientX: number };
type Box<T> = { current: T | null };
const DATA = [320, 410, 380, 505, 460, 620, 545];
const DAY = ["월", "화", "수", "목", "금", "토", "일"];
const VW = 440, VH = 200;
const PAD_L = 34, PAD_R = 432, TOP = 14, BASE = 160;
const MAX_Y = 700, BAR_W = 30;
const PEAK = DATA.indexOf(Math.max(...DATA));
/** 데이터 값 → viewBox y 좌표 (BASE 가 밑변 축) */
const scaleY = (v: number) => BASE - (BASE - TOP) * v / MAX_Y;
const band = (PAD_R - PAD_L) / DATA.length;
const centerX = (i: number) => PAD_L + band * (i + .5);
export default function ChartBarColumn({ demo = true }: ChartBarColumnProps) {
const svgRef: Box<SVGSVGElement> = useRef(null);
const [live, setLive] = useState(!demo);
const [hover, setHover] = useState(PEAK);
/** pointermove — 화면 좌표를 viewBox 좌표로 바꿔 커서 아래 막대를 고른다.
* live 를 켜야 .isDemo 루프가 떨어진다 (live=false 가 미리보기 상태다) */
function onMove(e: PtrEvt) {
const svg = svgRef.current;
if (!svg) return;
setLive(true);
const r = svg.getBoundingClientRect();
const vx = (e.clientX - r.left) / r.width * VW;
setHover(Math.max(0, Math.min(DATA.length - 1, Math.floor((vx - PAD_L) / band))));
}
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}>단위 만원</span>
</div>
<div className={styles.plot}>
<svg
className={styles.svg} ref={svgRef} viewBox={`0 0 ${VW} ${VH}`} role="img"
aria-label="주간 매출 막대 그래프, 토요일 620만원 최고"
onPointerMove={onMove}
onPointerLeave={() => setHover(PEAK)}
>
{/* 가로 격자 3단 — y 스케일 눈금(200/400/600)과 같은 간격 */}
<g className={styles.grid}>
{[600, 400, 200].map((v) => (
<line key={v} x1={PAD_L} y1={scaleY(v)} x2={PAD_R} y2={scaleY(v)} />
))}
</g>
<g className={styles.ticks}>
{[600, 400, 200].map((v) => (
<text key={v} x={28} y={scaleY(v)}>{v}</text>
))}
</g>
<line className={styles.axis} x1={PAD_L} y1={BASE} x2={PAD_R} y2={BASE} />
{/* 막대 하나에 셀 하나 — 막대와 두 값 라벨이 같은 --d 를 공유한다 */}
{DATA.map((v, i) => {
const cx = centerX(i), top = scaleY(v);
const peak = i === PEAK;
return (
<g key={DAY[i]} className={styles.cell} style={{ "--d": `${(i * .08).toFixed(2)}s` } as Vars}>
<rect
className={peak ? styles.barPeak : styles.bar}
x={(cx - BAR_W / 2).toFixed(1)} y={top.toFixed(1)}
width={BAR_W} height={(BASE - top).toFixed(1)}
/>
{/* 값 라벨 두 장 — 미리보기에서 반값과 최종값을 컷으로 바꾼다. 실사용은 최종값만 */}
<text className={[styles.val, styles.vHalf, peak ? styles.vPeak : ""].filter(Boolean).join(" ")}
x={cx.toFixed(1)} y={(top - 7).toFixed(1)}>{Math.round(v / 2)}</text>
<text className={[styles.val, styles.vFull, peak ? styles.vPeak : ""].filter(Boolean).join(" ")}
x={cx.toFixed(1)} y={(top - 7).toFixed(1)}>{v}</text>
<text className={styles.day} x={cx.toFixed(1)} y={176}>{DAY[i]}</text>
</g>
);
})}
</svg>
{/* 툴팁 — 커서 아래 막대 위에 뜬다 (viewBox 좌표를 plot 비율 % 로) */}
<div
className={[styles.tip, live ? styles.tipIsOn : ""].filter(Boolean).join(" ")}
aria-hidden="true"
style={{
left: `${(centerX(hover) / VW * 100).toFixed(2)}%`,
bottom: `${((VH - scaleY(DATA[hover]) + 8) / VH * 100).toFixed(2)}%`,
}}
>
<span className={styles.tipDay}>{DAY[hover]}</span>
<strong className={styles.tipVal}>{DATA[hover]}만원</strong>
</div>
{/* 스크린리더용 데이터 표 — 화면에 안 보인다 */}
<table className={styles.sr}>
<caption>주간 매출</caption>
<thead><tr><th scope="col">요일</th><th scope="col">매출 (만원)</th></tr></thead>
<tbody>
{DATA.map((v, i) => (
<tr key={DAY[i]}><th scope="row">{DAY[i]}</th><td>{v}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}02 밑변에서 자라는 세로 막대 그래프 — 측정 누적면적 14.688% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 그래프 만들기 SVG로 9종 — 라이브러리 없이
이 데모가 속한 글
css 그래프 만들기 SVG로 9종 — 라이브러리 없이css 그래프는 숫자를 눈에 보이는 선·막대·면으로 바꾸는 가장 가벼운 방법입니다. 여기 아홉 가지는 선·막대·도넛·레이더·히트맵·스파크라인·누적 영역·트리맵·게이지를 라이브러리 없이 SVG 좌표 계산과 CSS 애니 이 글의 데모 전부 보기

