02Saved-flag flash — the completion value flips to one the instant a save finishes, then falls back to zero and disappears
The instant a save finishes the completion value flips to one and briefly appears, then after a set time returns to zero and disappears.
Best forA note app's save indicator, a settings screen's instant-save notice
DRAW_MSHOLD_MSGAP_MS

<main class="stage">
<div class="ssc is-demo" id="ssc">
<div class="ssc__badge" id="sscBadge">
<svg class="ssc__ring" viewBox="0 0 40 40" width="40" height="40">
<circle class="ssc__circle" cx="20" cy="20" r="17"></circle>
<polyline class="ssc__check" id="sscCheck" points="12,21 17,26 28,13"></polyline>
</svg>
</div>
<span class="ssc__label" id="sscLabel">Save complete</span>
<button class="ssc__btn" id="sscBtn" type="button">Save changes</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: SVG 경로 실측 길이(getTotalLength) 기준 stroke-dashoffset 산술로 체크를 그린다 — index.html 참조
@include stage($stage-paper);
.ssc {
display: grid;
justify-items: center;
gap: $sp-3;
}
.ssc__badge {
width: 56px;
height: 56px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(.7);
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.ssc__badge--show {
opacity: 1;
transform: scale(1);
}
.ssc__circle {
fill: $subject-mint;
opacity: .16;
}
.ssc__check {
fill: none;
stroke: $subject-mint;
stroke-width: 3.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.ssc__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
}
.ssc__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) {
.ssc__badge { width: 44px; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.ssc__badge { transition: none; }
.ssc__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);
}
.ssc {
display: grid;
justify-items: center;
gap: 12px;
}
.ssc__badge {
width: 56px;
height: 56px;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.7);
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ssc__badge--show {
opacity: 1;
transform: scale(1);
}
.ssc__circle {
fill: #4cd4a6;
opacity: 0.16;
}
.ssc__check {
fill: none;
stroke: #4cd4a6;
stroke-width: 3.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.ssc__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
}
.ssc__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;
}
.ssc__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.ssc__badge {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.ssc__badge {
transition: none;
}
.ssc__btn {
transition: none;
}
}(function () {
var root = document.getElementById('ssc');
var btn = document.getElementById('sscBtn');
var badge = document.getElementById('sscBadge');
var check = document.getElementById('sscCheck');
// 상수: 체크 선이 그려지는 시간 · 다 그려진 뒤 머무는 시간 · 사라지는 시간 · 다음 저장까지 간격
var DRAW_MS = 300;
var HOLD_MS = 380;
var FADE_MS = 200;
var GAP_MS = 260;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: getTotalLength() 로 실제 경로 길이를 재고, stroke-dashoffset = 길이×(1−t) 로 그려 나간다
var len = check.getTotalLength();
check.style.strokeDasharray = String(len);
check.style.strokeDashoffset = String(len);
var timer = null;
function draw(cb) {
if (reduce) { check.style.strokeDashoffset = '0'; cb(); return; }
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / DRAW_MS);
check.style.strokeDashoffset = (len * (1 - t)).toFixed(2);
if (t < 1) requestAnimationFrame(frame); else cb();
}
requestAnimationFrame(frame);
}
function cycle() {
badge.classList.add('ssc__badge--show');
draw(function () {
timer = setTimeout(function () {
badge.classList.remove('ssc__badge--show');
timer = setTimeout(function () {
check.style.strokeDashoffset = String(len);
timer = setTimeout(cycle, GAP_MS);
}, reduce ? 0 : FADE_MS);
}, HOLD_MS);
});
}
cycle();
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
check.style.strokeDashoffset = String(len);
badge.classList.remove('ssc__badge--show');
requestAnimationFrame(cycle);
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusSavedCheck.module.scss";
type Box<T> = { current: T };
export default function SaveStatusSavedCheck() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const badge = root.querySelector(`.${styles.badge}`) as HTMLElement | null;
const check = root.querySelector(`.${styles.check}`) as SVGPolylineElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!badge || !check || !btn) return;
// 상수: 체크 선이 그려지는 시간 · 다 그려진 뒤 머무는 시간 · 사라지는 시간 · 다음 저장까지 간격
const DRAW_MS = 300;
const HOLD_MS = 380;
const FADE_MS = 200;
const GAP_MS = 260;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹: getTotalLength() 로 실제 경로 길이를 재고, stroke-dashoffset = 길이×(1−t) 로 그려 나간다
const len = check.getTotalLength();
check.style.strokeDasharray = String(len);
check.style.strokeDashoffset = String(len);
let timer = 0;
let rafId = 0;
let stopped = false;
function draw(cb: () => void) {
if (reduce) { check!.style.strokeDashoffset = "0"; cb(); return; }
let start: number | null = null;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / DRAW_MS);
check!.style.strokeDashoffset = (len * (1 - t)).toFixed(2);
if (t < 1) rafId = requestAnimationFrame(frame); else cb();
}
rafId = requestAnimationFrame(frame);
}
function cycle() {
if (stopped) return;
badge!.classList.add(styles["badge--show"]);
draw(() => {
timer = window.setTimeout(() => {
badge!.classList.remove(styles["badge--show"]);
timer = window.setTimeout(() => {
check!.style.strokeDashoffset = String(len);
timer = window.setTimeout(cycle, GAP_MS);
}, reduce ? 0 : FADE_MS);
}, HOLD_MS);
});
}
cycle();
function onClick() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
check!.style.strokeDashoffset = String(len);
badge!.classList.remove(styles["badge--show"]);
rafId = requestAnimationFrame(cycle);
}
btn.addEventListener("click", onClick);
return () => {
stopped = true;
window.clearTimeout(timer);
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ssc} ${styles["is-demo"]}`}>
<div className={styles.badge}>
<svg className={styles.ring} viewBox="0 0 40 40" width="40" height="40">
<circle className={styles.circle} cx="20" cy="20" r="17" />
<polyline className={styles.check} points="12,21 17,26 28,13" />
</svg>
</div>
<span className={styles.label}>저장 완료</span>
<button className={styles.btn} type="button">변경 저장</button>
</div>
);
}02 Saved-flag flash — the completion value flips to one the instant a save finishes, then falls back to zero and disappears — Measured area 0.64% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · Autosave Indicator: 9 Save-Status Patterns
The post this demo lives in
Autosave Indicator: 9 Save-Status PatternsIn this autosave collection: One of nine repeats a saving label with dots, another turns elapsed time into a minutes-ago See all demos in this post


