9 CSS Keyframe Animations, One Line, Copy-Paste
These nine CSS keyframe animation classes each wrap one keyframes block in a single class name, so adding that one class to any element runs the whole motion,
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
Auto-plays · click a tile to jump to its section · all nine in one zip
The nine below aren't ordered by name. They follow what a moving element is telling the page: 01 and 02 say it exists, 03 and 04 say it's working, 05 and 06 ask for a second look, and 07 through 09 mark a change that already happened. Skim the table below for the shape of each motion, then open any item's own section for the full class and its keyframes. Motion that stays in the frame rather than moving the whole box lives in 9 CSS border animations, and the focus-state versions built for form fields are collected in 9 CSS input focus animations.
| Class | What moves | Duration | Loop |
|---|---|---|---|
.a-float |
translateY | 3s | infinite |
.a-bounce |
translateY | 1s | infinite |
.a-spin |
rotate | 1s | infinite |
.a-pulse |
scale, opacity | 2s | infinite |
.a-shake |
translateX | 500ms | once, on trigger |
.a-wobble |
translateX, rotate | 1s | infinite |
.a-flash |
opacity | 1s | infinite |
.a-heartbeat |
scale | 1.2s | infinite |
.a-jelly |
scaleX, scaleY | 600ms | once, on trigger |
01Float up and down
A 16px rise on an ease-in-out curve
.a-float moves an element up 16px and back on an ease-in-out curve, so nothing about the motion reads as mechanical — it eases in and out the way a slow breath does.

<main class="stage">
<div class="badge a-float">
<span class="badge__dot" aria-hidden="true"></span>
New arrival
</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 · Float up and down — Measured area 4.311% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Holding a corner quietly
It suits an illustration or icon badge that needs to hold a corner of the screen without shouting for attention.
$duration: 3s;
$easing: ease-in-out;
.a-float { animation: float $duration $easing infinite; }
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-16px); }
}
02Bounce
Why a css keyframe animation bounce needs five stops
.a-bounce runs the same translateY property as float but through five uneven keyframe stops instead of two, so it dips, overshoots, and settles the way something actually landing on a surface would.

<main class="stage">
<div class="hint a-bounce">
<span class="hint__arrow" aria-hidden="true"></span>
See more
</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 · Bounce — Measured area 0.997% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Saying tap here without a label
Scroll-hint arrows and "load more" icons use it to say a tap here does something, with no label needed.
$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); }
}
03Spin
Why linear, not an easing curve
.a-spin turns an element a full 360 degrees on a strict linear timing function, since any easing curve would make the rotation visibly speed up or slow down once per loop.

<main class="stage">
<button class="btn" type="button">
<span class="icon a-spin" aria-hidden="true"></span>
Refresh
</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 · Spin — Measured area 0.233% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Refresh and sync icons too
It works equally well on a refresh icon or a sync icon, not only on a dedicated loading spinner.
$duration: 1s;
$easing: linear;
.a-spin { animation: spin $duration $easing infinite; }
@keyframes spin {
to { transform: rotate(360deg); }
}
04Pulse
Scale and opacity at the same midpoint
.a-pulse eases scale between 1 and 1.06 while opacity dips slightly at the same midpoint, so a live-status dot or a CTA button gets a soft nudge of attention without ever repainting the page's layout.

<main class="stage">
<button class="btn a-pulse" type="button">Get started now</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 · Pulse — Measured area 7.199% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
A nudge that never repaints layout
The two properties move together, so the opacity dip and the scale change never look out of sync.
$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; }
}
05Shake
Six steps that stop dead
.a-shake fires once on a trigger class rather than looping — six short left-right steps that finish in half a second and stop dead.

<main class="stage">
<button class="badge a-shake is-demo" type="button">New</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 · Shake — Measured area 0.845% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
Notice this, not error
It's built as a general "notice this" cue rather than a form-validation error, so it suits a new-feature badge just as well as a broken input.
$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); }
}
06Wobble
Staggered percentages, uneven motion
.a-wobble layers translateX and rotate inside one keyframes block at staggered percentages, so it never settles into the clean back-and-forth of 05's shake.

<main class="stage">
<div class="card a-wobble">
<span class="card__handle" aria-hidden="true">⋮⋮</span>
Card 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 · Wobble — Measured area 2.026% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Move me, not something is wrong
A draggable card or a list item mid-reorder leans on that extra unevenness to read as "move me" rather than "something's wrong."
$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); }
}
07Flash
Stepping opacity, never to zero
.a-flash steps opacity from 1 down to roughly 0.15 and back, twice, instead of fading all the way to zero, so the element never fully disappears between blinks.

<main class="stage">
<div class="notice">
<span class="notice__dot a-flash" aria-hidden="true"></span>
Urgent notice
</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 · Flash — Measured area 0.15% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Readable on a light or a dark stage
An urgent-notice icon or a live-broadcast dot reads clearly against a light or a dark stage either way.
$duration: 1s;
$easing: ease-in-out;
.a-flash { animation: flash $duration $easing infinite; }
@keyframes flash {
0%, 50%, 100% { opacity: 1; }
25%, 75% { opacity: .15; }
}
08Heartbeat
Two bumps and a rest
.a-heartbeat splits its keyframes into two quick scale bumps followed by a longer rest, copying the uneven beat-beat-pause rhythm of an actual pulse instead of a smooth, evenly spaced loop.

