9 CSS Bounce Animations — One Line, Copy-Paste
A CSS bounce animation makes an element squash, overshoot, and spring back instead of snapping into place, the way a cartoon character lands a jump.
01 onomatopoeia-motion-fuwafuwa-float
02 onomatopoeia-motion-poyon-bounce
03 onomatopoeia-motion-purupuru-jelly
04 onomatopoeia-motion-yurayura-sway
05 onomatopoeia-motion-kurukuru-spin
06 onomatopoeia-motion-pyonpyon-hop
07 onomatopoeia-motion-gunya-blob
08 onomatopoeia-motion-kira-sparkle
09 onomatopoeia-motion-nuru-smooth
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Fuwafuwa floaty motion
- 02 Poyon bounce
- 03 Purupuru jelly wobble
- 04 Yurayura sway
- 05 Kurukuru spin
- 06 Pyonpyon hop
- 07 Gunya blob morph
- 08 Kira sparkle flash
- 09 Nuru smooth glide
These nine aren't ordered alphabetically; they follow what a single interactive element is doing on the screen. 01 just keeps existing, breathing gently in a corner. 02 and 03 both read as eager for a touch, though only one of them needs it: 02 stays still until a cursor actually lands on it and snaps back like a squeezed toy, while 03 keeps quivering on its own the whole time, as if it's still waiting. 04 through 06 sit in the background doing their job without needing a click: swaying, spinning, hopping. 07 and 08 are decoration and reward, a shape that keeps reshaping itself and a badge that keeps catching light. 09 hands the visitor off to whatever comes next, and all nine take their names from Japanese sound-effect words for exactly this kind of springy motion. Skim the table for duration and trigger, then open any numbered section below for the real code. Add paper to the pop and the confetti celebration effects throw the party, and when a small gesture has to point the way, the CSS arrow animations show the direction.
| Class | What moves | Duration | Trigger |
|---|---|---|---|
.o-float |
translateY + rotate | 2.8s | always-on loop |
.o-poyon |
scale | 550ms | hover / focus, once |
.o-puru |
horizontal / vertical scale | 900ms | always-on loop |
.o-yura |
rotate | 2.4s | always-on loop |
.o-kuru |
rotate | 1.6s | always-on loop |
.o-pyon |
translateY + scale | 900ms | always-on loop |
.o-gunya |
border-radius | 3.2s | always-on loop |
.o-kira |
opacity + scale | 1.8s | always-on loop |
.o-nuru |
translateX + scale | 700ms | hover / focus, once |
01Fuwafuwa floaty motion
What you see
Add .o-float to a badge and it slowly drifts up about 14 pixels while tilting a few degrees, then eases back down, on a loop that never fully stops — like it's breathing.

<main class="stage">
<div class="badge o-float">
<span class="badge__dot" aria-hidden="true"></span>
New arrival
</div>
<span class="tag">Floaty</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────
$duration: 2.8s;
$easing: ease-in-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.stage { position: relative; }
.badge {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-6;
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;
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .12);
color: $subject-cream;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── .o-float 하나만 붙이면 어떤 요소든 떠오른다 ─────
.o-float {
animation: o-float $duration $easing infinite;
}
@keyframes o-float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-14px) rotate(-3deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.o-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);
}
.stage {
position: relative;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
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;
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.o-float {
animation: o-float 2.8s ease-in-out infinite;
}
@keyframes o-float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-14px) rotate(-3deg);
}
}
@media (prefers-reduced-motion: reduce) {
.o-float {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./FloatBadge.module.scss";
export type FloatBadgeProps = {
children: ReactNode;
/** 한 번 오가는 데 걸리는 시간 (기본 2800ms) */
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 = 2800,
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 · Fuwafuwa floaty motion — Measured area 3.606% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
It suits anything that needs to keep announcing itself in a corner of the screen without ever asking for a click, such as a new-arrival badge or an onboarding arrow.
$duration: 2.8s;
$easing: ease-in-out;
.o-float {
animation: o-float $duration $easing infinite;
}
@keyframes o-float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-14px) rotate(-3deg); }
}
02Poyon bounce
Where a css bounce animation on hover fits
.o-poyon only moves once a visitor's mouse or keyboard focus lands on it: the button shrinks in first, then overshoots past its resting size before settling, all inside one 550ms elastic curve.

