05버튼 자신이 진행 막대가 된다
다운로드를 시작하면 버튼 안쪽이 왼쪽부터 차오르고 숫자도 같이 올라가는데, 퍼센트를 등록된 커스텀 속성으로 잡아야 브라우저가 그 값을 보간합니다.
쓰임파일 내려받기·내보내기처럼 몇 초가 걸리는 작업의 버튼
@propertylinear-gradientaria-valuenow

<main class="stage">
<div class="dl is-demo">
<button class="dl__btn" id="dl-btn" type="button" data-state="idle" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span class="dl__fill" aria-hidden="true"></span>
<span class="dl__face" data-face="idle">
<span class="dl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z"/></g></svg></span>
<span class="dl__t">파일 받기</span>
</span>
<span class="dl__face" data-face="busy">
<span class="dl__pct"></span>
</span>
<span class="dl__face" data-face="done">
<span class="dl__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="dl__t">받았습니다</span>
</span>
</button>
<p class="dl__note">버튼 안쪽이 그대로 진행 막대입니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
// 등록하지 않은 커스텀 속성은 브라우저가 글자로만 보고 보간하지 않는다 — 숫자로 등록해야 0 에서 100 으로 흐른다
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: $r-control;
background-color: $subject-cream;
color: $subject-ink;
cursor: pointer;
box-shadow: $shadow-raised;
}
.dl__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// 채움 — 단일 변수 --p 가 그라디언트의 색 경계 자리를 정한다
.dl__fill {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: linear-gradient(90deg, $color 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
opacity: 0;
}
.dl__ic { display: inline-flex; flex: 0 0 auto; }
.dl__ic svg { display: block; }
.dl__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
// 같은 변수를 숫자로도 읽는다 — 카운터에 넣으면 글자가 된다
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-size: 15px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
// 사람이 실제로 누른 경로
.dl:not(.is-demo) .dl__btn[data-state="idle"] .dl__face[data-face="idle"],
.dl:not(.is-demo) .dl__btn[data-state="busy"] .dl__face[data-face="busy"],
.dl:not(.is-demo) .dl__btn[data-state="done"] .dl__face[data-face="done"] { opacity: 1; }
.dl:not(.is-demo) .dl__btn[data-state="busy"] .dl__face[data-face="busy"] { color: $subject-cream; }
.dl:not(.is-demo) .dl__btn[data-state="done"] .dl__fill { background-image: none; background-color: $subject-mint; }
.dl__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: $subject-cream;
}
// ── 자동 루프: 12% 에 시작해 78% 에 100% 를 채우고 82% 에 완료로 끊었다가 88% 에 원복 ──
// --p 는 등속으로 흐르고, 완료 색은 한 프레임에 끊는다 — 한 요소에 두 애니메이션을 겹쳐 건다
.dl.is-demo .dl__btn {
animation-name: dl-p, dl-skin;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dl-p {
0% { --p: 0; }
12% { --p: 0; }
78% { --p: 100; }
100% { --p: 100; }
}
@keyframes dl-skin {
0% { background-color: $subject-cream; }
82% { background-color: $subject-mint; }
88% { background-color: $subject-cream; }
100% { background-color: $subject-cream; }
}
.dl.is-demo .dl__face {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__face[data-face="idle"] { animation-name: dl-idle; }
.dl.is-demo .dl__face[data-face="busy"] { animation-name: dl-busy; }
.dl.is-demo .dl__face[data-face="done"] { animation-name: dl-done; }
@keyframes dl-idle {
0% { opacity: 1; }
12% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes dl-busy {
0% { opacity: 0; color: $subject-ink; }
12% { opacity: 1; color: $subject-ink; }
40% { opacity: 1; color: $subject-cream; }
82% { opacity: 0; color: $subject-cream; }
100% { opacity: 0; color: $subject-cream; }
}
@keyframes dl-done {
0% { opacity: 0; }
82% { opacity: 1; }
88% { opacity: 0; }
100% { opacity: 0; }
}
// 완료로 끊는 동안은 채움 띠를 걷어 버튼 자체 색이 그대로 보이게 한다
.dl.is-demo .dl__fill {
animation-name: dl-veil;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-veil {
0% { opacity: 1; }
82% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@media (max-width: 340px) {
.dl { gap: $sp-2; }
.dl__btn { height: 44px; }
.dl__t { font-size: 13px; line-height: 18px; }
.dl__note { font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__btn,
.dl.is-demo .dl__face,
.dl.is-demo .dl__fill { animation: none !important; }
.dl.is-demo .dl__face[data-face="idle"] { opacity: 1; }
}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 --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.dl {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(280px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dl__btn {
position: relative;
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 48px;
overflow: hidden;
border: 0;
border-radius: 12px;
background-color: #fff7e6;
color: #17141a;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.dl__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.dl__fill {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-image: linear-gradient(90deg, #2f6df6 0 calc(var(--p) * 1%), transparent calc(var(--p) * 1%) 100%);
}
.dl__face {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
}
.dl__ic {
display: inline-flex;
flex: 0 0 auto;
}
.dl__ic svg {
display: block;
}
.dl__t {
font-size: 15px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.dl__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
font-size: 15px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dl:not(.is-demo) .dl__btn[data-state=idle] .dl__face[data-face=idle],
.dl:not(.is-demo) .dl__btn[data-state=busy] .dl__face[data-face=busy],
.dl:not(.is-demo) .dl__btn[data-state=done] .dl__face[data-face=done] {
opacity: 1;
}
.dl:not(.is-demo) .dl__btn[data-state=busy] .dl__face[data-face=busy] {
color: #fff7e6;
}
.dl:not(.is-demo) .dl__btn[data-state=done] .dl__fill {
background-image: none;
background-color: #4cd4a6;
}
.dl__note {
margin: 0;
font-size: 12px;
line-height: 18px;
font-weight: 500;
color: #fff7e6;
}
.dl.is-demo .dl__btn {
animation-name: dl-p, dl-skin;
animation-duration: 2s, 2s;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dl-p {
0% {
--p: 0;
}
12% {
--p: 0;
}
78% {
--p: 100;
}
100% {
--p: 100;
}
}
@keyframes dl-skin {
0% {
background-color: #fff7e6;
}
82% {
background-color: #4cd4a6;
}
88% {
background-color: #fff7e6;
}
100% {
background-color: #fff7e6;
}
}
.dl.is-demo .dl__face {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dl.is-demo .dl__face[data-face=idle] {
animation-name: dl-idle;
}
.dl.is-demo .dl__face[data-face=busy] {
animation-name: dl-busy;
}
.dl.is-demo .dl__face[data-face=done] {
animation-name: dl-done;
}
@keyframes dl-idle {
0% {
opacity: 1;
}
12% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes dl-busy {
0% {
opacity: 0;
color: #17141a;
}
12% {
opacity: 1;
color: #17141a;
}
40% {
opacity: 1;
color: #fff7e6;
}
82% {
opacity: 0;
color: #fff7e6;
}
100% {
opacity: 0;
color: #fff7e6;
}
}
@keyframes dl-done {
0% {
opacity: 0;
}
82% {
opacity: 1;
}
88% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dl.is-demo .dl__fill {
animation-name: dl-veil;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dl-veil {
0% {
opacity: 1;
}
82% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.dl {
gap: 8px;
}
.dl__btn {
height: 44px;
}
.dl__t {
font-size: 13px;
line-height: 18px;
}
.dl__note {
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__btn,
.dl.is-demo .dl__face,
.dl.is-demo .dl__fill {
animation: none !important;
}
.dl.is-demo .dl__face[data-face=idle] {
opacity: 1;
}
}// 실제 동작: 퍼센트를 CSS 변수 --p 하나에만 적는다. 채움 너비도 숫자도 그 변수에서 나온다
var root = document.querySelector('.dl');
var btn = document.getElementById('dl-btn');
var timer = 0;
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (btn.getAttribute('data-state') === 'busy') return;
var p = 0;
btn.setAttribute('data-state', 'busy');
clearInterval(timer);
timer = setInterval(function () {
p = Math.min(100, p + 4);
btn.style.setProperty('--p', p);
btn.setAttribute('aria-valuenow', String(p));
if (p === 100) { clearInterval(timer); btn.setAttribute('data-state', 'done'); }
}, 80);
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DownloadProgressButton.module.scss";
export type DownloadProgressButtonProps = {
label?: string;
doneLabel?: string;
note?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--p"?: string;
};
type State = "idle" | "busy" | "done";
const STEP = 4; // 한 번에 올리는 퍼센트
const TICK = 80; // 올리는 간격(ms)
export default function DownloadProgressButton({
label = "Download",
doneLabel = "Saved",
note = "The inside of the button is the progress bar",
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: DownloadProgressButtonProps) {
const [demo, setDemo] = useState(true);
const [state, setState] = useState<State>("idle");
const [p, setP] = useState(0);
// 퍼센트를 --p 하나에만 적는다 — 채움 너비도 숫자도 그 변수에서 나온다
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const btnVars: Vars = demo ? {} : { "--p": String(p) };
useEffect(() => {
if (state !== "busy") return;
let v = 0;
setP(0);
const id = setInterval(() => {
v = Math.min(100, v + STEP);
setP(v);
if (v === 100) {
clearInterval(id);
setState("done");
}
}, TICK);
return () => clearInterval(id);
}, [state]);
function start() {
setDemo(false);
if (state === "busy") return;
setState("busy");
}
return (
<div className={`${styles.dl} ${demo ? styles.isDemo : ""}`} style={vars}>
<button
className={styles.btn}
type="button"
style={btnVars}
data-state={state}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={demo ? 0 : p}
onClick={start}
>
<span className={styles.fill} aria-hidden="true" />
<span className={styles.face} data-face="idle">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">
<g fill="currentColor">
<path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0m-101.66 5.66a8 8 0 0 0 11.32 0l40-40a8 8 0 0 0-11.32-11.32L136 124.69V32a8 8 0 0 0-16 0v92.69L93.66 98.34a8 8 0 0 0-11.32 11.32Z" />
</g>
</svg>
</span>
<span className={styles.t}>{label}</span>
</span>
<span className={styles.face} data-face="busy">
<span className={styles.pct} />
</span>
<span className={styles.face} data-face="done">
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="20" height="20">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
</span>
<span className={styles.t}>{doneLabel}</span>
</span>
</button>
<p className={styles.note}>{note}</p>
</div>
);
}05 버튼 자신이 진행 막대가 된다 — 측정 누적면적 9.269% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 버튼 클릭 효과 9종 — 누른 다음이 진짜다
이 데모가 속한 글
css 버튼 클릭 효과 9종 — 누른 다음이 진짜다css 버튼 클릭 효과는 누른 순간부터 결과가 나올 때까지 버튼이 스스로 모습을 바꿔 알려 주는 방식입니다. 아홉 개 모두 색만 바뀌는 흉내가 아니라 실제로 잠기고 돌고 되돌아오며, 그 사실을 ARIA 속성 한 곳에 이 글의 데모 전부 보기


