GODRICH

원형 프로그레스바 CSS 9종 — 값만 바꾸면 되는

원형 프로그레스바 CSS는 값 하나만 바꾸면 전체가 다시 그려지는 채움·게이지 애니메이션을 말합니다. 업로드 바부터 반원 속도계, 배터리 아이콘까지 실제로 돌아가는 아홉 개를 모았고, 하나같이 width가 아니라 transform과 conic-gradient만 다시 그립니다.

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

아홉 개는 인기 순이 아니라 값을 다루는 난이도 순으로 놓았습니다. 막대 하나로 채우는 가장 단순한 형태에서 시작해 원형·반원으로 모양을 바꾸고, 값을 모르는 상태와 여러 단계로 이어지는 흐름까지 갑니다. 항목마다 코드는 핵심만 실었고, 전체 파일은 zip에 있습니다.

01기본 선형 채움

다른 조건 없이 값 하나만 있으면 되는 가장 단순한 막대입니다. --percent 커스텀 속성이 애니메이션되는 동안 안쪽 막대는 width가 아니라 scaleX로만 늘어나, 값이 커져도 레이아웃을 다시 계산하지 않습니다.

scaleX@property2초 루프
@property --percent { syntax: "<number>"; inherits: true; initial-value: 0; }
.track { --percent: 0; position: relative; overflow: hidden; }
.bar {
  position: absolute; inset: 0; background: $color;
  transform: scaleX(calc(var(--percent) / 100));
  transform-origin: left center;
}
.track.is-demo { animation: fill $duration $easing infinite; }
@keyframes fill { 0% { --percent: 0; } 100% { --percent: 76; } }

02스트라이프 애니메이션

값은 그대로인데 줄무늬만 계속 흘러, 멈춘 것처럼 보이지 않게 만드는 막대입니다. 대량 처리나 동기화처럼 진행이 느린 작업에 씁니다.

background-positionrepeating-linear-gradient1초 루프
.bar {
  position: absolute; inset: 0;
  background-image: repeating-linear-gradient(45deg, $color 0 10px, rgba($color, .55) 10px 20px);
  background-size: 28px 28px;
}
.bar.is-demo { animation: stripe-move $duration $easing infinite; }
@keyframes stripe-move {
  from { background-position: 0 0; }
  to   { background-position: 28px 0; }
}

03conic-gradient 원형 게이지

도넛 모양 링이 값만큼 채워지지만 숫자는 보여주지 않습니다. 대신 값이 낮음·중간·높음 구간을 지날 때마다 링 색 자체가 바뀌어, 숫자를 읽지 않아도 상태를 한눈에 알 수 있습니다.

