07저장 충돌 안내 — 같은 항목이 다른 곳에서 먼저 저장되면 안내 값이 나타나 선택을 기다리는 방식
같은 항목이 다른 곳에서 먼저 저장된 것을 감지하면 안내의 등장 값이 1로 바뀌어 화면에 머무르고, 선택 뒤에야 값이 0으로 사라집니다.
쓰임여러 탭에서 열린 문서 편집, 협업 스프레드시트 충돌 안내
SPRING_KSPRING_CAUTO_RESOLVE_MS

<main class="stage">
<div class="scb is-demo" id="scb">
<div class="scb__banner" id="scbBanner">
<span class="scb__msg">다른 곳에서 먼저 저장되었습니다</span>
<div class="scb__actions">
<button class="scb__choice" id="scbKeep" type="button">내 변경 유지</button>
<button class="scb__choice scb__choice--ghost" id="scbReload" type="button">새로고침</button>
</div>
</div>
<div class="scb__idle">저장됨</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 배너 위치를 스프링-감쇠 물리로 적분(강성·감쇠) — index.html 참조
@include stage($stage-orange);
.scb {
position: relative;
width: 100%;
max-width: 260px;
min-height: 160px;
display: grid;
align-content: end;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
}
.scb__banner {
position: absolute;
top: 0;
left: 0;
right: 0;
transform: translateY(-60%);
display: grid;
gap: $sp-2;
background: $error;
color: $subject-cream;
padding: $sp-3 $sp-4;
text-align: center;
}
.scb__msg {
font: 700 12px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.scb__actions {
display: flex;
gap: $sp-2;
justify-content: center;
}
.scb__choice {
border: 0;
border-radius: $r-sm;
padding: $sp-1 $sp-3;
background: $subject-cream;
color: $error;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.scb__choice--ghost {
background: transparent;
color: $subject-cream;
border: 1px solid rgba($subject-cream, .6);
}
.scb__idle {
padding: $sp-6 $sp-4 $sp-5;
text-align: center;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
}
@media (max-width: 340px) {
.scb { max-width: 200px; min-height: 140px; }
.scb__msg { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.scb__choice { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.scb {
position: relative;
width: 100%;
max-width: 260px;
min-height: 160px;
display: grid;
align-content: end;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
}
.scb__banner {
position: absolute;
top: 0;
left: 0;
right: 0;
transform: translateY(-60%);
display: grid;
gap: 8px;
background: #c2361a;
color: #fff7e6;
padding: 12px 16px;
text-align: center;
}
.scb__msg {
font: 700 12px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.scb__actions {
display: flex;
gap: 8px;
justify-content: center;
}
.scb__choice {
border: 0;
border-radius: 8px;
padding: 4px 12px;
background: #fff7e6;
color: #c2361a;
font: 700 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
}
.scb__choice--ghost {
background: transparent;
color: #fff7e6;
border: 1px solid rgba(255, 247, 230, 0.6);
}
.scb__idle {
padding: 24px 16px 20px;
text-align: center;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
}
@media (max-width: 340px) {
.scb {
max-width: 200px;
min-height: 140px;
}
.scb__msg {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.scb__choice {
transition: none;
}
}(function () {
var root = document.getElementById('scb');
var banner = document.getElementById('scbBanner');
var keepBtn = document.getElementById('scbKeep');
var reloadBtn = document.getElementById('scbReload');
// 상수: 스프링 강성·감쇠(질량 1) · 저절로 충돌이 나타나는 간격 · 선택 없을 때 자동 해소까지 대기
var SPRING_K = 200;
var SPRING_C = 19;
var APPEAR_MS = 1400;
var AUTO_RESOLVE_MS = 850;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var pos = -60;
var vel = 0;
var target = -60;
var rafId = null;
var timer = null;
// 계산 겹: 배너 위치를 스프링-감쇠 물리로 적분 — 속도 += (−K×변위 − C×속도)×dt, 위치 += 속도×dt
function tick(ts, last) {
var dt = Math.min(32, ts - last) / 1000;
var disp = pos - target;
var acc = -SPRING_K * disp - SPRING_C * vel;
vel += acc * dt;
pos += vel * dt;
banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
rafId = requestAnimationFrame(function (t) { tick(t, ts); });
} else {
pos = target;
banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
}
}
function spring(to) {
target = to;
if (reduce) { pos = to; vel = 0; banner.style.transform = 'translateY(' + pos + '%)'; return; }
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(function (ts) { tick(ts, ts - 16); });
}
function showConflict() {
root.classList.add('scb--conflict');
spring(0);
timer = setTimeout(resolve, AUTO_RESOLVE_MS);
}
function resolve() {
root.classList.remove('scb--conflict');
spring(-60);
timer = setTimeout(showConflict, APPEAR_MS);
}
timer = setTimeout(showConflict, APPEAR_MS);
function choose() {
root.classList.remove('is-demo');
if (timer) clearTimeout(timer);
resolve();
}
keepBtn.addEventListener('click', choose);
reloadBtn.addEventListener('click', choose);
})();import { useEffect, useRef } from "react";
import styles from "./SaveStatusConflictBanner.module.scss";
type Box<T> = { current: T };
export default function SaveStatusConflictBanner() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const banner = root.querySelector(`.${styles.banner}`) as HTMLElement | null;
const keepBtn = root.querySelector(`.${styles.choice}`) as HTMLElement | null;
const choiceEls = Array.from(root.querySelectorAll(`.${styles.choice}`)) as HTMLElement[];
const reloadBtn = choiceEls[1] as HTMLElement | undefined;
if (!banner || !keepBtn || !reloadBtn) return;
// 상수: 스프링 강성·감쇠(질량 1) · 저절로 충돌이 나타나는 간격 · 선택 없을 때 자동 해소까지 대기
const SPRING_K = 200;
const SPRING_C = 19;
const APPEAR_MS = 1400;
const AUTO_RESOLVE_MS = 850;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let pos = -60;
let vel = 0;
let target = -60;
let rafId = 0;
let timer = 0;
// 계산 겹: 배너 위치를 스프링-감쇠 물리로 적분 — 속도 += (−K×변위 − C×속도)×dt, 위치 += 속도×dt
function tick(ts: number, last: number) {
const dt = Math.min(32, ts - last) / 1000;
const disp = pos - target;
const acc = -SPRING_K * disp - SPRING_C * vel;
vel += acc * dt;
pos += vel * dt;
banner!.style.transform = "translateY(" + pos.toFixed(2) + "%)";
if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
rafId = requestAnimationFrame((t) => tick(t, ts));
} else {
pos = target;
banner!.style.transform = "translateY(" + pos.toFixed(2) + "%)";
}
}
function spring(to: number) {
target = to;
if (reduce) { pos = to; vel = 0; banner!.style.transform = "translateY(" + pos + "%)"; return; }
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame((ts) => tick(ts, ts - 16));
}
function showConflict() {
root!.classList.add(styles["scb--conflict"]);
spring(0);
timer = window.setTimeout(resolve, AUTO_RESOLVE_MS);
}
function resolve() {
root!.classList.remove(styles["scb--conflict"]);
spring(-60);
timer = window.setTimeout(showConflict, APPEAR_MS);
}
timer = window.setTimeout(showConflict, APPEAR_MS);
function choose() {
root!.classList.remove(styles["is-demo"]);
window.clearTimeout(timer);
resolve();
}
keepBtn.addEventListener("click", choose);
reloadBtn.addEventListener("click", choose);
return () => {
window.clearTimeout(timer);
cancelAnimationFrame(rafId);
keepBtn.removeEventListener("click", choose);
reloadBtn.removeEventListener("click", choose);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.scb} ${styles["is-demo"]}`}>
<div className={styles.banner}>
<span className={styles.msg}>다른 곳에서 먼저 저장되었습니다</span>
<div className={styles.actions}>
<button className={styles.choice} type="button">내 변경 유지</button>
<button className={`${styles.choice} ${styles["choice--ghost"]}`} type="button">새로고침</button>
</div>
</div>
<div className={styles.idle}>저장됨</div>
</div>
);
}07 저장 충돌 안내 — 같은 항목이 다른 곳에서 먼저 저장되면 안내 값이 나타나 선택을 기다리는 방식 — 측정 누적면적 11.108% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지
이 데모가 속한 글
자동저장 표시 9종 — 값이 초 단위로 갱신되는 저장 상태 아홉 가지자동저장 표시는 지금 저장이 어느 상태에 있는지를 값 하나로 계산해 화면에 드러내는 인터랙션을 말하는데, 아홉 개 중 하나는 저장 중이라는 문구가 점 세 개와 함께 반복되고, 하나는 마지막 저장 시각 값이 초 단위로 이 글의 데모 전부 보기


