05揺れる
`.a-shake` は translateX を左右に6回短く往復させて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 オリジナル · SCSS と React は zip に同梱 · CSS キーフレーム アニメーション 9選 — 一行でコピペOK
このデモが属する記事
CSS キーフレーム アニメーション 9選 — 一行でコピペOKCSS キーフレーム アニメーションは、@keyframesで定義した一連の変化をクラス名ひとつにまとめ、任意の要素へそのまま適用する書き方です。浮く・跳ねる・回る・脈打つ・揺れる・ぐらつく・点滅する・鼓動する・ぷるぷるする、9種類をtra この記事のデモをすべて見る


