OHP 필름을 겹치듯 — conic-gradient와 attr()로 순수 CSS 파이 차트 만들기

9 min read
CSSconic-gradientattr()데이터 시각화접근성
OHP 필름을 겹치듯 — conic-gradient와 attr()로 순수 CSS 파이 차트 만들기

파이 차트를 그리겠다고 Chart.js나 D3를 꺼내드는 건 자연스러운 일이다. 하지만 CSS의 conic-gradient()가 등장한 이후, "순수 CSS로도 가능하지 않을까?"라는 질문이 계속 나온다. 가능하다. 다만 예쁘기만 하고 의미 없는 원이 아니라, 시맨틱하고 접근 가능한 차트를 만들려면 몇 가지 함정을 피해야 한다.

단순한 접근의 함정

가장 직관적인 방법은 하나의 divconic-gradient()를 때려 넣는 것이다.

naive-pie.css
.pie {
  width: 200px;
  height: 200px;
  border-radius: 50%;
  background: conic-gradient(
    #ff6666 0% 35%,
    #4fff66 35% 60%,
    #66ffff 60% 85%,
    #b366ff 85% 100%
  );
}

이건 비유하면 포스터 한 장에 데이터를 그려놓는 것이다. 사람 눈에는 보이지만, 스크린 리더는 이것이 "초콜릿 35%, 젤리 25%"라는 데이터를 담고 있다는 사실을 전혀 알 수 없다. CSS 그라디언트는 본질적으로 이미지이고, 이미지에는 시맨틱 정보가 없다.

문제가 하나 더 있다. 슬라이스가 늘어날수록 conic-gradient() 안의 색상 정지점을 일일이 수동 계산해야 한다. 이전 슬라이스의 끝을 다음 슬라이스의 시작으로 넘겨야 하니, 유지보수가 악몽이 된다.

[💡 잠깐! 이 용어는?] conic-gradient(): 원뿔형 그라디언트. 중심점을 기준으로 시계 방향으로 색상이 전환되며, 파이 차트나 도넛 차트를 만드는 데 활용된다. linear-gradient()가 직선이라면, conic-gradient()는 원이다.


시맨틱 HTML 설계 — 데이터는 목록이다

파이 차트의 데이터는 "항목 A가 35%, 항목 B가 25%…"처럼 각 항목의 비중을 나열하는 것이니, HTML에서도 목록으로 표현하는 것이 자연스럽다. 구조가 탄탄한 집에 인테리어를 입히는 것이 맞는 순서다.

semantic-pie-chart.html
<figure>
  <figcaption>지난달 사탕 판매 비율</figcaption>
  <ul class="pie-chart" role="list">
    <li data-percentage="35" data-color="#ff6666" aria-label="초콜릿 35%">
      <strong>초콜릿</strong>
    </li>
    <li data-percentage="25" data-color="#4fff66" aria-label="젤리 25%">
      <strong>젤리</strong>
    </li>
    <li data-percentage="25" data-color="#66ffff" aria-label="하드캔디 25%">
      <strong>하드캔디</strong>
    </li>
    <li data-percentage="15" data-color="#b366ff" aria-label="풍선껌 15%">
      <strong>풍선껌</strong>
    </li>
  </ul>
</figure>

figurefigcaption으로 차트의 맥락을 제공하고, ul > li로 각 데이터 항목을 표현한다. data-percentagedata-color는 CSS에서 시각적 렌더링에 사용하고, aria-label은 스크린 리더가 각 항목의 의미를 전달할 수 있게 보장한다.


핵심 트릭 — 투명 필름을 겹쳐 쌓기

여기서 핵심 아이디어는 li가 자기 슬라이스만 그리고, 전부 같은 위치에 겹쳐놓는 것이다. 마치 투명한 OHP 필름 여러 장을 겹치는 것과 비슷하다. 각 필름에는 파이의 한 조각만 그려져 있고, 전부 합치면 완성된 원이 나타난다.

stacked-slices.css
.pie-chart {
  --radius: 100px;
  --size: calc(var(--radius) * 2);
  position: relative;
  width: var(--size);
  height: var(--size);
  list-style: none;
  padding: 0;
  margin: 0;
}
 
.pie-chart li {
  position: absolute;
  inset: 0;
  border-radius: 50%;
}

모든 liposition: absolute로 같은 위치에 쌓고, border-radius: 50%로 원형을 만든다. 이제 각 슬라이스에 conic-gradient()를 적용하면 된다.

[💡 잠깐! 이 용어는?] attr() 타입 구문: CSS attr() 함수의 확장 기능으로, HTML 속성 값을 특정 타입(number, length 등)으로 파싱해 CSS 수학 연산에 사용할 수 있게 한다. attr(data-percentage number)처럼 사용한다.


attr()로 데이터와 CSS 연결

CSS의 새로운 attr() 타입 구문으로 data-percentage 값을 직접 숫자로 파싱할 수 있다. 각 슬라이스의 시작 각도는 인라인 스타일로 누적값을 전달하는 방식이 현실적이다.

pie-inline-start.html
<li data-percentage="35" data-color="#ff6666" style="--start: 0" aria-label="초콜릿 35%">
  <strong>초콜릿</strong>
</li>
<li data-percentage="25" data-color="#4fff66" style="--start: 35" aria-label="젤리 25%">
  <strong>젤리</strong>
</li>
<li data-percentage="25" data-color="#66ffff" style="--start: 60" aria-label="하드캔디 25%">
  <strong>하드캔디</strong>
