cubic-bezier css 이징 커브·스프링 9종 — 복붙용 애니메이션 곡선
버튼을 누르거나 카드가 나타날 때, 움직임이 일정한 속도면 딱딱해 보이고 처음엔 빠르다가 점점 느려지거나 끝에서 살짝 튕기면 자연스럽다. 이 속도 변화를 CSS 에서 숫자 네 개로 적는 방법이 cubic-bezier css 값이고, 통통 튀는 움직임은 스프링 방정식을 곡선으로 바꾼
01 easing-curves-springs-ease-out-quart
02 easing-curves-springs-ease-in-out-back
03 easing-curves-springs-ease-out-expo
04 easing-curves-springs-bounce-linear
05 easing-curves-springs-spring-gentle
06 easing-curves-springs-spring-snappy
07 easing-curves-springs-scss-easing-map
08 easing-curves-springs-tailwind-ease-token
09 easing-curves-springs-compare-side-by-side
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 ease-out-quart 곡선
- 02 ease-in-out-back 곡선
- 03 ease-out-expo 곡선
- 04 linear() 바운스
- 05 부드러운 스프링
- 06 탄력적인 스프링
- 07 SCSS 이징 맵 믹스인
- 08 Tailwind v4 --ease 토큰
- 09 이징 비교 데모
아홉 개는 인기순이 아니라 값을 다루는 방법이 늘어나는 순서로 놓았다. 화면에 처음 등장하는 요소가 겪는 감속 곡선 세 개(01~03)로 시작해, cubic-bezier 로는 못 만드는 물리적인 튕김 세 개(04~06)를 지나, 그 값을 코드에서 재사용하는 두 가지 방법(07·08)을 거쳐, 마지막으로 넷을 한 화면에 놓고 눈으로 비교하는 데모(09)로 끝난다. 곡선을 실제 화면에 얹은 결과가 궁금하면 벤토 그리드 호버 애니메이션이 같은 값으로 움직이고, 컴포넌트 쪽에서 같은 곡선을 재사용하는 방법은 라이브러리 없는 리액트 CSS 애니메이션에 있습니다.
01ease-out-quart 곡선
css 곡선 애니메이션에서 어디에 쓰나
cubic-bezier(.25, 1, .5, 1) 을 translateX 에 물리면 처음부터 빠르게 튀어나가 관성 없이 목표 지점 근처에서 부드럽게 멈춘다.

