05흔들리는
`.a-shake`는 translateX를 좌우로 여섯 번 짧게 왕복시켜 0.5초 만에 멈추는 일회성 클래스로, 폼 에러가 아니라 '이 요소를 봐 달라'는 범용 주의 환기에 쓴다.
쓰임새 기능 배지 흔들기, 강조가 필요한 아이콘, 튜토리얼 하이라이트
translateX6회 왕복0.5초

<main class="stage">
<button class="badge a-shake is-demo" type="button">신규</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 500ms; // 흔들리는 한 번의 시간
$easing: cubic-bezier(.36, .07, .19, .97);
$color: $subject-cream;
$amount: 8px; // 흔들리는 폭 (변수는 아니지만 셋만 바꾸면 되게 여기 모아 둔다)
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.badge {
border: 0;
border-radius: $r-pill;
padding: 16px 36px;
background: $color;
color: $stage-ink;
font-weight: 700; font-size: clamp(15px, 4vw, 18px); line-height: 1; font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
// ── 실제 쓰임: is-shaking 을 켜면 여섯 번 왕복하고 스스로 멈춘다 ─
.a-shake.is-shaking { animation: shake $duration $easing 1; }
// ── 데모: 클릭 전까지 2초마다 스스로 다시 흔든다 ──────────
.a-shake.is-demo { animation: shake-loop $dur-loop $easing infinite; }
@keyframes shake {
10%, 90% { transform: translateX($amount * -.5); }
20%, 80% { transform: translateX($amount); }
30%, 70% { transform: translateX($amount * -1); }
40%, 60% { transform: translateX($amount * .7); }
50% { transform: translateX($amount * -.7); }
}
// shake 와 같은 모양을 2초 루프의 60~80% 구간에 압축해 넣고 나머지는 쉰다
@keyframes shake-loop {
0%, 60% { transform: translateX(0); }
62%, 78% { transform: translateX($amount * -.5); }
64%, 76% { transform: translateX($amount); }
66%, 74% { transform: translateX($amount * -1); }
68%, 72% { transform: translateX($amount * .7); }
70% { transform: translateX($amount * -.7); }
80%, 100% { transform: translateX(0); }
}
// ── 접근성: 흔들림 대신 배경으로 알린다 ──────────────────
@media (prefers-reduced-motion: reduce) {
.a-shake.is-shaking, .a-shake.is-demo { animation: none; }
.a-shake.is-shaking { background: $error-soft; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.badge {
border: 0;
border-radius: 999px;
padding: 16px 36px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.a-shake.is-shaking {
animation: shake 500ms cubic-bezier(0.36, 0.07, 0.19, 0.97) 1;
}
.a-shake.is-demo {
animation: shake-loop 2s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite;
}
@keyframes shake {
10%, 90% {
transform: translateX(-4px);
}
20%, 80% {
transform: translateX(8px);
}
30%, 70% {
transform: translateX(-8px);
}
40%, 60% {
transform: translateX(5.6px);
}
50% {
transform: translateX(-5.6px);
}
}
@keyframes shake-loop {
0%, 60% {
transform: translateX(0);
}
62%, 78% {
transform: translateX(-4px);
}
64%, 76% {
transform: translateX(8px);
}
66%, 74% {
transform: translateX(-8px);
}
68%, 72% {
transform: translateX(5.6px);
}
70% {
transform: translateX(-5.6px);
}
80%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.a-shake.is-shaking, .a-shake.is-demo {
animation: none;
}
.a-shake.is-shaking {
background: #ffe0d8;
}
}// 누르면 다시 흔든다. 실제 서비스에서는 원하는 시점에 is-shaking 을 켠다.
var b = document.querySelector('.a-shake');
b.addEventListener('click', function () {
b.classList.remove('is-demo'); b.classList.remove('is-shaking');
void b.offsetWidth;
b.classList.add('is-shaking');
});
b.addEventListener('animationend', function () { b.classList.remove('is-shaking'); });import { useCallback, useState } from "react";
import type { CSSProperties, MouseEvent, ReactNode } from "react";
import styles from "./ShakeBadge.module.scss";
export type ShakeBadgeProps = {
children: ReactNode;
/** 한 번 흔들리는 데 걸리는 시간 (기본 500ms) */
duration?: number;
/** CSS easing */
easing?: string;
/** 배지 색 */
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// 실제 쓰임: shakeKey 를 바꿀 때마다(예: 검증 실패 횟수) 다시 흔든다.
export default function ShakeBadge({
children,
duration = 500,
easing = "cubic-bezier(.36,.07,.19,.97)",
color = "#fff7e6",
onClick,
}: ShakeBadgeProps) {
const [shaking, setShaking] = useState(false);
const handle = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
setShaking(false);
requestAnimationFrame(() => setShaking(true));
onClick?.(e);
},
[onClick]
);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button
type="button"
className={`${styles.badge} ${shaking ? styles.shaking : ""}`}
style={vars}
onClick={handle}
onAnimationEnd={() => setShaking(false)}
>
{children}
</button>
);
}05 흔들리는 — 측정 누적면적 0.845% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 키프레임 애니메이션 9종 — 한 줄 복붙으로
이 데모가 속한 글
CSS 키프레임 애니메이션 9종 — 한 줄 복붙으로CSS 키프레임 애니메이션은 @keyframes로 만든 움직임을 클래스 하나에 담아 어떤 요소에든 그대로 붙여 쓰는 방식입니다. 뜨기·튀기·돌기·펄스·흔들기·비틀기·깜빡임·심장박동·젤리, 아홉 가지를 한 번에 모았고 이 글의 데모 전부 보기


