09Reduced-motion instant update — under a reduced-motion setting, the value jumps straight to its next state with no movement
When the reduced-motion setting is on, the value skips its gradual change and switches straight to the next state.
Best forAn accessibility-conscious authentication screen, a login screen that supports low-end devices
MAX_TRIESAMPSHAKE_MS

<main class="stage">
<div class="arm is-demo" id="arm">
<div class="arm__panel">
<span class="arm__tag">Normal motion</span>
<span class="arm__count" id="armCountA">5</span>
</div>
<div class="arm__panel">
<span class="arm__tag">Reduced motion</span>
<span class="arm__count arm__count--static" id="armCountB">5</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 일반 판은 감쇠 사인 흔들림, 모션 감소 판은 같은 값을 트윈 없이 즉시 반영 — index.html 참조
@include stage($stage-paper);
.arm {
display: flex;
gap: $sp-6;
cursor: pointer;
}
.arm__panel {
display: grid;
justify-items: center;
gap: $sp-2;
padding: $sp-5 $sp-6;
border-radius: $r-card;
background: $subject-cream;
}
.arm__tag {
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .6);
text-transform: uppercase;
letter-spacing: .04em;
}
.arm__count {
font: 800 40px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.arm { gap: $sp-3; }
.arm__panel { padding: $sp-3 $sp-4; }
.arm__count { font-size: 28px; }
}
@media (prefers-reduced-motion: reduce) {
// 모션 감소 판의 값 갱신은 원래 트윈이 없다 — OS 설정이 켜지면 일반 판도 흔들림 계산을 건너뛴다(index.html)
.arm__count { 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);
}
.arm {
display: flex;
gap: 24px;
cursor: pointer;
}
.arm__panel {
display: grid;
justify-items: center;
gap: 8px;
padding: 20px 24px;
border-radius: 16px;
background: #fff7e6;
}
.arm__tag {
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.6);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.arm__count {
font: 800 40px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.arm {
gap: 12px;
}
.arm__panel {
padding: 12px 16px;
}
.arm__count {
font-size: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.arm__count {
transition: none;
}
}(function () {
var root = document.getElementById('arm');
var a = document.getElementById('armCountA');
var b = document.getElementById('armCountB');
// 상수: 최대 시도 횟수 · Normal motion 흔들림 진폭·지속시간
var MAX_TRIES = 5;
var AMP = 6;
var SHAKE_MS = 300;
var remaining = MAX_TRIES;
// 계산 겹: 일반 판은 감쇠 사인으로 흔들며 값을 바꾸고, Reduced motion 판은 같은 값을 애니메이션 없이 즉시 반영한다
function shakeA() {
var start = null;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / SHAKE_MS);
var x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
a.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
if (t < 1) requestAnimationFrame(frame);
else a.style.transform = 'translateX(0)';
}
requestAnimationFrame(frame);
}
function step() {
remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
a.textContent = String(remaining);
shakeA();
// Reduced motion 판: 같은 값이 트윈 없이 그 자리에서 바로 바뀐다
b.textContent = String(remaining);
}
root.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
step();
});
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(step, 420); }
else { autoTimer = setInterval(function () { remaining = remaining > 0 ? remaining - 1 : MAX_TRIES; a.textContent = b.textContent = String(remaining); }, 420); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptReducedMotionStatic.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptReducedMotionStatic() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const a = root.querySelector(`.${styles.a}`) as HTMLElement | null;
const b = root.querySelector(`.${styles.b}`) as HTMLElement | null;
if (!a || !b) return;
// 상수: 최대 시도 횟수 · 일반 모션 흔들림 진폭·지속시간
const MAX_TRIES = 5;
const AMP = 6;
const SHAKE_MS = 300;
let remaining = MAX_TRIES;
// 계산 겹: 일반 판은 감쇠 사인으로 흔들며 값을 바꾸고, 모션 감소 판은 같은 값을 애니메이션 없이 즉시 반영한다
function shakeA() {
let start: number | null = null;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / SHAKE_MS);
const x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
a!.style.transform = "translateX(" + x.toFixed(2) + "px)";
if (t < 1) requestAnimationFrame(frame);
else a!.style.transform = "translateX(0)";
}
requestAnimationFrame(frame);
}
function step() {
remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
a!.textContent = String(remaining);
shakeA();
// 모션 감소 판: 같은 값이 트윈 없이 그 자리에서 바로 바뀐다
b!.textContent = String(remaining);
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
step();
}
root.addEventListener("click", onClick);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoTimer = window.setInterval(step, 420);
} else {
autoTimer = window.setInterval(() => {
remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
a!.textContent = b!.textContent = String(remaining);
}, 420);
}
return () => {
stopAutoplay();
root!.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.arm} ${styles["is-demo"]}`}>
<div className={styles.panel}>
<span className={styles.tag}>일반 모션</span>
<span className={`${styles.count} ${styles.a}`}>5</span>
</div>
<div className={styles.panel}>
<span className={styles.tag}>모션 감소</span>
<span className={`${styles.count} ${styles["count--static"]} ${styles.b}`}>5</span>
</div>
</div>
);
}09 Reduced-motion instant update — under a reduced-motion setting, the value jumps straight to its next state with no movement — Measured area 0.868% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · Too Many Failed Login Attempts: 9 Patterns
The post this demo lives in
Too Many Failed Login Attempts: 9 PatternsOne value drops by one per failed try, another doubles the wait time as failures pile up, and another counts a remaining See all demos in this post
If you like this effect - similar demos
Auth UI08 Grid-scan glow — the scan area is split into a grid, and each cell glows in turn on its own delay valueFingerprint Scan Animation: 9 Biometric UI Demos
Auth UI05 Two-factor — a code entry that sets its confirm button's state from a fill ratio9 Login UI Design Patterns (Copy-Paste)
Auth UI05 A Check Where the Pointer Trail Drawn Along a Winding Guide Becomes a Score From Its Mean Distance to That GuideCSS Captcha: 9 Warped Text and Rotate Checks