<main class="stage">
<div class="like">
<span class="heart a-heartbeat" aria-hidden="true"></span>
267 likes
</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 · Heartbeat — Measured area 0.356% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why irregularity reads as alive
A like icon's idle state or a monitoring dashboard's status dot both benefit from that irregularity reading as "alive."
$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); }
}
09Jelly wobble
scaleX and scaleY out of phase
.a-jelly moves scaleX and scaleY out of phase with each other, so an element squashes wide while it's short and tall while it's narrow before settling back into shape.

<main class="stage">
<button class="btn a-jelly is-demo" type="button">Add to cart</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 · Jelly wobble — Measured area 7.007% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Firing once, on release
It fires once, on release, which is exactly the timing an "added to cart" icon or a pressed button needs.
$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); }
}
What order do the animation shorthand values go in?
The eight slots in order
Writing .a-float's animation as animation: float 3s ease-in-out infinite; only works because each value lands in a fixed slot — swap two of them and a duration can get read as a delay instead. MDN's animation shorthand reference lists the eight slots in this order:
| Position | Sub-property | This post's example | Default if omitted |
|---|---|---|---|
| 1 | animation-name | float | none |
| 2 | animation-duration | 3s | 0s |
| 3 | animation-timing-function | ease-in-out | ease |
| 4 | animation-delay | (not set) | 0s |
| 5 | animation-iteration-count | infinite | 1 |
| 6 | animation-direction | (not set) | normal |
| 7 | animation-fill-mode | (not set) | none |
| 8 | animation-play-state | (not set) | running |
What fill-mode decides
animation-fill-mode decides whether an element keeps a keyframe's styling before the animation starts or after it ends, and MDN's animation-fill-mode page documents all four keywords:
| Value | Holds the first frame before start | Holds the last frame after end |
|---|---|---|
| none | no | no |
| forwards | no | yes |
| backwards | yes | no |
| both | yes | yes |
Why 05 and 09 need no fill-mode
05's shake and 09's jelly both run with a bare 1 for iteration-count and no fill-mode at all, and neither needs one: their 0% and 100% keyframes both land back on the resting transform, so the element returns to its starting look the instant playback finishes. Making a css keyframe animation stop at end on a frame that doesn't match the resting style needs forwards added, or the element snaps back to its pre-animation state the moment the animation ends.
Where it breaks — the trap
Two classes, one animation shorthand
Stack two of these classes on one element — .a-float next to .a-spin, say — and only one of them actually moves. Each class declares the full animation shorthand, and the cascade lets whichever rule wins on order or specificity replace the other's value outright instead of merging with it. Combining two keyframes on the same element means writing animation-name: float, spin; and repeating animation-duration and animation-timing-function as matching comma-separated lists, one entry per name, in the same order. Even that isn't automatically safe: because float and spin both animate transform, the default animation-composition: replace still lets the second value in the list overwrite the first frame by frame, so only the rotation would show. Setting animation-composition: add — documented on MDN's animation-composition page — tells the browser to sum both transforms instead of replacing one with the other, so the element both floats and spins at once.
The smallest footprint of the nine
Item 07's flash measured the smallest footprint of the nine when this site's render check ran them: a 0.15% cumulative changed area, barely above the 0.08% floor the check requires before calling something "moving," since two opacity steps shift far fewer pixels than any transform in this set. The nine files already carry values tuned past that floor, unlocked with the password nft4q6u2, typed exactly as it reads on this line.
Item 07's thin margin above that floor is possible because its path is perfectly even; the opposite goal, a line that wobbles on purpose, needs a different set of keyframes entirely. The crayon hand-drawn landing sections build nine of those out of the same two properties used here.
Accessibility
What replaces the motion
All nine drop out under prefers-reduced-motion: reduce, replacing animation: name duration easing infinite with animation: none per class rather than letting a css keyframe animation repeat at a faster or shorter length. 05's shake keeps its meaning without motion by switching to a background-color change on .is-shaking instead of the translateX steps, so a motion-sensitive viewer still gets told something happened. The other eight simply hold whatever static frame they were already resting on — a flat badge, an unblinking dot, and a button at scale(1) — since none of them carry information a static frame would lose. Which operating-system settings flip this media query on is listed on MDN's prefers-reduced-motion page.
Where to go next
Browse more of this line of demos on the CSS category hub, and see what's actually free to read here versus what's only in the zip on the about page.
FAQ
Can I use a CSS keyframe animation without Sass or a build step?
Yes — each class compiles down to plain CSS in the zip, so linking that stylesheet and adding the matching class name is all .a-float or .a-bounce need to run. The $duration and $easing variables only exist in the SCSS source for retuning the timing before compiling; the shipped CSS file already has fixed values baked in. The same no-build rule holds for the SVG icon animation set, where a stroke rather than the whole box is what moves.
How do I run two css keyframe animation examples on one element at once?
List every name in animation-name separated by commas, then repeat animation-duration and animation-timing-function with one comma-separated value per name in the same order. If both animations touch the same property, such as transform, add animation-composition: add so the values combine instead of the later one silently overwriting the earlier one — the trap section above walks through a .a-float plus .a-spin example.
What does the React version in the zip do differently from the class toggle?
The react/ folder's components take duration, easing, and color as props and pass them through as CSS custom properties instead of hard-coded SCSS variables. For the two that only play once, 05's shake and 09's jelly, the component flips a shaking or jellying flag off and straight back on with useState, then clears it again in onAnimationEnd, so clicking the button a second time replays the animation instead of doing nothing.