<main class="stage">
<button class="btn o-poyon is-demo" type="button">Add to cart</button>
<span class="tag">Bouncy</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 550ms;
$easing: cubic-bezier(.34, 1.56, .64, 1); // 넘겼다 돌아오는 elastic overshoot
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.stage { position: relative; }
.btn {
position: relative;
border: 0;
border-radius: $r-control;
padding: $sp-4 $sp-8;
background: $stage-ink;
color: $color;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
// 바닥 접촉 그림자 — 몸이 눌릴 때(스쿼시) 넓고 짙게, 늘어날 때(오버슛) 좁고 옅게. 역수 관계 (형태 2차)
.btn::after {
content: "";
position: absolute;
left: 50%;
bottom: -8px;
width: 70%;
height: 8px;
border-radius: 50%;
background: rgba($stage-ink, .35);
filter: blur(2px);
transform: translateX(-50%) scale(1, 1);
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .12);
color: $stage-ink;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── 실제 쓰임: 마우스를 올리면(또는 포커스) 한 번 튕긴다 ─────
.btn.o-poyon:hover,
.btn.o-poyon:focus-visible { animation: o-poyon $duration $easing 1; }
.btn.o-poyon:hover::after,
.btn.o-poyon:focus-visible::after { animation: o-poyon-shadow $duration $easing 1; }
// ── 데모: 클릭 전까지 2초마다 스스로 튕긴다 ─────
.btn.o-poyon.is-demo { animation: o-poyon-loop $dur-loop $easing infinite; }
.btn.o-poyon.is-demo::after { animation: o-poyon-shadow-loop $dur-loop $easing infinite; }
// 사인파 soft-body — scale 을 두 직교 성분 scale(1+δ, 1-δ·.55) 로 갈라 면적을 대략 보존한다.
// δ 는 탄성 정착 진동의 표본(30%→-.16 · 55%→+.20 · 75%→-.06) — 몸이 가로로 눌리는 순간 세로는
// 역위상으로 상승한다. 서로 반대로 출렁여야 말랑한 인상이 산다 (형태 2차, 사용자 2026-09-14).
@keyframes o-poyon {
0% { transform: scale(1, 1); }
30% { transform: scale(.84, 1.09); }
55% { transform: scale(1.20, .89); }
75% { transform: scale(.94, 1.03); }
100% { transform: scale(1, 1); }
}
// 같은 모양을 2초 루프의 뒤쪽 40% 구간에 압축해 넣고 나머지는 쉰다
@keyframes o-poyon-loop {
0%, 60% { transform: scale(1, 1); }
74% { transform: scale(.84, 1.09); }
85% { transform: scale(1.20, .89); }
93% { transform: scale(.94, 1.03); }
100% { transform: scale(1, 1); }
}
// 그림자는 몸의 역(逆): 스쿼시(30·74%) 땐 가로 1.25배·진하게 — 무게가 바닥에 실리고,
// 오버슛(55·85%) 땐 .65배·옅게 — 몸이 떠 있음을 그림자가 알린다.
@keyframes o-poyon-shadow {
0% { transform: translateX(-50%) scale(1, 1); opacity: .85; }
30% { transform: translateX(-50%) scale(1.25, 1.35); opacity: 1; }
55% { transform: translateX(-50%) scale(.65, .8); opacity: .35; }
75% { transform: translateX(-50%) scale(1.08, 1.15); opacity: .9; }
100% { transform: translateX(-50%) scale(1, 1); opacity: .85; }
}
@keyframes o-poyon-shadow-loop {
0%, 60% { transform: translateX(-50%) scale(1, 1); opacity: .85; }
74% { transform: translateX(-50%) scale(1.25, 1.35); opacity: 1; }
85% { transform: translateX(-50%) scale(.65, .8); opacity: .35; }
93% { transform: translateX(-50%) scale(1.08, 1.15); opacity: .9; }
100% { transform: translateX(-50%) scale(1, 1); opacity: .85; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.btn.o-poyon:hover, .btn.o-poyon:focus-visible,
.btn.o-poyon:hover::after, .btn.o-poyon:focus-visible::after,
.btn.o-poyon.is-demo, .btn.o-poyon.is-demo::after { 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);
}
.stage {
position: relative;
}
.btn {
position: relative;
border: 0;
border-radius: 12px;
padding: 16px 32px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.btn::after {
content: "";
position: absolute;
left: 50%;
bottom: -8px;
width: 70%;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.35);
filter: blur(2px);
transform: translateX(-50%) scale(1, 1);
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.btn.o-poyon:hover,
.btn.o-poyon:focus-visible {
animation: o-poyon 550ms cubic-bezier(0.34, 1.56, 0.64, 1) 1;
}
.btn.o-poyon:hover::after,
.btn.o-poyon:focus-visible::after {
animation: o-poyon-shadow 550ms cubic-bezier(0.34, 1.56, 0.64, 1) 1;
}
.btn.o-poyon.is-demo {
animation: o-poyon-loop 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
.btn.o-poyon.is-demo::after {
animation: o-poyon-shadow-loop 2s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
@keyframes o-poyon {
0% {
transform: scale(1, 1);
}
30% {
transform: scale(0.84, 1.09);
}
55% {
transform: scale(1.2, 0.89);
}
75% {
transform: scale(0.94, 1.03);
}
100% {
transform: scale(1, 1);
}
}
@keyframes o-poyon-loop {
0%, 60% {
transform: scale(1, 1);
}
74% {
transform: scale(0.84, 1.09);
}
85% {
transform: scale(1.2, 0.89);
}
93% {
transform: scale(0.94, 1.03);
}
100% {
transform: scale(1, 1);
}
}
@keyframes o-poyon-shadow {
0% {
transform: translateX(-50%) scale(1, 1);
opacity: 0.85;
}
30% {
transform: translateX(-50%) scale(1.25, 1.35);
opacity: 1;
}
55% {
transform: translateX(-50%) scale(0.65, 0.8);
opacity: 0.35;
}
75% {
transform: translateX(-50%) scale(1.08, 1.15);
opacity: 0.9;
}
100% {
transform: translateX(-50%) scale(1, 1);
opacity: 0.85;
}
}
@keyframes o-poyon-shadow-loop {
0%, 60% {
transform: translateX(-50%) scale(1, 1);
opacity: 0.85;
}
74% {
transform: translateX(-50%) scale(1.25, 1.35);
opacity: 1;
}
85% {
transform: translateX(-50%) scale(0.65, 0.8);
opacity: 0.35;
}
93% {
transform: translateX(-50%) scale(1.08, 1.15);
opacity: 0.9;
}
100% {
transform: translateX(-50%) scale(1, 1);
opacity: 0.85;
}
}
@media (prefers-reduced-motion: reduce) {
.btn.o-poyon:hover, .btn.o-poyon:focus-visible,
.btn.o-poyon:hover::after, .btn.o-poyon:focus-visible::after,
.btn.o-poyon.is-demo, .btn.o-poyon.is-demo::after {
animation: none;
}
}import type { CSSProperties, MouseEvent, ReactNode } from "react";
import styles from "./PoyonButton.module.scss";
export type PoyonButtonProps = {
children: ReactNode;
/** 한 번 튕기는 데 걸리는 시간 (기본 550ms) */
duration?: number;
/** CSS easing (기본 넘겼다 돌아오는 elastic overshoot) */
easing?: string;
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
// 마우스를 올리거나 키보드로 포커스하면 한 번 튕긴다. 상태 없이 :hover·:focus-visible 로 CSS 가 처리한다.
export default function PoyonButton({
children,
duration = 550,
easing = "cubic-bezier(.34,1.56,.64,1)",
color = "#fff7e6",
onClick,
}: PoyonButtonProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button type="button" className={styles.btn} style={vars} onClick={onClick}>
{children}
</button>
);
}02 · Poyon bounce — Measured area 5.788% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
That squeeze-then-spring shape is what makes an add-to-cart or a like button feel like it's reacting to being pressed rather than just registering a click.
$duration: 550ms;
$easing: cubic-bezier(.34, 1.56, .64, 1);
.btn.o-poyon:hover,
.btn.o-poyon:focus-visible { animation: o-poyon $duration $easing 1; }
@keyframes o-poyon {
0% { transform: scale(1, 1); }
30% { transform: scale(.84, 1.09); }
55% { transform: scale(1.20, .89); }
75% { transform: scale(.94, 1.03); }
100% { transform: scale(1, 1); }
}
03Purupuru jelly wobble
What moves
.o-puru never stops: it nudges horizontal and vertical scale a few percent apart in opposite directions, so a wishlist heart or a notification bell keeps trembling just enough to catch the eye without ever changing size overall.

<main class="stage">
<button class="icon o-puru is-demo" type="button" aria-label="Wishlist">
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M12 21s-7.5-4.6-10-9.3C.4 8 2 4.5 5.6 4 8 3.6 10 5 12 7c2-2 4-3.4 6.4-3 3.6.5 5.2 4 3.6 7.7C19.5 16.4 12 21 12 21z" fill="currentColor"/></svg>
</button>
<span class="tag">Jelly</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 900ms;
$easing: ease-in-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.stage { position: relative; }
.icon {
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: $stage-ink;
color: $color;
cursor: pointer;
box-shadow: $shadow-raised;
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba($subject-cream, .25); // 먹 틴트는 주황 면을 어둡게 해 잉크 글자가 4.32:1 이었다 — 크림 틴트로 7.00:1
color: $stage-ink;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── 사인파 soft-body 진동 — scale(1+δ, 1-δ·φ) 로 부피를 보존한 채 위상차로 흔든다 ──
// δ = 0.07·sin(2πt), 세로는 가로보다 위상이 π/2 늦는 cos 을 쓴다(직교 성분 — 서로 반대로 출렁).
// scaleX 가 최대인 순간 scaleY 는 0을 지난다 — 몸이 한 방향으로 눌리면 다른 방향은 잠시 쉰다 (형태 2차, 사용자 2026-09-14).
// keyframes 네 지점은 t = pct/100·2π 대입 결과: 25%→sin 1·cos 0 / 50%→sin 0·cos -1 / 75%→sin -1·cos 0
.o-puru {
animation: o-puru $duration $easing infinite;
}
@keyframes o-puru {
0%, 100% { transform: scale(1, 1); }
25% { transform: scale(1.07, .94); }
50% { transform: scale(1, 1.07); }
75% { transform: scale(.93, 1); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.o-puru { 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);
}
.stage {
position: relative;
}
.icon {
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: #17141a;
color: #fff7e6;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.25);
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.o-puru {
animation: o-puru 900ms ease-in-out infinite;
}
@keyframes o-puru {
0%, 100% {
transform: scale(1, 1);
}
25% {
transform: scale(1.07, 0.94);
}
50% {
transform: scale(1, 1.07);
}
75% {
transform: scale(0.93, 1);
}
}
@media (prefers-reduced-motion: reduce) {
.o-puru {
animation: none;
}
}import type { CSSProperties, MouseEvent } from "react";
import styles from "./JellyHeartIcon.module.scss";
export type JellyHeartIconProps = {
/** 스크린리더용 라벨 (기본 "찜하기") */
label?: string;
/** 한 번 떠는 데 걸리는 시간 (기본 900ms) */
duration?: number;
easing?: string;
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function JellyHeartIcon({
label = "찜하기",
duration = 900,
easing = "ease-in-out",
color = "#fff7e6",
onClick,
}: JellyHeartIconProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button type="button" className={styles.icon} style={vars} aria-label={label} onClick={onClick}>
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path
d="M12 21s-7.5-4.6-10-9.3C.4 8 2 4.5 5.6 4 8 3.6 10 5 12 7c2-2 4-3.4 6.4-3 3.6.5 5.2 4 3.6 7.7C19.5 16.4 12 21 12 21z"
fill="currentColor"
/>
</svg>
</button>
);
}03 · Purupuru jelly wobble — Measured area 0.772% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
It's built for icons that are asking for a tap, not ones only reporting a status.
$duration: 900ms;
$easing: ease-in-out;
.o-puru {
animation: o-puru $duration $easing infinite;
}
@keyframes o-puru {
0%, 100% { transform: scale(1, 1); }
25% { transform: scale(1.07, .94); }
50% { transform: scale(1, 1.07); }
75% { transform: scale(.93, 1); }
}
04Yurayura sway
The moment it fires
.o-yura pins its transform-origin to the top edge and rotates the whole element a few degrees left, then right, over 2.4 slow seconds — the way a paper tag actually swings when it's hanging from a string.

<main class="stage">
<div class="tagwrap">
<span class="tagwrap__string" aria-hidden="true"></span>
<span class="tagwrap__tag o-yura">Sale</span>
</div>
<span class="tag">Sway</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 2.4s;
$easing: ease-in-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.stage { position: relative; }
.tagwrap {
display: flex;
flex-direction: column;
align-items: center;
}
.tagwrap__string {
width: 2px;
height: 28px;
background: $stage-ink;
opacity: .4;
}
.tagwrap__tag {
padding: $sp-3 $sp-6;
border-radius: $r-sm;
background: $stage-ink;
color: $subject-cream;
font-weight: 700;
font-size: clamp(16px, 4.5vw, 20px);
line-height: 1;
font-family: inherit;
box-shadow: $shadow-raised;
transform-origin: top center; // 규칙8: 줄 매달림 top 피벗
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── transform-origin 을 위쪽에 고정하고 rotate 만 아주 천천히 오간다 ─────
.o-yura {
animation: o-yura $duration $easing infinite;
color: $color;
}
@keyframes o-yura {
0%, 100% { transform: rotate(-6deg); }
50% { transform: rotate(6deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.o-yura { 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);
}
.stage {
position: relative;
}
.tagwrap {
display: flex;
flex-direction: column;
align-items: center;
}
.tagwrap__string {
width: 2px;
height: 28px;
background: #17141a;
opacity: 0.4;
}
.tagwrap__tag {
padding: 12px 24px;
border-radius: 8px;
background: #17141a;
color: #fff7e6;
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);
transform-origin: top center;
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.o-yura {
animation: o-yura 2.4s ease-in-out infinite;
color: #2f6df6;
}
@keyframes o-yura {
0%, 100% {
transform: rotate(-6deg);
}
50% {
transform: rotate(6deg);
}
}
@media (prefers-reduced-motion: reduce) {
.o-yura {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SwayTag.module.scss";
export type SwayTagProps = {
children: ReactNode;
/** 한 번 오가는 데 걸리는 시간 (기본 2400ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function SwayTag({
children,
duration = 2400,
easing = "ease-in-out",
color = "#2f6df6",
}: SwayTagProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.wrap}>
<span className={styles.string} aria-hidden="true" />
<span className={styles.tag} style={vars}>
{children}
</span>
</div>
);
}04 · Yurayura sway — Measured area 1.628% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
It reads best on anything drawn as literally dangling from something: a price tag, a bell, a banner accent.
$duration: 2.4s;
$easing: ease-in-out;
.o-yura {
animation: o-yura $duration $easing infinite;
transform-origin: top center;
}
@keyframes o-yura {
0%, 100% { transform: rotate(-6deg); }
50% { transform: rotate(6deg); }
}
05Kurukuru spin
What the user notices
.o-kuru still turns a full 360 degrees like any spinner, but it passes through 230 and 330 degrees at uneven points along the timeline instead of a flat linear sweep, so the rotation carries a small hitch instead of a perfectly even motor spin.

<main class="stage">
<button class="icon o-kuru is-demo" type="button" aria-label="Refresh">
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><path d="M20 4v5h-5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="tag">Spin</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.6s;
$easing: cubic-bezier(.65, 0, .35, 1);
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.stage { position: relative; }
.icon {
width: 60px;
height: 60px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 247, 230, .1);
color: $color;
cursor: pointer;
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .12);
color: $subject-cream;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── linear 대신 구간마다 다른 지점을 지나게 해 한 박자 튕기는 듯한 회전을 만든다 ─────
.o-kuru {
animation: o-kuru $duration $easing infinite;
}
@keyframes o-kuru {
0% { transform: rotate(0deg); }
55% { transform: rotate(230deg); }
80% { transform: rotate(330deg); }
100% { transform: rotate(360deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.o-kuru { 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);
}
.stage {
position: relative;
}
.icon {
width: 60px;
height: 60px;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
cursor: pointer;
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.o-kuru {
animation: o-kuru 1.6s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
@keyframes o-kuru {
0% {
transform: rotate(0deg);
}
55% {
transform: rotate(230deg);
}
80% {
transform: rotate(330deg);
}
100% {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.o-kuru {
animation: none;
}
}import type { CSSProperties, MouseEvent } from "react";
import styles from "./SpinRefreshIcon.module.scss";
export type SpinRefreshIconProps = {
label?: string;
/** 한 바퀴 도는 데 걸리는 시간 (기본 1600ms) */
duration?: number;
easing?: string;
color?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function SpinRefreshIcon({
label = "새로고침",
duration = 1600,
easing = "cubic-bezier(.65,0,.35,1)",
color = "#fff7e6",
onClick,
}: SpinRefreshIconProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<button type="button" className={styles.icon} style={vars} aria-label={label} onClick={onClick}>
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true">
<path d="M20 12a8 8 0 1 1-2.34-5.66" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" />
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}05 · Kurukuru spin — Measured area 0.403% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
That one-beat catch is what separates a refresh or sync icon from a plain loading wheel.
$duration: 1.6s;
$easing: cubic-bezier(.65, 0, .35, 1);
.o-kuru {
animation: o-kuru $duration $easing infinite;
}
@keyframes o-kuru {
0% { transform: rotate(0deg); }
55% { transform: rotate(230deg); }
80% { transform: rotate(330deg); }
100% { transform: rotate(360deg); }
}
06Pyonpyon hop
Where this shows up
.o-pyon squashes its vertical scale flat right before takeoff and right before landing, then stretches taller at the peak of a 16px jump, borrowing the timing a real hop needs to look springy instead of just sliding up and down.

<main class="stage">
<div class="hint o-pyon is-demo">
<span class="hint__arrow" aria-hidden="true">↓</span>
Load more
</div>
<span class="tag">Hop</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 900ms;
$easing: ease-in-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-yellow);
.stage { position: relative; }
.hint {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-6;
border-radius: $r-pill;
background: $stage-ink;
color: $subject-cream;
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
box-shadow: $shadow-raised;
}
// 바닥 접촉 그림자 — 알약 자체와 별개 레이어. 착지 순간(스쿼시) 넓고 짙게, 정점(공중)엔 좁고 옅게 ──
.hint::after {
content: "";
position: absolute;
left: 50%;
bottom: -8px;
width: 60%;
height: 8px;
border-radius: 50%;
background: rgba($stage-ink, .38);
filter: blur(2px);
transform: translateX(-50%) scale(1, 1);
}
.hint__arrow { font-size: 18px; line-height: 1; color: $subject-blue-light; } // 먹 알약 위 파란 글자 — AA 4.5
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .12);
color: $stage-ink;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── 사인파 soft-body + 바닥 그림자 (형태 2차, 사용자 2026-09-14) ─────
// 몸: border-radius 여덟 값(가로4·세로4)을 위상차 π/4 사인파 r_k = 50 + 10·sin(2πt + k·π/4) 로 흔들고,
// scaleX·scaleY 는 곱이 1 (면적 보존) — 눌리면 옆으로 퍼지고 늘어나면 가늘어진다.
// keyframes 다섯 지점(0·10·40·70·85%)은 t = pct/100·2π 를 대입한 계산 결과값.
// 그림자: 몸이 -16px 뜬 순간 +16px 내려와 바닥에 붙어 있다(상쇄). 착지 스쿼시 땐 넓고 짙게(무게),
// 공중 정점 땐 좁고 옅게 — 몸이 높을수록 그림자는 퍼져 약해진다는 역수 관계로 계산했다.
.o-pyon {
animation: o-pyon $duration $easing infinite;
}
.o-pyon::after {
animation: o-pyon-shadow $duration $easing infinite;
}
@keyframes o-pyon {
0%, 100% { transform: translateY(0) scale(1, 1); border-radius: 50% 57% 60% 57% / 50% 43% 40% 43%; }
10% { transform: translateY(2px) scale(1.11, .9); border-radius: 56% 60% 58% 52% / 44% 40% 42% 48%; }
40% { transform: translateY(-16px) scale(.93, 1.08); border-radius: 56% 48% 42% 40% / 44% 52% 58% 60%; }
70% { transform: translateY(2px) scale(1.11, .9); border-radius: 40% 41% 47% 55% / 60% 59% 53% 45%; }
85% { transform: translateY(0) scale(1.02, 1.03); border-radius: 42% 48% 56% 60% / 58% 52% 44% 40%; }
}
@keyframes o-pyon-shadow {
0%, 100% { transform: translate(-50%, 0) scale(1, 1); opacity: .9; }
10% { transform: translate(-50%, 0) scale(1.14, 1.3); opacity: 1; }
40% { transform: translate(-50%, 16px) scale(.6, .75); opacity: .4; }
70% { transform: translate(-50%, 0) scale(1.14, 1.3); opacity: 1; }
85% { transform: translate(-50%, 0) scale(1.05, 1.1); opacity: .95; }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.o-pyon, .o-pyon::after { 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);
}
.stage {
position: relative;
}
.hint {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: clamp(14px, 4vw, 17px);
line-height: 1;
font-family: inherit;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.hint::after {
content: "";
position: absolute;
left: 50%;
bottom: -8px;
width: 60%;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.38);
filter: blur(2px);
transform: translateX(-50%) scale(1, 1);
}
.hint__arrow {
font-size: 18px;
line-height: 1;
color: #5b8cf8;
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.o-pyon {
animation: o-pyon 900ms ease-in-out infinite;
}
.o-pyon::after {
animation: o-pyon-shadow 900ms ease-in-out infinite;
}
@keyframes o-pyon {
0%, 100% {
transform: translateY(0) scale(1, 1);
border-radius: 50% 57% 60% 57%/50% 43% 40% 43%;
}
10% {
transform: translateY(2px) scale(1.11, 0.9);
border-radius: 56% 60% 58% 52%/44% 40% 42% 48%;
}
40% {
transform: translateY(-16px) scale(0.93, 1.08);
border-radius: 56% 48% 42% 40%/44% 52% 58% 60%;
}
70% {
transform: translateY(2px) scale(1.11, 0.9);
border-radius: 40% 41% 47% 55%/60% 59% 53% 45%;
}
85% {
transform: translateY(0) scale(1.02, 1.03);
border-radius: 42% 48% 56% 60%/58% 52% 44% 40%;
}
}
@keyframes o-pyon-shadow {
0%, 100% {
transform: translate(-50%, 0) scale(1, 1);
opacity: 0.9;
}
10% {
transform: translate(-50%, 0) scale(1.14, 1.3);
opacity: 1;
}
40% {
transform: translate(-50%, 16px) scale(0.6, 0.75);
opacity: 0.4;
}
70% {
transform: translate(-50%, 0) scale(1.14, 1.3);
opacity: 1;
}
85% {
transform: translate(-50%, 0) scale(1.05, 1.1);
opacity: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.o-pyon, .o-pyon::after {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./HopHint.module.scss";
export type HopHintProps = {
children: ReactNode;
/** 한 번 뛰는 데 걸리는 시간 (기본 900ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function HopHint({
children,
duration = 900,
easing = "ease-in-out",
color = "#2f6df6",
}: HopHintProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.hint} style={vars}>
<span className={styles.arrow} aria-hidden="true">
↓
</span>
{children}
</div>
);
}06 · Pyonpyon hop — Measured area 5.925% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
A "load more" arrow or a scroll hint gets its bounce from exactly that squash-and-stretch pair.
$duration: 900ms;
$easing: ease-in-out;
.o-pyon {
animation: o-pyon $duration $easing infinite;
}
@keyframes o-pyon {
0%, 100% { transform: translateY(0) scale(1, 1); }
10% { transform: translateY(2px) scale(1.11, .9); }
40% { transform: translateY(-16px) scale(.93, 1.08); }
70% { transform: translateY(2px) scale(1.11, .9); }
85% { transform: translateY(0) scale(1.02, 1.03); }
}
07Gunya blob morph
What happens on screen
.o-gunya changes all four corners of border-radius on their own schedule inside one keyframes block, so a background shape keeps reshaping into a new blob every third of its 3.2-second loop instead of holding one fixed curve.

<main class="stage">
<div class="blob o-gunya" aria-hidden="true"></div>
<span class="tag">Blobby</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 3.2s;
$easing: ease-in-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.stage { position: relative; }
.blob {
width: 140px;
height: 140px;
background: $color;
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .18);
color: $stage-ink;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── 사인파 soft-body — border-radius 여덟 값(가로4·세로4)을 위상차 π/4 의 사인파로 흔든다 ──
// r_k = 50 + 18·sin(2πt + k·π/4). 매끈한 원이 아니라 **여러 지점이 각자 다른 위상으로** 출렁여야
// 말랑한 덩어리로 보인다 — 한 값만 흔들면 타원이 커졌다 작아질 뿐이다 (형태 2차, 사용자 2026-09-14).
// keyframes 다섯 지점(0·20·45·70·95%)은 이 식을 t = pct/100·2π 로 대입한 계산 결과값이다.
.o-gunya {
animation: o-gunya $duration $easing infinite;
}
@keyframes o-gunya {
0%, 100% { border-radius: 50% 63% 68% 63% / 50% 37% 32% 37%; transform: rotate(0deg); }
20% { border-radius: 67% 66% 56% 42% / 33% 34% 44% 58%; transform: rotate(5deg); }
45% { border-radius: 56% 42% 33% 34% / 44% 58% 67% 66%; transform: rotate(2deg); }
70% { border-radius: 33% 34% 44% 58% / 67% 66% 56% 42%; transform: rotate(-5deg); }
95% { border-radius: 44% 58% 67% 66% / 56% 42% 33% 34%; transform: rotate(-2deg); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.o-gunya { 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);
}
.stage {
position: relative;
}
.blob {
width: 140px;
height: 140px;
background: #fff7e6;
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.18);
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.o-gunya {
animation: o-gunya 3.2s ease-in-out infinite;
}
@keyframes o-gunya {
0%, 100% {
border-radius: 50% 63% 68% 63%/50% 37% 32% 37%;
transform: rotate(0deg);
}
20% {
border-radius: 67% 66% 56% 42%/33% 34% 44% 58%;
transform: rotate(5deg);
}
45% {
border-radius: 56% 42% 33% 34%/44% 58% 67% 66%;
transform: rotate(2deg);
}
70% {
border-radius: 33% 34% 44% 58%/67% 66% 56% 42%;
transform: rotate(-5deg);
}
95% {
border-radius: 44% 58% 67% 66%/56% 42% 33% 34%;
transform: rotate(-2deg);
}
}
@media (prefers-reduced-motion: reduce) {
.o-gunya {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./BlobMorph.module.scss";
export type BlobMorphProps = {
/** 한 바퀴(세 모양) 도는 데 걸리는 시간 (기본 3200ms) */
duration?: number;
easing?: string;
color?: string;
size?: number;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--size"?: string;
};
export default function BlobMorph({
duration = 3200,
easing = "ease-in-out",
color = "#fff7e6",
size = 140,
}: BlobMorphProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--size": `${size}px`,
};
return <div className={styles.blob} style={vars} aria-hidden="true" />;
}07 · Gunya blob morph — Measured area 1.768% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
It's built to sit behind something else, decorating a hero section or a feature card without asking to be looked at directly.
$duration: 3.2s;
$easing: ease-in-out;
.o-gunya {
animation: o-gunya $duration $easing infinite;
}
@keyframes o-gunya {
0%, 100% { border-radius: 50% 63% 68% 63% / 50% 37% 32% 37%; transform: rotate(0deg); }
20% { border-radius: 67% 66% 56% 42% / 33% 34% 44% 58%; transform: rotate(5deg); }
45% { border-radius: 56% 42% 33% 34% / 44% 58% 67% 66%; transform: rotate(2deg); }
70% { border-radius: 33% 34% 44% 58% / 67% 66% 56% 42%; transform: rotate(-5deg); }
95% { border-radius: 44% 58% 67% 66% / 56% 42% 33% 34%; transform: rotate(-2deg); }
}
08Kira sparkle flash
What it looks like
.o-kira flashes two small dots built from ::before and ::after, one running 0.4 seconds behind the other, so a badge catches light twice per loop instead of blinking once as a whole.

<main class="stage">
<div class="badge o-kira">Recommended</div>
<span class="tag">Sparkle</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 1.8s;
$easing: ease-in-out;
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-paper);
.stage { position: relative; }
.badge {
position: relative;
display: inline-flex;
align-items: center;
padding: $sp-3 $sp-6;
border-radius: $r-pill;
background: $stage-ink;
color: $subject-cream;
font-weight: 700;
font-size: clamp(15px, 4vw, 18px);
line-height: 1;
font-family: inherit;
box-shadow: $shadow-raised;
}
// ── 실제 두 개의 ::before·::after 별을 서로 다른 delay 로 반짝인다 ─────
.badge::before,
.badge::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: $color;
opacity: 0;
}
.badge::before { top: -5px; left: 8px; }
.badge::after { bottom: -4px; right: 4px; }
.badge.o-kira::before { animation: o-kira $duration $easing infinite; }
.badge.o-kira::after { animation: o-kira $duration $easing infinite; animation-delay: .4s; }
@keyframes o-kira {
0%, 45%, 100% { opacity: 0; transform: scale(.4); }
62% { opacity: 1; transform: scale(1.3); }
78% { opacity: 0; transform: scale(.6); }
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .08);
color: $stage-ink;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.badge.o-kira::before, .badge.o-kira::after { 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);
}
.stage {
position: relative;
}
.badge {
position: relative;
display: inline-flex;
align-items: center;
padding: 12px 24px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
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);
}
.badge::before,
.badge::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: #2f6df6;
opacity: 0;
}
.badge::before {
top: -5px;
left: 8px;
}
.badge::after {
bottom: -4px;
right: 4px;
}
.badge.o-kira::before {
animation: o-kira 1.8s ease-in-out infinite;
}
.badge.o-kira::after {
animation: o-kira 1.8s ease-in-out infinite;
animation-delay: 0.4s;
}
@keyframes o-kira {
0%, 45%, 100% {
opacity: 0;
transform: scale(0.4);
}
62% {
opacity: 1;
transform: scale(1.3);
}
78% {
opacity: 0;
transform: scale(0.6);
}
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
@media (prefers-reduced-motion: reduce) {
.badge.o-kira::before, .badge.o-kira::after {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SparkleBadge.module.scss";
export type SparkleBadgeProps = {
children: ReactNode;
/** 한 번 반짝이는 주기 (기본 1800ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function SparkleBadge({
children,
duration = 1800,
easing = "ease-in-out",
color = "#2f6df6",
}: SparkleBadgeProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.badge} style={vars}>
{children}
</div>
);
}08 · Kira sparkle flash — Measured area 0.399% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
A "recommended" or premium badge leans on that stagger to read as a glint rather than a warning flash.
$duration: 1.8s;
$easing: ease-in-out;
.badge.o-kira::before { animation: o-kira $duration $easing infinite; }
.badge.o-kira::after { animation: o-kira $duration $easing infinite; animation-delay: .4s; }
@keyframes o-kira {
0%, 45%, 100% { opacity: 0; transform: scale(.4); }
62% { opacity: 1; transform: scale(1.3); }
78% { opacity: 0; transform: scale(.6); }
}
09Nuru smooth glide
When to use it
.o-nuru waits for a hover or a keyboard focus, then slides an arrow 28px sideways while it grows slightly, on a curve close to ease-out-expo that decelerates hard right at the end.

<main class="stage">
<a class="link o-nuru is-demo" href="#">
Learn more
<span class="link__arrow" aria-hidden="true">→</span>
</a>
<span class="tag">Smooth</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 700ms;
$easing: cubic-bezier(.16, 1, .3, 1); // ease-out-expo 근사 — 매끄럽게 감속한다
$color: $subject-blue;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-ink);
.stage { position: relative; }
.link {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: $sp-3 $sp-2;
color: $subject-cream;
text-decoration: none;
font-weight: 700;
font-size: clamp(16px, 4.5vw, 19px);
line-height: 1;
font-family: inherit;
border-bottom: 2px solid rgba(255, 247, 230, .3);
}
.link__arrow {
display: inline-block;
color: $color;
font-size: 22px;
}
.tag {
position: absolute;
top: $sp-4;
right: $sp-4;
padding: $sp-1 $sp-3;
border-radius: $r-sm;
background: rgba(255, 255, 255, .12);
color: $subject-cream;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: .04em;
}
// ── 실제 쓰임: 마우스를 올리면(또는 포커스) 화살표가 미끄러지듯 이동한다 ─────
.link.o-nuru:hover .link__arrow,
.link.o-nuru:focus-visible .link__arrow { animation: o-nuru $duration $easing 1; }
// ── 데모: 2초마다 스스로 미끄러진다 ─────
.link.o-nuru.is-demo .link__arrow { animation: o-nuru-loop $dur-loop $easing infinite; }
@keyframes o-nuru {
0% { transform: translateX(0) scale(1); }
100% { transform: translateX(28px) scale(1.15); }
}
@keyframes o-nuru-loop {
0%, 40% { transform: translateX(0) scale(1); }
78% { transform: translateX(28px) scale(1.15); }
100% { transform: translateX(0) scale(1); }
}
// ── 접근성 ──────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.link.o-nuru:hover .link__arrow, .link.o-nuru:focus-visible .link__arrow,
.link.o-nuru.is-demo .link__arrow { 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);
}
.stage {
position: relative;
}
.link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 8px;
color: #fff7e6;
text-decoration: none;
font-weight: 700;
font-size: clamp(16px, 4.5vw, 19px);
line-height: 1;
font-family: inherit;
border-bottom: 2px solid rgba(255, 247, 230, 0.3);
}
.link__arrow {
display: inline-block;
color: #2f6df6;
font-size: 22px;
}
.tag {
position: absolute;
top: 16px;
right: 16px;
padding: 4px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1;
font-family: inherit;
letter-spacing: 0.04em;
}
.link.o-nuru:hover .link__arrow,
.link.o-nuru:focus-visible .link__arrow {
animation: o-nuru 700ms cubic-bezier(0.16, 1, 0.3, 1) 1;
}
.link.o-nuru.is-demo .link__arrow {
animation: o-nuru-loop 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
@keyframes o-nuru {
0% {
transform: translateX(0) scale(1);
}
100% {
transform: translateX(28px) scale(1.15);
}
}
@keyframes o-nuru-loop {
0%, 40% {
transform: translateX(0) scale(1);
}
78% {
transform: translateX(28px) scale(1.15);
}
100% {
transform: translateX(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.link.o-nuru:hover .link__arrow, .link.o-nuru:focus-visible .link__arrow,
.link.o-nuru.is-demo .link__arrow {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SmoothLink.module.scss";
export type SmoothLinkProps = {
children: ReactNode;
href?: string;
/** 미끄러지는 데 걸리는 시간 (기본 700ms) */
duration?: number;
/** CSS easing (기본 ease-out-expo 근사) */
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export default function SmoothLink({
children,
href = "#",
duration = 700,
easing = "cubic-bezier(.16,1,.3,1)",
color = "#2f6df6",
}: SmoothLinkProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<a className={styles.link} href={href} style={vars}>
{children}
<span className={styles.arrow} aria-hidden="true">
→
</span>
</a>
);
}09 · Nuru smooth glide — Measured area 0.172% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
Next to the spring and squash of the other eight, this one is the deliberately smooth outlier, built for a "learn more" link handing someone off to the next screen.
$duration: 700ms;
$easing: cubic-bezier(.16, 1, .3, 1);
.link.o-nuru:hover .link__arrow,
.link.o-nuru:focus-visible .link__arrow { animation: o-nuru $duration $easing 1; }
@keyframes o-nuru {
0% { transform: translateX(0) scale(1); }
100% { transform: translateX(28px) scale(1.15); }
}
Where the hover trigger quietly breaks in production
What goes wrong
Items 02 and 09 are the only two of these nine that wait for a hover or a keyboard focus instead of looping by themselves, and both carry a second, demo-only rule alongside the real one: a .is-demo class that forces the same shape to replay every two seconds so this page has something to show without anyone touching it. That .is-demo rule and the real :hover/:focus-visible rule share the exact same specificity — the same number of classes each — so nothing about selector weight decides which one plays; only the order the two rules are written in the stylesheet does. Both files list the hover rule first and the .is-demo rule second on purpose, which is why moving your own mouse over this page still shows the correct animation: the cascade always keeps whichever matching rule was declared later.
How to avoid it
The blur that appears once a wave crosses its threshold is guarded by the same scaling rule in the crayon landing kit, where sketch lines must not smudge.
Paste this CSS into a real project without deleting is-demo from the markup, though, and that same tie-break turns against you — the class is still sitting there, its rule is still declared second, and the button or link keeps replaying a two-second loop instead of springing once when a visitor actually hovers it. Deleting is-demo from the element's class list is the entire fix; nothing in the CSS itself needs to change once that one class is gone. Both the vanilla and React folders in the zip below open with the password kxj6u9k6, printed here as an ordinary word inside this sentence instead of a box, so it only turns up if the paragraph gets read.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Shorter nuru glide | Item 09's translateX cut from 28px to 14px with scale dropped |
Matches this item's very first build, which measured 0.06% cumulative area — just under this site's 0.08% visibility floor — so the motion barely registered at all |
| Snappier poyon | Item 02's $duration cut from 550ms to 300ms |
The squeeze-then-overshoot compresses into something closer to a flicker than a bounce, even though the elastic curve's shape stays identical |
| Calmer kuru | Item 05's cubic-bezier(.65, 0, .35, 1) swapped for plain linear |
The rotation loses its one-beat catch and turns into an ordinary loading-wheel spin |
Accessibility
What reduced motion leaves
All nine — item 05's css wobble animation included — switch off under prefers-reduced-motion: reduce, but what's left standing depends on the item. For 01, 03, 04, 05, 06, and 07, nothing of value disappears — a badge, a heart, a tag, an icon, a hint, and a blob each just hold their resting shape instead of moving through it, since none of them were carrying information only the motion could give. Item 08's two flashing dots vanish the same way, leaving the "recommended" text on its badge exactly as readable as always.
What else to check
Items 02 and 09 lose their flourish but keep their function: with the animation gone, the add-to-cart button still presses and the "learn more" link still moves the page, because both are a real <button> and a real <a> underneath, not a div standing in for one. For a wider set of always-on loop motions — float, spin, pulse — that skip the hover step entirely, 9 CSS Keyframe Animations covers the same transform-and-opacity toolkit with a completely different set of keyframes. Everything else this site has published under the same one-line, copy-paste idea sits in the CSS category hub.
FAQ
Does a css jelly animation need JavaScript?
No — all nine run on CSS alone: animation for the seven that loop by themselves, plus :hover and :focus-visible for the two that wait on a cursor or keyboard focus. The zip's vanilla folder ships plain .css, already compiled from the Sass shown throughout this post.
Which of these still work on a phone with no mouse?
The seven always-on loops — float, jelly, sway, spin, hop, blob, and sparkle — play the same whether or not anyone ever touches the screen, since nothing about them depends on a pointer. The two hover-triggered ones, poyon and nuru, only fire on a phone if that browser happens to simulate :hover after a tap, which isn't consistent across touch browsers, so treat those two as desktop-first.
What's different about the React version in the zip?
The components take duration, easing, and color as props and forward them as CSS custom properties instead of Sass variables, so retuning a bounce no longer means recompiling anything. For poyon and nuru, the two hover-triggered items, the React version carries no is-demo class or extra state at all — just a plain :hover/:focus-visible rule inside a CSS module, the same trigger left standing once the trap section above strips the demo class away. To press that very texture onto a button, the CSS button animations are the first look.









