9 CSS Progress Bar Circle Gauges, Copy-Paste Ready
A css progress bar circle is a ring or arc that fills toward a value with conic-gradient and a custom property instead of a growing box — nine working versions
01 progress-bar-gauge-linear-fill
02 progress-bar-gauge-striped-animated
03 progress-bar-gauge-conic-ring
04 progress-bar-gauge-half-gauge
05 progress-bar-gauge-segmented-steps
06 progress-bar-gauge-indeterminate-bar
07 progress-bar-gauge-percent-label
08 progress-bar-gauge-stepper-progress
09 progress-bar-gauge-battery-shape
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Linear fill
- 02 Animated stripes
- 03 Conic-gradient ring
- 04 Half-circle gauge
- 05 Segmented steps
- 06 Indeterminate bar
- 07 Percent label sync
- 08 Stepper progress
- 09 Battery-shape gauge
The order below follows what the value itself has to do, not popularity. 01 and 02 fill a straight track — one with a real number behind it, one that only needs to look busy. 03 and 04 bend that same fill into a ring and a half-circle dial. 05 and 06 drop the smooth fill entirely, one stepping through discrete blocks and the other admitting it has no number to show at all. 07 through 09 attach a live label, a css progress bar with steps, and a color threshold to the mechanic the first six already established. The grid above sits in a small iframe with nothing to click and no server behind it. So every tile loops on a timer through the .is-demo class rather than waiting on a value that would never actually arrive there. When there is no number to wait on at all, CSS loading spinners admit that more honestly than item 06 does, and a chat screen waiting on a reply is better served by AI chat loading animations.
01Linear fill
How it works
The fill bar inside the track scales through scaleX rather than animating width, driven by a --percent custom property that's registered as a plain number with @property. That registration is what lets the browser tween smoothly between whatever values it's given.
Where it fits
Reach for this one wherever a real number sits behind the bar, like a file upload or a form submission, rather than a stand-in for progress nobody can actually measure.

