CSS 키프레임 애니메이션 9종 — 한 줄 복붙으로
CSS 키프레임 애니메이션은 @keyframes로 만든 움직임을 클래스 하나에 담아 어떤 요소에든 그대로 붙여 쓰는 방식입니다. 뜨기·튀기·돌기·펄스·흔들기·비틀기·깜빡임·심장박동·젤리, 아홉 가지를 한 번에 모았고 전부 transform과 opacity 두 속성만 씁니다.
01 basic-keyframe-motions-float-updown
02 basic-keyframe-motions-bounce
03 basic-keyframe-motions-spin
04 basic-keyframe-motions-pulse
05 basic-keyframe-motions-shake
06 basic-keyframe-motions-wobble
07 basic-keyframe-motions-flash
08 basic-keyframe-motions-heartbeat
09 basic-keyframe-motions-jelly
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
아홉 개는 이름 가나다순이 아니라 한 요소가 화면에서 맡는 역할 순서로 놓았습니다. 존재를 알리고(01~02), 대기 중임을 말하고(03~04), 주의를 끌고(05~06), 상태가 바뀌었음을 알립니다(07~09). 아래 표로 css 애니메이션 예제 아홉 개를 먼저 훑고, 각 항목 아래에서 코드를 확인하세요. 여기 아홉 개가 요소 전체를 움직이는 것과 달리 테두리 선만 도는 움직임은 CSS 테두리 애니메이션 9종에, 입력창에 포커스가 들어간 순간의 반응은 입력창 검색창 포커스 애니메이션에 따로 모아 두었습니다.
| 클래스 | 무엇이 움직이나 | duration | 반복 |
|---|---|---|---|
.a-float |
translateY | 3초 | 무한 |
.a-bounce |
translateY | 1초 | 무한 |
.a-spin |
rotate | 1초 | 무한 |
.a-pulse |
scale·opacity | 2초 | 무한 |
.a-shake |
translateX | 0.5초 | 한 번(트리거) |
.a-wobble |
translateX·rotate | 1초 | 무한 |
.a-flash |
opacity | 1초 | 무한 |
.a-heartbeat |
scale | 1.2초 | 무한 |
.a-jelly |
scaleX·scaleY | 0.6초 | 한 번(트리거) |
01둥둥 떠 있는
둥둥 뜨는 값 하나
.a-float 하나만 붙이면 요소가 위아래로 천천히 오갑니다.