conic-gradient색 구간 전환@property
@property --percent { syntax: "<number>"; inherits: false; initial-value: 0; }
.ring {
  --percent: 0; --zone: #{$color};
  background: conic-gradient(var(--zone) calc(var(--percent) * 1%), rgba(255, 255, 255, .35) 0);
}
.ring.is-demo { animation: sweep $duration $easing infinite, zone $duration steps(1) infinite; }
@keyframes zone {
  40%  { --zone: #{$color}; }
  41%  { --zone: #{$stage-yellow}; }
  75%  { --zone: #{$subject-ink}; }
}

04반원 게이지

원 전체가 아니라 위쪽 절반만 보여주는 속도계 모양입니다. 컨테이너 높이를 폭의 절반으로 잘라 완전한 원의 아래쪽을 가리고, 바늘은 같은 값으로 함께 돕니다.

transform: rotate()반원 clip바늘
@property --percent { syntax: "<number>"; inherits: true; initial-value: 0; }
.gauge { --percent: 0; overflow: hidden; }
.gauge::before {
  width: 100%; height: 200%; border-radius: 50%;
  background: conic-gradient(from -90deg, $color calc(var(--percent) * 1.8deg), rgba($subject-ink, .12) 0);
}
.gauge__needle {
  transform-origin: bottom center;
  transform: translateX(-50%) rotate(calc(var(--percent) * 1.8deg - 90deg));
}

05세그먼트 단계 표시

막대가 하나로 이어지지 않고 여러 조각으로 나뉘어, 신호 세기처럼 조각이 순서대로 켜집니다. 연속값이 아니라 단계값을 보여줄 때 씁니다.

opacity 단계 전환animation-delay조각 5개
.seg {
  width: 16px; height: 46px; border-radius: 4px;
  opacity: .35; transform: scaleY(.7); transform-origin: bottom;
}
.segs.is-demo .seg { animation: light 2s $easing infinite; }
.segs.is-demo .seg:nth-child(2) { animation-delay: 160ms; }
@keyframes light {
  0%, 60%, 100% { opacity: .35; transform: scaleY(.7); }
  20%, 45%      { opacity: 1;   transform: scaleY(1); background: $color; }
}

06불확정 무한 바

진행률을 아예 모를 때 씁니다. 짧은 막대 하나가 좌우로 오가는 흔한 방식 대신, 길이가 다른 두 조각이 시간차를 두고 겹쳐 지나가며 굴러가는 인상을 만듭니다.

두 세그먼트animation-delay 차등cubic-bezier
.seg {
  position: absolute; inset: 0;
  background: $color; transform-origin: left center;
}
.seg--a.is-demo { animation: seg-a $duration $easing infinite; }
.seg--b.is-demo { animation: seg-b $duration $easing infinite; animation-delay: $duration * .35; }
@keyframes seg-a {
  0%   { transform: translateX(-100%) scaleX(.15); }
  55%  { transform: translateX(30%)   scaleX(.7); }
  100% { transform: translateX(120%)  scaleX(.15); }
}

07퍼센트 라벨 동기

링이 아니라 가로 막대 위에서, 채움의 끝을 따라다니는 말풍선이 지금 값을 보여줍니다. 컨테이너 쿼리 단위 cqw로 막대 폭 기준 위치를 계산합니다.

translateX 동기말풍선CSS 변수
@property --percent { syntax: "<integer>"; inherits: true; initial-value: 0; }
.track { --percent: 0; container-type: inline-size; }
.bar { transform: scaleX(calc(var(--percent) / 100)); transform-origin: left center; }
.bubble {
  position: absolute; top: -30px; left: 0;
  transform: translateX(calc(var(--percent) * 1cqw - 50%));
}
.bubble__num { counter-reset: p var(--percent); &::before { content: counter(p) "%"; } }

08단계형 진행 표시

번호가 매겨진 원 세 개가 선으로 이어지고, 앞 단계가 끝나야 다음 원과 선이 채워집니다. 회원가입이나 결제처럼 순서가 있는 흐름을 보여줄 때 씁니다.

scaleX 연결선원 채움단계 3개
.stepper.is-demo .step__dot { animation: dot-fill $duration $easing both; }
.stepper.is-demo .line { animation: line-grow $duration linear both; }
.stepper.is-demo > :nth-child(2) { animation-delay: 400ms; }
.stepper.is-demo > :nth-child(3) .step__dot { animation-delay: 800ms; }
@keyframes dot-fill {
  from { background: rgba(255, 255, 255, .14); transform: scale(.85); }
  to   { background: $color; transform: scale(1); }
}
@keyframes line-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }

09배터리 모양 게이지

배터리 아이콘 안쪽 막대가 채워지고, 남은 양이 적을수록 색이 경고색으로 바뀌는 게이지입니다. 잔량이 낮아지면 테두리가 살짝 깜빡입니다.

scaleX색 임계값 전환테두리 펄스
@property --percent { syntax: "<number>"; inherits: false; initial-value: 100; }
.battery__fill {
  --percent: 100;
  transform: scaleX(calc(var(--percent) / 100));
  animation: drain $duration $easing infinite;
}
@keyframes drain {
  0%   { --percent: 100; background: #{$color}; }
  55%  { --percent: 35;  background: #{$stage-yellow}; }
  100% { --percent: 8;   background: #{$error}; }
}

어디서 깨지는가 — 함정 하나

아홉 개를 만들면서 진짜로 걸린 함정은 두 가지입니다. 하나는 06 불확정 바입니다. 겹쳐 지나가는 두 조각을 absolute로 띄우면서 폭을 따로 주지 않았더니, position: absolute인 요소는 left·right가 없으면 폭이 콘텐츠 크기 그대로라 빈 요소는 0이 되고, scaleX를 아무리 걸어도 화면엔 아무것도 나타나지 않았습니다. inset: 0으로 폭을 먼저 채운 뒤에야 값이 움직이기 시작했습니다. 다른 하나는 04·07처럼 부모가 --percent를 애니메이션하고 바늘·말풍선·::before 같은 자식이 그 값을 읽는 구조입니다. MDN의 @property 문서에 나오듯 inherits: false로 등록하면 애니메이션을 건 요소 자신만 값을 보고 자식은 초기값에 그대로 머무릅니다. 이 아홉 개 파일은 전부 상황에 맞는 inherits 값을 쓰고 있고, 압축 비밀번호는 betrch52이며 아래 버튼으로 받을 수 있습니다.

접근성

아홉 개 전부 role="progressbar"aria-valuemin·aria-valuemax를 갖습니다. 값을 아는 01~05·07~09는 aria-valuenow도 함께 두지만, 값을 모르는 06 불확정 바만 이 속성을 뺍니다. 실수가 아니라 ARIA 명세가 정한 방식입니다.

<div class="track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="업로드 중">
  <div class="bar is-demo"></div>
</div>

prefers-reduced-motion: reduce에서는 09 배터리와 05 세그먼트처럼 색이 바뀌는 것들은 마지막 상태로 고정되고, 06 불확정 바는 애니메이션이 멈춘 채 흐릿한 정지 막대로만 남습니다. 03의 구간 색도 진행 중이던 값에 맞는 구간 색으로 고정됩니다.

지금까지 만든 다른 로딩·상태 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.

FAQ

@property 없이도 이 게이지들을 만들 수 있나요?

네, 하지만 값이 바뀔 때 화면이 중간 단계 없이 한 번에 튑니다. @property로 커스텀 속성을 숫자로 등록해야 브라우저가 매 프레임 값을 보간해 부드럽게 채웁니다. 등록 없이 쓰려면 requestAnimationFrame으로 직접 값을 갱신하는 JS가 필요합니다.

반원 게이지와 원형 게이지 중 뭘 써야 하나요?

값의 범위가 명확한 하나의 지표라면 반원이 더 읽기 쉽고, 사용률·배터리처럼 낮음과 경고를 색으로 구분해야 한다면 원형 쪽이 공간을 덜 씁니다. 같은 화면에 두 형태를 섞지 않는 편이 낫습니다.

값을 서버에서 실시간으로 받아 바꾸려면요?

React 판처럼 percent를 props나 상태로 받아 인라인 스타일의 --percent 값만 갱신하면 됩니다. @property로 등록해 둔 속성은 값이 바뀔 때 transition만 걸어도 새 값까지 자동으로 보간되므로, 별도의 애니메이션 루프가 필요 없습니다.

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

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