04Perimeter countdown — a button whose surrounding perimeter progress value depletes over a set duration
The progress value of the perimeter surrounding the button decreases from its starting value to zero over a set duration, and once that value reaches zero it moves to a set action.
Best forA limited-time discount button, an auto-advance button
R=38CIRCstartCountdown

<main class="stage">
<div class="ctaprc is-demo" id="ctaprc">
<div class="ctaprc__panel">
<div class="ctaprc__wrap">
<svg class="ctaprc__ring" viewBox="0 0 84 84" id="ctaprcSvg">
<circle class="ctaprc__track" cx="42" cy="42" r="38"></circle>
<circle class="ctaprc__bar" id="ctaprcBar" cx="42" cy="42" r="38"></circle>
</svg>
<button class="ctaprc__btn" id="ctaprcBtn" type="button">Next step</button>
</div>
<span class="ctaprc__hint">Click and it automatically moves to the next step after 1.5 seconds</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(둘레·dashoffset 산술)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.ctaprc__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.ctaprc__wrap {
position: relative;
width: 92px;
height: 92px;
display: grid;
place-items: center;
}
.ctaprc__ring {
position: absolute;
inset: 0;
width: 92px;
height: 92px;
transform: rotate(-90deg);
}
.ctaprc__track {
fill: none;
stroke: rgba($subject-ink, .12);
stroke-width: 6;
}
.ctaprc__bar {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 6;
stroke-linecap: round;
}
.ctaprc__btn {
position: relative;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
width: 68px;
height: 68px;
cursor: pointer;
}
.ctaprc__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaprc__panel { padding: $sp-4; gap: $sp-3; }
.ctaprc__wrap { width: 76px; height: 76px; }
.ctaprc__ring { width: 76px; height: 76px; }
.ctaprc__btn { width: 56px; height: 56px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ctaprc__ring { transition: none !important; }
}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);
}
.stage {
position: relative;
}
.ctaprc__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.ctaprc__wrap {
position: relative;
width: 92px;
height: 92px;
display: grid;
place-items: center;
}
.ctaprc__ring {
position: absolute;
inset: 0;
width: 92px;
height: 92px;
transform: rotate(-90deg);
}
.ctaprc__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 6;
}
.ctaprc__bar {
fill: none;
stroke: #0a4fe8;
stroke-width: 6;
stroke-linecap: round;
}
.ctaprc__btn {
position: relative;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
width: 68px;
height: 68px;
cursor: pointer;
}
.ctaprc__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.ctaprc__panel {
padding: 16px;
gap: 12px;
}
.ctaprc__wrap {
width: 76px;
height: 76px;
}
.ctaprc__ring {
width: 76px;
height: 76px;
}
.ctaprc__btn {
width: 56px;
height: 56px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ctaprc__ring {
transition: none !important;
}
}(function () {
var root = document.getElementById('ctaprc');
var btn = document.getElementById('ctaprcBtn');
var bar = document.getElementById('ctaprcBar');
// 계산 겹: 반지름(r)으로 둘레(circumference = 2*PI*r)를 구하고, 남은 시간 비율(progress)을
// stroke-dashoffset = circumference * (1 - progress) 산술로 매 프레임 다시 그린다.
var R = 38;
var CIRC = 2 * Math.PI * R;
bar.style.strokeDasharray = CIRC.toFixed(2);
var DURATION = 1500;
var raf = null, startT = 0, running = false;
function frame(now) {
var t = now - startT;
var progress = Math.min(1, t / DURATION);
var offset = CIRC * (1 - progress);
bar.style.strokeDashoffset = offset.toFixed(2);
if (progress < 1) {
raf = requestAnimationFrame(frame);
} else {
running = false; raf = null;
btn.textContent = 'Completed';
setTimeout(function () { btn.textContent = 'Next step'; bar.style.strokeDashoffset = CIRC.toFixed(2); }, 500);
}
}
function startCountdown() {
if (running) { return; }
running = true;
startT = performance.now();
raf = requestAnimationFrame(frame);
}
function wireReal() {
btn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); startCountdown(); });
}
wireReal();
bar.style.strokeDashoffset = CIRC.toFixed(2);
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { startCountdown(); autoplayTimer = setInterval(startCountdown, 2000); }
})();import { useEffect, useRef } from "react";
import styles from "./CtaBtnProgressRingCountdown.module.scss";
export type CtaBtnProgressRingCountdownProps = {
color?: string;
};
export default function CtaBtnProgressRingCountdown({
color = "#0a4fe8",
}: CtaBtnProgressRingCountdownProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector<HTMLButtonElement>(`.${styles.btn}`);
const bar = root.querySelector<SVGCircleElement>(`.${styles.bar}`);
if (!btn || !bar) return;
// 계산 겹: 반지름(r)으로 둘레(circumference = 2*PI*r)를 구하고, 남은 시간 비율(progress)을
// stroke-dashoffset = circumference * (1 - progress) 산술로 매 프레임 다시 그린다.
const R = 38;
const CIRC = 2 * Math.PI * R;
bar.style.strokeDasharray = CIRC.toFixed(2);
bar.style.strokeDashoffset = CIRC.toFixed(2);
const DURATION = 1500;
let raf: number | null = null;
let startT = 0;
let running = false;
function frame(now: number) {
const t = now - startT;
const progress = Math.min(1, t / DURATION);
const offset = CIRC * (1 - progress);
bar!.style.strokeDashoffset = offset.toFixed(2);
if (progress < 1) {
raf = requestAnimationFrame(frame);
} else {
running = false; raf = null;
btn!.textContent = "완료됨";
window.setTimeout(() => { btn!.textContent = "다음 단계"; bar!.style.strokeDashoffset = CIRC.toFixed(2); }, 500);
}
}
function startCountdown() {
if (running) return;
running = true;
startT = performance.now();
raf = requestAnimationFrame(frame);
}
function onClick() { stopAutoplay(); startCountdown(); }
btn.addEventListener("click", onClick);
let autoplayTimer: number | null = null;
function stopAutoplay() { if (autoplayTimer !== null) { window.clearInterval(autoplayTimer); autoplayTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { startCountdown(); autoplayTimer = window.setInterval(startCountdown, 2000); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.panel}>
<div className={styles.wrap}>
<svg className={styles.ring} viewBox="0 0 84 84">
<circle className={styles.track} cx={42} cy={42} r={38} />
<circle className={styles.bar} cx={42} cy={42} r={38} style={{ stroke: color }} />
</svg>
<button className={styles.btn} type="button" style={{ background: color }}>다음 단계</button>
</div>
<span className={styles.hint}>클릭하면 1.5초 뒤 자동으로 다음 단계로 넘어갑니다</span>
</div>
</div>
);
}04 Perimeter countdown — a button whose surrounding perimeter progress value depletes over a set duration — Measured area 1.598% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · CTA Button Design: 9 Interaction Patterns
The post this demo lives in
CTA Button Design: 9 Interaction PatternsOne shadow length value grows and shrinks with hover state, one perimeter progress value depletes to zero over time, and See all demos in this post
If you like this effect - similar demos
Buttons09 Disabled ripple block — pressing a button whose disabled value is on stops the ripple-spread value midway and answers with a shake value insteadButton Group CSS: 9 Adjoined Patterns
Buttons07 Progress Button That Piles Up Like Sand With a Progress ValueButton Click Effect CSS: 9 Canvas Pixel Patterns
Buttons01 Button Whose Glow Widens With the Distance ValueCSS Button Hover Effects — 9 Proximity Patterns