GODRICH

원형 프로그레스바 css 스탯 링 위젯 9종 — 숫자가 진짜 올라가는

원형 프로그레스바 css 는 막대 대신 원을 따라 값을 채우는 진행 표시이고, SVG 원 하나에 stroke-dasharray 와 stroke-dashoffset 두 속성만 주면 만들 수 있습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 링을 몇 개 쓰느냐와 링 안에 무엇을 더 넣느냐를 따라갑니다. 동심원 셋으로 시작해 링 하나에 숫자를 붙이고, 원을 칸으로 끊고, 선 자체에 색을 입히고, 반원으로 잘라 바늘을 답니다. 그다음이 링 안에 꺾은선을 넣는 것, 다 채운 순간을 체크로 바꾸는 것, 두 링을 나란히 두는 것, 마지막이 작은 링을 목록 줄마다 붙이는 것입니다. 아홉 개가 쓰는 계산은 같고 반지름만 다릅니다.

반지름 r 둘레 C = 2πr 쓴 항목
14 87.965 09 목록 줄 안의 작은 링
28 175.929 01 안쪽 링
36 226.195 08 비교 링 두 개
40 251.327 01 가운데 링
44 276.460 03 세그먼트 · 07 배지
52 326.726 01 바깥 링 · 02 · 04 · 06
62 (반원) 194.779 05 반원 게이지

013중 활동 링

동심원 세 개가 각자 다른 값까지 감기고, '다시 채우기'를 누르면 세 값이 새로 뽑혀 링과 옆의 퍼센트가 같이 바뀝니다. 걸음·운동·수면처럼 지표 셋을 카드 하나에 겹쳐 보여줄 때 씁니다.

stroke-dashoffset3 circlesclick
.ar__ring--move { stroke-dasharray: 326.726; stroke-dashoffset: calc(326.726 * (1 - var(--p1) / 100)); }
.ar__ring--burn { stroke-dasharray: 251.327; stroke-dashoffset: calc(251.327 * (1 - var(--p2) / 100)); }
.ar__ring--rest { stroke-dasharray: 175.929; stroke-dashoffset: calc(175.929 * (1 - var(--p3) / 100)); }

@keyframes ar-wind {
  0%       { --p1: 0;  --p2: 0;  --p3: 0; }
  45%, 55% { --p1: 82; --p2: 64; --p3: 45; }
  100%     { --p1: 0;  --p2: 0;  --p3: 0; }
}

02목표 달성 링 + 숫자 카운트업

링이 감기는 동안 가운데 숫자가 0부터 한 칸씩 올라갑니다. 움직이는 값은 --num 하나뿐이고 링의 오프셋과 숫자가 같은 값을 읽으므로, 숫자는 72 인데 링은 60 에 서 있는 어긋남이 생기지 않습니다.

@propertycounter()click
@property --num {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0;
}
.gr__ring {
  stroke-dasharray: 326.726;
  stroke-dashoffset: calc(326.726 * (1 - var(--num) / 100));
}
.gr__num::after {
  counter-reset: n var(--num);
  content: counter(n) "%";
}

03세그먼트 링 (단계별)

원 하나를 여덟 칸으로 끊어 놓고 '다음 단계'를 누를 때마다 한 칸씩 켭니다. 칸은 따로 그린 호 여덟 개가 아니라 같은 원을 여덟 번 겹쳐 놓은 것이고, 각각 점선 한 칸만 칠한 뒤 오프셋으로 제자리에 밀어 둔 것입니다.

stroke-dasharrayrotateclick
$C: 276.460;     // 2π × 44
$slot: 34.5575;  // C ÷ 8 = 한 칸의 몫
$dash: 26;       // 그중 칠하는 길이. 나머지 8.5575 가 칸 사이 틈이 된다