</li>
<li data-percentage="15" data-color="#b366ff" style="--start: 75" aria-label="풍선껌 15%">
  <strong>풍선껌</strong>
</li>

--start는 이전 슬라이스들의 퍼센트 합계다. 초콜릿은 0에서 시작, 젤리는 35에서 시작(초콜릿 35%), 하드캔디는 60에서 시작(35+25%), 이런 식이다. 비유하면, 릴레이 경주에서 이전 주자가 끝난 지점에서 다음 주자가 출발하는 것과 같다.

pie-slice-final.css
.pie-chart li {
  --pct: attr(data-percentage number, 0);
  --col: attr(data-color type(<color>), gray);
  --from: calc(var(--start) * 3.6deg);
  --to: calc((var(--start) + var(--pct)) * 3.6deg);
 
  background: conic-gradient(
    transparent 0deg var(--from),
    var(--col) var(--from) var(--to),
    transparent var(--to) 360deg
  );
}

3.6deg360 / 100이다. 퍼센트 1%가 3.6도에 해당하므로, 퍼센트 값에 3.6을 곱하면 각도로 변환된다. 각 슬라이스는 자기 영역만 색칠하고 나머지는 transparent로 비워두기 때문에, 겹쳐도 간섭 없이 완전한 원이 완성된다.

[💡 잠깐! 이 용어는?] 색상 정지점(Color Stop): 그라디언트에서 특정 색상이 시작되거나 끝나는 위치. conic-gradient(red 0deg 90deg, blue 90deg 180deg)에서 0deg, 90deg, 180deg가 각각 색상 정지점이다.


구현 방식 비교

기준단일 conic-gradient슬라이스 겹치기JavaScript 라이브러리
시맨틱 HTML불가가능 (ul > li)라이브러리 의존
접근성없음aria-label 활용라이브러리 의존
유지보수정지점 수동 계산data 속성만 변경API 기반
JavaScript 의존없음없음필수
브라우저 지원우수Chromium 중심우수
번들 크기0KB0KB30~200KB

attr() 타입 구문이 아직 Chromium 기반 브라우저에서만 지원되므로, 프로덕션에서는 폴백이 필요하다.

[💡 잠깐! 이 용어는?] 폴백(Fallback): 특정 기능이 지원되지 않는 환경에서 대체 동작을 제공하는 것. @supportsattr() 타입 구문 지원 여부를 감지하고, 미지원 시 JavaScript 기반 차트를 로드하는 식으로 구현한다.


마무리

CSS conic-gradient()attr() 타입 구문을 조합하면 JavaScript 없이도 시맨틱하고 접근 가능한 파이 차트를 만들 수 있다. 핵심은 단일 그라디언트에 모든 데이터를 우겨넣는 게 아니라, 각 슬라이스를 독립적인 리스트 아이템으로 분리한 뒤 OHP 필름처럼 겹치는 것이다. attr() 타입 구문의 브라우저 지원이 확대되면, 간단한 데이터 시각화에 별도 라이브러리가 필요 없는 날이 올 것이다. 지금 당장은 Chromium 한정이지만, 기법을 익혀두면 충분히 가치가 있다.


참고:

관심 있을 만한 포스트

배경색이 바뀌면 글자색도 따라가야 한다 — contrast-color() 없이 살아남는 법

브라우저 지원이 부족한 CSS contrast-color()를 color-mix(), relative color syntax, custom properties로 근사 구현하는 방법을 정리한다.

CSScontrast-color

포크레인 없이 못 박기 — CSS 수학 함수만으로 바 차트 그리기

CSS 수학 함수와 커스텀 프로퍼티, 시맨틱 HTML을 조합해 JavaScript 없이 반응형 바 차트를 구현하는 방법을 다룬다.

CSScalc()

CSS @property — 커스텀 속성에 타입을 부여하는 방법

CSS @property at-rule로 커스텀 속성에 타입 정의, 상속 제어, 폴백을 추가해 렌더링 안정성과 애니메이션 가능성을 확보하는 방법을 다룬다.

CSS@property

CSS :near() — 마우스가 '가까이' 오면 반응하는 새로운 의사 클래스

CSS Working Group에 제안된 :near() 의사 클래스는 포인터 근접성을 감지해 호버 전에 UI를 활성화하는 새로운 상호작용 패턴을 연다.

CSS:near()

CSS만으로 커스텀 셀렉트 박스 — JavaScript 150줄이 사라지는 순간

Chrome 135에 도입된 appearance: base-select와 sibling-index()로 JavaScript 없이 완전한 커스텀 드롭다운을 구현하는 방법을 분석한다.

CSSbase-select

sibling-index()로 만드는 CSS 스크롤 소용돌이 — JavaScript 없이 수백 개 요소 애니메이션

CSS sibling-index()와 scroll-driven animations를 결합해 순수 CSS만으로 텍스트 보텍스 효과를 구현하는 기법을 다룬다.

CSSsibling-index

Interop 2026 — 브라우저 전쟁이 끝나고 표준 전쟁이 시작됐다

Chrome, Safari, Firefox가 합의한 20개 웹 표준 집중 영역과 프론트엔드 개발자가 주목해야 할 핵심 기능을 정리한다.

InteropCSS

CSS Stacking Context — z-index: 99999를 줬는데 왜 안 올라올까

CSS 쌓임 맥락의 생성 조건, z-index의 실제 작동 원리, 그리고 레이아웃 버그를 디버깅하는 실전 전략을 정리한다.

CSSz-index