<main class="stage is-demo">
<div class="track">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 왕복 한 주기
$easing: cubic-bezier(.25, 1, .5, 1); // ease-out-quart — 처음부터 빠르게 튀어나가 끝에서 천천히 멈춘다
$color: $subject-blue;
@include stage($stage-ink);
$track-w: 220px;
$puck-w: $sp-6;
$travel: $track-w - $puck-w;
.track {
position: relative;
width: $track-w;
height: $sp-2;
}
.dot {
position: absolute;
top: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .25);
transform: translate(-50%, -50%);
}
.dot--start { left: 0; }
.dot--end { left: $track-w; }
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(0, -50%);
}
// transform 만 움직인다 — top/left 는 그대로 두고 puck 만 왕복
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}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);
}
.track {
position: relative;
width: 220px;
height: 8px;
}
.dot {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
transform: translate(-50%, -50%);
}
.dot--start {
left: 0;
}
.dot--end {
left: 220px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 24px;
height: 24px;
border-radius: 999px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move 2s cubic-bezier(0.25, 1, 0.5, 1) infinite alternate;
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(196px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(196px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./EaseOutQuart.module.scss";
export type EaseOutQuartProps = {
/** 왕복 한 주기 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function EaseOutQuart({ duration = 2000, color = "#2f6df6" }: EaseOutQuartProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck} />
</div>
</div>
);
}01 · ease-out-quart 곡선 — 측정 누적면적 3.2% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
모달이나 드로어가 열릴 때, 카드가 화면에 등장할 때 쓰기 좋다.
$duration: $dur-loop;
$easing: cubic-bezier(.25, 1, .5, 1); // ease-out-quart
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
02ease-in-out-back 곡선
어디에 쓰나
cubic-bezier(.68, -.6, .32, 1.6) 은 y값이 0과 1을 벗어난다.

<main class="stage is-demo">
<div class="track">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s
$easing: cubic-bezier(.68, -.6, .32, 1.6); // ease-in-out-back — 출발 전 살짝 뒤로 당겼다가, 도착 전 목표를 넘었다 되돌아온다
$color: $subject-ink;
@include stage($stage-yellow);
$track-w: 220px;
$puck-w: $sp-6;
$travel: $track-w - $puck-w;
.track {
position: relative;
width: $track-w;
height: $sp-2;
}
.dot {
position: absolute;
top: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .25);
transform: translate(-50%, -50%);
}
.dot--start { left: 0; }
.dot--end { left: $track-w; }
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}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);
}
.track {
position: relative;
width: 220px;
height: 8px;
}
.dot {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.25);
transform: translate(-50%, -50%);
}
.dot--start {
left: 0;
}
.dot--end {
left: 220px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 24px;
height: 24px;
border-radius: 999px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move 2s cubic-bezier(0.68, -0.6, 0.32, 1.6) infinite alternate;
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(196px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(196px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./EaseInOutBack.module.scss";
export type EaseInOutBackProps = {
duration?: number;
color?: string;
};
export default function EaseInOutBack({ duration = 2000, color = "#17141a" }: EaseInOutBackProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck} />
</div>
</div>
);
}02 · ease-in-out-back 곡선 — 측정 누적면적 6.664% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
그래서 출발 전 살짝 뒤로 당겼다가, 도착 뒤 목표를 넘었다 되돌아오는 두 번의 예비동작이 생긴다. 토스트·배지가 튀어나오듯 등장할 때 쓴다.
$easing: cubic-bezier(.68, -.6, .32, 1.6); // ease-in-out-back
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
03ease-out-expo 곡선
눈에 보이는 것
cubic-bezier(.16, 1, .3, 1) 은 첫 몇 프레임 만에 이동 대부분을 끝내고, 남은 구간은 육안으로 거의 안 움직일 만큼 천천히 기어가 도착을 길게 늘인다.

<main class="stage is-demo">
<div class="track">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s
$easing: cubic-bezier(.16, 1, .3, 1); // ease-out-expo — 첫 프레임부터 거의 다 이동해 있고, 나머지 구간은 아주 천천히 기어간다
$color: $subject-cream;
@include stage($stage-orange);
$track-w: 220px;
$puck-w: $sp-6;
$travel: $track-w - $puck-w;
.track {
position: relative;
width: $track-w;
height: $sp-2;
}
.dot {
position: absolute;
top: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .3);
transform: translate(-50%, -50%);
}
.dot--start { left: 0; }
.dot--end { left: $track-w; }
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}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);
}
.track {
position: relative;
width: 220px;
height: 8px;
}
.dot {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
}
.dot--start {
left: 0;
}
.dot--end {
left: 220px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 24px;
height: 24px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move 2s cubic-bezier(0.16, 1, 0.3, 1) infinite alternate;
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(196px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(196px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./EaseOutExpo.module.scss";
export type EaseOutExpoProps = {
duration?: number;
color?: string;
};
export default function EaseOutExpo({ duration = 2000, color = "#fff7e6" }: EaseOutExpoProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck} />
</div>
</div>
);
}03 · ease-out-expo 곡선 — 측정 누적면적 5.573% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
사이드바가 훅 펼쳐지는 속도감을 강조하고 싶을 때 쓴다.
$easing: cubic-bezier(.16, 1, .3, 1); // ease-out-expo
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
04linear() 바운스
어떤 상황인가
cubic-bezier 는 값이 한 번만 오르내리는 곡선이라, 공이 바닥에서 몇 번을 더 튕기는 움직임은 못 만든다. Robert Penner 의 easeOutBounce 공식을 25개 점으로 직접 표본화해 linear() 에 넣으면 값이 커지다 줄었다를 반복하는 진짜 튕기는 곡선이 나온다.

<main class="stage is-demo">
<div class="track track--v">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s
// linear() — Penner easeOutBounce 공식을 25개 점으로 표본화한 값. cubic-bezier 로는 못 만드는,
// 방향이 꺾이는(제자리로 되튀는) 곡선이라 CSS Easing Functions Level 2 의 linear() 가 필요하다
$easing: linear(0, .013, .053, .118, .21, .328, .473, .643, .84, .97, .875, .807, .766, .75, .761, .798, .861, .951, .973, .943, .939, .962, .995, .984, 1);
$color: $subject-blue;
@include stage($stage-paper);
$track-h: 132px; // 320×200 모바일 체크에서 세로 여백을 남겨야 한다 (BD9 실측: 180px 는 넘쳤다)
$puck-w: $sp-6;
$drop: $track-h - $puck-w;
.track--v {
position: relative;
width: $sp-2;
height: $track-h;
}
.dot {
position: absolute;
left: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
transform: translate(-50%, -50%);
}
.dot--start { top: 0; }
.dot--end { top: $track-h; }
.puck {
position: absolute;
top: 0;
left: 50%;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(-50%, 0);
}
// 공이 떨어져 튀어오르는 방향이라 alternate 를 안 쓴다 — 한 번 떨어지고 나면 다음 루프에서 제자리로 순간 복귀한다
.stage.is-demo .puck {
animation: drop $duration $easing infinite;
}
@keyframes drop {
from { transform: translate(-50%, 0); }
to { transform: translate(-50%, $drop); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate(-50%, $drop); }
}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);
}
.track--v {
position: relative;
width: 8px;
height: 132px;
}
.dot {
position: absolute;
left: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
transform: translate(-50%, -50%);
}
.dot--start {
top: 0;
}
.dot--end {
top: 132px;
}
.puck {
position: absolute;
top: 0;
left: 50%;
width: 24px;
height: 24px;
border-radius: 999px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(-50%, 0);
}
.stage.is-demo .puck {
animation: drop 2s linear(0, 0.013, 0.053, 0.118, 0.21, 0.328, 0.473, 0.643, 0.84, 0.97, 0.875, 0.807, 0.766, 0.75, 0.761, 0.798, 0.861, 0.951, 0.973, 0.943, 0.939, 0.962, 0.995, 0.984, 1) infinite;
}
@keyframes drop {
from {
transform: translate(-50%, 0);
}
to {
transform: translate(-50%, 108px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(-50%, 108px);
}
}import type { CSSProperties } from "react";
import styles from "./BounceLinear.module.scss";
export type BounceLinearProps = {
duration?: number;
color?: string;
};
export default function BounceLinear({ duration = 2000, color = "#2f6df6" }: BounceLinearProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck} />
</div>
</div>
);
}04 · linear() 바운스 — 측정 누적면적 3.203% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
장바구니에 아이콘이 담기거나 알림 배지가 새로 뜰 때 쓴다.
$easing: linear(0, .013, .053, .118, .21, .328, .473, .643, .84, .97, .875, .807, .766, .75, .761, .798, .861, .951, .973, .943, .939, .962, .995, .984, 1);
.stage.is-demo .puck {
animation: drop $duration $easing infinite;
}
@keyframes drop {
from { transform: translate(-50%, 0); }
to { transform: translate(-50%, $drop); }
}
05부드러운 스프링
무슨 일이 일어나나
감쇠비(zeta) .62·각진동수(omega) 7.0 인 감쇠 스프링 방정식 x(t) = 1 - e^(-ζωt)(cos(ω_d t) + (ζω/ω_d)sin(ω_d t)) 를 23개 점으로 표본화하면, 목표를 8% 정도만 넘겼다가 빠르게 가라앉는 값이 나온다. 감쇠비가 1에 가까울수록 오버슈트가 줄어든다.

<main class="stage is-demo">
<div class="track">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s
// linear() — 감쇠비(zeta) .62 · 각진동수(w) 7.0 인 스프링 방정식을 23개 점으로 표본화했다.
// 목표를 8% 정도만 넘겼다가 금방 가라앉는다 — 감쇠비가 1에 가까울수록 오버슈트가 줄어든다
$easing: linear(0, .044, .153, .297, .452, .602, .736, .849, .937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1);
$color: $subject-cream;
@include stage($stage-ink);
$track-w: 220px;
$puck-w: $sp-6;
$travel: $track-w - $puck-w;
.track {
position: relative;
width: $track-w;
height: $sp-2;
}
.dot {
position: absolute;
top: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .25);
transform: translate(-50%, -50%);
}
.dot--start { left: 0; }
.dot--end { left: $track-w; }
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}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);
}
.track {
position: relative;
width: 220px;
height: 8px;
}
.dot {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
transform: translate(-50%, -50%);
}
.dot--start {
left: 0;
}
.dot--end {
left: 220px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 24px;
height: 24px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move 2s linear(0, 0.044, 0.153, 0.297, 0.452, 0.602, 0.736, 0.849, 0.937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1) infinite alternate;
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(196px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(196px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./SpringGentle.module.scss";
export type SpringGentleProps = {
duration?: number;
color?: string;
};
export default function SpringGentle({ duration = 2000, color = "#fff7e6" }: SpringGentleProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck} />
</div>
</div>
);
}05 · 부드러운 스프링 — 측정 누적면적 3.44% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
설정 패널이 옆에서 미끄러져 들어오는 것처럼 평소 쓰는 UI 전환에 맞는다.
$easing: linear(0, .044, .153, .297, .452, .602, .736, .849, .937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1);
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
06탄력적인 스프링
화면에서 벌어지는 일
같은 방정식에서 감쇠비를 .35 로 낮추고 각진동수를 9.0 으로 올리면, 목표를 30% 넘게(1.307) 지나쳤다가 몇 번을 더 오가는 탄력이 뚜렷한 값이 된다.

<main class="stage is-demo">
<div class="track">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s
// linear() — 감쇠비(zeta) .35 · 각진동수(w) 9.0 인 스프링 방정식을 27개 점으로 표본화했다.
// 감쇠비를 낮추고 각진동수를 올리면 목표를 30% 넘게 지나쳤다가 몇 번을 더 오간다 — 탄력이 셀수록 오버슈트가 크다
$easing: linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1);
$color: $subject-ink;
@include stage($stage-yellow);
$track-w: 220px;
$puck-w: $sp-6;
$travel: $track-w - $puck-w;
.track {
position: relative;
width: $track-w;
height: $sp-2;
}
.dot {
position: absolute;
top: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .25);
transform: translate(-50%, -50%);
}
.dot--start { left: 0; }
.dot--end { left: $track-w; }
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}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);
}
.track {
position: relative;
width: 220px;
height: 8px;
}
.dot {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.25);
transform: translate(-50%, -50%);
}
.dot--start {
left: 0;
}
.dot--end {
left: 220px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 24px;
height: 24px;
border-radius: 999px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move 2s linear(0, 0.055, 0.197, 0.394, 0.612, 0.824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, 0.982, 0.942, 0.916, 0.905, 0.907, 0.917, 0.935, 0.955, 0.976, 0.995, 1) infinite alternate;
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(196px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(196px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./SpringSnappy.module.scss";
export type SpringSnappyProps = {
duration?: number;
color?: string;
};
export default function SpringSnappy({ duration = 2000, color = "#17141a" }: SpringSnappyProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck} />
</div>
</div>
);
}06 · 탄력적인 스프링 — 측정 누적면적 6.622% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
좋아요 버튼을 눌렀을 때, 드래그 후 손을 놓아 카드가 튕겨 돌아올 때 쓴다.
$easing: linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1);
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
07SCSS 이징 맵 믹스인
어떻게 보이나
편마다 곡선값을 복붙하지 않으려고, 이 편에서 쓴 값들을 $easings 맵 하나에 이름으로 묶고 @mixin ease($name) 이 map.get 으로 꺼내 쓰게 했다.

<main class="stage is-demo">
<div class="track">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck">S</div>
</div>
</main>@use "sass:map";
@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s
$easing: spring-snappy; // 아래 $easings 맵의 키 — 값 자체가 아니라 "이름"을 변수로 넘긴다
$color: $subject-cream;
// 이 편 9종에 쓴 곡선을 이름으로 묶어두면, 편마다 값을 복붙하지 않고 이름만 불러 쓴다
$easings: (
"ease-out-quart": cubic-bezier(.25, 1, .5, 1),
"ease-in-out-back": cubic-bezier(.68, -.6, .32, 1.6),
"spring-gentle": linear(0, .044, .153, .297, .452, .602, .736, .849, .937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1),
"spring-snappy": linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1),
);
@mixin ease($name, $prop: animation-timing-function) {
#{$prop}: map.get($easings, $name);
}
@include stage($stage-orange);
$track-w: 220px;
$puck-w: $sp-8;
$travel: $track-w - $puck-w;
.track {
position: relative;
width: $track-w;
height: $sp-2;
}
.dot {
position: absolute;
top: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .3);
transform: translate(-50%, -50%);
}
.dot--start { left: 0; }
.dot--end { left: $track-w; }
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
color: $stage-ink;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation-name: move;
animation-duration: $duration;
animation-iteration-count: infinite;
animation-direction: alternate;
@include ease($easing); // ← 맵에서 spring-snappy 값을 꺼내 animation-timing-function 에 꽂는다
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}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);
}
.track {
position: relative;
width: 220px;
height: 8px;
}
.dot {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
}
.dot--start {
left: 0;
}
.dot--end {
left: 220px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 32px;
height: 32px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font: 700 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation-name: move;
animation-duration: 2s;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-timing-function: linear(0, 0.055, 0.197, 0.394, 0.612, 0.824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, 0.982, 0.942, 0.916, 0.905, 0.907, 0.917, 0.935, 0.955, 0.976, 0.995, 1);
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(188px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(188px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./EasingMap.module.scss";
// SCSS $easings 맵과 같은 값을 JS 객체로 옮겼다 — 이름으로 곡선을 찾는 패턴은 언어가 달라도 같다
const EASINGS = {
"ease-out-quart": "cubic-bezier(.25, 1, .5, 1)",
"ease-in-out-back": "cubic-bezier(.68, -.6, .32, 1.6)",
"spring-gentle": "linear(0, .044, .153, .297, .452, .602, .736, .849, .937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1)",
"spring-snappy": "linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1)",
} as const;
export type EasingMapProps = {
duration?: number;
color?: string;
/** 위 EASINGS 의 키. 기본은 SCSS 판과 같은 spring-snappy */
easing?: keyof typeof EASINGS;
};
export default function EasingMap({ duration = 2000, color = "#fff7e6", easing = "spring-snappy" }: EasingMapProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--color": color,
"--ease": EASINGS[easing],
} as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck}>S</div>
</div>
</div>
);
}07 · SCSS 이징 맵 믹스인 — 측정 누적면적 9.358% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
여러 컴포넌트가 같은 브랜드 이징을 공유해야 하는 디자인 시스템에서 쓰는 패턴이다.
$easings: (
"ease-out-quart": cubic-bezier(.25, 1, .5, 1),
"ease-in-out-back": cubic-bezier(.68, -.6, .32, 1.6),
"spring-gentle": linear(0, .044, .153, .297, .452, .602, .736, .849, .937, 1.002, 1.045, 1.071, 1.082, 1.083, 1.077, 1.066, 1.053, 1.04, 1.028, 1.018, 1.009, 1.003, 1),
"spring-snappy": linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1),
);
@mixin ease($name, $prop: animation-timing-function) {
#{$prop}: map.get($easings, $name);
}
.stage.is-demo .puck {
animation-name: move;
animation-duration: $duration;
animation-iteration-count: infinite;
animation-direction: alternate;
@include ease("spring-snappy"); // 맵에서 06번과 같은 값을 꺼낸다
}
08Tailwind v4 --ease 토큰
언제 쓰나
Tailwind v4 는 @theme 블록에 --ease-* 를 등록하면 이징을 유틸리티 클래스처럼 이름으로 쓸 수 있다.

