08다음 자동저장 카운트다운 — 다음 저장까지 남은 시간 값이 0을 향해 줄어드는 링 방식
다음 자동저장까지 남은 시간 값이 정해진 값에서 시작해 0을 향해 계속 줄어듭니다.
쓰임정기 자동저장 설정 화면, 긴 입력 폼의 저장 예고
CYCLE_SECRDEMO_TICK_MS

<main class="stage">
<div class="sac is-demo" id="sac">
<div class="sac__ringwrap">
<svg class="sac__ring" viewBox="0 0 100 100" width="120" height="120">
<circle class="sac__track" cx="50" cy="50" r="40"></circle>
<circle class="sac__prog" id="sacProg" cx="50" cy="50" r="40"></circle>
</svg>
<div class="sac__mid">
<span class="sac__num" id="sacNum">10</span>
<span class="sac__unit">초 후 저장</span>
</div>
</div>
<button class="sac__btn" id="sacBtn" type="button">지금 저장</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: stroke-dashoffset = 둘레×(1 − 남은초/전체초) — index.html 참조
@include stage($stage-paper);
.sac {
display: grid;
justify-items: center;
gap: $sp-4;
}
.sac__ringwrap {
position: relative;
display: grid;
place-items: center;
}
.sac__ring { transform: rotate(-90deg); }
.sac__track {
fill: none;
stroke: rgba($stage-ink, .12);
stroke-width: 8;
}
.sac__prog {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 8;
stroke-linecap: round;
transition: stroke $dur-base $ease-out;
}
.sac--saving .sac__prog { stroke: $subject-mint; }
.sac__mid {
position: absolute;
display: grid;
justify-items: center;
}
.sac__num {
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
font-variant-numeric: tabular-nums;
}
.sac__unit {
font: 500 10px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .6);
}
.sac__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.sac__ring { width: 92px; height: 92px; }
.sac__num { font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.sac__prog { transition: none; }
.sac__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sac {
display: grid;
justify-items: center;
gap: 16px;
}
.sac__ringwrap {
position: relative;
display: grid;
place-items: center;
}
.sac__ring {
transform: rotate(-90deg);
}
.sac__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 8;
}
.sac__prog {
fill: none;
stroke: #0a4fe8;
stroke-width: 8;
stroke-linecap: round;
transition: stroke 300ms ease-out;
}
.sac--saving .sac__prog {
stroke: #4cd4a6;
}
.sac__mid {
position: absolute;
display: grid;
justify-items: center;
}
.sac__num {
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.sac__unit {
font: 500 10px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.6);
}
.sac__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sac__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.sac__ring {
width: 92px;
height: 92px;
}
.sac__num {
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.sac__prog {
transition: none;
}
.sac__btn {
transition: none;
}
}(function () {
var root = document.getElementById('sac');
var btn = document.getElementById('sacBtn');
var numEl = document.getElementById('sacNum');
var prog = document.getElementById('sacProg');
// 상수: 다음 저장까지 초 · 링 반지름(SVG 좌표) · 데모 안 "1초"의 실제 ms(가속)
var CYCLE_SEC = 10;
var R = 40;
var DEMO_TICK_MS = 130;
var CIRC = 2 * Math.PI * R;
prog.style.strokeDasharray = CIRC.toFixed(2);
var remaining = CYCLE_SEC;
var timerId = null;
// 계산 겹: 링 오프셋 = 둘레 × (1 − 남은초/전체초) — stroke-dashoffset 산술
function paint() {
numEl.textContent = String(remaining);
var offset = CIRC * (1 - remaining / CYCLE_SEC);
prog.style.strokeDashoffset = offset.toFixed(2);
root.classList.toggle('sac--saving', remaining <= 0);
}
function start() {
remaining = CYCLE_SEC;
root.classList.remove('sac--saving');
paint();
if (timerId) clearInterval(timerId);
timerId = setInterval(function () {
remaining -= 1;
if (remaining <= 0) {
remaining = 0;
paint();
clearInterval(timerId);
timerId = setTimeout(start, 500);
return;
}
paint();
}, DEMO_TICK_MS);
}
start();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timerId) { clearInterval(timerId); clearTimeout(timerId); }
remaining = 0;
paint();
timerId = setTimeout(start, 500);
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusAutosaveCountdown.module.scss";
type Box<T> = { current: T };
export default function SaveStatusAutosaveCountdown() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const numEl = root.querySelector(`.${styles.num}`) as HTMLElement | null;
const prog = root.querySelector(`.${styles.prog}`) as SVGCircleElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!numEl || !prog || !btn) return;
// 상수: 다음 저장까지 초 · 링 반지름(SVG 좌표) · 데모 안 "1초"의 실제 ms(가속)
const CYCLE_SEC = 10;
const R = 40;
const DEMO_TICK_MS = 130;
const CIRC = 2 * Math.PI * R;
prog.style.strokeDasharray = CIRC.toFixed(2);
let remaining = CYCLE_SEC;
let timerId = 0;
// 계산 겹: 링 오프셋 = 둘레 × (1 − 남은초/전체초) — stroke-dashoffset 산술
function paint() {
numEl!.textContent = String(remaining);
const offset = CIRC * (1 - remaining / CYCLE_SEC);
prog!.style.strokeDashoffset = offset.toFixed(2);
root!.classList.toggle(styles["sac--saving"], remaining <= 0);
}
function start() {
remaining = CYCLE_SEC;
root!.classList.remove(styles["sac--saving"]);
paint();
window.clearInterval(timerId);
timerId = window.setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
remaining = 0;
paint();
window.clearInterval(timerId);
timerId = window.setTimeout(start, 500);
return;
}
paint();
}, DEMO_TICK_MS);
}
start();
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearInterval(timerId);
window.clearTimeout(timerId);
remaining = 0;
paint();
timerId = window.setTimeout(start, 500);
}
btn.addEventListener("click", onClick);
return () => {
window.clearInterval(timerId);
window.clearTimeout(timerId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.sac} ${styles["is-demo"]}`}>
<div className={styles.ringwrap}>
<svg className={styles.ring} viewBox="0 0 100 100" width="120" height="120">
<circle className={styles.track} cx="50" cy="50" r="40" />
<circle className={styles.prog} cx="50" cy="50" r="40" />
</svg>
<div className={styles.mid}>
<span className={styles.num}>10</span>
<span className={styles.unit}>초 후 저장</span>
</div>
</div>
<button className={styles.btn} type="button">지금 저장</button>
</div>
);
}08 다음 자동저장 카운트다운 — 다음 저장까지 남은 시간 값이 0을 향해 줄어드는 링 방식 — 측정 누적면적 2.631% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지
이 데모가 속한 글
자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지자동저장 표시는 지금 저장이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 저장 중이라는 문구가 점 세 개와 함께 반복되고, 하나는 마지막 저장 시각 값이 초 단위로 이 글의 데모 전부 보기

