06유효성 실패 시 링이 떨리며 조여드는 단계
값이 비어 있는 채로 다음 단계를 누르면 그 단계 링이 좌우로 떨리며 붉게 조여든다.
쓰임필수 입력 단계 검증
STEP_ERROR_SHAKE_DECAYSTEP_ERROR_SHAKE_FREQSTEP_ERROR_RING_CONTRACT_PX

<main class="stage">
<div class="er is-demo" id="er">
<span class="er__glow" id="erGlow" aria-hidden="true"></span>
<ol class="er__row" id="erRow" role="list" aria-label="필수 입력 단계">
<li class="er__node" data-i="0"><span class="er__ring" id="ring0"></span><span class="er__circle is-active" id="n0">1</span><span class="er__label">이름</span></li>
<li class="er__connector"></li>
<li class="er__node" data-i="1"><span class="er__ring" id="ring1"></span><span class="er__circle" id="n1">2</span><span class="er__label">연락처</span></li>
<li class="er__connector"></li>
<li class="er__node" data-i="2"><span class="er__ring" id="ring2"></span><span class="er__circle" id="n2">3</span><span class="er__label">완료</span></li>
</ol>
<label class="er__field">
<span class="er__fieldLabel">이름 (필수)</span>
<input type="text" id="erInput" class="er__input" autocomplete="off">
</label>
<p class="er__status" id="erStatus" aria-live="assertive"></p>
<div class="er__controls">
<button type="button" class="er__btn er__btn--primary" id="erNext">다음</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 오류가 난 노드의 x 오프셋은 감쇠 사인 AMP·exp(-STEP_ERROR_SHAKE_DECAY·t)·sin(FREQ·t) 로
// 매 프레임 계산하고, 링 반경은 같은 감쇠 값을 이용해 STEP_ERROR_RING_CONTRACT_PX 만큼 조여든다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-yellow);
.er {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(90vw, 280px);
}
.er__glow {
position: absolute;
inset: 0;
border-radius: $r-sm;
background: $error;
opacity: 0;
pointer-events: none;
z-index: 0;
}
.er__row {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.er__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.er__ring {
position: absolute;
top: -10px;
left: 50%;
width: 64px;
height: 64px;
margin-left: -32px;
border-radius: 50%;
border: 2px solid $error;
opacity: 0;
pointer-events: none;
}
.er__circle {
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: $stage-ink;
background: $stage-paper;
border: 2px solid rgba(23, 20, 26, .2);
}
.er__circle.is-active { border-color: $subject-blue-deep; }
.er__circle.is-done { background: $stage-ink; color: $stage-yellow; border-color: $stage-ink; }
.er__circle.is-error { background: $error-soft; border-color: $error; color: $error; }
.er__label { font-size: 9px; color: rgba(23, 20, 26, .7); font-weight: 600; }
.er__connector {
flex: 1 1 auto;
height: 3px;
min-width: 18px;
margin: 20px 4px 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
}
.er__field {
display: grid;
gap: 4px;
width: 100%;
max-width: 220px;
}
.er__fieldLabel { font-size: 10px; font-weight: 600; color: rgba(23, 20, 26, .75); }
.er__input {
border: 1px solid rgba(23, 20, 26, .3);
border-radius: $r-sm;
padding: $sp-2 $sp-3;
font-size: 12px;
background: $stage-paper;
color: $stage-ink;
}
.er__status {
margin: 0;
min-height: 14px;
font-size: 10px;
font-weight: 700;
color: $subject-orange-deep;
}
.er__controls { display: flex; gap: $sp-2; }
.er__btn {
border: 1px solid rgba(23, 20, 26, .3);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.er__btn--primary { background: $subject-blue-deep; border-color: $subject-blue-deep; color: $stage-paper; }
.er__btn:focus-visible, .er__input:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.er { gap: $sp-1; }
.er__circle { width: 36px; height: 36px; font-size: 13px; }
.er__ring { width: 48px; height: 48px; margin-left: -24px; top: -6px; }
.er__connector { margin-top: 16px; }
.er__field { gap: 0; }
.er__input { padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.er__circle { 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);
}
.er {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(90vw, 280px);
}
.er__glow {
position: absolute;
inset: 0;
border-radius: 8px;
background: #c2361a;
opacity: 0;
pointer-events: none;
z-index: 0;
}
.er__row {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.er__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.er__ring {
position: absolute;
top: -10px;
left: 50%;
width: 64px;
height: 64px;
margin-left: -32px;
border-radius: 50%;
border: 2px solid #c2361a;
opacity: 0;
pointer-events: none;
}
.er__circle {
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: #17141a;
background: #ffffff;
border: 2px solid rgba(23, 20, 26, 0.2);
}
.er__circle.is-active {
border-color: #0a4fe8;
}
.er__circle.is-done {
background: #17141a;
color: #ffd23f;
border-color: #17141a;
}
.er__circle.is-error {
background: #ffe0d8;
border-color: #c2361a;
color: #c2361a;
}
.er__label {
font-size: 9px;
color: rgba(23, 20, 26, 0.7);
font-weight: 600;
}
.er__connector {
flex: 1 1 auto;
height: 3px;
min-width: 18px;
margin: 20px 4px 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.er__field {
display: grid;
gap: 4px;
width: 100%;
max-width: 220px;
}
.er__fieldLabel {
font-size: 10px;
font-weight: 600;
color: rgba(23, 20, 26, 0.75);
}
.er__input {
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
background: #ffffff;
color: #17141a;
}
.er__status {
margin: 0;
min-height: 14px;
font-size: 10px;
font-weight: 700;
color: #c93000;
}
.er__controls {
display: flex;
gap: 8px;
}
.er__btn {
border: 1px solid rgba(23, 20, 26, 0.3);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.er__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.er__btn:focus-visible, .er__input:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.er {
gap: 4px;
}
.er__circle {
width: 36px;
height: 36px;
font-size: 13px;
}
.er__ring {
width: 48px;
height: 48px;
margin-left: -24px;
top: -6px;
}
.er__connector {
margin-top: 16px;
}
.er__field {
gap: 0;
}
.er__input {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.er__circle {
transition: none;
}
}(function () {
var STEP_ERROR_SHAKE_DECAY = 5.5;
var STEP_ERROR_SHAKE_FREQ = 26;
var STEP_ERROR_SHAKE_AMPLITUDE_PX = 6;
var STEP_ERROR_RING_CONTRACT_PX = 6;
var SHAKE_DURATION_S = 0.6;
var STEP_COUNT = 3;
var RING_BASE_PX = 64;
var NEIGHBOR_SHAKE_RATIO = 0.3; // 인접 노드 미세 흔들림 — 같은 감쇠 함수의 축소판
var root = document.getElementById('er');
var glow = document.getElementById('erGlow');
var input = document.getElementById('erInput');
var status = document.getElementById('erStatus');
var nextBtn = document.getElementById('erNext');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var circles = [0, 1, 2].map(function (i) { return document.getElementById('n' + i); });
var rings = [0, 1, 2].map(function (i) { return document.getElementById('ring' + i); });
var current = 0;
var errorAt = null;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function render() {
circles.forEach(function (el, i) {
el.classList.toggle('is-active', i === current);
el.classList.toggle('is-done', i < current);
});
}
function clearShakeVisuals() {
circles[current].style.transform = '';
rings[current].style.boxShadow = 'none';
glow.style.opacity = '0';
[current - 1, current + 1].forEach(function (idx) {
if (idx >= 0 && idx < STEP_COUNT) circles[idx].style.transform = '';
});
}
function triggerError() {
errorAt = performance.now();
status.textContent = '이름을 입력해야 다음 단계로 갈 수 있습니다';
if (reduce) {
circles[current].classList.add('is-error');
rings[current].classList.add('is-error');
}
}
function advance() {
if (current === 0 && input.value.trim() === '') { triggerError(); return; }
circles[current].classList.remove('is-error');
rings[current].classList.remove('is-error');
current = clamp(current + 1, 0, STEP_COUNT - 1);
status.textContent = current === STEP_COUNT - 1 ? '입력 완료' : '';
render();
}
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); advance(); });
input.addEventListener('input', function () {
if (input.value.trim() !== '') { errorAt = null; circles[0].classList.remove('is-error'); rings[0].classList.remove('is-error'); status.textContent = ''; clearShakeVisuals(); }
});
var startTime = null;
var phase = 0;
function tick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2;
if (root.classList.contains('is-demo')) {
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.20) { triggerError(); phase = 1; }
if (phase === 1 && t > 1.10) { input.value = ''; errorAt = null; circles[0].classList.remove('is-error'); rings[0].classList.remove('is-error'); triggerError(); phase = 2; }
}
if (!reduce && errorAt !== null) {
var tt = (now - errorAt) / 1000;
if (tt <= SHAKE_DURATION_S) {
var envelope = Math.exp(-STEP_ERROR_SHAKE_DECAY * tt);
var x = STEP_ERROR_SHAKE_AMPLITUDE_PX * envelope * Math.sin(STEP_ERROR_SHAKE_FREQ * tt);
circles[current].style.transform = 'translateX(' + x.toFixed(2) + 'px)';
circles[current].classList.add('is-error');
var contract = STEP_ERROR_RING_CONTRACT_PX * (1 - envelope);
var ringSize = RING_BASE_PX - contract;
rings[current].style.width = ringSize.toFixed(1) + 'px';
rings[current].style.height = ringSize.toFixed(1) + 'px';
rings[current].style.marginLeft = (-ringSize / 2).toFixed(1) + 'px';
rings[current].style.opacity = String(envelope);
rings[current].style.boxShadow = '0 0 ' + (envelope * 22).toFixed(1) + 'px ' + (envelope * 8).toFixed(1) + 'px rgba(194, 54, 26, ' + (envelope * 0.55).toFixed(2) + ')';
rings[current].classList.add('is-error');
glow.style.opacity = (envelope * 0.4).toFixed(2);
[current - 1, current + 1].forEach(function (idx) {
if (idx >= 0 && idx < STEP_COUNT) circles[idx].style.transform = 'translateX(' + (x * NEIGHBOR_SHAKE_RATIO).toFixed(2) + 'px)';
});
} else {
clearShakeVisuals();
}
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// StepErrorRingShake — vanilla 판(06-step-precision-error-ring-shake) 의 React 포트.
// 값 하나: 오류 발생 시점부터의 경과 tt 에 대해 감쇠 사인 envelope·x오프셋을 매 프레임 계산하고,
// 같은 envelope 로 링 반경을 STEP_ERROR_RING_CONTRACT_PX 만큼 조인다 — 흔들림과 조임이 한 값에서 나온다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepErrorRingShake.module.scss";
type Box<T> = { current: T };
const STEP_ERROR_SHAKE_DECAY = 5.5;
const STEP_ERROR_SHAKE_FREQ = 26;
const STEP_ERROR_SHAKE_AMPLITUDE_PX = 6;
const STEP_ERROR_RING_CONTRACT_PX = 6;
const SHAKE_DURATION_S = 0.6;
const STEP_COUNT = 3;
const RING_BASE_PX = 64;
const NEIGHBOR_SHAKE_RATIO = 0.3; // 인접 노드 미세 흔들림 — 같은 감쇠 함수의 축소판
const LABELS = ["이름", "연락처", "완료"];
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StepErrorRingShake() {
const [current, setCurrent] = useState(0);
const [errorIdx, setErrorIdx] = useState<number | null>(null);
const [status, setStatus] = useState("");
const [value, setValue] = useState("");
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const errorAtRef = useRef<number | null>(null) as Box<number | null>;
const errorIdxRef = useRef<number | null>(null) as Box<number | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const circleRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const ringRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const glowRef = useRef<HTMLSpanElement | null>(null);
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function triggerError(): void {
errorAtRef.current = performance.now();
errorIdxRef.current = currentRef.current;
setErrorIdx(currentRef.current);
setStatus("이름을 입력해야 다음 단계로 갈 수 있습니다");
}
function clearError(): void {
errorAtRef.current = null;
errorIdxRef.current = null;
setErrorIdx(null);
}
function advance(currentValue: string): void {
if (currentRef.current === 0 && currentValue.trim() === "") { triggerError(); return; }
clearError();
const next = clamp(currentRef.current + 1, 0, STEP_COUNT - 1);
currentRef.current = next;
setCurrent(next);
setStatus(next === STEP_COUNT - 1 ? "입력 완료" : "");
}
function stopDemo(): void { demoRef.current = false; }
function onInputChange(v: string): void {
setValue(v);
if (v.trim() !== "") { clearError(); setStatus(""); }
}
useEffect(() => {
let startTime = 0;
let phase = 0;
function clearShakeVisuals(eIdx: number): void {
const circle = circleRefs.current[eIdx];
if (circle) circle.style.transform = "";
const ring = ringRefs.current[eIdx];
if (ring) ring.style.boxShadow = "none";
if (glowRef.current) glowRef.current.style.opacity = "0";
[eIdx - 1, eIdx + 1].forEach((idx) => {
if (idx >= 0 && idx < STEP_COUNT) {
const neighbor = circleRefs.current[idx];
if (neighbor) neighbor.style.transform = "";
}
});
}
function tick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2;
if (demoRef.current) {
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.20) { triggerError(); phase = 1; }
if (phase === 1 && t > 1.10) { setValue(""); clearError(); triggerError(); phase = 2; }
}
const eIdx = errorIdxRef.current;
if (!reduce && errorAtRef.current !== null && eIdx !== null) {
const tt = (now - errorAtRef.current) / 1000;
const circle = circleRefs.current[eIdx];
const ring = ringRefs.current[eIdx];
if (tt <= SHAKE_DURATION_S) {
const envelope = Math.exp(-STEP_ERROR_SHAKE_DECAY * tt);
const x = STEP_ERROR_SHAKE_AMPLITUDE_PX * envelope * Math.sin(STEP_ERROR_SHAKE_FREQ * tt);
if (circle) circle.style.transform = "translateX(" + x.toFixed(2) + "px)";
const contract = STEP_ERROR_RING_CONTRACT_PX * (1 - envelope);
const ringSize = RING_BASE_PX - contract;
if (ring) {
ring.style.width = ringSize.toFixed(1) + "px";
ring.style.height = ringSize.toFixed(1) + "px";
ring.style.marginLeft = (-ringSize / 2).toFixed(1) + "px";
ring.style.opacity = String(envelope);
ring.style.boxShadow = "0 0 " + (envelope * 22).toFixed(1) + "px " + (envelope * 8).toFixed(1) + "px rgba(194, 54, 26, " + (envelope * 0.55).toFixed(2) + ")";
}
if (glowRef.current) glowRef.current.style.opacity = (envelope * 0.4).toFixed(2);
[eIdx - 1, eIdx + 1].forEach((idx) => {
if (idx >= 0 && idx < STEP_COUNT) {
const neighbor = circleRefs.current[idx];
if (neighbor) neighbor.style.transform = "translateX(" + (x * NEIGHBOR_SHAKE_RATIO).toFixed(2) + "px)";
}
});
} else {
clearShakeVisuals(eIdx);
}
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
return (
<main className={styles.stage}>
<div className={styles.er}>
<span className={styles.glow} ref={glowRef} aria-hidden="true" />
<ol className={styles.row} role="list" aria-label="필수 입력 단계">
{LABELS.map((label, i) => (
<li key={label} style={{ display: "contents" }}>
<div className={styles.node}>
<span className={[styles.ring, errorIdx === i ? styles.ringError : ""].join(" ")} ref={(el: HTMLSpanElement | null) => { ringRefs.current[i] = el; }} />
<span
className={[styles.circle, i === current ? styles.circleActive : "", i < current ? styles.circleDone : "", errorIdx === i ? styles.circleError : ""].join(" ")}
ref={(el: HTMLSpanElement | null) => { circleRefs.current[i] = el; }}
>
{i + 1}
</span>
<span className={styles.label}>{label}</span>
</div>
{i < STEP_COUNT - 1 && <div className={styles.connector} />}
</li>
))}
</ol>
<label className={styles.field}>
<span className={styles.fieldLabel}>이름 (필수)</span>
<input
type="text"
className={styles.input}
autoComplete="off"
value={value}
onChange={(e: { target: { value: string } }) => onInputChange(e.target.value)}
/>
</label>
<p className={styles.status} aria-live="assertive">{status}</p>
<div className={styles.controls}>
<button
type="button"
className={[styles.btn, styles.btnPrimary].join(" ")}
onClick={() => { stopDemo(); advance(value); }}
>
다음
</button>
</div>
</div>
</main>
);
}06 유효성 실패 시 링이 떨리며 조여드는 단계 — 측정 누적면적 37.371% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기
이 데모가 속한 글
진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기진행 단계 ui 아홉 가지를 한 페이지에 모았다. 디자이너는 03번 체크 표시가 그려지는 순간을 마음에 들어 했고, 개발자는 05번의 마찰 값 하나 때문에 스크러버가 매번 다른 자리에서 멈춰 애를 먹었다. 09번에서 이 글의 데모 전부 보기


