CSS 통통 튀는 애니메이션 9종 — 한 줄 복붙으로
CSS 통통 튀는 애니메이션은 요소를 그냥 나타나게 하지 않고 뜨고, 튕기고, 떨고, 흔들리게 만들어 살아 있는 느낌을 주는 움직임입니다. 흔한 영어 이름 대신 둥실둥실·포용·푸루푸루 같은 의성어로 아홉 개를 골랐고, 그중 두 개(포용·스르륵)는 실제로 마우스를 올려야 움직이는 진짜
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 둥실둥실 뜨는(ふわふわ)
- 02 포용 튕기는(ぽよん)
- 03 푸루푸루 젤리(ぷるぷる)
- 04 살랑살랑 흔들리는(ゆらゆら)
- 05 빙글빙글 도는(くるくる)
- 06 깡충깡충 뛰는(ぴょんぴょん)
- 07 말랑말랑 휘는(ぐにゃぐにゃ)
- 08 반짝(キラッ)
- 09 스르륵 매끄러운(ぬるっと)
아홉 개는 이름 가나다순이 아니라 버튼 하나가 화면에서 밟는 순서로 놓았습니다. 존재를 알리고(01), 눌러 달라고 조르고(02~03), 옆에서 조용히 숨 쉬고(04~05), 더 보라고 손짓하고(06), 뒤에서 배경을 꾸미고(07), 잘 골랐다고 반짝이고(08), 다음 화면으로 미끄러지듯 넘어갑니다(09). 표에서 duration과 트리거만 먼저 훑고, 각 항목 섹션에서 실제 코드를 열어 보십시오. 통통 튀는 소리에 색종이까지 얹으면 컨페티 축하 애니메이션이 그 파티를 벌이고, 방향을 짚어 주는 작은 움직임이 필요하면 CSS 화살표 애니메이션이 길을 가리킵니다.
| 클래스 | 무엇이 움직이나 | duration | 트리거 |
|---|---|---|---|
.o-float |
translateY·rotate | 2.8s | 상시 |
.o-poyon |
scale | 550ms | 호버·포커스 |
.o-puru |
scaleX·scaleY | 900ms | 상시 |
.o-yura |
rotate | 2.4s | 상시 |
.o-kuru |
rotate | 1.6s | 상시 |
.o-pyon |
translateY·scaleY | 900ms | 상시 |
.o-gunya |
border-radius | 3.2s | 상시 |
.o-kira |
opacity·scale | 1.8s | 상시 |
.o-nuru |
translateX·scale | 700ms | 호버·포커스 |
01둥실둥실 뜨는(ふわふわ)
어떤 화면인가
.o-float 하나만 붙이면 배지가 translateY와 약한 rotate로 천천히 오르내립니다.

<main class="stage">
<div class="badge o-float">
<span class="badge__dot" aria-hidden="true"></span>
신상품
</div>
<span class="tag">둥실둥실</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 · 둥실둥실 뜨는(ふわふわ) — 측정 누적면적 3.606% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
신상품 배지나 프로모션 알림 칩처럼 화면 한구석에서 계속 존재를 알려야 하는 요소에 씁니다.
$duration: 2.8s;
$easing: ease-in-out;
$color: $subject-blue;
.o-float {
animation: o-float $duration $easing infinite;
}
@keyframes o-float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-14px) rotate(-3deg); }
}
02포용 튕기는(ぽよん)
css 버튼 호버 애니메이션은 어디에 쓰나
.o-poyon은 마우스를 올리거나 키보드로 포커스하면 scale이 눌렸다 크게 튕기는 elastic 이징으로 한 번 움직입니다.

<main class="stage">
<button class="btn o-poyon is-demo" type="button">장바구니 담기</button>
<span class="tag">포용</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 · 포용 튕기는(ぽよん) — 측정 누적면적 5.788% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
장바구니 담기나 좋아요처럼 누르고 싶게 만들어야 하는 버튼에 씁니다.
$duration: 550ms;
$easing: cubic-bezier(.34, 1.56, .64, 1);
$color: $subject-cream;
.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); }
}
03푸루푸루 젤리(ぷるぷる)
어디에 쓰나
.o-puru는 가로·세로 scale을 아주 작은 폭으로 서로 어긋나게 반복합니다.

<main class="stage">
<button class="icon o-puru is-demo" type="button" aria-label="찜하기">
<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">푸루푸루</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 · 푸루푸루 젤리(ぷるぷる) — 측정 누적면적 0.772% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
찜하기 하트나 알림 종 아이콘이 탭해 달라는 듯 계속 미세하게 떠는 느낌을 냅니다.
$duration: 900ms;
$easing: ease-in-out;
$color: $subject-cream;
.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); }
}
04살랑살랑 흔들리는(ゆらゆら)
눈에 보이는 것
.o-yura는 transform-origin을 위쪽에 고정하고 rotate만 좌우로 아주 천천히 오갑니다.

