02진행률 곡선으로 차오르는 테두리 링
버튼 둘레의 링이 진행률만큼 채워지고, 진행률은 일정하지 않고 계산한 곡선을 따라 균일한 진행보다 앞서 나간다.
쓰임대용량 파일 받기 안내
CURVE_POWRING_RRUN_MS

<main class="stage">
<div class="rg is-demo" id="rgRoot">
<div class="rg__wrap" id="rgBar" role="progressbar" aria-labelledby="rgLabel" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%">
<svg class="rg__svg" viewBox="0 0 232 96" aria-hidden="true">
<rect class="rg__ghost" id="rgGhost" x="11" y="11" width="210" height="74" pathLength="100"/>
<rect class="rg__ring" id="rgRing" x="3" y="3" width="226" height="90" pathLength="100"/>
</svg>
<button class="rg__btn" id="rgBtn" type="button"><span id="rgLabel">내려받기</span><b id="rgPct">0%</b></button>
</div>
<p class="rg__meta"><span class="rg__tag">POW</span> <b id="rgPow">2</b> <span class="rg__tag">LEAD</span> <b id="rgLead">0</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 링 길이는 SVG stroke-dashoffset, 값은 JS 가 곡선으로 계산한다. 버튼 안 채움도 같은 진행률(--f)을 쓴다.
$ring-face: $subject-sky;
$ring-fill: $subject-mint;
@include stage($stage-ink);
.rg {
width: min(100%, 280px);
display: grid;
gap: $sp-3;
justify-items: center;
}
.rg__wrap {
position: relative;
width: 232px;
max-width: 100%;
aspect-ratio: 232 / 96;
}
.rg__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.rg__ring, .rg__ghost {
fill: none;
stroke-dasharray: 100 100;
stroke-dashoffset: 100;
stroke-linecap: round;
}
.rg__ring { stroke: $subject-mint; stroke-width: 5; }
.rg__ghost { stroke: $stage-paper; stroke-width: 2; }
.rg__btn {
--f: 0;
position: absolute;
left: 7%;
right: 7%;
top: 21%;
bottom: 21%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 $sp-4;
border: 0;
border-radius: $r-control;
background: linear-gradient(90deg, #{$ring-fill} calc(var(--f) * 100%), #{$ring-face} calc(var(--f) * 100%));
color: $subject-ink;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.rg__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 4px; }
.rg__btn b { font-variant-numeric: tabular-nums; }
.rg__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: $sp-2;
font-size: 12px;
line-height: 16px;
color: $stage-paper;
}
.rg__tag { font-weight: 700; letter-spacing: .08em; }
.rg__meta b { min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.rg__btn { transition: 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);
}
.rg {
width: min(100%, 280px);
display: grid;
gap: 12px;
justify-items: center;
}
.rg__wrap {
position: relative;
width: 232px;
max-width: 100%;
aspect-ratio: 232/96;
}
.rg__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.rg__ring, .rg__ghost {
fill: none;
stroke-dasharray: 100 100;
stroke-dashoffset: 100;
stroke-linecap: round;
}
.rg__ring {
stroke: #4cd4a6;
stroke-width: 5;
}
.rg__ghost {
stroke: #ffffff;
stroke-width: 2;
}
.rg__btn {
--f: 0;
position: absolute;
left: 7%;
right: 7%;
top: 21%;
bottom: 21%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: linear-gradient(90deg, #4cd4a6 calc(var(--f) * 100%), #6ec8ff calc(var(--f) * 100%));
color: #17141a;
font-family: inherit;
font-size: 16px;
font-weight: 700;
line-height: 24px;
cursor: pointer;
}
.rg__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 4px;
}
.rg__btn b {
font-variant-numeric: tabular-nums;
}
.rg__meta {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 16px;
color: #ffffff;
}
.rg__tag {
font-weight: 700;
letter-spacing: 0.08em;
}
.rg__meta b {
min-width: 3ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.rg__btn {
transition: none;
}
}(function () {
// 기법: 진행률을 시간에 비례시키지 않고 곡선 p = 1 - (1 - u)^CURVE_POW 로 계산한다 (u 는 시간 비율).
// 굵은 링 = 곡선 진행률, 가는 안쪽 링 = 같은 시간의 균일 진행률. 둘의 차이(LEAD)가 곡선이 앞서는 만큼이다.
var CURVE_POW = 2;
var RING_R = 16;
var RUN_MS = 1400;
var START_MS = 200;
var CYCLE_MS = 2000;
var root = document.getElementById('rgRoot');
var bar = document.getElementById('rgBar');
var btn = document.getElementById('rgBtn');
var ring = document.getElementById('rgRing');
var ghost = document.getElementById('rgGhost');
var labelEl = document.getElementById('rgLabel');
var pctEl = document.getElementById('rgPct');
var powEl = document.getElementById('rgPow');
var leadEl = document.getElementById('rgLead');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var clock = 0;
var runStart = null;
var t0 = null;
var lastTs = null;
function ringCurve(u) { return 1 - Math.pow(1 - u, CURVE_POW); }
function stopDemo() { root.classList.remove('is-demo'); }
ring.setAttribute('rx', String(RING_R));
ghost.setAttribute('rx', String(RING_R - 6));
powEl.textContent = String(CURVE_POW);
function startOf(now) {
if (root.classList.contains('is-demo')) {
return now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS;
}
return runStart === 'done' ? null : runStart;
}
function render(now) {
var s = startOf(now);
var u = s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
if (reduce && runStart === 'done') u = 1;
var p = ringCurve(u);
var pct = Math.round(p * 100);
ring.style.strokeDashoffset = String((100 - p * 100).toFixed(2));
ghost.style.strokeDashoffset = String((100 - u * 100).toFixed(2));
ring.style.opacity = p < 0.004 ? '0' : '1';
ghost.style.opacity = u < 0.004 ? '0' : '1';
btn.style.setProperty('--f', p.toFixed(3));
pctEl.textContent = pct + '%';
leadEl.textContent = String(Math.round((p - u) * 100));
labelEl.textContent = u >= 1 ? '완료' : (u > 0 ? '받는 중' : '내려받기');
bar.setAttribute('aria-valuenow', String(pct));
bar.setAttribute('aria-valuetext', pct + '%');
}
function activate() {
stopDemo();
if (reduce) { runStart = 'done'; render(clock); return; }
if (runStart !== null && runStart !== 'done' && clock - runStart < RUN_MS) return;
runStart = clock;
}
btn.addEventListener('click', activate);
function tick(ts) {
if (t0 === null) t0 = ts;
clock = ts - t0;
lastTs = ts;
render(clock);
requestAnimationFrame(tick);
}
if (reduce) {
stopDemo();
render(0);
} else {
requestAnimationFrame(tick);
}
})();// RingProgressButton — vanilla 판(02-dlbtn-ring-progress-curve) 의 React 포트.
// 진행률은 곡선 p = 1 - (1 - u)^CURVE_POW 로 계산한다. 굵은 링 = 곡선, 가는 링 = 같은 시간의 균일 진행률.
import { useEffect, useRef } from "react";
import styles from "./RingProgressButton.module.scss";
const CURVE_POW = 2;
const RING_R = 16;
const RUN_MS = 1400;
const START_MS = 200;
const CYCLE_MS = 2000;
type Start = number | "done" | null;
function ringCurve(u: number): number {
return 1 - Math.pow(1 - u, CURVE_POW);
}
export default function RingProgressButton() {
const barRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
const ringRef = useRef<SVGRectElement | null>(null);
const ghostRef = useRef<SVGRectElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
const pctRef = useRef<HTMLElement | null>(null);
const leadRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const bar = barRef.current;
const btn = btnRef.current;
const ring = ringRef.current;
const ghost = ghostRef.current;
const labelEl = labelRef.current;
const pctEl = pctRef.current;
const leadEl = leadRef.current;
if (!bar || !btn || !ring || !ghost || !labelEl || !pctEl || !leadEl) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let clock = 0;
let runStart: Start = null;
let t0: number | null = null;
let raf = 0;
ring.setAttribute("rx", String(RING_R));
ghost.setAttribute("rx", String(RING_R - 6));
const startOf = (now: number): number | null => {
if (demo) return now < START_MS ? null : START_MS + Math.floor((now - START_MS) / CYCLE_MS) * CYCLE_MS;
return runStart === "done" ? null : runStart;
};
const render = (now: number): void => {
const s = startOf(now);
let u = s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
if (reduce && runStart === "done") u = 1;
const p = ringCurve(u);
const pct = Math.round(p * 100);
ring.style.strokeDashoffset = (100 - p * 100).toFixed(2);
ghost.style.strokeDashoffset = (100 - u * 100).toFixed(2);
ring.style.opacity = p < 0.004 ? "0" : "1";
ghost.style.opacity = u < 0.004 ? "0" : "1";
btn.style.setProperty("--f", p.toFixed(3));
pctEl.textContent = pct + "%";
leadEl.textContent = String(Math.round((p - u) * 100));
labelEl.textContent = u >= 1 ? "완료" : u > 0 ? "받는 중" : "내려받기";
bar.setAttribute("aria-valuenow", String(pct));
bar.setAttribute("aria-valuetext", pct + "%");
};
const activate = (): void => {
demo = false;
if (reduce) { runStart = "done"; render(clock); return; }
if (typeof runStart === "number" && clock - runStart < RUN_MS) return;
runStart = clock;
};
btn.addEventListener("click", activate);
function tick(ts: number): void {
if (t0 === null) t0 = ts;
clock = ts - t0;
render(clock);
raf = requestAnimationFrame(tick);
}
if (reduce) render(0);
else raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
btn.removeEventListener("click", activate);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.rg}>
<div className={styles.rg__wrap} ref={barRef} role="progressbar" aria-labelledby="rgLabel" aria-valuemin={0} aria-valuemax={100} aria-valuenow={0} aria-valuetext="0%">
<svg className={styles.rg__svg} viewBox="0 0 232 96" aria-hidden="true">
<rect className={styles.rg__ghost} ref={ghostRef} x="11" y="11" width="210" height="74" pathLength="100" />
<rect className={styles.rg__ring} ref={ringRef} x="3" y="3" width="226" height="90" pathLength="100" />
</svg>
<button className={styles.rg__btn} type="button" ref={btnRef}>
<span id="rgLabel" ref={labelRef}>내려받기</span>
<b ref={pctRef}>0%</b>
</button>
</div>
<p className={styles.rg__meta}>
<span className={styles.rg__tag}>POW</span> <b>{CURVE_POW}</b> <span className={styles.rg__tag}>LEAD</span> <b ref={leadRef}>0</b>
</p>
</div>
</main>
);
}02 진행률 곡선으로 차오르는 테두리 링 — 측정 누적면적 11.07% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · css download button 9가지 — 눌리고 차오르고 파일이 날아가는 버튼
이 데모가 속한 글
css download button 9가지 — 눌리고 차오르고 파일이 날아가는 버튼css download button 을 라벨만 바꾸는 상태 전환으로 만들면 화살표는 뻣뻣하고 진행은 눈금처럼 끊기고 파일이 어디 갔는지 모른다 — 01번·02번·03번이 계산으로 푼다. 받기 버튼 하나를 누름에서 완 이 글의 데모 전부 보기


