05Last-saved timestamp refresh — the elapsed-time-since-save value recalculates every second and turns into a changing phrase
After a save finishes, the elapsed-time value recalculates every second, and that value keeps turning into a changing minutes-ago phrase.
Best forA collaborative document's last-saved notice, a draft's autosave notice
DEMO_SEC_MSMIN_THRESHOLD_SECRESET_AFTER_SEC

<main class="stage">
<div class="str is-demo" id="str">
<span class="str__dot" id="strDot"></span>
<span class="str__label" id="strLabel">Saved just now</span>
<button class="str__btn" id="strBtn" type="button">Save now</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 경과 초 = (지금−저장시각)÷가속ms, 초·분 환산 문구 + 신선도 투명도 = 1−min(1,경과÷기준) — index.html 참조
@include stage($stage-yellow);
.str {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-5;
border-radius: $r-pill;
background: $stage-ink;
}
.str__dot {
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: $subject-mint;
flex: 0 0 auto;
}
.str__label {
font: 600 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
min-width: 128px;
}
.str__btn {
margin-left: $sp-2;
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-3;
background: $subject-cream;
color: $stage-ink;
font: 700 12px/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) {
.str { padding: $sp-2 $sp-4; flex-wrap: wrap; justify-content: center; }
.str__label { font-size: 12px; min-width: 96px; }
}
@media (prefers-reduced-motion: reduce) {
.str__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.str {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-radius: 999px;
background: #17141a;
}
.str__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #4cd4a6;
flex: 0 0 auto;
}
.str__label {
font: 600 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
min-width: 128px;
}
.str__btn {
margin-left: 8px;
border: 0;
border-radius: 12px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.str__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.str {
padding: 8px 16px;
flex-wrap: wrap;
justify-content: center;
}
.str__label {
font-size: 12px;
min-width: 96px;
}
}
@media (prefers-reduced-motion: reduce) {
.str__btn {
transition: none;
}
}(function () {
var root = document.getElementById('str');
var btn = document.getElementById('strBtn');
var dot = document.getElementById('strDot');
var label = document.getElementById('strLabel');
// 상수: 데모 안에서 "1초"를 표현하는 실제 ms(가속) · 분으로 넘어가는 기준 초 · 리셋되는 기준 초
var DEMO_SEC_MS = 90;
var MIN_THRESHOLD_SEC = 60;
var RESET_AFTER_SEC = 150;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var savedAt = performance.now();
// 계산 겹: 경과 초 = (지금 − 저장시각)÷가속ms, 초·분 환산으로 문구를 만들고 신선도(투명도)는 1 − min(1, 경과÷기준)
function raf(ts) {
var elapsedSec = Math.floor((ts - savedAt) / DEMO_SEC_MS);
if (elapsedSec >= RESET_AFTER_SEC) { savedAt = ts; elapsedSec = 0; }
var phrase;
if (elapsedSec < 5) phrase = 'Saved just now';
else if (elapsedSec < MIN_THRESHOLD_SEC) phrase = elapsedSec + 'sec ago';
else phrase = Math.floor(elapsedSec / 60) + 'min ago';
label.textContent = phrase;
var fresh = 1 - Math.min(1, elapsedSec / RESET_AFTER_SEC);
dot.style.opacity = reduce ? '1' : (0.35 + 0.65 * fresh).toFixed(3);
requestAnimationFrame(raf);
}
requestAnimationFrame(raf);
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
savedAt = performance.now();
label.textContent = 'Saved just now';
dot.style.opacity = '1';
});
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusTimestampRelative.module.scss";
type Box<T> = { current: T };
export default function SaveStatusTimestampRelative() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const dot = root.querySelector(`.${styles.dot}`) as HTMLElement | null;
const label = root.querySelector(`.${styles.label}`) as HTMLElement | null;
const btn = root.querySelector(`.${styles.btn}`) as HTMLElement | null;
if (!dot || !label || !btn) return;
// 상수: 데모 안에서 "1초"를 표현하는 실제 ms(가속) · 분으로 넘어가는 기준 초 · 리셋되는 기준 초
const DEMO_SEC_MS = 90;
const MIN_THRESHOLD_SEC = 60;
const RESET_AFTER_SEC = 150;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let savedAt = performance.now();
let rafId = 0;
// 계산 겹: 경과 초 = (지금 − 저장시각)÷가속ms, 초·분 환산으로 문구를 만들고 신선도(투명도)는 1 − min(1, 경과÷기준)
function raf(ts: number) {
let elapsedSec = Math.floor((ts - savedAt) / DEMO_SEC_MS);
if (elapsedSec >= RESET_AFTER_SEC) { savedAt = ts; elapsedSec = 0; }
let phrase: string;
if (elapsedSec < 5) phrase = "방금 저장됨";
else if (elapsedSec < MIN_THRESHOLD_SEC) phrase = elapsedSec + "초 전 저장됨";
else phrase = Math.floor(elapsedSec / 60) + "분 전 저장됨";
label!.textContent = phrase;
const fresh = 1 - Math.min(1, elapsedSec / RESET_AFTER_SEC);
dot!.style.opacity = reduce ? "1" : (0.35 + 0.65 * fresh).toFixed(3);
rafId = requestAnimationFrame(raf);
}
rafId = requestAnimationFrame(raf);
function onClick() {
root!.classList.remove(styles["is-demo"]);
savedAt = performance.now();
label!.textContent = "방금 저장됨";
dot!.style.opacity = "1";
}
btn.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.str} ${styles["is-demo"]}`}>
<span className={styles.dot} />
<span className={styles.label}>방금 저장됨</span>
<button className={styles.btn} type="button">지금 저장</button>
</div>
);
}05 Last-saved timestamp refresh — the elapsed-time-since-save value recalculates every second and turns into a changing phrase — Measured area 0.822% · moving frames 21/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
If you like this effect - similar demos
Feedback01 Extruded Letters Whose Layer Value Stacks Into DepthCSS Text Shadow Effects — 9 Patterns
Feedback04 Letters That Dodge the Pointer and Spring Back HomeFalling Text Animation: 9 Letters With Physics
Feedback04 A Row-by-Row Roll When the Phrase ChangesDot Matrix Display: 9 Canvas LED Sign Scenes