포크레인 없이 못 박기 — CSS 수학 함수만으로 바 차트 그리기
간단한 바 차트 하나 그리겠다고 Chart.js(약 60KB)나 D3(약 90KB)를 로드하는 건 못 하나 박으려고 포크레인을 부르는 것이다. 대시보드에 매출 지표 5개를 막대로 보여주는 정도라면, CSS의 모던 수학 함수와 커스텀 프로퍼티만으로 충분하다. JavaScript는 한 줄도 필요 없다.
시맨틱 마크업이 먼저다
바 차트의 데이터는 본질적으로 순서 있는 목록이다. HTML에서 가장 자연스러운 그릇은 <ol>이다. CSS가 로드되지 않아도 데이터 자체는 읽을 수 있고, 스크린 리더도 올바르게 해석한다. 비유하면, 집의 골조가 탄탄해야 인테리어가 의미 있는 것과 같다.
<figure>
<figcaption>월별 매출 (단위: 만원)</figcaption>
<ol class="bar-chart" role="list">
<li style="--value: 75" aria-label="1월: 75만원">
<span class="label">1월</span>
<span class="value">75</span>
</li>
<li style="--value: 92" aria-label="2월: 92만원">
<span class="label">2월</span>
<span class="value">92</span>
</li>
<li style="--value: 68" aria-label="3월: 68만원">
<span class="label">3월</span>
<span class="value">68</span>
</li>
<li style="--value: 100" aria-label="4월: 100만원">
<span class="label">4월</span>
<span class="value">100</span>
</li>
<li style="--value: 85" aria-label="5월: 85만원">
<span class="label">5월</span>
<span class="value">85</span>
</li>
</ol>
</figure>각 li에 --value 커스텀 프로퍼티로 데이터를 전달한다. aria-label은 스크린 리더가 "1월: 75만원"처럼 맥락과 함께 읽을 수 있도록 보장한다.
[💡 잠깐! 이 용어는?]
CSS 커스텀 프로퍼티(Custom Properties): --로 시작하는 변수로, var()로 참조한다. 인라인 style 속성에서 설정하면 HTML에서 CSS로 데이터를 전달하는 파이프 역할을 한다.
수직 바 차트 — 핵심 공식 하나
calc()로 --value를 픽셀 높이로 변환한다. 핵심 공식은 딱 하나, calc(value / max * dimension)이다. 온도계의 수은주가 온도에 비례해 올라가는 원리와 동일하다.
.bar-chart {
--max: 100;
--bar-width: 48px;
--chart-height: 300px;
display: flex;
align-items: flex-end;
gap: 12px;
height: var(--chart-height);
padding: 0;
margin: 0;
list-style: none;
border-bottom: 2px solid #333;
}
.bar-chart li {
display: flex;
flex-direction: column;
align-items: center;
width: var(--bar-width);
}
.bar-chart li::before {
content: "";
width: 100%;
height: calc(var(--value) / var(--max) * var(--chart-height));
background: #4a90d9;
border-radius: 4px 4px 0 0;
transition: height 0.3s ease;
}
.bar-chart .label {
margin-top: 8px;
font-size: 14px;
color: #666;
}
.bar-chart .value {
order: -1;
font-size: 12px;
font-weight: 600;
color: #333;
}값을 최대값으로 나눠 비율을 구하고, 차트 높이를 곱해 실제 픽셀로 변환한다. --max를 커스텀 프로퍼티로 빼둔 덕분에 데이터 범위가 바뀌어도 CSS 한 줄만 수정하면 된다.
clamp()로 안전장치 걸기
데이터 값이 예상 범위를 벗어나면 차트가 깨진다. 값이 0이면 바가 아예 안 보이고, 값이 최대를 초과하면 차트 영역을 뚫고 나간다. clamp()는 이런 사고를 막는 가드레일이다.
.bar-chart li::before {
height: clamp(
4px,
calc(var(--value) / var(--max) * var(--chart-height)),
var(--chart-height)
);
}[💡 잠깐! 이 용어는?]
clamp(MIN, VAL, MAX): CSS 비교 함수로, max(MIN, min(VAL, MAX))와 동일하다. 값(VAL)을 최솟값(MIN)과 최댓값(MAX) 사이에 고정한다. 반응형 폰트, 간격, 차트 바 높이 등에 두루 쓰인다.
| 함수 | 역할 | 바 차트에서의 쓰임 |
|---|---|---|
calc() | 사칙연산 | 값을 높이/너비로 변환 |
min() | 두 값 중 작은 값 | 바 높이의 상한선 |
max() | 두 값 중 큰 값 | 바 높이의 하한선 (0 방지) |
clamp() | 최소·선호·최대 | 상한+하한 동시 설정 |
clamp(4px, ..., var(--chart-height))를 적용하면, 값이 0이어도 최소 4px은 렌더링되고(빈 바 문제 방지), 최대값을 초과해도 차트 영역을 넘지 않는다.
수평 바 차트 — 방향만 바꾸면 된다
레이블이 긴 데이터(프로그래밍 언어 이름, 국가명 등)에는 수평 바가 더 적합하다. Grid로 레이블·바·값 영역을 깔끔하게 분리한다.
.bar-chart--horizontal {
--max: 100;
--bar-height: 32px;
--chart-width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
margin: 0;
list-style: none;
}
.bar-chart--horizontal li {
display: grid;
grid-template-columns: 100px 1fr 48px;
align-items: center;
gap: 8px;
height: var(--bar-height);
}
.bar-chart--horizontal li::after {
content: "";
height: 100%;
width: calc(var(--value) / var(--max) * 100%);
min-width: 4px;
background: #4a90d9;
border-radius: 0 4px 4px 0;
transition: width 0.3s ease;
grid-column: 2;
grid-row: 1;
}
.bar-chart--horizontal .label {
text-align: right;
font-size: 14px;
grid-column: 1;
}
.bar-chart--horizontal .value {
font-size: 12px;
font-weight: 600;
grid-column: 3;
}바의 너비를 calc(var(--value) / var(--max) * 100%)로 계산해 부모 너비 대비 비율로 렌더링한다. 컨테이너 크기가 바뀌면 바 너비도 자동으로 따라간다.
캐스케이드 애니메이션 추가
바가 순서대로 올라오는 진입 애니메이션은 시각적 임팩트를 확 높인다. @keyframes와 animation-delay의 조합이다.
@keyframes grow-up {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.bar-chart li::before {
transform-origin: bottom;
animation: grow-up 0.6s ease-out forwards;
}
.bar-chart li:nth-child(1)::before { animation-delay: 0ms; }
.bar-chart li:nth-child(2)::before { animation-delay: 100ms; }
.bar-chart li:nth-child(3)::before { animation-delay: 200ms; }
.bar-chart li:nth-child(4)::before { animation-delay: 300ms; }
.bar-chart li:nth-child(5)::before { animation-delay: 400ms; }transform-origin: bottom으로 바가 아래에서 위로 자라나게 한다. animation-delay를 100ms씩 증가시키면 도미노가 순서대로 쓰러지듯 바가 차례로 올라온다.
[💡 잠깐! 이 용어는?]
transform-origin: transform이 적용되는 기준점. 기본값은 요소의 중심(50% 50%)이다. bottom으로 설정하면 아래쪽을 축으로 스케일이 적용되어, 바가 밑에서부터 성장하는 효과가 난다.
CSS 전용 vs JavaScript 라이브러리
| 기준 | CSS 전용 | Chart.js | D3.js |
|---|---|---|---|
| 번들 크기 | 0KB | ~60KB | ~90KB |
| 인터랙션 | hover 정도 | 툴팁, 클릭, 줌 | 무제한 |
| 데이터 복잡도 | 단순 1차원 | 다차원 | 무제한 |
| 반응형 | CSS 기본 지원 | resize 이벤트 필요 | 직접 구현 |
| 접근성 | 시맨틱 HTML 기반 | canvas (추가 작업) | SVG (추가 작업) |
| 학습 곡선 | 낮음 | 중간 | 높음 |
| 적합한 경우 | 대시보드 내 간단한 지표 | 데이터 분석 페이지 | 인터랙티브 시각화 |
단순한 바 차트, 진행 바, 비교 지표라면 CSS만으로 충분하다. 번들 크기가 0이고, 시맨틱 HTML 기반이라 접근성도 기본으로 확보된다.
마무리
CSS 수학 함수와 커스텀 프로퍼티를 조합하면 JavaScript 없이도 실용적인 바 차트가 완성된다. 핵심 공식은 calc(value / max * dimension) 하나다. clamp()로 안전장치를 걸고, 시맨틱 HTML로 접근성을 확보하면, 대시보드나 통계 페이지에서 별도 라이브러리 없이 가벼운 차트를 제공할 수 있다. 못 하나 박는 데 포크레인은 필요 없다.
참고:
관심 있을 만한 포스트
OHP 필름을 겹치듯 — conic-gradient와 attr()로 순수 CSS 파이 차트 만들기
conic-gradient, CSS 커스텀 프로퍼티, 새로운 attr() 타입 구문을 활용해 JavaScript 없이 시맨틱한 파이 차트를 구현하는 방법을 다룬다.
CSS @property — 커스텀 속성에 타입을 부여하는 방법
CSS @property at-rule로 커스텀 속성에 타입 정의, 상속 제어, 폴백을 추가해 렌더링 안정성과 애니메이션 가능성을 확보하는 방법을 다룬다.
CSS :near() — 마우스가 '가까이' 오면 반응하는 새로운 의사 클래스
CSS Working Group에 제안된 :near() 의사 클래스는 포인터 근접성을 감지해 호버 전에 UI를 활성화하는 새로운 상호작용 패턴을 연다.
CSS만으로 커스텀 셀렉트 박스 — JavaScript 150줄이 사라지는 순간
Chrome 135에 도입된 appearance: base-select와 sibling-index()로 JavaScript 없이 완전한 커스텀 드롭다운을 구현하는 방법을 분석한다.
sibling-index()로 만드는 CSS 스크롤 소용돌이 — JavaScript 없이 수백 개 요소 애니메이션
CSS sibling-index()와 scroll-driven animations를 결합해 순수 CSS만으로 텍스트 보텍스 효과를 구현하는 기법을 다룬다.
Interop 2026 — 브라우저 전쟁이 끝나고 표준 전쟁이 시작됐다
Chrome, Safari, Firefox가 합의한 20개 웹 표준 집중 영역과 프론트엔드 개발자가 주목해야 할 핵심 기능을 정리한다.
CSS Stacking Context — z-index: 99999를 줬는데 왜 안 올라올까
CSS 쌓임 맥락의 생성 조건, z-index의 실제 작동 원리, 그리고 레이아웃 버그를 디버깅하는 실전 전략을 정리한다.
배경색이 바뀌면 글자색도 따라가야 한다 — contrast-color() 없이 살아남는 법
브라우저 지원이 부족한 CSS contrast-color()를 color-mix(), relative color syntax, custom properties로 근사 구현하는 방법을 정리한다.