<main class="stage">
<div class="tagwrap">
<span class="tagwrap__string" aria-hidden="true"></span>
<span class="tagwrap__tag o-yura">세일</span>
</div>
<span class="tag">살랑살랑</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 · 살랑살랑 흔들리는(ゆらゆら) — 측정 누적면적 1.628% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
매달린 세일 태그나 알림 종 장식이 바람에 살랑이듯 보이게 할 때 씁니다.
$duration: 2.4s;
$easing: ease-in-out;
$color: $subject-blue;
.o-yura {
animation: o-yura $duration $easing infinite;
transform-origin: top center;
}
@keyframes o-yura {
0%, 100% { transform: rotate(-6deg); }
50% { transform: rotate(6deg); }
}
05빙글빙글 도는(くるくる)
어떤 상황인가
.o-kuru는 rotate 360도를 한 번에 linear로 밀지 않고 구간마다 통과 지점을 다르게 둬 한 박자 튕기는 듯한 리듬을 만듭니다. 새로고침이나 동기화 아이콘에 씁니다.

<main class="stage">
<button class="icon o-kuru is-demo" type="button" aria-label="새로고침">
<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">빙글빙글</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 · 빙글빙글 도는(くるくる) — 측정 누적면적 0.403% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
$duration: 1.6s;
$easing: cubic-bezier(.65, 0, .35, 1);
$color: $subject-cream;
.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); }
}
06깡충깡충 뛰는(ぴょんぴょん)
무슨 일이 일어나나
.o-pyon은 착지 직전엔 세로 scale을 눌러 납작하게, 뛰어오르는 순간엔 translateY와 scale을 늘려 통통 뛰는 리듬을 만듭니다.

<main class="stage">
<div class="hint o-pyon is-demo">
<span class="hint__arrow" aria-hidden="true">↓</span>
더 보기
</div>
<span class="tag">깡충깡충</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 · 깡충깡충 뛰는(ぴょんぴょん) — 측정 누적면적 5.925% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
"더 보기" 화살표나 스크롤 유도 아이콘에 씁니다.
$duration: 900ms;
$easing: ease-in-out;
$color: $subject-blue;
.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); }
}
07말랑말랑 휘는(ぐにゃぐにゃ)
화면에서 벌어지는 일
.o-gunya는 border-radius 네 모서리 값을 각각 다른 타이밍으로 keyframes 안에서 바꿔 배경 블롭 도형이 말랑하게 형태를 바꾸며 떠다니게 합니다.

<main class="stage">
<div class="blob o-gunya" aria-hidden="true"></div>
<span class="tag">말랑말랑</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 · 말랑말랑 휘는(ぐにゃぐにゃ) — 측정 누적면적 1.768% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
히어로 배경이나 기능 카드 뒤 악센트 도형에 씁니다.
$duration: 3.2s;
$easing: ease-in-out;
$color: $subject-cream;
.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); }
}
08반짝(キラッ)
어떻게 보이나
.o-kira는 ::before·::after로 만든 작은 별 두 개에 서로 다른 delay를 줘 scale·opacity로 번갈아 반짝이게 합니다.

<main class="stage">
<div class="badge o-kira">추천</div>
<span class="tag">반짝</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 · 반짝(キラッ) — 측정 누적면적 0.399% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
추천 배지나 신규 기능 표시가 주기적으로 반짝이며 눈길을 끌게 합니다.
$duration: 1.8s;
$easing: ease-in-out;
$color: $subject-blue;
.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); }
}
09스르륵 매끄러운(ぬるっと)
언제 쓰나
.o-nuru는 마우스를 올리거나 포커스하면 화살표가 ease-out-expo에 가까운 cubic-bezier로 미끄러지듯 이동합니다.