<main class="stage">
<div class="badge a-float">
<span class="badge__dot" aria-hidden="true"></span>
신상품
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────
$duration: 3s; // 한 번 오가는 데 걸리는 시간
$easing: ease-in-out; // 뜨고 가라앉는 리듬이라 스프링보다 사인 곡선에 가까운 이 값을 쓴다
$color: $subject-blue; // 점 색
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
// ── 배지: .a-float 하나만 붙이면 어떤 요소든 떠오른다 ─────
.badge {
display: inline-flex;
align-items: center;
gap: clamp(8px, 3vw, 12px);
padding: clamp(14px, 4vw, 18px) clamp(24px, 6vw, 32px);
border-radius: $r-pill;
background: $subject-cream;
color: $stage-ink;
font-weight: 700; font-size: clamp(16px, 4.5vw, 20px); line-height: 1; font-family: inherit;
box-shadow: $shadow-raised;
}
.badge__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: $color;
flex: none;
}
.a-float {
animation: float $duration $easing infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
// ── 접근성: 움직임 줄이기 설정이면 뜨지 않는다 ──────────────
@media (prefers-reduced-motion: reduce) {
.a-float { animation: none; }
}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 {
display: inline-flex;
align-items: center;
gap: clamp(8px, 3vw, 12px);
padding: clamp(14px, 4vw, 18px) clamp(24px, 6vw, 32px);
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: clamp(16px, 4.5vw, 20px);
line-height: 1;
font-family: inherit;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.badge__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2f6df6;
flex: none;
}
.a-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-16px);
}
}
@media (prefers-reduced-motion: reduce) {
.a-float {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./FloatBadge.module.scss";
export type FloatBadgeProps = {
children: ReactNode;
/** 한 번 오가는 데 걸리는 시간 (기본 3000ms) */
duration?: number;
/** CSS easing (기본 ease-in-out) */
easing?: string;
/** 점 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function FloatBadge({
children,
duration = 3000,
easing = "ease-in-out",
color = "#2f6df6",
}: FloatBadgeProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.badge} style={vars}>
<span className={styles.dot} aria-hidden="true" />
{children}
</div>
);
}01 · 둥둥 떠 있는 — 측정 누적면적 4.311% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 붙이나
일러스트나 아이콘 배지처럼 화면 한구석에서 계속 존재를 알려야 하는 요소에 씁니다.
$duration: 3s;
$easing: ease-in-out;
.a-float { animation: float $duration $easing infinite; }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
02통통 튀는
바닥에 튕기는 리듬
.a-bounce는 바닥에 튕기는 리듬으로 짧게 내려갔다 크게 튀어 오르길 반복합니다.

<main class="stage">
<div class="hint a-bounce">
<span class="hint__arrow" aria-hidden="true"></span>
더 보기
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s;
$easing: cubic-bezier(.28, .84, .42, 1); // 떨어질 때 빠르고 튀어 오를 때 감속 — 중력을 흉내낸다
$color: $stage-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.hint {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: $color;
font-weight: 700; font-size: clamp(14px, 4vw, 17px); line-height: 1; font-family: inherit;
}
.hint__arrow {
width: 14px;
height: 14px;
border-right: 3px solid $color;
border-bottom: 3px solid $color;
transform: rotate(45deg);
}
// ── .a-bounce 하나로 바닥에 튕기는 리듬을 만든다 ──────────
.a-bounce {
animation: bounce $duration $easing infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
55% { transform: translateY(10px); }
75% { transform: translateY(-2px); }
90% { transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.a-bounce { animation: 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);
}
.hint {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: #17141a;
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
}
.hint__arrow {
width: 14px;
height: 14px;
border-right: 3px solid #17141a;
border-bottom: 3px solid #17141a;
transform: rotate(45deg);
}
.a-bounce {
animation: bounce 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
30% {
transform: translateY(-4px);
}
55% {
transform: translateY(10px);
}
75% {
transform: translateY(-2px);
}
90% {
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.a-bounce {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./BounceHint.module.scss";
export type BounceHintProps = {
children: ReactNode;
/** 한 번 튀는 데 걸리는 시간 (기본 1000ms) */
duration?: number;
/** CSS easing (기본 중력을 흉내낸 커스텀 베지어) */
easing?: string;
/** 화살표·글자 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function BounceHint({
children,
duration = 1000,
easing = "cubic-bezier(.28,.84,.42,1)",
color = "#17141a",
}: BounceHintProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.hint} style={vars}>
<span className={styles.arrow} aria-hidden="true" />
{children}
</div>
);
}02 · 통통 튀는 — 측정 누적면적 0.997% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
눌러도 되는 자리 알리기
스크롤 유도 화살표나 "더 보기" 아이콘처럼 눌러도 되는 자리라는 걸 몸짓으로 알려야 할 때 씁니다.
$duration: 1s;
$easing: cubic-bezier(.28, .84, .42, 1);
.a-bounce { animation: bounce $duration $easing infinite; }
@keyframes bounce {
0%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
55% { transform: translateY(10px); }
75% { transform: translateY(-2px); }
90% { transform: translateY(4px); }
}
03회전
로딩 스피너 전용이 아니다
.a-spin은 로딩 스피너 전용이 아닙니다.

<main class="stage">
<button class="btn" type="button">
<span class="icon a-spin" aria-hidden="true"></span>
새로고침
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s;
$easing: linear; // 가감속 없이 일정한 속도라야 회전이 끊겨 보이지 않는다
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.btn {
display: inline-flex;
align-items: center;
gap: 10px;
border: 0;
border-radius: $r-control;
padding: 14px 26px;
background: rgba(255, 255, 255, .14);
color: $subject-ink; // AA: 주황 면(흰 14% 겹침 #d2ada4) 위 6.28:1 — 스피너 링은 크림 유지
font-weight: 700; font-size: clamp(15px, 4vw, 18px); line-height: 1; font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.icon {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, .3);
border-top-color: $color; // 이 한 변만 다른 색이라 회전이 보인다
flex: none;
}
// ── .a-spin 은 로딩뿐 아니라 새로고침·동기화 아이콘에도 그대로 쓴다 ─
.a-spin {
animation: spin $duration $easing infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.a-spin { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.btn {
display: inline-flex;
align-items: center;
gap: 10px;
border: 0;
border-radius: 12px;
padding: 14px 26px;
background: rgba(255, 255, 255, 0.14);
color: #17141a;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.icon {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff7e6;
flex: none;
}
.a-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.a-spin {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SpinIcon.module.scss";
export type SpinIconProps = {
children: ReactNode;
/** 한 바퀴 도는 데 걸리는 시간 (기본 1000ms) */
duration?: number;
/** CSS easing (기본 linear — 끊김 없이 일정한 속도) */
easing?: string;
/** 링 강조 색 */
color?: string;
onClick?: () => void;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function SpinIcon({
children,
duration = 1000,
easing = "linear",
color = "#fff7e6",
onClick,
}: SpinIconProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button type="button" className={styles.btn} style={vars} onClick={onClick}>
<span className={styles.icon} aria-hidden="true" />
{children}
</button>
);
}03 · 회전 — 측정 누적면적 0.233% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
css 애니메이션 무한 반복을 붙일 수 있는 아이콘
새로고침 버튼이나 동기화 아이콘처럼 "지금 진행 중"을 말해야 하는 어떤 아이콘에도 그대로 붙일 수 있는 범용 회전 클래스입니다.
$duration: 1s;
$easing: linear; // 가감속 없이 일정해야 끊겨 보이지 않는다
.a-spin { animation: spin $duration $easing infinite; }
@keyframes spin {
to { transform: rotate(360deg); }
}
04펄스
레이아웃을 건드리지 않는 강조
.a-pulse는 scale과 opacity를 함께 오가며 레이아웃을 다시 그리지 않고도 시선을 붙잡습니다.

<main class="stage">
<button class="btn a-pulse" type="button">지금 시작하기</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2초 — 너무 빠르면 강조가 아니라 깜빡임으로 읽힌다
$easing: $ease-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.btn {
border: 0;
border-radius: $r-control;
padding: 16px 40px;
background: $subject-blue-deep; // AA: 파란 면을 깊게 — 흰 글자 6.3:1
color: #fff;
font-weight: 700; font-size: clamp(15px, 4vw, 18px); line-height: 1; font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
// ── .a-pulse 는 레이아웃을 다시 그리지 않고 scale·opacity 만 쓴다 ─
.a-pulse {
animation: pulse $duration $easing infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.06); opacity: .92; }
}
@media (prefers-reduced-motion: reduce) {
.a-pulse { animation: 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);
}
.btn {
border: 0;
border-radius: 12px;
padding: 16px 40px;
background: #0a4fe8;
color: #fff;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.a-pulse {
animation: pulse 2s ease-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.06);
opacity: 0.92;
}
}
@media (prefers-reduced-motion: reduce) {
.a-pulse {
animation: none;
}
}import type { CSSProperties, MouseEvent, ReactNode } from "react";
import styles from "./PulseButton.module.scss";
export type PulseButtonProps = {
children: ReactNode;
/** 한 번 오가는 데 걸리는 시간 (기본 2000ms) */
duration?: number;
/** CSS easing (기본 ease-out) */
easing?: string;
/** 버튼 색 */
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
type?: "button" | "submit";
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function PulseButton({
children,
duration = 2000,
easing = "ease-out",
color = "#0a4fe8",
onClick,
type = "button",
}: PulseButtonProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button type={type} className={styles.btn} style={vars} onClick={onClick}>
{children}
</button>
);
}04 · 펄스 — 측정 누적면적 7.199% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
라이브 점과 CTA 에
라이브 상태 점, 신규 기능 배지, 강조가 필요한 CTA 버튼에 씁니다.
$duration: 2s;
$easing: ease-out;
.a-pulse { animation: pulse $duration $easing infinite; }
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.06); opacity: .92; }
}
05흔들리는
폼 오류 전용이 아니다
.a-shake는 폼 검증 실패 전용이 아닙니다. "이 요소를 봐 달라"는 범용 주의 환기용으로, 짧게 왕복하고 스스로 멈춥니다.