.sg__seg {
  stroke-dasharray: $dash ($C - $dash);
  stroke-dashoffset: calc(var(--i) * -#{$slot});
  opacity: .18;
}
.sg__seg.is-on { opacity: 1; }

04그라디언트 스트로크 링

링의 선 자체가 하늘색에서 분홍으로 물드는 그라디언트이고, 슬라이더를 끌면 그 값까지 실제로 채워집니다. 색을 선에 넣으려면 채우기가 아니라 strokeurl(#id) 로 그라디언트를 걸어야 합니다.

linearGradientinput rangestroke-linecap
// <linearGradient id="gdStroke" gradientUnits="userSpaceOnUse" x1="12" y1="12" x2="116" y2="116">
.gd__stop-a { stop-color: $color; }        // 하늘색
.gd__stop-b { stop-color: $subject-pink; } // 분홍
.gd__ring {
  stroke: url(#gdStroke);
  stroke-width: 14;
  stroke-linecap: round;
  stroke-dasharray: 326.726;
  stroke-dashoffset: calc(326.726 * (1 - var(--num) / 100));
}

05반원 게이지 + 바늘

호가 값만큼 차오르는 동시에 바늘도 같은 각도만큼 돕니다. 반원의 길이는 원 둘레의 절반인 πr 이라 반지름 62 면 194.779 이고, 바늘은 0 에서 왼쪽 끝, 100 에서 오른쪽 끝이므로 값 1 당 1.8도씩 돌면 됩니다.

path arcrotateclick
.hg__arc {
  stroke-dasharray: 194.779;   // π × 62 = 반원의 길이
  stroke-dashoffset: calc(194.779 * (1 - var(--num) / 100));
}
.hg__needle {
  // 180도를 100 으로 나누면 값 1 당 1.8도
  transform: rotate(calc(-90deg + var(--num) * 1.8deg));
  transform-origin: 76px 76px;
}

06링 안 미니 스파크라인

바깥 링이 이번 달 진행률을 감는 동안 안쪽에서 7일치 꺾은선이 왼쪽부터 그려집니다. 꺾은선은 좌표가 바뀌면 실제 길이도 달라지는데, pathLength="100" 을 주면 길이를 늘 100 으로 세기 때문에 좌표가 어떻게 흔들려도 같은 값으로 제어할 수 있습니다.

polylinepathLengthclick
/* <polyline class="sp__line" pathLength="100" points="28,76 40,66 52,80 64,58 76,68 88,50 100,60"/> */
.sp__line { stroke-dasharray: 100; stroke-dashoffset: 0; }
@keyframes sp-draw {
  0%       { stroke-dashoffset: 100; }
  45%, 55% { stroke-dashoffset: 0; }
  100%     { stroke-dashoffset: 100; }
}
// 링만 12시에서 출발하게 돌린다. 안쪽 꺾은선까지 같이 돌면 눕는다
.sp__rings { transform: rotate(-90deg); transform-origin: 64px 64px; }

07링 배지 (달성 시 체크 모프)

'완료 처리'를 누르면 링이 끝까지 감기고, 가운데 퍼센트가 빠진 자리에 체크가 선을 따라 그려집니다. 체크는 아이콘 글리프가 아니라 꼭짓점 세 개짜리 polyline 이라 링과 똑같이 오프셋으로 그릴 수 있습니다.

stroke-dashoffsetopacityclick
.bg__check {
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  transition: stroke-dashoffset $dur-enter $easing;
}
.bg.is-done .bg__check { stroke-dashoffset: 0; }
.bg.is-done .bg__num { opacity: 0; }

@keyframes bg-draw {
  0%, 35%   { stroke-dashoffset: 100; }
  50%, 62%  { stroke-dashoffset: 0; }
  72%, 100% { stroke-dashoffset: 100; }
}

08비교 링 쌍 (이번 주/지난 주)

같은 반지름 링 두 개를 나란히 두면 각도 차이가 곧 값 차이라서 숫자를 읽기 전에 눈이 먼저 압니다. '주 넘기기'를 누르면 이번 주 값이 지난주 자리로 밀려가고, 그 사이의 차이 숫자가 부호와 색까지 함께 바뀝니다.

2 ringsdeltaclick
.bp__cell--now .bp__ring  { stroke-dashoffset: calc(226.195 * (1 - var(--a) / 100)); }
.bp__cell--prev .bp__ring { stroke-dashoffset: calc(226.195 * (1 - var(--b) / 100)); }
.bp__delta::after { counter-reset: d var(--d); content: "+" counter(d) "%"; }
.bp.is-down .bp__delta::after { content: "−" counter(d) "%"; }
.bp.is-down .bp__delta { background: $error-soft; color: $error; }
.bp.is-down .bp__ic { transform: scaleY(-1); }

09링 진행률 카드 리스트

작은 링 세 개가 목록 줄마다 붙어 조금씩 시차를 두고 차오르고, 줄을 누르면 그 줄만 10 씩 올라갑니다. 줄마다 목표가 달라서 키프레임도 줄마다 따로 찍어야 하는데, SCSS 반복문 하나면 세 벌이 한 번에 나옵니다.

r=14animation-delayclick
$goals: 78, 52, 34;

@each $g in $goals {
  $i: index($goals, $g);
  .pl.is-demo .pl__row:nth-child(#{$i}) {
    animation: pl-fill-#{$i} $dur-loop $easing infinite;
    animation-delay: #{($i - 1) * 0.12}s;
  }
  @keyframes pl-fill-#{$i} {
    0%       { --v: 0; }
    45%, 55% { --v: #{$g}; }
    100%     { --v: 0; }
  }
}

어디서 깨지는가 — 함정

이 아홉 개를 만들면서 가장 크게 물린 곳은 커스텀 속성을 등록하지 않은 것이었습니다. 01 번의 첫 판은 --p 를 그냥 인라인 스타일로만 두고 키프레임 안에서 calc(var(--c) * (1 - var(--p))) 를 썼는데, 렌더 측정에서 24장 중 움직인 프레임이 2장으로 나왔습니다. 등록하지 않은 커스텀 속성은 타입이 없어서 브라우저가 두 값 사이를 이어 주지 못하고, 중간이 없으니 45% 지점에서 한 번 툭 바뀌고 끝난 것입니다. @property<integer> 라고 선언해 주자 같은 데모가 18장으로 올라갔습니다. 반쯤 감기다 마는 링을 보고 있다면 값 타입부터 의심하는 편이 빠릅니다. 두 번째로 자주 어긋나는 것은 stroke-linecap: round 입니다. 둥근 끝은 선 두께의 절반만큼 양쪽으로 삐져나오기 때문에, 값이 0 이어도 점 하나가 남고 100 에서는 시작점과 끝점이 겹쳐 살짝 두꺼워 보입니다. 세 번째는 루프 주기입니다. 2초 창으로 프레임을 뽑는데 애니메이션을 3초로 잡아 두면 마지막 프레임과 첫 프레임이 안 맞아 한 바퀴마다 튀어 보이므로, 감는 구간과 푸는 구간을 한 주기 안에 넣어 제자리로 돌아오게 했습니다. 이 세 가지를 다 반영한 아홉 벌이 zip 비밀번호 zg9hdbsu 로 열리고, 리액트 판에도 같은 등록 선언이 그대로 들어 있습니다.

접근성

아홉 개 전부 prefers-reduced-motion: reduce 에서 움직임만 끄고 값은 남깁니다. 01·02·04·05·08 은 자동 루프를 멈추고 목표 값에 선 채로 있고, 03 은 여덟 칸 중 다섯 칸이 켜진 상태로 고정됩니다. 06·07·09 는 되감기와 체크 그리기 애니메이션을 끄되 클릭했을 때의 상태 변화는 그대로 둡니다. 숫자를 크게 보여 주는 02·03·04·05·07·08·09 는 그 숫자 요소에 role="status" 를, 01·06 은 링 그림 통째에 role="img"aria-label 을 달아 두어, 링을 못 보는 사람도 값이 바뀌면 읽을 수 있습니다. 화면에 보이는 퍼센트는 전부 counter() 로 찍은 실제 값이라 그림과 낭독이 어긋나지 않습니다.

FAQ

conic-gradient 로 만드는 원형 진행 표시와 뭐가 다른가요?

conic-gradient 는 원판을 부채꼴로 칠하는 방식이라 도넛으로 만들려면 가운데를 다시 덮어야 하고, 끝을 둥글게 다듬기 어렵습니다. SVG 원과 stroke-dashoffset 은 선 하나를 잘라 쓰는 방식이라 두께·둥근 끝·여러 겹이 전부 자연스럽고, 값도 길이 계산 하나로 끝납니다.

둘레를 직접 계산하지 않고 자바스크립트로 구해도 되나요?

됩니다. circle.getTotalLength() 가 같은 값을 돌려주므로 반지름을 바꿔도 코드를 고칠 필요가 없습니다. 다만 이 아홉 개는 반지름이 고정이라 계산 결과를 그대로 적어 넣어, 자동 루프만큼은 자바스크립트 없이 돌게 두었습니다. 자세한 것은 MDN stroke-dasharray 문서에 있습니다.

링이 3시 방향에서 시작하는데 12시로 옮기려면요?

SVG 원의 0도는 오른쪽입니다. 링이 들어 있는 그룹에 transform: rotate(-90deg) 를 주고 원의 중심을 transform-origin 으로 잡으면 12시에서 출발합니다. 06 번처럼 링 안에 다른 그림이 있으면 그 그림까지 같이 돌아가므로, 링만 따로 그룹으로 묶어야 합니다.

더 큰 화면을 짜는 중이라면 대시보드 컴포넌트 9종에서 헤더·칸반·팔레트 같은 조각을, 대시보드 사이드바·위젯 UI 9종에서 사이드바·표·빈 상태를 가져다 붙일 수 있습니다. 커스텀 속성 등록은 MDN @property 문서의 설명이 가장 짧고 정확합니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.