<main class="stage">
<a class="link o-nuru is-demo" href="#">
자세히 보기
<span class="link__arrow" aria-hidden="true">→</span>
</a>
<span class="tag">스르륵</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 · 스르륵 매끄러운(ぬるっと) — 측정 누적면적 0.172% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
다른 여덟 개의 탄성과 대비되는 매끈한 느낌이라 "자세히 보기" 링크에 어울립니다.
$duration: 700ms;
$easing: cubic-bezier(.16, 1, .3, 1);
$color: $subject-blue;
.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); }
}
어디서 깨지는가 — is-demo 클래스를 안 지우면 생기는 함정
무엇이 문제인가
css 버튼 클릭 애니메이션이 아니라 호버로 트리거되는 02와 09 같은 항목은 실제 동작용 규칙(:hover·:focus-visible)과 이 페이지를 자동 재생시키는 .is-demo 루프용 규칙을 같은 요소에 함께 둡니다. 두 규칙은 특정성이 똑같아서(클래스 두세 개, 동점) 나중에 선언된 쪽이 이깁니다. 그래서 .is-demo를 진짜 화면에 붙인 채로 배포하면, 사용자가 마우스를 올려도 짧은 트리거 애니메이션 대신 2초짜리 데모 루프가 재생됩니다. zip을 코드에 붙여 쓸 때는 is-demo 클래스만 지우면 되고, 반대로 is-demo를 남겨 두고 싶다면 실사용 규칙을 스타일시트 더 아래에 적어야 합니다.
어떻게 피하나
물결 임계값을 넘겼을 때 흐릿해지는 문제는 손그림 질감을 살리는 크레용 랜딩 키트에서도 스케치 선이 뭉개지지 않게 같은 배율 규칙으로 막습니다.
아홉 개 중 07 말랑말랑 휘는 항목의 평균 강도(강도_평균)는 42.9으로 가장 낮게 측정됐는데, border-radius만으로 만든 곡선이 배경과의 대비가 완만해서입니다. 대비가 약한 배경 위에 올리면 거의 안 보일 수 있으니 배경과 색 차이가 뚜렷한 곳에 둬야 합니다. duration·easing·색을 전부 맞춰 둔 vanilla·React 파일 아홉 벌을 묶은 zip은 압축 비밀번호 kxj6u9k6로 열립니다.
접근성
reduced-motion 에서 남는 것
운영체제에 동작 줄이기 설정이 켜져 있다면 아홉 개 모두 @media (prefers-reduced-motion: reduce) 블록이 animation: none으로 움직임을 지웁니다. 01 둥실둥실·04 살랑살랑·07 말랑말랑처럼 배경을 꾸미기만 하는 동작은 지워도 잃는 정보가 없고, 02 포용·09 스르륵은 애니메이션이 빠져도 버튼 클릭이나 링크 이동 같은 실제 기능은 그대로 살아 있습니다. 이 미디어 쿼리를 지원하는 브라우저 목록은 MDN의 prefers-reduced-motion 문서 아래쪽 호환성 표에서 확인할 수 있습니다.
@media (prefers-reduced-motion: reduce) {
.o-float, .o-puru, .o-yura,
.o-kuru, .o-pyon, .o-gunya { animation: none; }
.badge.o-kira::before, .badge.o-kira::after { animation: none; }
.btn.o-poyon:hover, .btn.o-poyon:focus-visible,
.link.o-nuru:hover .link__arrow,
.link.o-nuru:focus-visible .link__arrow { animation: none; }
}
이어서 볼 글
이 시리즈의 다른 묶음은 CSS 애니메이션 카테고리에서 모아 보고, 이 사이트를 처음 본다면 소개 페이지부터 훑어보십시오.
FAQ
css 애니메이션 무한 반복을 아홉 개나 한 화면에 다 써도 됩니까?
기술적으로는 가능하지만 한 화면에 두세 개까지만 권합니다. 02 포용과 08 반짝은 똑같이 "여기를 봐 달라"고 조르는 역할이라 한 화면에 같이 두면 시선이 갈라집니다. 반대로 01 둥실둥실처럼 조용한 것과 05 빙글빙글처럼 상태를 알리는 것은 역할이 겹치지 않아 같이 둬도 됩니다.
왜 데모 화면에서는 02와 09가 계속 움직입니까?
실제 화면에서 02와 09는 마우스를 올리거나 포커스할 때만 움직이는 호버 트리거입니다. 이 페이지의 데모는 사람이 손을 대지 않아도 움직임을 보여줘야 해서 .is-demo 클래스로 2초마다 스스로 재생되게 강제한 것이고, 실제 코드에 붙일 때는 이 클래스를 지우면 됩니다.
React에서는 호버 트리거를 어떻게 재현합니까?
zip의 react/ 폴더는 duration·easing·color를 props로 받아 CSS 변수로 넘기고, 02·09처럼 호버로 트리거되는 항목도 상태 없이 CSS 모듈의 :hover·:focus-visible 선택자로 그대로 처리합니다. 01·03~08처럼 상시 반복되는 항목은 컴포넌트가 마운트되는 순간부터 애니메이션이 돕니다. 누르는 순간의 질감을 버튼에 그대로 얹고 싶다면 CSS 버튼 애니메이션이 그 첫 보기입니다.









