09모션 축소 대응 비교 전환 — 같은 체크 값 변화를 일반 판은 선 길이 값을 그려서, 축소 판은 중간 단계 없이 바로 보여주는 방식
같은 체크 값이 바뀔 때 일반 판은 선 길이 값이 그려지듯 바뀌고, 축소 판은 중간 단계 없이 목표 값으로 바로 바뀝니다.
쓰임접근성 설정을 켠 사용자의 화면, 애니메이션을 줄이고 싶은 폼
DURATION_MSCYCLE_MSmatchMedia

<main class="stage">
<div class="cbr is-demo" id="cbr">
<div class="cbr__panel">
<p class="cbr__label">일반</p>
<label class="cbr__row">
<input type="checkbox" class="cbr__input" id="cbrNormalInput">
<span class="cbr__box" id="cbrNormalBox" aria-hidden="true">
<svg viewBox="0 0 20 20" width="14" height="14"><path id="cbrNormalPath" class="cbr__path" d="M4 10.5l3.5 3.5L16 5.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="cbr__text">뉴스레터 수신</span>
</label>
</div>
<div class="cbr__panel">
<p class="cbr__label">축소</p>
<label class="cbr__row">
<input type="checkbox" class="cbr__input" id="cbrReducedInput">
<span class="cbr__box cbr__box--instant" id="cbrReducedBox" aria-hidden="true">
<svg viewBox="0 0 20 20" width="14" height="14"><path class="cbr__check" d="M4 10.5l3.5 3.5L16 5.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="cbr__text">뉴스레터 수신</span>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 일반 판은 getTotalLength() 로 실측한 체크 선 길이를 easeOutCubic 로 rAF 적분해
// strokeDashoffset 을 그린다. 축소 판은 같은 change 로직에서 중간 프레임 계산을 건너뛰고
// 목표 클래스만 즉시 토글한다 — 두 판이 "matchMedia 분기"를 나란히 보여준다.
@include stage($stage-yellow);
.cbr {
display: flex;
gap: $sp-6;
width: 100%;
max-width: 320px;
}
.cbr__panel {
flex: 1 1 0;
display: grid;
gap: $sp-2;
}
.cbr__label {
margin: 0;
color: rgba($subject-ink, .55);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
}
.cbr__row {
display: flex;
align-items: center;
gap: $sp-2;
cursor: pointer;
user-select: none;
}
.cbr__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbr__box {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: $r-sm;
border: 2px solid rgba($subject-ink, .35);
color: $subject-cream;
display: grid;
place-items: center;
box-sizing: border-box;
transition: background-color $dur-quick $ease-out, border-color $dur-quick $ease-out;
}
.cbr__box--on {
background: $subject-blue;
border-color: $subject-blue;
}
.cbr__box--instant {
transition: none;
}
.cbr__path {
opacity: 1;
}
.cbr__check {
opacity: 0;
}
.cbr__box--on .cbr__check {
opacity: 1;
}
.cbr__box--instant .cbr__check {
transition: none;
}
.cbr__input:focus-visible + .cbr__box {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.cbr__text {
color: $subject-ink;
font-size: 12px;
font-weight: 600;
}
@media (max-width: 340px) {
.cbr { gap: $sp-4; }
.cbr__text { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.cbr__box { 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);
}
.cbr {
display: flex;
gap: 24px;
width: 100%;
max-width: 320px;
}
.cbr__panel {
flex: 1 1 0;
display: grid;
gap: 8px;
}
.cbr__label {
margin: 0;
color: rgba(23, 20, 26, 0.55);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cbr__row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.cbr__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbr__box {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: 8px;
border: 2px solid rgba(23, 20, 26, 0.35);
color: #fff7e6;
display: grid;
place-items: center;
box-sizing: border-box;
transition: background-color 220ms ease-out, border-color 220ms ease-out;
}
.cbr__box--on {
background: #2f6df6;
border-color: #2f6df6;
}
.cbr__box--instant {
transition: none;
}
.cbr__path {
opacity: 1;
}
.cbr__check {
opacity: 0;
}
.cbr__box--on .cbr__check {
opacity: 1;
}
.cbr__box--instant .cbr__check {
transition: none;
}
.cbr__input:focus-visible + .cbr__box {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.cbr__text {
color: #17141a;
font-size: 12px;
font-weight: 600;
}
@media (max-width: 340px) {
.cbr {
gap: 16px;
}
.cbr__text {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.cbr__box {
transition: none;
}
}(function () {
var DURATION_MS = 460; // 계산 겹 상수: 일반 판에서 체크 선이 그려지는 데 걸리는 실제 시간(ms)
var CYCLE_MS = 900; // 자동 루프가 두 판을 함께 켜고 끄는 간격
var systemReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('cbr');
var normalInput = document.getElementById('cbrNormalInput');
var normalBox = document.getElementById('cbrNormalBox');
var normalPath = document.getElementById('cbrNormalPath');
var reducedInput = document.getElementById('cbrReducedInput');
var reducedBox = document.getElementById('cbrReducedBox');
var isDemo = true;
var LEN = normalPath.getTotalLength();
normalPath.style.strokeDasharray = String(LEN);
normalPath.style.strokeDashoffset = String(LEN);
var drawing = false, drawStart = 0, drawFrom = LEN, drawTo = LEN;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function currentOffset(ts) {
if (!drawing) return drawTo;
var t = Math.min(1, (ts - drawStart) / DURATION_MS);
return drawFrom + (drawTo - drawFrom) * easeOutCubic(t);
}
function setState(next, ts) {
// 일반 판: DURATION_MS 동안 draw-on 을 실측 길이 기준으로 보간(matchMedia 가 false 일 때의 실제 경로)
normalInput.checked = next;
normalBox.classList.toggle('cbr__box--on', next);
drawFrom = currentOffset(ts);
drawTo = next ? 0 : LEN;
drawStart = ts;
drawing = true;
// 축소 판: matchMedia 결과와 같은 논리 분기로, 중간 프레임 없이 즉시 목표 상태로 점프
reducedInput.checked = next;
reducedBox.classList.toggle('cbr__box--on', next);
}
normalInput.addEventListener('change', function () { human(); setState(normalInput.checked, performance.now()); });
reducedInput.addEventListener('change', function () {
human();
reducedBox.classList.toggle('cbr__box--on', reducedInput.checked);
normalInput.checked = reducedInput.checked;
normalBox.classList.toggle('cbr__box--on', reducedInput.checked);
normalPath.style.strokeDashoffset = reducedInput.checked ? '0' : String(LEN);
drawTo = reducedInput.checked ? 0 : LEN;
drawing = false;
});
var cycleStart = 0;
var lastState = false;
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !systemReduce) {
var step = Math.floor((ts - cycleStart) / CYCLE_MS) % 2 === 1;
if (step !== lastState) { lastState = step; setState(step, ts); }
}
if (drawing) {
var off = currentOffset(ts);
normalPath.style.strokeDashoffset = String(off);
if (Math.abs(off - drawTo) < 0.05) drawing = false;
}
requestAnimationFrame(loop);
}
// 일반 판의 draw-on 계산(DURATION_MS)은 matchMedia 값과 무관하게 항상 그대로 수행한다 —
// 실제 OS/브라우저가 reduce 라면 위 자동 루프 자체가 멈춰(강제 모션 없음) 데모가 클릭을
// 기다리고, 클릭 시에는 여전히 두 판의 차이(그리기 vs 즉시 점프)가 그대로 보인다.
requestAnimationFrame(loop);
})();// ReducedMotionStatic — vanilla 판(run/368/demo/09-custom-checkbox-reduced-motion-static/index.html)
// 의 React 포트. 일반 판은 getTotalLength() 실측 길이를 easeOutCubic 로 rAF 적분해
// strokeDashoffset 을 그리고, 축소 판은 같은 change 로직에서 중간 프레임을 건너뛰고 즉시 점프한다.
import { useEffect, useRef } from "react";
import styles from "./ReducedMotionStatic.module.scss";
type Box<T> = { current: T };
export default function ReducedMotionStatic() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const normalInputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const normalBoxRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const normalPathRef = useRef(null) as unknown as Box<SVGPathElement>;
const reducedInputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const reducedBoxRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const DURATION_MS = 460; // 계산 겹 상수: 일반 판에서 체크 선이 그려지는 데 걸리는 실제 시간(ms)
const CYCLE_MS = 900; // 자동 루프가 두 판을 함께 켜고 끄는 간격
const systemReduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const normalInput = normalInputRef.current;
const normalBox = normalBoxRef.current;
const normalPath = normalPathRef.current;
const reducedInput = reducedInputRef.current;
const reducedBox = reducedBoxRef.current;
if (!root || !normalInput || !normalBox || !normalPath || !reducedInput || !reducedBox) return;
let isDemo = true;
const LEN = normalPath.getTotalLength();
normalPath.style.strokeDasharray = String(LEN);
normalPath.style.strokeDashoffset = String(LEN);
let drawing = false, drawStart = 0, drawFrom = LEN, drawTo = LEN;
function human() {
if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; }
}
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function currentOffset(ts: number) {
if (!drawing) return drawTo;
const t = Math.min(1, (ts - drawStart) / DURATION_MS);
return drawFrom + (drawTo - drawFrom) * easeOutCubic(t);
}
function setState(next: boolean, ts: number) {
// 일반 판: DURATION_MS 동안 draw-on 을 실측 길이 기준으로 보간(matchMedia 가 false 일 때의 실제 경로)
normalInput!.checked = next;
normalBox!.classList.toggle(styles.boxOn, next);
drawFrom = currentOffset(ts);
drawTo = next ? 0 : LEN;
drawStart = ts;
drawing = true;
// 축소 판: matchMedia 결과와 같은 논리 분기로, 중간 프레임 없이 즉시 목표 상태로 점프
reducedInput!.checked = next;
reducedBox!.classList.toggle(styles.boxOn, next);
}
function onNormalChange() { human(); setState(normalInput!.checked, performance.now()); }
normalInput.addEventListener("change", onNormalChange);
function onReducedChange() {
human();
reducedBox!.classList.toggle(styles.boxOn, reducedInput!.checked);
normalInput!.checked = reducedInput!.checked;
normalBox!.classList.toggle(styles.boxOn, reducedInput!.checked);
normalPath!.style.strokeDashoffset = reducedInput!.checked ? "0" : String(LEN);
drawTo = reducedInput!.checked ? 0 : LEN;
drawing = false;
}
reducedInput.addEventListener("change", onReducedChange);
let cycleStart = 0;
let lastState = false;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !systemReduce) {
const step = Math.floor((ts - cycleStart) / CYCLE_MS) % 2 === 1;
if (step !== lastState) { lastState = step; setState(step, ts); }
}
if (drawing) {
const off = currentOffset(ts);
normalPath!.style.strokeDashoffset = String(off);
if (Math.abs(off - drawTo) < 0.05) drawing = false;
}
rafRef.current = requestAnimationFrame(loop);
}
// 일반 판의 draw-on 계산(DURATION_MS)은 matchMedia 값과 무관하게 항상 그대로 수행한다 —
// 실제 OS/브라우저가 reduce 라면 위 자동 루프 자체가 멈춰(강제 모션 없음) 데모가 클릭을
// 기다리고, 클릭 시에는 여전히 두 판의 차이(그리기 vs 즉시 점프)가 그대로 보인다.
rafRef.current = requestAnimationFrame(loop);
return () => {
normalInput.removeEventListener("change", onNormalChange);
reducedInput.removeEventListener("change", onReducedChange);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cbr + " " + styles.isDemo} ref={rootRef}>
<div className={styles.cbrPanel}>
<p className={styles.cbrLabel}>일반</p>
<label className={styles.cbrRow}>
<input type="checkbox" className={styles.cbrInput} ref={normalInputRef} />
<span className={styles.cbrBox} ref={normalBoxRef} aria-hidden="true">
<svg viewBox="0 0 20 20" width={14} height={14}>
<path ref={normalPathRef} className={styles.cbrPath} d="M4 10.5l3.5 3.5L16 5.5" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className={styles.cbrText}>뉴스레터 수신</span>
</label>
</div>
<div className={styles.cbrPanel}>
<p className={styles.cbrLabel}>축소</p>
<label className={styles.cbrRow}>
<input type="checkbox" className={styles.cbrInput} ref={reducedInputRef} />
<span className={styles.cbrBox + " " + styles.boxInstant} ref={reducedBoxRef} aria-hidden="true">
<svg viewBox="0 0 20 20" width={14} height={14}>
<path className={styles.cbrCheck} d="M4 10.5l3.5 3.5L16 5.5" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className={styles.cbrText}>뉴스레터 수신</span>
</label>
</div>
</div>
</main>
);
}09 모션 축소 대응 비교 전환 — 같은 체크 값 변화를 일반 판은 선 길이 값을 그려서, 축소 판은 중간 단계 없이 바로 보여주는 방식 — 측정 누적면적 0.617% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 커스텀 체크박스 9종 — 체크 값으로 바뀌는 아홉 가지 방식
이 데모가 속한 글
커스텀 체크박스 9종 — 체크 값으로 바뀌는 아홉 가지 방식커스텀 체크박스 아홉 종을 값 기준으로 나누면, 아홉 가지 중 하나는 체크 값이 켜지면 체크 표시의 선 길이 값이 0에서 끝까지 그려지고, 하나는 같은 값이 바뀌는 순간 박스의 크기 값이 잠깐 커졌다가 튕기듯 가라앉 이 글의 데모 전부 보기