<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 포함 · 비밀번호 보기
튜토리얼에서 한 지점 짚기
새 기능 배지를 흔들거나 튜토리얼에서 한 지점을 짚을 때 씁니다.
$duration: 500ms;
$easing: cubic-bezier(.36, .07, .19, .97);
$amount: 8px;
.a-shake.is-shaking { animation: shake $duration $easing 1; }
@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); }
}
06비틀거림
05 흔들림과 무엇이 다른가
.a-wobble은 translateX와 rotate를 같은 keyframes 안에서 서로 다른 지점에 겹쳐 05 흔들림보다 불규칙하게 움직입니다.

<main class="stage">
<div class="card a-wobble">
<span class="card__handle" aria-hidden="true">⋮⋮</span>
카드 A
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s;
$easing: ease-in-out;
$color: $stage-ink;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.card {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 16px 28px;
border-radius: $r-card;
background: $subject-cream;
color: $color;
font-weight: 700; font-size: clamp(15px, 4vw, 18px); line-height: 1; font-family: inherit;
box-shadow: $shadow-raised;
}
.card__handle {
letter-spacing: -2px;
opacity: .72; // AA: 크림 면 위 핸들 글자 6.9:1
}
// ── .a-wobble 은 translateX 와 rotate 를 같은 keyframes 안에서 어긋나게 겹친다 ─
.a-wobble {
animation: wobble $duration $easing infinite;
}
@keyframes wobble {
0%, 100% { transform: translateX(0) rotate(0deg); }
15% { transform: translateX(-6px) rotate(-4deg); }
30% { transform: translateX(5px) rotate(3deg); }
45% { transform: translateX(-4px) rotate(-2deg); }
60% { transform: translateX(3px) rotate(2deg); }
75% { transform: translateX(-2px) rotate(-1deg); }
}
@media (prefers-reduced-motion: reduce) {
.a-wobble { animation: 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);
}
.card {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 16px 28px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.card__handle {
letter-spacing: -2px;
opacity: 0.72;
}
.a-wobble {
animation: wobble 1s ease-in-out infinite;
}
@keyframes wobble {
0%, 100% {
transform: translateX(0) rotate(0deg);
}
15% {
transform: translateX(-6px) rotate(-4deg);
}
30% {
transform: translateX(5px) rotate(3deg);
}
45% {
transform: translateX(-4px) rotate(-2deg);
}
60% {
transform: translateX(3px) rotate(2deg);
}
75% {
transform: translateX(-2px) rotate(-1deg);
}
}
@media (prefers-reduced-motion: reduce) {
.a-wobble {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./WobbleCard.module.scss";
export type WobbleCardProps = {
children: ReactNode;
/** 한 번 도는 데 걸리는 시간 (기본 1000ms) */
duration?: number;
/** CSS easing (기본 ease-in-out) */
easing?: string;
/** 글자·핸들 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function WobbleCard({
children,
duration = 1000,
easing = "ease-in-out",
color = "#17141a",
}: WobbleCardProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.card} style={vars}>
<span className={styles.handle} aria-hidden="true">⋮⋮</span>
{children}
</div>
);
}06 · 비틀거림 — 측정 누적면적 2.026% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
드래그 가능한 카드에
드래그 가능한 카드나 재정렬되는 리스트 항목이 "저를 옮겨보세요"라고 말할 때 씁니다.
$duration: 1s;
$easing: ease-in-out;
.a-wobble { animation: wobble $duration $easing infinite; }
@keyframes wobble {
0%, 100% { transform: translateX(0) rotate(0deg); }
15% { transform: translateX(-6px) rotate(-4deg); }
30% { transform: translateX(5px) rotate(3deg); }
45% { transform: translateX(-4px) rotate(-2deg); }
60% { transform: translateX(3px) rotate(2deg); }
75% { transform: translateX(-2px) rotate(-1deg); }
}
07깜빡임
완전히 지우지 않는 깜빡임
.a-flash는 opacity를 두 번 끊어 깜빡이되 요소를 완전히 지우지는 않습니다.

<main class="stage">
<div class="notice">
<span class="notice__dot a-flash" aria-hidden="true"></span>
긴급 공지
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1s;
$easing: ease-in-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.notice {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 14px 28px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
color: $subject-ink; // AA: 주황 면(흰 14% 겹침 #d2ada4) 위 6.28:1 — 알림 점은 크림 유지
font-weight: 700; font-size: clamp(15px, 4vw, 18px); line-height: 1; font-family: inherit;
}
.notice__dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: $color;
flex: none;
}
// ── .a-flash 는 opacity 만으로 두 번 끊어 깜빡인다. 요소는 사라지지 않는다 ─
.a-flash {
animation: flash $duration $easing infinite;
}
@keyframes flash {
0%, 50%, 100% { opacity: 1; }
25%, 75% { opacity: .15; }
}
@media (prefers-reduced-motion: reduce) {
.a-flash { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.notice {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 14px 28px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #17141a;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
}
.notice__dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff7e6;
flex: none;
}
.a-flash {
animation: flash 1s ease-in-out infinite;
}
@keyframes flash {
0%, 50%, 100% {
opacity: 1;
}
25%, 75% {
opacity: 0.15;
}
}
@media (prefers-reduced-motion: reduce) {
.a-flash {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./FlashNotice.module.scss";
export type FlashNoticeProps = {
children: ReactNode;
/** 한 번 깜빡이는 데 걸리는 시간 (기본 1000ms) */
duration?: number;
/** CSS easing (기본 ease-in-out) */
easing?: string;
/** 점·글자 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function FlashNotice({
children,
duration = 1000,
easing = "ease-in-out",
color = "#fff7e6",
}: FlashNoticeProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.notice} style={vars}>
<span className={styles.dot} aria-hidden="true" />
{children}
</div>
);
}07 · 깜빡임 — 측정 누적면적 0.15% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
긴급 공지 아이콘에
긴급 공지 아이콘, 라이브 방송 중 표시 점처럼 확실히 눈에 띄어야 하는 곳에 씁니다.
$duration: 1s;
$easing: ease-in-out;
.a-flash { animation: flash $duration $easing infinite; }
@keyframes flash {
0%, 50%, 100% { opacity: 1; }
25%, 75% { opacity: .15; }
}
08심장박동
불균등한 심장 리듬
.a-heartbeat는 scale을 짧게 두 번 튀긴 뒤 쉬는 구간을 둬 실제 심장처럼 불균등한 리듬으로 뛰게 합니다.

<main class="stage">
<div class="like">
<span class="heart a-heartbeat" aria-hidden="true"></span>
좋아요 267
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.2s;
$easing: ease-in-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.like {
display: inline-flex;
align-items: center;
gap: 12px;
color: $stage-ink;
font-weight: 700; font-size: clamp(15px, 4vw, 18px); line-height: 1; font-family: inherit;
}
// 순수 CSS 하트 — 원 두 개 + 45도 회전한 사각형
.heart {
position: relative;
display: inline-block;
width: 34px;
height: 30px;
flex: none;
}
.heart::before,
.heart::after {
content: "";
position: absolute;
top: 0;
width: 18px;
height: 28px;
background: $color;
border-radius: 18px 18px 0 0;
}
.heart::before { left: 17px; transform: rotate(-45deg); transform-origin: 0 100%; } // 규칙8: 하트반쪽 밑변꼭짓점 회전
.heart::after { left: 0; transform: rotate(45deg); transform-origin: 100% 100%; } // 규칙8: 하트반쪽 밑변꼭짓점 회전
// ── .a-heartbeat 는 scale 을 두 번 짧게 튀우고 쉬어, 실제 심박처럼 불균등하다 ─
.a-heartbeat {
animation: heartbeat $duration $easing infinite;
}
@keyframes heartbeat {
0%, 100% { transform: scale(1); }
14% { transform: scale(1.24); }
28% { transform: scale(1); }
42% { transform: scale(1.18); }
70% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.a-heartbeat { animation: 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);
}
.like {
display: inline-flex;
align-items: center;
gap: 12px;
color: #17141a;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
}
.heart {
position: relative;
display: inline-block;
width: 34px;
height: 30px;
flex: none;
}
.heart::before,
.heart::after {
content: "";
position: absolute;
top: 0;
width: 18px;
height: 28px;
background: #2f6df6;
border-radius: 18px 18px 0 0;
}
.heart::before {
left: 17px;
transform: rotate(-45deg);
transform-origin: 0 100%;
}
.heart::after {
left: 0;
transform: rotate(45deg);
transform-origin: 100% 100%;
}
.a-heartbeat {
animation: heartbeat 1.2s ease-in-out infinite;
}
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
14% {
transform: scale(1.24);
}
28% {
transform: scale(1);
}
42% {
transform: scale(1.18);
}
70% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.a-heartbeat {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./HeartbeatLike.module.scss";
export type HeartbeatLikeProps = {
children: ReactNode;
/** 한 박자가 걸리는 시간 (기본 1200ms) */
duration?: number;
/** CSS easing (기본 ease-in-out) */
easing?: string;
/** 하트 색 */
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function HeartbeatLike({
children,
duration = 1200,
easing = "ease-in-out",
color = "#2f6df6",
}: HeartbeatLikeProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.like} style={vars}>
<span className={styles.heart} aria-hidden="true" />
{children}
</div>
);
}08 · 심장박동 — 측정 누적면적 0.356% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
모니터링 대시보드 점에
좋아요 아이콘의 대기 상태나 실시간 모니터링 대시보드의 상태 점에 씁니다.
$duration: 1.2s;
$easing: ease-in-out;
.a-heartbeat { animation: heartbeat $duration $easing infinite; }
@keyframes heartbeat {
0%, 100% { transform: scale(1); }
14% { transform: scale(1.24); }
28% { transform: scale(1); }
42% { transform: scale(1.18); }
70% { transform: scale(1); }
}
09젤리처럼 출렁
scaleX 와 scaleY 를 반대로
.a-jelly는 scaleX와 scaleY를 서로 반대 방향으로 움직여 눌렸다 놓인 순간 젤리처럼 출렁이다 제 모양으로 돌아옵니다.

<main class="stage">
<button class="btn a-jelly is-demo" type="button">장바구니 담기</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 600ms;
$easing: cubic-bezier(.68, -0.55, .27, 1.55); // 과도하게 넘겼다 돌아오는 탄성
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.btn {
border: 0;
border-radius: $r-control;
padding: 16px 34px;
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-jellying 을 켜면 한 번 출렁이고 멈춘다 ─────
.a-jelly.is-jellying { animation: jelly $duration $easing 1; }
// ── 데모: 클릭 전까지 2초마다 스스로 다시 출렁인다 ─────────
.a-jelly.is-demo { animation: jelly-loop $dur-loop $easing infinite; }
// scaleX 와 scaleY 를 서로 반대로 어긋나게 — 커지면서 좁아지고, 좁아지면서 커진다
@keyframes jelly {
0% { transform: scale(1, 1); }
20% { transform: scale(1.25, .75); }
40% { transform: scale(.75, 1.25); }
60% { transform: scale(1.15, .85); }
80% { transform: scale(.95, 1.05); }
100% { transform: scale(1, 1); }
}
// 같은 모양을 2초 루프의 60~100% 구간에 압축해 넣고 나머지는 쉰다
@keyframes jelly-loop {
0%, 60% { transform: scale(1, 1); }
68% { transform: scale(1.25, .75); }
76% { transform: scale(.75, 1.25); }
84% { transform: scale(1.15, .85); }
92% { transform: scale(.95, 1.05); }
100% { transform: scale(1, 1); }
}
@media (prefers-reduced-motion: reduce) {
.a-jelly.is-jellying, .a-jelly.is-demo { animation: none; }
}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);
}
.btn {
border: 0;
border-radius: 12px;
padding: 16px 34px;
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-jelly.is-jellying {
animation: jelly 600ms cubic-bezier(0.68, -0.55, 0.27, 1.55) 1;
}
.a-jelly.is-demo {
animation: jelly-loop 2s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite;
}
@keyframes jelly {
0% {
transform: scale(1, 1);
}
20% {
transform: scale(1.25, 0.75);
}
40% {
transform: scale(0.75, 1.25);
}
60% {
transform: scale(1.15, 0.85);
}
80% {
transform: scale(0.95, 1.05);
}
100% {
transform: scale(1, 1);
}
}
@keyframes jelly-loop {
0%, 60% {
transform: scale(1, 1);
}
68% {
transform: scale(1.25, 0.75);
}
76% {
transform: scale(0.75, 1.25);
}
84% {
transform: scale(1.15, 0.85);
}
92% {
transform: scale(0.95, 1.05);
}
100% {
transform: scale(1, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.a-jelly.is-jellying, .a-jelly.is-demo {
animation: none;
}
}// 누르면 젤리처럼 출렁이고 멈춘다. 실제로는 담기 요청이 성공했을 때 is-jellying 을 켠다.
var b = document.querySelector('.a-jelly');
b.addEventListener('click', function () {
b.classList.remove('is-demo'); b.classList.remove('is-jellying');
void b.offsetWidth;
b.classList.add('is-jellying');
});
b.addEventListener('animationend', function () { b.classList.remove('is-jellying'); });import { useCallback, useState } from "react";
import type { CSSProperties, MouseEvent, ReactNode } from "react";
import styles from "./JellyButton.module.scss";
export type JellyButtonProps = {
children: ReactNode;
/** 한 번 출렁이는 데 걸리는 시간 (기본 600ms) */
duration?: number;
/** CSS easing (기본 과도하게 넘겼다 돌아오는 커스텀 베지어) */
easing?: string;
/** 버튼 색 */
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// 실제 쓰임: 담기 요청이 성공했을 때 onClick 안에서 API 를 부르고, 결과와 무관하게 한 번 출렁인다.
export default function JellyButton({
children,
duration = 600,
easing = "cubic-bezier(.68,-0.55,.27,1.55)",
color = "#fff7e6",
onClick,
}: JellyButtonProps) {
const [jellying, setJellying] = useState(false);
const handle = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
setJellying(false);
requestAnimationFrame(() => setJellying(true));
onClick?.(e);
},
[onClick]
);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button
type="button"
className={`${styles.btn} ${jellying ? styles.jellying : ""}`}
style={vars}
onClick={handle}
onAnimationEnd={() => setJellying(false)}
>
{children}
</button>
);
}09 · 젤리처럼 출렁 — 측정 누적면적 7.007% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
장바구니 담기 완료에
장바구니 담기 완료나 클릭 후 통통 튀는 버튼 피드백에 씁니다.
$duration: 600ms;
$easing: cubic-bezier(.68, -0.55, .27, 1.55);
.a-jelly.is-jellying { animation: jelly $duration $easing 1; }
@keyframes jelly {
0% { transform: scale(1, 1); }
20% { transform: scale(1.25, .75); }
40% { transform: scale(.75, 1.25); }
60% { transform: scale(1.15, .85); }
80% { transform: scale(.95, 1.05); }
100% { transform: scale(1, 1); }
}
animation 축약형은 왜 8개 값이 이 순서인가
여덟 자리의 공식 순서
animation: 한 줄에 값을 몰아 쓸 때 순서를 외우지 않으면 duration과 delay를 헷갈리기 쉽습니다. MDN의 animation 단축 속성 문서가 정리한 공식 순서는 이렇습니다. animation-name은 다른 값과 겹칠 일이 없어 이 편의 코드처럼 맨 앞에 써도 그대로 파싱되지만, MDN은 모호함을 피하려면 맨 뒤에 두길 권장합니다.
| 자리 | 하위 속성 | 이 편의 예 | 기본값 |
|---|---|---|---|
| 1 | animation-duration |
3s |
0s |
| 2 | animation-timing-function |
ease-in-out |
ease |
| 3 | animation-delay |
(없으면 0) | 0s |
| 4 | animation-iteration-count |
infinite |
1 |
| 5 | animation-direction |
(없으면 normal) | normal |
| 6 | animation-fill-mode |
(없으면 none) | none |
| 7 | animation-play-state |
(없으면 running) | running |
| 8 | animation-name |
float |
none |
fill-mode 가 필요한 때
animation-fill-mode는 애니메이션이 뛰기 전·뛴 뒤에도 keyframes 값을 유지할지 정합니다. 05 흔들림과 09 젤리는 1(한 번만 재생)로 끝나는데, 0%와 100%가 둘 다 원래 모양이라 fill-mode 없이도 자연스럽게 제자리로 돌아옵니다. 만약 keyframes의 마지막 프레임이 원래 모습과 다르다면 forwards를 더해야 애니메이션이 끝난 뒤에도 그 모양이 유지됩니다.
어디서 깨지는가 — 함정 둘
클래스 둘을 겹치면 하나만 움직인다
.a-float와 .a-spin처럼 서로 다른 유틸리티 클래스를 같은 요소에 동시에 붙이면 둘 다 animation 축약 속성을 통째로 선언하기 때문에, CSS 캐스케이드 규칙에 따라 스타일시트에 나중에 정의된 규칙이 앞의 것을 완전히 덮어써 하나만 움직입니다. (class 속성에 적는 순서는 상관없습니다.) 한 요소에 두 움직임을 겹치려면 animation-name: float, spin;처럼 이름을 나열하고 animation-duration·animation-timing-function도 같은 개수로 나열해야 합니다. 이때 두 애니메이션이 같은 속성(transform)을 건드리면 기본값 replace가 서로를 덮어써 결국 하나만 보이는데, animation-composition: add를 주면 두 transform 값을 더해 함께 적용합니다. 이 속성은 2023년 7월부터 주요 브라우저에 널리 퍼졌습니다.
가장 낮게 측정된 04 펄스
아홉 개 중 04 펄스의 평균 강도(강도_평균)는 30.9로 가장 낮게 측정됐습니다. scale과 opacity를 미세하게만 움직이기 때문인데, 점처럼 작은 요소에 걸면 그 낮은 강도가 더 두드러져 거의 안 보일 수 있습니다. 버튼처럼 면적이 넓은 요소에 걸어야 은은하면서도 확실한 강조로 읽힙니다. 이 값들이 이미 들어간 아홉 개 파일의 압축 비밀번호는 nft4q6u2이고, 아래 버튼으로 받을 수 있습니다.
04 펄스처럼 강도가 낮아도 읽히는 움직임은 궤도가 반듯해서 가능한 것이고, 반대로 선 자체가 일부러 삐뚤빼뚤한 질감이 필요할 때도 있습니다. 같은 transform·opacity 두 속성만으로 그 결을 낸 예가 손그림 디자인 랜딩 UI 키트의 크레용 섹션 아홉 개입니다.
접근성
움직임만 끄고 정보는 남긴다
시스템에서 동작 줄이기를 켠 사용자를 위해 아홉 개 전부 @media (prefers-reduced-motion: reduce) 안에서 animation: none으로 끕니다. 움직임을 지우되 정보는 남기는 게 원칙이라, 05 흔들림은 배경색으로 대신 알리고 나머지는 애니메이션이 멈춘 정적 상태 그대로 읽을 수 있습니다. 이 미디어 쿼리를 어떤 브라우저가 읽는지는 MDN 문서의 호환성 표에 정리돼 있습니다.
@media (prefers-reduced-motion: reduce) {
.a-float, .a-bounce, .a-spin, .a-pulse,
.a-wobble, .a-flash, .a-heartbeat { animation: none; }
.a-shake.is-shaking, .a-jelly.is-jellying { animation: none; }
}
이어서 볼 글
다른 CSS 애니메이션 글은 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
아홉 개를 한 화면에 다 써도 되나요?
넣을 수는 있지만 한 화면에는 두세 개 이하가 좋습니다. 04 펄스와 07 깜빡임처럼 둘 다 "주목하라"는 신호를 가진 클래스를 한 화면에 여러 개 두면 어느 것도 특별해 보이지 않습니다. 03 회전과 08 심장박동처럼 상태가 다른 것끼리는 같이 둬도 자연스럽습니다. 한 화면에서 아이콘 하나만 움직이게 하고 싶다면 요소 전체 대신 SVG 선을 움직이는 CSS 아이콘 애니메이션 9종 쪽이 덜 시끄럽습니다.
css 애니메이션 만들기에서 두 개를 한 요소에 겹치려면 어떻게 하나요?
클래스 두 개를 그냥 같이 붙이면 스타일시트에 나중 정의된 클래스가 animation 전체를 덮어써 하나만 남습니다. animation-name에 이름을 쉼표로 나열하고, 같은 속성을 건드린다면 animation-composition: add나 accumulate로 합치는 방식을 쓰세요. 자세한 예시는 함정 섹션에 있습니다.
React에서는 클래스 토글 대신 뭘 쓰나요?
zip의 react/ 폴더는 duration·easing·color를 props로 받아 CSS 변수로 넘깁니다. 05 흔들림과 09 젤리처럼 한 번만 재생하는 것은 useState로 상태 클래스를 켜고 onAnimationEnd에서 꺼서, 다시 클릭했을 때 재생되게 만들었습니다.









