07Required-error shake toggle — when a submit attempt hits an empty required box, its horizontal-position value shakes briefly side to side
The instant a submit-attempt value fires, the empty required box's horizontal-position value shakes briefly and its border-color value turns red.
Best forA signup form with a required agreement, a checked item that needs confirmation before submit
SHAKE_MStranslateXborder-color

<main class="stage">
<div class="cbe is-demo" id="cbe">
<label class="cbe__row" id="cbeRow">
<input type="checkbox" class="cbe__input" id="cbeInput" required>
<span class="cbe__box" id="cbeBox" aria-hidden="true">
<svg viewBox="0 0 20 20" width="14" height="14"><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="cbe__text">I agree to the required terms</span>
</label>
<p class="cbe__hint" id="cbeHint">Check this box to continue</p>
<button type="button" class="cbe__submit" id="cbeSubmit">Submit</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html shakeOffset() 이 감쇠 사인 진동(exp(-ZETA·t)·sin(FREQ·t))을 직접 계산해
// translateX 값으로 삼는다 — 트윈 하나로 끝내지 않고 매 프레임 진폭이 줄어드는 물리량을 적분한다.
@include stage($stage-paper);
.cbe {
display: grid;
gap: $sp-3;
width: 100%;
max-width: 260px;
}
.cbe__row {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3;
border-radius: $r-sm;
border: 2px solid transparent;
cursor: pointer;
user-select: none;
transition: border-color $dur-quick $ease-out, background-color $dur-quick $ease-out;
}
.cbe__row--error {
border-color: $error;
background: $error-soft;
}
.cbe__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbe__box {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: $r-sm;
border: 2px solid rgba($subject-ink, .32);
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;
svg { opacity: 0; transition: opacity $dur-quick $ease-out; }
}
.cbe__input:checked + .cbe__box {
background: $subject-blue;
border-color: $subject-blue;
svg { opacity: 1; }
}
.cbe__input:focus-visible + .cbe__box {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.cbe__text {
color: $subject-ink;
font-size: 14px;
font-weight: 600;
}
.cbe__hint {
margin: 0;
padding-left: $sp-3;
color: rgba($subject-ink, .55);
font-size: 12px;
transition: color $dur-quick $ease-out, opacity $dur-quick $ease-out;
}
.cbe__hint--error {
color: $error;
opacity: 1;
}
.cbe__submit {
justify-self: start;
padding: $sp-2 $sp-5;
border: none;
border-radius: $r-sm;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
@media (max-width: 340px) {
.cbe { max-width: 100%; }
.cbe__text { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.cbe__row, .cbe__box, .cbe__box svg, .cbe__hint { 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);
}
.cbe {
display: grid;
gap: 12px;
width: 100%;
max-width: 260px;
}
.cbe__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
border: 2px solid transparent;
cursor: pointer;
user-select: none;
transition: border-color 220ms ease-out, background-color 220ms ease-out;
}
.cbe__row--error {
border-color: #c2361a;
background: #ffe0d8;
}
.cbe__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbe__box {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: 8px;
border: 2px solid rgba(23, 20, 26, 0.32);
color: #fff7e6;
display: grid;
place-items: center;
box-sizing: border-box;
transition: background-color 220ms ease-out, border-color 220ms ease-out;
}
.cbe__box svg {
opacity: 0;
transition: opacity 220ms ease-out;
}
.cbe__input:checked + .cbe__box {
background: #2f6df6;
border-color: #2f6df6;
}
.cbe__input:checked + .cbe__box svg {
opacity: 1;
}
.cbe__input:focus-visible + .cbe__box {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.cbe__text {
color: #17141a;
font-size: 14px;
font-weight: 600;
}
.cbe__hint {
margin: 0;
padding-left: 12px;
color: rgba(23, 20, 26, 0.55);
font-size: 12px;
transition: color 220ms ease-out, opacity 220ms ease-out;
}
.cbe__hint--error {
color: #c2361a;
opacity: 1;
}
.cbe__submit {
justify-self: start;
padding: 8px 20px;
border: none;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
@media (max-width: 340px) {
.cbe {
max-width: 100%;
}
.cbe__text {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.cbe__row, .cbe__box, .cbe__box svg, .cbe__hint {
transition: none;
}
}(function () {
var SHAKE_MS = 420; // 계산 겹 상수: 흔들림 감쇠가 끝나는 데 걸리는 실제 시간(ms)
var SHAKE_FREQ = 2 * Math.PI * 5 / SHAKE_MS; // 흔들림 진동 주파수
var SHAKE_ZETA = 4.2; // 감쇠 계수
var SHAKE_AMP = 8; // px, 최대 흔들림 폭
var CYCLE_MS = 480;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('cbe');
var input = document.getElementById('cbeInput');
var box = document.getElementById('cbeBox');
var row = document.getElementById('cbeRow');
var hint = document.getElementById('cbeHint');
var submit = document.getElementById('cbeSubmit');
var isDemo = true;
var shaking = false, shakeStart = 0;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function shakeOffset(t) {
// 계산 겹: 감쇠 사인 진동 — 시간이 지날수록 진폭이 줄어드는 흔들림 값을 직접 계산
var amp = Math.exp(-SHAKE_ZETA * (t / SHAKE_MS));
return SHAKE_AMP * amp * Math.sin(SHAKE_FREQ * t);
}
function triggerError() {
row.classList.add('cbe__row--error');
hint.classList.add('cbe__hint--error');
shakeStart = performance.now();
shaking = true;
}
function clearError() {
row.classList.remove('cbe__row--error');
hint.classList.remove('cbe__hint--error');
}
input.addEventListener('change', function () {
human();
if (input.checked) clearError();
});
submit.addEventListener('click', function () {
human();
if (!input.checked) { triggerError(); } else { clearError(); }
});
var cycleStart = 0;
var lastStep = -1;
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
var step = Math.floor((ts - cycleStart) / CYCLE_MS) % 3;
if (step !== lastStep) {
lastStep = step;
if (step === 0) { input.checked = false; clearError(); }
else if (step === 1) { triggerError(); }
else { input.checked = true; clearError(); }
}
}
if (shaking) {
var t = ts - shakeStart;
if (t >= SHAKE_MS) {
shaking = false;
row.style.transform = '';
} else {
row.style.transform = 'translateX(' + shakeOffset(t).toFixed(2) + 'px)';
}
}
requestAnimationFrame(loop);
}
if (reduce) { SHAKE_MS = 1; }
requestAnimationFrame(loop);
})();// ErrorShakeRequired — vanilla 판(run/368/demo/07-custom-checkbox-error-shake-required/index.html)
// 의 React 포트. 감쇠 사인 진동(exp(-SHAKE_ZETA·t)·sin(SHAKE_FREQ·t))을 직접 계산해 translateX
// 값으로 삼는다 — 트윈 하나로 끝내지 않고 매 프레임 진폭이 줄어드는 물리량을 적분한다.
import { useEffect, useRef } from "react";
import styles from "./ErrorShakeRequired.module.scss";
type Box<T> = { current: T };
export default function ErrorShakeRequired() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const inputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const rowRef = useRef(null) as unknown as Box<HTMLLabelElement>;
const hintRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const submitRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
let SHAKE_MS = 420; // 계산 겹 상수: 흔들림 감쇠가 끝나는 데 걸리는 실제 시간(ms)
const SHAKE_FREQ = (2 * Math.PI * 5) / SHAKE_MS; // 흔들림 진동 주파수
const SHAKE_ZETA = 4.2; // 감쇠 계수
const SHAKE_AMP = 8; // px, 최대 흔들림 폭
const CYCLE_MS = 480;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const input = inputRef.current;
const row = rowRef.current;
const hint = hintRef.current;
const submit = submitRef.current;
if (!root || !input || !row || !hint || !submit) return;
let isDemo = true;
let shaking = false, shakeStart = 0;
function human() {
if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; }
}
function shakeOffset(t: number) {
// 계산 겹: 감쇠 사인 진동 — 시간이 지날수록 진폭이 줄어드는 흔들림 값을 직접 계산
const amp = Math.exp(-SHAKE_ZETA * (t / SHAKE_MS));
return SHAKE_AMP * amp * Math.sin(SHAKE_FREQ * t);
}
function triggerError() {
row!.classList.add(styles.rowError);
hint!.classList.add(styles.hintError);
shakeStart = performance.now();
shaking = true;
}
function clearError() {
row!.classList.remove(styles.rowError);
hint!.classList.remove(styles.hintError);
}
function onInputChange() { human(); if (input!.checked) clearError(); }
input.addEventListener("change", onInputChange);
function onSubmitClick() {
human();
if (!input!.checked) { triggerError(); } else { clearError(); }
}
submit.addEventListener("click", onSubmitClick);
let cycleStart = 0;
let lastStep = -1;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
const step = Math.floor((ts - cycleStart) / CYCLE_MS) % 3;
if (step !== lastStep) {
lastStep = step;
if (step === 0) { input!.checked = false; clearError(); }
else if (step === 1) { triggerError(); }
else { input!.checked = true; clearError(); }
}
}
if (shaking) {
const t = ts - shakeStart;
if (t >= SHAKE_MS) {
shaking = false;
row!.style.transform = "";
} else {
row!.style.transform = "translateX(" + shakeOffset(t).toFixed(2) + "px)";
}
}
rafRef.current = requestAnimationFrame(loop);
}
if (reduce) { SHAKE_MS = 1; }
rafRef.current = requestAnimationFrame(loop);
return () => {
input.removeEventListener("change", onInputChange);
submit.removeEventListener("click", onSubmitClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cbe + " " + styles.isDemo} ref={rootRef}>
<label className={styles.cbeRow} ref={rowRef}>
<input type="checkbox" className={styles.cbeInput} ref={inputRef} required />
<span className={styles.cbeBox} aria-hidden="true">
<svg viewBox="0 0 20 20" width={14} height={14}>
<path 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.cbeText}>필수 약관에 동의합니다</span>
</label>
<p className={styles.cbeHint} ref={hintRef}>체크해야 다음으로 진행됩니다</p>
<button type="button" className={styles.cbeSubmit} ref={submitRef}>제출</button>
</div>
</main>
);
}07 Required-error shake toggle — when a submit attempt hits an empty required box, its horizontal-position value shakes briefly side to side — Measured area 9.7% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · CSS Custom Checkbox: 9 Interaction Patterns
The post this demo lives in
CSS Custom Checkbox: 9 Interaction PatternsCSS Custom Checkbox: One of nine draws its checkmark length to full on check, one bounces the box size then settles on t See all demos in this post
If you like this effect - similar demos
Inputs08 Fields that shake when submit catches them9 Form Validation UI Patterns — As You Type
Inputs06 Error-value shake toggle — when the number's error value turns on, the field's shake value rises then settles while the input value clearsCredit Card Input UI: 9 Patterns
Inputs05 Side summary that stacks up as you type9 Multi Step Form UI Patterns — Branch and Resume