05Rung lắc
`.a-shake` đẩy translateX qua lại sáu lần thật ngắn rồi tự dừng trong 0.5 giây, là class dùng một lần cho lời nhắc 'nhìn vào đây' chứ không riêng lỗi form.
Dùng choRung một badge tính năng mới, nhấn mạnh một icon, đánh dấu bước trong hướng dẫn sử dụng
translateX6회 왕복0.5초

<main class="stage">
<button class="badge a-shake is-demo" type="button">Mới</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 Rung lắc — Diện tích đo được 0.845% · khung hình chuyển động 6/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 CSS Keyframe Animations — dán 1 dòng là chạy
Bài viết chứa demo này
9 CSS Keyframe Animations — dán 1 dòng là chạyCSS keyframe animation là chuyển động lặp được định nghĩa bằng @keyframes rồi gộp vào một class duy nhất, dán vào phần t Xem tất cả demo trong bài này