<main class="stage is-demo">
<div class="track">
<span class="dot dot--start"></span>
<span class="dot dot--end"></span>
<div class="puck"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s
$easing: var(--ease-spring); // 실제로 움직이는 값은 :root 커스텀 프로퍼티에 있다
$color: $subject-blue;
// Tailwind v4 는 @theme 블록에 --ease-* 를 등록하면 ease-(--ease-spring) 유틸이 생긴다.
// 이 zip 은 Tailwind 를 불러오지 않으므로(외부 의존 0), 같은 패턴을 순수 CSS 커스텀 프로퍼티로만 재현했다
:root {
--ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}
@include stage($stage-paper);
$track-w: 220px;
$puck-w: $sp-6;
$travel: $track-w - $puck-w;
.track {
position: relative;
width: $track-w;
height: $sp-2;
}
.dot {
position: absolute;
top: 50%;
width: $sp-2;
height: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
transform: translate(-50%, -50%);
}
.dot--start { left: 0; }
.dot--end { left: $track-w; }
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}:root {
--ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}
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);
}
.track {
position: relative;
width: 220px;
height: 8px;
}
.dot {
position: absolute;
top: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
transform: translate(-50%, -50%);
}
.dot--start {
left: 0;
}
.dot--end {
left: 220px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 24px;
height: 24px;
border-radius: 999px;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(0, -50%);
}
.stage.is-demo .puck {
animation: move 2s var(--ease-spring) infinite alternate;
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(196px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(196px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./TailwindEaseToken.module.scss";
export type TailwindEaseTokenProps = {
duration?: number;
color?: string;
};
// Tailwind v4 는 @theme 에 --ease-spring 을 등록하면 ease-(--ease-spring) 유틸이 생긴다.
// 이 컴포넌트는 Tailwind 를 불러오지 않고(외부 의존 0) 같은 --ease-* 커스텀 프로퍼티 패턴만 재현한다
export default function TailwindEaseToken({ duration = 2000, color = "#2f6df6" }: TailwindEaseTokenProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--color": color,
"--ease-spring": "cubic-bezier(.34, 1.56, .64, 1)",
} as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<div className={styles.track}>
<span className={`${styles.dot} ${styles.dotStart}`} />
<span className={`${styles.dot} ${styles.dotEnd}`} />
<div className={styles.puck} />
</div>
</div>
);
}08 · Tailwind v4 --ease 토큰 — 측정 누적면적 6.524% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
이 zip 은 외부 의존을 안 넣으려고 Tailwind 를 직접 불러오지 않지만, 같은 패턴을 :root 커스텀 프로퍼티만으로 그대로 재현했다.
$easing: var(--ease-spring);
:root {
--ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
09이징 비교 데모
어떤 화면인가
quart(01)·back(02)·snappy 스프링(06)·bounce(04) 네 곡선을 같은 2초·같은 거리로 나란히 돌리면, 감속의 모양이 눈으로 바로 갈린다.

<main class="stage is-demo">
<div class="compare">
<div class="lane">
<span class="label">quart</span>
<div class="track"><div class="puck puck--quart"></div></div>
</div>
<div class="lane">
<span class="label">back</span>
<div class="track"><div class="puck puck--back"></div></div>
</div>
<div class="lane">
<span class="label">spring</span>
<div class="track"><div class="puck puck--spring"></div></div>
</div>
<div class="lane">
<span class="label">bounce</span>
<div class="track"><div class="puck puck--bounce"></div></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 네 레인 전부 같은 길이라야 곡선 모양만으로 비교가 된다
$easing: cubic-bezier(.25, 1, .5, 1); // 대표값(quart). 레인마다 실제 값은 아래 참고
$color: $subject-blue;
@include stage($stage-ink);
$track-w: 184px;
$puck-w: $sp-3;
$travel: $track-w - $puck-w;
.compare {
display: flex;
flex-direction: column;
gap: $sp-2;
width: $track-w + $sp-12 + $sp-2; // 라벨 폭 + 간격 + 트랙 폭
}
.lane {
display: flex;
align-items: center;
gap: $sp-2;
}
.label {
width: $sp-12;
flex: 0 0 auto;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(255, 255, 255, .6);
text-transform: uppercase;
letter-spacing: .03em;
}
.track {
position: relative;
flex: 1 1 auto;
width: $track-w;
height: 2px;
background: rgba(255, 255, 255, .15);
border-radius: $r-xs;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: $puck-w;
height: $puck-w;
border-radius: $r-pill;
transform: translate(0, -50%);
}
.puck--quart { background: $subject-blue; }
.puck--back { background: $subject-cream; }
.puck--spring { background: $stage-yellow; }
.puck--bounce { background: $error; }
.stage.is-demo .puck--quart {
// ease-out-quart — 01번과 같은 값
animation: move $duration cubic-bezier(.25, 1, .5, 1) infinite alternate;
}
.stage.is-demo .puck--back {
// ease-in-out-back — 02번과 같은 값. 출발 전 뒤로 당겼다가 도착 뒤 넘어간다
animation: move $duration cubic-bezier(.68, -.6, .32, 1.6) infinite alternate;
}
.stage.is-demo .puck--spring {
// spring-snappy — 06번과 같은 값. 오버슈트 뒤 여러 번 오간다
animation: move $duration linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1) infinite alternate;
}
.stage.is-demo .puck--bounce {
// bounce-linear — 04번과 같은 값. 방향을 안 바꾸고 매 루프 제자리로 순간 복귀한다
animation: move-once $duration linear(0, .013, .053, .118, .21, .328, .473, .643, .84, .97, .875, .807, .766, .75, .761, .798, .861, .951, .973, .943, .939, .962, .995, .984, 1) infinite;
}
@keyframes move {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@keyframes move-once {
from { transform: translate(0, -50%); }
to { transform: translate($travel, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck { animation: none; transform: translate($travel, -50%); }
}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);
}
.compare {
display: flex;
flex-direction: column;
gap: 8px;
width: 240px;
}
.lane {
display: flex;
align-items: center;
gap: 8px;
}
.label {
width: 48px;
flex: 0 0 auto;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(255, 255, 255, 0.6);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.track {
position: relative;
flex: 1 1 auto;
width: 184px;
height: 2px;
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
.puck {
position: absolute;
top: 50%;
left: 0;
width: 12px;
height: 12px;
border-radius: 999px;
transform: translate(0, -50%);
}
.puck--quart {
background: #2f6df6;
}
.puck--back {
background: #fff7e6;
}
.puck--spring {
background: #ffd23f;
}
.puck--bounce {
background: #c2361a;
}
.stage.is-demo .puck--quart {
animation: move 2s cubic-bezier(0.25, 1, 0.5, 1) infinite alternate;
}
.stage.is-demo .puck--back {
animation: move 2s cubic-bezier(0.68, -0.6, 0.32, 1.6) infinite alternate;
}
.stage.is-demo .puck--spring {
animation: move 2s linear(0, 0.055, 0.197, 0.394, 0.612, 0.824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, 0.982, 0.942, 0.916, 0.905, 0.907, 0.917, 0.935, 0.955, 0.976, 0.995, 1) infinite alternate;
}
.stage.is-demo .puck--bounce {
animation: move-once 2s linear(0, 0.013, 0.053, 0.118, 0.21, 0.328, 0.473, 0.643, 0.84, 0.97, 0.875, 0.807, 0.766, 0.75, 0.761, 0.798, 0.861, 0.951, 0.973, 0.943, 0.939, 0.962, 0.995, 0.984, 1) infinite;
}
@keyframes move {
from {
transform: translate(0, -50%);
}
to {
transform: translate(172px, -50%);
}
}
@keyframes move-once {
from {
transform: translate(0, -50%);
}
to {
transform: translate(172px, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .puck {
animation: none;
transform: translate(172px, -50%);
}
}import type { CSSProperties } from "react";
import styles from "./EasingCompare.module.scss";
export type EasingCompareProps = {
/** 네 레인 공통 주기 (기본 2000ms) — 같은 길이라야 곡선 모양만으로 비교가 된다 */
duration?: number;
};
const LANES = [
{ key: "quart", label: "QUART", cls: styles.puckQuart },
{ key: "back", label: "BACK", cls: styles.puckBack },
{ key: "spring", label: "SPRING", cls: styles.puckSpring },
{ key: "bounce", label: "BOUNCE", cls: styles.puckBounce },
] as const;
export default function EasingCompare({ duration = 2000 }: EasingCompareProps) {
return (
<div className={styles.stage} style={{ "--duration": `${duration}ms` } as CSSProperties}>
<div className={styles.compare}>
{LANES.map((lane) => (
<div className={styles.lane} key={lane.key}>
<span className={styles.label}>{lane.label}</span>
<div className={styles.track}>
<div className={`${styles.puck} ${lane.cls}`} />
</div>
</div>
))}
</div>
</div>
);
}09 · 이징 비교 데모 — 측정 누적면적 4.24% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
값을 새로 만들지 않고 앞선 네 항목의 값을 그대로 재사용했다.
.puck--quart { animation: move $duration cubic-bezier(.25, 1, .5, 1) infinite alternate; }
.puck--back { animation: move $duration cubic-bezier(.68, -.6, .32, 1.6) infinite alternate; }
.puck--spring { animation: move $duration linear(0, .055, .197, .394, .612, .824, 1.009, 1.153, 1.25, 1.3, 1.307, 1.281, 1.231, 1.167, 1.099, 1.035, .982, .942, .916, .905, .907, .917, .935, .955, .976, .995, 1) infinite alternate; }
.puck--bounce {
animation: move-once $duration linear(0, .013, .053, .118, .21, .328, .473, .643, .84, .97, .875, .807, .766, .75, .761, .798, .861, .951, .973, .943, .939, .962, .995, .984, 1) infinite;
}
어디서 깨지는가 — 세로 트랙은 가로보다 먼저 넘친다
무엇이 문제인가
cubic-bezier 사용법으로는 못 만드는 04번(linear() 바운스)은 처음엔 공이 132px 가 아니라 180px 를 떨어지게 만들었다. 480×300 데스크톱 화면에선 멀쩡했다. 문제는 이 사이트가 모바일 대응을 재는 방식이다 — 폭 320px 로 줄일 때 높이도 같은 비율(320×300÷480)로 200px 까지 같이 줄어든다. 세로로 쌓이는 트랙 180px 에 위아래 여백을 더하면 200px 를 넘겨, 실제로 렌더해서 재 보니 폰넘침: true 가 나왔다.
어떻게 피하나
곡선 하나를 제품 전체에서 돌려쓸 때 생기는 불일치는 값을 토큰으로 묶어 두는 다크 SaaS 리니어 디자인 시스템에서 시스템 단위로 다룹니다.
가로로 눕는 트랙은 폭 320px 안에서 여유가 있어 안 걸렸지만, 세로 트랙은 높이 200px 라는 더 좁은 예산을 바로 만난다. 트랙을 132px 로 줄이고 나서야 넘침이 사라졌다. zip 을 열 때 묻는 비밀번호는 wnxge56e 이고, 안내 그대로 입력하면 풀린다. 세로 방향 애니메이션을 넣을 땐 가로 기준으로만 반응형을 확인하고 넘어가기 쉬운데, 이 편처럼 모바일 뷰포트의 높이도 따로 재야 한다.
접근성
reduced-motion 에서 남는 것
9개 전부 prefers-reduced-motion: reduce 에서 animation: none 으로 떨어지고, transform 을 애니메이션의 도착 지점 값으로 고정한다 — 01·02·03·05·06·07·08번은 트랙 끝(196px 또는 188px)에, 04번은 낙하 지점(108px)에, 09번은 네 레인 모두 각자의 끝 지점에 멈춘다. 움직임이 꺼져도 정지 화면이 중간에 걸린 것처럼 보이지 않는다.
그 밖에 챙길 것
이 글 자체가 곡선의 모양 차이를 보여주는 게 목적이라 reduced-motion 에서는 그 차이를 못 느끼지만, 최종 위치 정보는 그대로 남아 레이아웃이 깨지지 않는다.
FAQ
cubic bezier transition 과 linear() 는 뭐가 다른가요?
cubic-bezier() 는 숫자 네 개로 정의되는 하나의 매끄러운 곡선이라 값이 방향을 여러 번 바꾸는 움직임(공이 몇 번을 더 튕기는 것 같은)은 표현하지 못한다. linear() 는 임의의 점을 원하는 만큼 나열해 곡선을 그리므로, 이 글의 04·05·06번처럼 물리 방정식을 직접 표본화한 값도 그대로 옮길 수 있다.
CSS 에 스프링 전용 키워드가 따로 있나요?
없다. spring() 같은 전용 이징 함수는 아직 브라우저에 표준으로 들어오지 않았다. 지금 스프링처럼 보이는 움직임을 CSS 로 내려면 이 글의 05·06번처럼 감쇠 스프링 방정식을 직접 계산해 linear() 로 표본화하는 방법이 현실적이다.
이 값을 직접 계산하지 않고 구할 방법이 있나요?
cubic-bezier() 는 이름별 프리셋을 모아둔 easings.net 같은 목록에서 값만 복사해도 되고, Chrome·Firefox 개발자 도구의 애니메이션 패널은 곡선을 눈으로 조정하면서 값을 바로 볼 수 있다. linear() 표본값은 이 글의 04~06번처럼 파이썬이나 자바스크립트로 방정식을 직접 풀어 뽑는 편이 물리적으로 정확하다. 두 함수의 문법은 MDN 의 easing-function 문서 에 정리돼 있다. 다른 CSS 애니메이션은 사이트 검색 에서 찾아볼 수 있고, 이 블로그가 데모를 만드는 방식은 소개 페이지 에 있다. 스프링 느낌만 빠르게 가져다 쓰고 싶으면 CSS 통통 튀는 애니메이션이 곡선을 이미 입혀 둔 예제입니다.