<main class="stage">
<div class="track is-demo" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="Uploading">
<div class="bar"></div>
</div>
<p class="cap">Uploading</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-loop; // 0 → 목표값까지 차는 시간
$easing: $ease-out;
$color: $subject-cream; // 채워지는 색
@include stage($stage-ink);
// 값은 등록된 숫자 커스텀 속성으로 애니메이션한다 (scroll() 이 아니라 실제 진행률 숫자)
@property --percent {
syntax: "<number>";
inherits: true; // .bar 가 부모 .track 의 애니메이션 값을 그대로 물려받는다
initial-value: 0;
}
.track {
--percent: 0;
position: relative;
width: clamp(200px, 58vw, 280px);
height: 14px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .16);
overflow: hidden;
}
.bar {
position: absolute;
inset: 0;
border-radius: inherit;
background: $color;
transform: scaleX(calc(var(--percent) / 100)); // width 대신 scaleX 로만 채운다
transform-origin: left center;
}
.track.is-demo { animation: fill $duration $easing infinite; }
@keyframes fill {
0% { --percent: 0; }
60% { --percent: 76; }
100% { --percent: 76; }
}
.cap {
margin: 14px 0 0;
color: rgba(255, 255, 255, .7);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 접근성: 움직임 줄이기 설정이면 목표값에서 정지 ────────────
@media (prefers-reduced-motion: reduce) {
.track.is-demo { animation: none; --percent: 76; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --percent {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.track {
--percent: 0;
position: relative;
width: clamp(200px, 58vw, 280px);
height: 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
overflow: hidden;
}
.bar {
position: absolute;
inset: 0;
border-radius: inherit;
background: #fff7e6;
transform: scaleX(calc(var(--percent) / 100));
transform-origin: left center;
}
.track.is-demo {
animation: fill 2s ease-out infinite;
}
@keyframes fill {
0% {
--percent: 0;
}
60% {
--percent: 76;
}
100% {
--percent: 76;
}
}
.cap {
margin: 14px 0 0;
color: rgba(255, 255, 255, 0.7);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.track.is-demo {
animation: none;
--percent: 76;
}
}import type { CSSProperties } from "react";
import styles from "./LinearFillBar.module.scss";
export type LinearFillBarProps = {
/** 0~100. 실제 진행률 숫자 그대로 넘긴다 (스크롤이 아니다) */
percent: number;
duration?: number;
easing?: string;
color?: string;
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--percent"?: number;
};
export default function LinearFillBar({
percent,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
label,
}: LinearFillBarProps) {
const p = Math.max(0, Math.min(100, percent));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--percent": p };
return (
<div
className={styles.track}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={p}
aria-label={label}
>
<div className={styles.bar} />
</div>
);
}01 · Linear fill — Measured area 2.051% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
@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; } }
02Animated stripes
How it works
The bar's own width holds still at whatever value it's set to, while a diagonal repeating-linear-gradient behind it scrolls through background-position on a short loop. That keeps something visibly moving even during the long stretches where the real number barely ticks.
Where it fits
It fits bulk data jobs and background sync bars, where staring at a frozen number reads as broken even when the job is still running fine.

<main class="stage">
<div class="track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="62" aria-label="Syncing">
<div class="bar is-demo"></div>
</div>
<p class="cap">Syncing</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s; // 줄무늬가 한 칸 흐르는 시간
$easing: linear;
$color: $subject-ink; // 줄무늬 색
@include stage($stage-yellow);
.track {
position: relative;
width: clamp(200px, 58vw, 280px);
height: 16px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .14);
overflow: hidden;
}
// 값은 62% 로 고정, 줄무늬만 background-position 으로 흐른다 (026 의 배경 애니메이션과 같은 예외 — 레이아웃이 아니라 페인트만 다시 한다)
.bar {
position: absolute;
inset: 0;
width: 62%;
border-radius: inherit;
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; }
}
.cap {
margin: 14px 0 0;
color: rgba(23, 20, 26, .75);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.bar.is-demo { animation: none; }
}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);
}
.track {
position: relative;
width: clamp(200px, 58vw, 280px);
height: 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
overflow: hidden;
}
.bar {
position: absolute;
inset: 0;
width: 62%;
border-radius: inherit;
background-image: repeating-linear-gradient(45deg, #17141a 0 10px, rgba(23, 20, 26, 0.55) 10px 20px);
background-size: 28px 28px;
}
.bar.is-demo {
animation: stripe-move 1s linear infinite;
}
@keyframes stripe-move {
from {
background-position: 0 0;
}
to {
background-position: 28px 0;
}
}
.cap {
margin: 14px 0 0;
color: rgba(23, 20, 26, 0.75);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.bar.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./StripedBar.module.scss";
export type StripedBarProps = {
/** 0~100 */
percent: number;
duration?: number;
easing?: string;
color?: string;
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--percent"?: number;
};
export default function StripedBar({
percent,
duration = 1000,
easing = "linear",
color = "#17141a",
label,
}: StripedBarProps) {
const p = Math.max(0, Math.min(100, percent));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--percent": p };
return (
<div
className={styles.track}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={p}
aria-label={label}
>
<div className={styles.bar} />
</div>
);
}02 · Animated stripes — Measured area 1.883% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.bar {
position: absolute;
inset: 0;
width: 62%;
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 ring
How the css circle progress bar animation fills
A donut-shaped ring fills to match --percent through conic-gradient, with no digit printed on it anywhere. As the value crosses a low, mid, and high zone, a second --zone property flips the ring's own color from blue to yellow to a dark warning tone.
Where it fits
The state then reads at a glance, without anyone having to do the math.

<main class="stage">
<div class="ring is-demo" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="Usage"></div>
<p class="cap">Usage</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 0 → 100 까지 차는 시간
$easing: $ease-out;
$color: $subject-blue; // 낮은 구간(0~40%) 색. 변형은 이 값만 바꾼다
@include stage($stage-orange);
// 채움 각도(--percent)와 구간 색(--zone)을 각각 등록한다. 숫자는 매끄럽게, 색은 구간이 바뀔 때 한 번에 전환한다
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.ring {
--percent: 0;
--zone: #{$color};
position: relative;
width: clamp(84px, 22vw, 108px);
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(var(--zone) calc(var(--percent) * 1%), rgba(255, 255, 255, .35) 0);
}
.ring::before { // 도넛 구멍 — 숫자를 보여주지 않고 색으로만 구간을 말한다
content: "";
position: absolute;
inset: 16%;
border-radius: 50%;
background: $stage-orange;
}
.ring.is-demo {
animation: sweep $duration $easing infinite, zone $duration steps(1) infinite;
}
@keyframes sweep { 0% { --percent: 0; } 100% { --percent: 100; } }
@keyframes zone {
0% { --zone: #{$color}; } // 낮음
40% { --zone: #{$color}; }
41% { --zone: #{$stage-yellow}; } // 중간
74% { --zone: #{$stage-yellow}; }
75% { --zone: #{$subject-ink}; } // 높음(경고) — 주황 무대라 빨강 대신 잉크 (대비)
100% { --zone: #{$subject-ink}; }
}
.cap {
margin: 14px 0 0;
color: $subject-ink; // AA: 주황 무대 위 글자 5.50:1
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.ring.is-demo { animation: none; --percent: 76; --zone: #{$subject-ink}; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.ring {
--percent: 0;
--zone: #2f6df6;
position: relative;
width: clamp(84px, 22vw, 108px);
aspect-ratio: 1;
border-radius: 50%;
background: conic-gradient(var(--zone) calc(var(--percent) * 1%), rgba(255, 255, 255, 0.35) 0);
}
.ring::before {
content: "";
position: absolute;
inset: 16%;
border-radius: 50%;
background: #ff4d1f;
}
.ring.is-demo {
animation: sweep 2s ease-out infinite, zone 2s steps(1) infinite;
}
@keyframes sweep {
0% {
--percent: 0;
}
100% {
--percent: 100;
}
}
@keyframes zone {
0% {
--zone: #2f6df6;
}
40% {
--zone: #2f6df6;
}
41% {
--zone: #ffd23f;
}
74% {
--zone: #ffd23f;
}
75% {
--zone: #17141a;
}
100% {
--zone: #17141a;
}
}
.cap {
margin: 14px 0 0;
color: #17141a;
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.ring.is-demo {
animation: none;
--percent: 76;
--zone: #17141a;
}
}import type { CSSProperties } from "react";
import styles from "./ConicRing.module.scss";
export type ConicRingProps = {
/** 0~100 */
percent: number;
duration?: number;
easing?: string;
/** 낮음 구간(0~40) 색 */
color?: string;
/** 중간 구간(40~75) 색 */
zoneMid?: string;
/** 높음 구간(75~100) 색 */
zoneHigh?: string;
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--percent"?: number;
"--zone"?: string;
};
export default function ConicRing({
percent,
duration = 600,
easing = "ease-out",
color = "#2f6df6",
zoneMid = "#ffd23f",
zoneHigh = "#17141a",
label,
}: ConicRingProps) {
const p = Math.max(0, Math.min(100, percent));
const zone = p < 40 ? color : p < 75 ? zoneMid : zoneHigh;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--percent": p, "--zone": zone };
return (
<div
className={styles.ring}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={p}
aria-label={label}
/>
);
}03 · Conic-gradient ring — Measured area 3.458% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
@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}; }
}
04Half-circle gauge
How it works
A needle rotates through transform: rotate() across a dial that only shows the top half of a circle, sweeping from one side to the other the way a speedometer does rather than tracing a full circle.
Where it fits
The same --percent value drives both the needle's angle and the conic-gradient arc sitting underneath it, so the two can never fall out of sync with each other.

<main class="stage">
<div class="gauge is-demo" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="Speed">
<span class="gauge__needle"></span>
</div>
<p class="cap">Speed</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 바늘이 왼쪽 끝에서 오른쪽 끝까지 도는 시간
$easing: $ease-out;
$color: $subject-blue; // 채워진 구간 색
@include stage($stage-paper);
// 채움 각도와 바늘 각도를 같은 값으로 구동한다. ::before·바늘 둘 다 자식이라 inherits 가 필요하다
@property --percent {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.gauge {
--percent: 0;
position: relative;
width: clamp(140px, 38vw, 170px);
height: clamp(70px, 19vw, 85px); // 폭의 절반 — 원 아래쪽 절반을 overflow 로 자른다
overflow: hidden;
}
.gauge::before {
content: "";
position: absolute;
top: 0; left: 0;
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 {
position: absolute;
bottom: 0;
left: 50%;
width: 3px;
height: calc(100% - 6px);
background: $subject-ink;
border-radius: 2px;
transform-origin: bottom center; // 규칙8: 바늘 회전 피벗
transform: translateX(-50%) rotate(calc(var(--percent) * 1.8deg - 90deg));
}
.gauge.is-demo { animation: sweep $duration $easing infinite; }
@keyframes sweep { 0% { --percent: 0; } 100% { --percent: 100; } }
.cap {
margin: 14px 0 0;
color: $subject-ink;
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.gauge.is-demo { animation: none; --percent: 76; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --percent {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
.gauge {
--percent: 0;
position: relative;
width: clamp(140px, 38vw, 170px);
height: clamp(70px, 19vw, 85px);
overflow: hidden;
}
.gauge::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 200%;
border-radius: 50%;
background: conic-gradient(from -90deg, #2f6df6 calc(var(--percent) * 1.8deg), rgba(23, 20, 26, 0.12) 0);
}
.gauge__needle {
position: absolute;
bottom: 0;
left: 50%;
width: 3px;
height: calc(100% - 6px);
background: #17141a;
border-radius: 2px;
transform-origin: bottom center;
transform: translateX(-50%) rotate(calc(var(--percent) * 1.8deg - 90deg));
}
.gauge.is-demo {
animation: sweep 2s ease-out infinite;
}
@keyframes sweep {
0% {
--percent: 0;
}
100% {
--percent: 100;
}
}
.cap {
margin: 14px 0 0;
color: #17141a;
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.gauge.is-demo {
animation: none;
--percent: 76;
}
}import type { CSSProperties } from "react";
import styles from "./HalfGauge.module.scss";
export type HalfGaugeProps = {
/** 0~100 */
percent: number;
duration?: number;
easing?: string;
color?: string;
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--percent"?: number;
};
export default function HalfGauge({
percent,
duration = 600,
easing = "ease-out",
color = "#2f6df6",
label,
}: HalfGaugeProps) {
const p = Math.max(0, Math.min(100, percent));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--percent": p };
return (
<div
className={styles.gauge}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={p}
aria-label={label}
>
<span className={styles.needle} />
</div>
);
}04 · Half-circle gauge — Measured area 7.955% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
@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));
}
05Segmented steps
How it works
Instead of one continuous bar, the track splits into five separate blocks, and each lights up in turn as the value climbs, on its own animation-delay rather than all igniting together.
Where it fits
It reads closer to a signal-strength meter than a smooth fill, which suits a value that only ever moves in fixed jumps rather than a continuous range.

<main class="stage">
<div class="segs is-demo" role="progressbar" aria-valuemin="0" aria-valuemax="5" aria-valuenow="3" aria-label="Signal">
<span class="seg"></span><span class="seg"></span><span class="seg"></span><span class="seg"></span><span class="seg"></span>
</div>
<p class="cap">Signal</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 260ms; // 조각 하나가 켜지는 시간
$easing: $ease-pop;
$color: $subject-cream; // 켜진 조각 색
@include stage($stage-ink);
.segs {
display: flex;
align-items: flex-end;
gap: 6px;
}
.seg {
width: 16px;
height: 46px;
border-radius: 4px;
background: rgba(255, 255, 255, .14);
opacity: .35;
transform: scaleY(.7);
transform-origin: bottom; // 규칙8: 아래서 차오름
}
// 조각마다 시작 시점을 다르게 — 하나로 이어진 바가 아니라 순서대로 켜지는 신호처럼
.segs.is-demo .seg { animation: light 2s $easing infinite; }
.segs.is-demo .seg:nth-child(1) { animation-delay: 0ms; }
.segs.is-demo .seg:nth-child(2) { animation-delay: 160ms; }
.segs.is-demo .seg:nth-child(3) { animation-delay: 320ms; }
.segs.is-demo .seg:nth-child(4) { animation-delay: 480ms; }
.segs.is-demo .seg:nth-child(5) { animation-delay: 640ms; }
@keyframes light {
0%, 60%, 100% { opacity: .35; transform: scaleY(.7); background: rgba(255, 255, 255, .14); }
20%, 45% { opacity: 1; transform: scaleY(1); background: $color; }
}
.cap {
margin: 14px 0 0;
color: rgba(255, 255, 255, .7);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.segs.is-demo .seg { animation: none; opacity: 1; transform: scaleY(1); background: $color; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.segs {
display: flex;
align-items: flex-end;
gap: 6px;
}
.seg {
width: 16px;
height: 46px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
opacity: 0.35;
transform: scaleY(0.7);
transform-origin: bottom;
}
.segs.is-demo .seg {
animation: light 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.segs.is-demo .seg:nth-child(1) {
animation-delay: 0ms;
}
.segs.is-demo .seg:nth-child(2) {
animation-delay: 160ms;
}
.segs.is-demo .seg:nth-child(3) {
animation-delay: 320ms;
}
.segs.is-demo .seg:nth-child(4) {
animation-delay: 480ms;
}
.segs.is-demo .seg:nth-child(5) {
animation-delay: 640ms;
}
@keyframes light {
0%, 60%, 100% {
opacity: 0.35;
transform: scaleY(0.7);
background: rgba(255, 255, 255, 0.14);
}
20%, 45% {
opacity: 1;
transform: scaleY(1);
background: #fff7e6;
}
}
.cap {
margin: 14px 0 0;
color: rgba(255, 255, 255, 0.7);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.segs.is-demo .seg {
animation: none;
opacity: 1;
transform: scaleY(1);
background: #fff7e6;
}
}import type { CSSProperties } from "react";
import styles from "./SegmentedSteps.module.scss";
export type SegmentedStepsProps = {
/** 켜진 조각 수 (0~total) */
active: number;
total?: number;
duration?: number;
easing?: string;
color?: string;
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function SegmentedSteps({
active,
total = 5,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
label,
}: SegmentedStepsProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div
className={styles.segs}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={active}
aria-label={label}
>
{Array.from({ length: total }, (_, i) => (
<span key={i} className={i < active ? `${styles.seg} ${styles.on}` : styles.seg} />
))}
</div>
);
}05 · Segmented steps — Measured area 2.794% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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; }
.segs.is-demo .seg:nth-child(3) { animation-delay: 320ms; }
@keyframes light {
0%, 60%, 100% { opacity: .35; transform: scaleY(.7); }
20%, 45% { opacity: 1; transform: scaleY(1); background: $color; }
}
06Indeterminate bar
How it works
Rather than a single short bar sweeping back and forth, two segments of different lengths cross the track at staggered timings, passing over each other the way Google's Material Design loading bar does.
Where it fits
Reach for it during a wait with no real percentage behind it at all — an initial page load, a search that hasn't returned yet.

<main class="stage">
<div class="track" role="progressbar" aria-label="Loading">
<span class="seg seg--a is-demo"></span>
<span class="seg seg--b is-demo"></span>
</div>
<p class="cap">Loading</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.6s; // 긴 조각 하나가 지나가는 시간
$easing: cubic-bezier(.4, 0, .2, 1);
$color: $subject-ink; // 조각 색
@include stage($stage-yellow);
// 진행률을 모르므로 aria-valuenow 를 주지 않는다 (ARIA 명세: 불확정 progressbar 는 값 속성을 뺀다)
.track {
position: relative;
width: clamp(200px, 58vw, 280px);
height: 8px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
overflow: hidden;
}
.seg {
position: absolute;
inset: 0; // 폭을 꽉 채운 뒤 transform 으로만 밀고 줄인다 (width 는 건드리지 않는다)
border-radius: inherit;
background: $color;
transform-origin: left center; // 규칙8: 좌변 기준 이동·신축
}
// 짧은 바 하나가 오가는 대신, 길이가 다른 두 조각이 시간차를 두고 서로 겹쳐 지나간다 (머티리얼 디자인 방식)
.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); }
}
@keyframes seg-b {
0% { transform: translateX(-140%) scaleX(.1); }
60% { transform: translateX(10%) scaleX(.4); }
100% { transform: translateX(120%) scaleX(.1); }
}
.cap {
margin: 14px 0 0;
color: rgba(23, 20, 26, .75);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.seg.is-demo { animation: none; transform: translateX(0) scaleX(.4); opacity: .7; }
}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);
}
.track {
position: relative;
width: clamp(200px, 58vw, 280px);
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.seg {
position: absolute;
inset: 0;
border-radius: inherit;
background: #17141a;
transform-origin: left center;
}
.seg--a.is-demo {
animation: seg-a 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.seg--b.is-demo {
animation: seg-b 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
animation-delay: 0.56s;
}
@keyframes seg-a {
0% {
transform: translateX(-100%) scaleX(0.15);
}
55% {
transform: translateX(30%) scaleX(0.7);
}
100% {
transform: translateX(120%) scaleX(0.15);
}
}
@keyframes seg-b {
0% {
transform: translateX(-140%) scaleX(0.1);
}
60% {
transform: translateX(10%) scaleX(0.4);
}
100% {
transform: translateX(120%) scaleX(0.1);
}
}
.cap {
margin: 14px 0 0;
color: rgba(23, 20, 26, 0.75);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.seg.is-demo {
animation: none;
transform: translateX(0) scaleX(0.4);
opacity: 0.7;
}
}import type { CSSProperties } from "react";
import styles from "./IndeterminateBar.module.scss";
export type IndeterminateBarProps = {
duration?: number;
easing?: string;
color?: string;
/** 진행률을 모르므로 값이 아니라 상태만 알린다 */
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function IndeterminateBar({
duration = 1600,
easing = "cubic-bezier(.4,0,.2,1)",
color = "#17141a",
label = "Loading",
}: IndeterminateBarProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.track} style={vars} role="progressbar" aria-label={label}>
<span className={`${styles.seg} ${styles.a}`} />
<span className={`${styles.seg} ${styles.b}`} />
</div>
);
}06 · Indeterminate bar — Measured area 1.542% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
.track { position: relative; overflow: hidden; }
.seg {
position: absolute;
inset: 0;
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); }
}
07Percent label sync
How it works
On a horizontal bar rather than a ring, a bubble label rides the leading edge of the fill and prints the running number itself through counter(). It reads from the identical --percent property that drives the bar's own scaleX.
Where it fits
Its horizontal position is calculated in cqw, a container query unit, so the label tracks the bar's own width rather than the viewport's.

<main class="stage">
<div class="track is-demo" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="Survey progress">
<div class="bar"></div>
<div class="bubble"><span class="bubble__num" aria-hidden="true"></span></div>
</div>
<p class="cap">Survey progress</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue; // 바·말풍선 공통 색
@include stage($stage-paper);
// 정수로 등록해야 counter() 가 자릿수를 깔끔하게 읽는다. 자식(bar·bubble)이 같이 읽으므로 inherits: true
@property --percent {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.track {
--percent: 0;
position: relative;
width: clamp(220px, 62vw, 280px);
height: 20px;
margin-top: 26px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
container-type: inline-size; // 말풍선이 이 폭을 기준으로 움직인다
}
.bar {
position: absolute;
inset: 0;
border-radius: inherit;
background: $color;
transform: scaleX(calc(var(--percent) / 100));
transform-origin: left center; // 규칙8: 좌변서 차오름
}
// 링이 아니라 막대 위에서, 채움의 끝자락을 translateX(cqw) 로 따라간다 — 1cqw = 컨테이너 폭의 1%
.bubble {
position: absolute;
top: -30px;
left: 0;
transform: translateX(calc(var(--percent) * 1cqw - 50%));
background: $subject-ink;
color: #fff;
border-radius: $r-control;
padding: 3px 9px;
font: 700 clamp(11px, 3vw, 13px)/1.6 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.bubble__num {
counter-reset: p var(--percent);
&::before { content: counter(p) "%"; }
}
.track.is-demo { animation: fill $duration $easing infinite; }
@keyframes fill { 0% { --percent: 0; } 60% { --percent: 76; } 100% { --percent: 76; } }
.cap {
margin: 34px 0 0;
color: $subject-ink;
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.track.is-demo { animation: none; --percent: 76; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --percent {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.track {
--percent: 0;
position: relative;
width: clamp(220px, 62vw, 280px);
height: 20px;
margin-top: 26px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
container-type: inline-size;
}
.bar {
position: absolute;
inset: 0;
border-radius: inherit;
background: #2f6df6;
transform: scaleX(calc(var(--percent) / 100));
transform-origin: left center;
}
.bubble {
position: absolute;
top: -30px;
left: 0;
transform: translateX(calc(var(--percent) * 1cqw - 50%));
background: #17141a;
color: #fff;
border-radius: 12px;
padding: 3px 9px;
font: 700 clamp(11px, 3vw, 13px)/1.6 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.bubble__num {
counter-reset: p var(--percent);
}
.bubble__num::before {
content: counter(p) "%";
}
.track.is-demo {
animation: fill 2s ease-out infinite;
}
@keyframes fill {
0% {
--percent: 0;
}
60% {
--percent: 76;
}
100% {
--percent: 76;
}
}
.cap {
margin: 34px 0 0;
color: #17141a;
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.track.is-demo {
animation: none;
--percent: 76;
}
}import type { CSSProperties } from "react";
import styles from "./PercentLabel.module.scss";
export type PercentLabelProps = {
/** 0~100 */
percent: number;
duration?: number;
easing?: string;
color?: string;
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--percent"?: number;
};
export default function PercentLabel({
percent,
duration = 600,
easing = "ease-out",
color = "#2f6df6",
label,
}: PercentLabelProps) {
const p = Math.max(0, Math.min(100, percent));
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--percent": p };
return (
<div
className={styles.track}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={p}
aria-label={label}
>
<div className={styles.bar} />
<div className={styles.bubble}>{Math.round(p)}%</div>
</div>
);
}07 · Percent label sync — Measured area 7.601% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
@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) "%"; }
}
08Stepper progress
How it works
Three numbered circles connect through a line, and each one fills only once the step before it has finished — the circle first, then the connecting segment growing through scaleX right after.
Where it fits
It fits flows with a real order to them, like a checkout or a signup form, rather than a single running total.

<main class="stage">
<div class="stepper is-demo" role="list" aria-label="Progress steps">
<div class="step" role="listitem"><span class="step__dot">1</span><span class="step__label">Info</span></div>
<div class="line"></div>
<div class="step" role="listitem"><span class="step__dot">2</span><span class="step__label">Shipping</span></div>
<div class="line"></div>
<div class="step" role="listitem"><span class="step__dot">3</span><span class="step__label">Payment</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 400ms; // 원 하나·선 하나가 채워지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 완료된 원·선 색
@include stage($stage-ink);
.stepper { display: flex; align-items: center; }
.step { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.step__dot {
width: clamp(30px, 9vw, 40px);
height: clamp(30px, 9vw, 40px);
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .14);
color: rgba(255, 255, 255, .72);
font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit;
transform: scale(.85);
}
.step__label { font-weight: 600; font-size: 11px; line-height: 1; font-family: inherit; color: rgba(255, 255, 255, .55); }
.line {
width: clamp(30px, 8vw, 44px);
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, .14);
transform: scaleX(0);
transform-origin: left; // 규칙8: 좌변서 차오름
}
// 원 → 선 → 원 → 선 → 원 순서로, 앞 단계가 끝나야 다음이 채워진다 (동시에 시작하지 않는다)
.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(1) .step__dot { animation-delay: 0ms; }
.stepper.is-demo > :nth-child(2) { animation-delay: 400ms; }
.stepper.is-demo > :nth-child(3) .step__dot { animation-delay: 800ms; }
.stepper.is-demo > :nth-child(4) { animation-delay: 1200ms; }
.stepper.is-demo > :nth-child(5) .step__dot { animation-delay: 1600ms; }
@keyframes dot-fill {
from { background: rgba(255, 255, 255, .14); color: rgba(255, 255, 255, .72); transform: scale(.85); }
to { background: $color; color: $stage-ink; transform: scale(1); }
}
@keyframes line-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@media (prefers-reduced-motion: reduce) {
.stepper.is-demo .step__dot,
.stepper.is-demo .line { animation: none; background: $color; transform: none; }
.stepper.is-demo .step__dot { color: $stage-ink; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stepper {
display: flex;
align-items: center;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.step__dot {
width: clamp(30px, 9vw, 40px);
height: clamp(30px, 9vw, 40px);
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.72);
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
transform: scale(0.85);
}
.step__label {
font-weight: 600;
font-size: 11px;
line-height: 1;
font-family: inherit;
color: rgba(255, 255, 255, 0.55);
}
.line {
width: clamp(30px, 8vw, 44px);
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.14);
transform: scaleX(0);
transform-origin: left;
}
.stepper.is-demo .step__dot {
animation: dot-fill 400ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.stepper.is-demo .line {
animation: line-grow 400ms linear both;
}
.stepper.is-demo > :nth-child(1) .step__dot {
animation-delay: 0ms;
}
.stepper.is-demo > :nth-child(2) {
animation-delay: 400ms;
}
.stepper.is-demo > :nth-child(3) .step__dot {
animation-delay: 800ms;
}
.stepper.is-demo > :nth-child(4) {
animation-delay: 1200ms;
}
.stepper.is-demo > :nth-child(5) .step__dot {
animation-delay: 1600ms;
}
@keyframes dot-fill {
from {
background: rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.72);
transform: scale(0.85);
}
to {
background: #fff7e6;
color: #17141a;
transform: scale(1);
}
}
@keyframes line-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stepper.is-demo .step__dot,
.stepper.is-demo .line {
animation: none;
background: #fff7e6;
transform: none;
}
.stepper.is-demo .step__dot {
color: #17141a;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./StepperProgress.module.scss";
export type StepperProgressProps = {
/** 완료된 단계 수 (0~labels.length) */
step: number;
labels?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function StepperProgress({
step,
labels = ["Info", "Shipping", "Payment"],
duration = 400,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: StepperProgressProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const nodes: ReactNode[] = [];
labels.forEach((l, i) => {
if (i > 0) {
nodes.push(<span key={`line-${i}`} className={i <= step - 1 ? `${styles.line} ${styles.on}` : styles.line} />);
}
nodes.push(
<div key={`step-${i}`} className={styles.step} role="listitem">
<span className={i < step ? `${styles.dot} ${styles.on}` : styles.dot}>{i + 1}</span>
<span className={styles.label}>{l}</span>
</div>
);
});
return (
<div className={styles.stepper} style={vars} role="list" aria-label="progress steps">
{nodes}
</div>
);
}08 · Stepper progress — Measured area 2.911% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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); }
}
09Battery-shape gauge
How it works
The fill inside a battery-shaped outline scales through scaleX the same way item 01's bar does, but its color shifts from a full-charge tone through yellow down to a warning red as the level drops. All of that runs off the same --percent value across one set of keyframe stops.
Where it fits
Below a set point, the whole outline gets a slow, gentle pulse to pull the eye toward it.

<main class="stage">
<div class="battery" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="18" aria-label="Battery level">
<div class="battery__cell is-demo">
<div class="battery__fill"></div>
</div>
<div class="battery__nub"></div>
</div>
<p class="cap">Battery level</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 100 → 낮음까지 소진되는 시간
$easing: linear;
$color: $subject-cream; // 가득 찼을 때 색
@include stage($stage-ink);
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 100;
}
.battery { display: flex; align-items: center; }
.battery__cell {
width: 70px;
height: 32px;
border: 3px solid rgba(255, 255, 255, .4);
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
overflow: hidden;
}
.battery__nub { width: 4px; height: 14px; background: rgba(255, 255, 255, .4); border-radius: 0 3px 3px 0; }
.battery__fill {
--percent: 100;
height: 100%;
border-radius: 2px;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(calc(var(--percent) / 100));
animation: drain $duration $easing infinite;
}
// 남은 양이 줄수록 색이 임계값마다 바뀐다 (초록 대신 브랜드 색 → 경고색, 토큰 밖 hex 를 새로 쓰지 않는다)
@keyframes drain {
0% { --percent: 100; background: #{$color}; }
55% { --percent: 35; background: #{$stage-yellow}; }
100% { --percent: 8; background: #{$error}; }
}
// 잔량이 낮아지는 마지막 구간에만 테두리가 살짝 깜빡인다
.battery__cell.is-demo { animation: pulse .6s ease-in-out infinite; animation-delay: 1.4s; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
.cap {
margin: 14px 0 0;
color: rgba(255, 255, 255, .75);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.battery__fill { animation: none; --percent: 18; background: #{$error}; }
.battery__cell.is-demo { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 100;
}
.battery {
display: flex;
align-items: center;
}
.battery__cell {
width: 70px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.4);
border-radius: 6px;
padding: 3px;
box-sizing: border-box;
overflow: hidden;
}
.battery__nub {
width: 4px;
height: 14px;
background: rgba(255, 255, 255, 0.4);
border-radius: 0 3px 3px 0;
}
.battery__fill {
--percent: 100;
height: 100%;
border-radius: 2px;
background: #fff7e6;
transform-origin: left center;
transform: scaleX(calc(var(--percent) / 100));
animation: drain 2s linear infinite;
}
@keyframes drain {
0% {
--percent: 100;
background: #fff7e6;
}
55% {
--percent: 35;
background: #ffd23f;
}
100% {
--percent: 8;
background: #c2361a;
}
}
.battery__cell.is-demo {
animation: pulse 0.6s ease-in-out infinite;
animation-delay: 1.4s;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.cap {
margin: 14px 0 0;
color: rgba(255, 255, 255, 0.75);
font: 600 clamp(12px, 3.2vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.battery__fill {
animation: none;
--percent: 18;
background: #c2361a;
}
.battery__cell.is-demo {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./BatteryGauge.module.scss";
export type BatteryGaugeProps = {
/** 0~100 */
percent: number;
duration?: number;
easing?: string;
colorFull?: string;
colorMid?: string;
colorLow?: string;
label?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--percent"?: number;
"--fill"?: string;
};
export default function BatteryGauge({
percent,
duration = 600,
easing = "linear",
colorFull = "#fff7e6",
colorMid = "#ffd23f",
colorLow = "#c2361a",
label,
}: BatteryGaugeProps) {
const p = Math.max(0, Math.min(100, percent));
const fill = p > 55 ? colorFull : p > 20 ? colorMid : colorLow;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--percent": p, "--fill": fill };
return (
<div
className={p <= 15 ? `${styles.battery} ${styles.low}` : styles.battery}
style={vars}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={p}
aria-label={label}
>
<div className={styles.cell}>
<div className={styles.fill} />
</div>
<div className={styles.nub} />
</div>
);
}09 · Battery-shape gauge — Measured area 1.205% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 100;
}
.battery__fill {
--percent: 100;
transform-origin: left center;
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}; }
}
.battery__cell.is-demo { animation: pulse .6s ease-in-out infinite; animation-delay: 1.4s; }
Where it breaks — the trap
Two things actually went wrong while building these nine. The first sits in item 06: the two overlapping segments are positioned absolutely with nothing telling them how wide to be.
A box with no width
An absolutely positioned box with no declared width or inset collapses to the size of its own content — an empty span has none, so it shrinks to zero. scaleX() scales that zero right along with it, and nothing shows up no matter how large the transform gets. Setting inset: 0 before any transform touches the segment is what actually gives scaleX() a box worth scaling.
Which way inherits goes
The second trap is inherits on @property, and it only bites items where a parent animates --percent while a different element reads it. Items 04 and 07 each animate the property on a parent — .gauge, .track — while a child, a needle or a label bubble, pulls the same value back out. Both need inherits: true, or that child freezes at the property's initial-value forever, no matter what the parent is doing. Items 03 and 09 apply --percent straight to the element painting with it, so inherits: false is the correct call there instead. Registering the wrong one either way fails without a console warning. MDN's @property reference covers the inherits descriptor directly. The same reference lists Chrome and Edge 85, Safari 16.4, and Firefox 128 as the versions that actually run any of this. Everything below those just paints the final value with no animation at all, since a custom property with unregistered syntax can't be interpolated. conic-gradient() itself needs far less: caniuse's conic-gradient table puts support at Chrome 69, Safari 12.1, and Firefox 83.
That same parent-animates-it, child-reads-it split turns up in CSS tab switch animations, where a sliding underline has to read a position the tab list owns rather than one of its own.
What's in the zip
Every file in the zip already has the right inherits value picked for its own structure, unlocked with betrch52 entered precisely as it's printed in this paragraph, capitalization and all.
Accessibility
What each one reports
All nine except 08 carry role="progressbar", but they don't all carry the same value attributes with it. 01 through 05, 07, and 09 add aria-valuenow because a real number sits behind each of them — 05's is just capped with aria-valuemax="5" instead of 100, since five lit blocks is genuinely what "done" means there, not a percentage. Item 06 has the role and an aria-label alone, with none of the three value attributes at all — the WAI-ARIA authoring practices allow dropping aria-valuenow, and by extension the min/max pair, on a progressbar with no determinate value to report, rather than inventing a number nobody actually has. Item 08 skips the progressbar role entirely: three named steps aren't one running value, so it's marked role="list" with role="listitem" on each stop instead.
<div class="track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="Uploading">
<div class="bar"></div>
</div>
Reduced motion
Under prefers-reduced-motion: reduce, every item that fills toward a value jumps straight to that value instead of easing there. 01's bar sits at 76% with no sweep, and 03's ring shows its final zone color with no color transition along the way. Item 06 is the one exception worth a second look: because it never had a real value to jump to, reduced motion just stops the two segments in place. That leaves "no motion" and "no progress shown" looking identical there. MDN's prefers-reduced-motion page documents the operating-system settings behind that media query.
The rest of this project's loading and status demos are gathered at the CSS catalog, while the about section covers what this project actually is and isn't.
FAQ
Does a css progress bar circle need JavaScript to update?
No, not for the fill itself. 01, 03, 04, 07, and 09 drive their value through a @property-registered --percent custom property animated with @keyframes, so the browser interpolates between values on its own. The rest — 02's stripes, 05's segments, 06's overlapping bars, and 08's stepper — reach the same look with plain @keyframes and fixed values instead, no custom property involved. Wiring any of them to something real — an upload's actual byte count, a live sensor reading — still needs a script from outside, but nothing about the animation itself depends on one.
Why does the css progress circle animation in item 03 change color instead of filling further?
Because the value it represents isn't meant to be read as a precise number in the first place — it's a resource gauge where "how close to the danger zone" matters more than the exact figure. Swapping the ring's own color at fixed thresholds through a second --zone property lets someone glance at it and register "fine," "watch it," or "act now" without stopping to read a digit at all. When the exact figure does have to be legible, the SVG progress ring widgets carry item 03's shape over to a stroke-dasharray ring that counts the number up beside it.
Can I make a half-circle gauge like item 04 go past 180 degrees?
Yes — the 1.8deg multiplier in both the needle's rotation and the conic-gradient's angle is what maps 0–100 onto exactly 180 degrees. Raising that number to, say, 2.4deg stretches the same dial across 240 degrees instead. The container's height: 200% trick that crops a full circle down to a visible arc works the same way regardless of how much of that circle the gauge actually uses.









