9 CSS Easing Curves You Can Copy-Paste
A CSS animation moving at one constant speed looks robotic; ease it down at the end, or spring past the target and settle, and it reads as natural.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 ease-out-quart curve
- 02 ease-in-out-back curve
- 03 ease-out-expo curve
- 04 Bounce via linear()
- 05 Gentle spring
- 06 Snappy spring
- 07 SCSS easing map mixin
- 08 Tailwind v4 --ease token
- 09 Side-by-side easing compare
These nine aren't ranked by popularity — they're ordered by how much control each one hands over. Three curves an arriving element might use come first (01–03), then three springs that only linear() can draw, the kind cubic-bezier() can't touch (04–06), then two ways to reuse those exact values instead of retyping them everywhere (07–08), and last, a demo that runs four of them side by side so the differences read at a glance (09). To see these curves land on a real surface, the bento grid hover animations run on the same values, and reusing one curve from the component side is covered in CSS animations in React without a library.
01ease-out-quart curve
Where it fits
Put cubic-bezier(.25, 1, .5, 1) on a translateX and the element shoots out fast from the very first frame, then settles near the target with no overshoot at all.

<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 curve — Measured area 3.2% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
It reads well on a modal or drawer opening, or a card arriving on screen.
$duration: $dur-loop; // 2s
$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 curve
What moves
cubic-bezier(.68, -.6, .32, 1.6) pushes its y-values past 0 and 1, so the element pulls back a little before it leaves, then overshoots the target and settles back after arriving.

<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 curve — Measured area 6.664% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
It suits a toast or badge that wants to pop in with some personality.
$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 curve
The moment it fires
cubic-bezier(.16, 1, .3, 1) covers most of the distance in the first few frames, then crawls the rest of the way so slowly it barely reads as moving at all.

<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 curve — Measured area 5.573% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
Use it on a sidebar or dropdown that should feel like it snaps open fast.
$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%); }
}
04Bounce via linear()
Where bounce effect css fits
cubic-bezier() can only bend one way, so it can never make a value rise, fall, and rise again — the way a dropped ball actually behaves.

<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 · Bounce via linear() — Measured area 3.203% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
Sampling Robert Penner's easeOutBounce formula into 25 points for linear() produces that real bounce, a shape cubic-bezier() alone cannot draw.
$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); }
}
05Gentle spring
Where this shows up
This linear() value is sampled 23 points deep from a damped-spring equation — damping ratio .62, angular frequency 7.0.

<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 · Gentle spring — Measured area 3.44% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
It overshoots the target by about 8% and settles quickly, no drama, which is a good match for a settings panel sliding into place.
$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%); }
}
06Snappy spring
What happens on screen
Lower that same equation's damping ratio to .35 and raise its angular frequency to 9.0, and the curve overshoots its target by more than 30% before oscillating a couple more times — visibly springy.

<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 · Snappy spring — Measured area 6.622% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
It fits a like-button press or a card snapping back after a drag release.
$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 easing map mixin
What it looks like
A single $easings Sass map holds every curve from this post by name, and @mixin ease($name) pulls the right one through map.get() so no other component ever has to paste a raw curve value again.

<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 easing map mixin — Measured area 9.358% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
It's the pattern a design system reaches for once several parts need to share one brand easing.
$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"); // pulls item 06's exact value out of the map
}
08Tailwind v4 --ease token
When to use it
Declaring --ease-spring as a plain :root custom property and dropping var(--ease-spring) into the easing slot of the animation shorthand reproduces Tailwind v4's @theme --ease-* token pattern without ever loading Tailwind itself.

<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 token — Measured area 6.524% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Reach for it when a project wants its easing named and reused like a utility class.
$easing: var(--ease-spring);
:root {
--ease-spring: cubic-bezier(.34, 1.56, .64, 1);
}
.stage.is-demo .puck {
animation: move $duration $easing infinite alternate;
}
09Side-by-side easing compare
What you see
Quart, back, snappy spring, and bounce run over the same two seconds and the same distance, side by side, so the shape of each deceleration is obvious at a glance.

<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 · Side-by-side easing compare — Measured area 4.24% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
None of the four values are new — they're items 01, 02, 06, and 04, reused exactly as they are.
.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;
}
Where does the vertical bounce track break?
What goes wrong
Item 04, the roughest of these css transition curves to tune, first fell 180px, not 132px. On the 480×300 desktop stage that looked completely fine — nothing overflowed, nothing clipped, and that first pass shipped without anyone noticing a problem. The trouble only showed up once this site's mobile check kicked in: shrinking the stage to a 320px width also shrinks its height by the same ratio, 320×300÷480, down to 200px, and a 180px vertical track plus its top and bottom dots blew past that 200px budget with room to spare. Rendering the small version and measuring the actual frames is what caught it — the automated overflow flag in this post's measurement file read true for item 04 alone, while every other item here, all of them sideways instead of vertical, read false. A sideways track has slack inside a 320px width that a vertical one never gets, because a vertical track runs straight into the narrower 200px height budget the moment it's tall enough to matter.
How to avoid it
The drift that appears once a single curve is shared across a whole product is treated as a system problem in the dark SaaS linear design system, where the values live as tokens.
Cutting the track to 132px, set once as a Sass variable rather than a number copied into several places, removed the overflow for good and flipped that flag back to false. Item 04 is also the only demo in this set that moves along a vertical axis at all — the other eight all run translateX sideways, which is exactly why the narrower mobile height budget never got exercised until this one bounced into it. Nothing else about the curve changed: the same 25-point linear() value drives the same 2-second loop, only the track it travels got shorter, and the 8px dot markers at each end stayed exactly the size they were before. Opening the zip asks for a password; it's wnxge56e, typed exactly as it reads here, and the files unlock. A vertical animation checked only against the usual horizontal breakpoint is an easy trap — the mobile viewport's height needs its own pass, the same way this one finally got one.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Slower quart | Item 01's cubic-bezier(.25, 1, .5, 1) swapped for item 03's cubic-bezier(.16, 1, .3, 1) |
The arrival stretches out instead of settling right away |
| Heavier back | Item 02's -.6 / 1.6 control points pushed further, to -.9 / 1.9 |
Both the pull-back and the overshoot read as far more exaggerated |
| Calmer spring | Item 06's damping ratio raised from .35 toward item 05's .62 |
Overshoot drops from 30%+ to roughly 8%, and the extra oscillation nearly disappears |
Accessibility
What reduced motion leaves
All nine drop to animation: none under prefers-reduced-motion: reduce, and each one freezes on its arrival point in transform rather than some mid-motion frame — 196px or 188px along the track for 01, 02, 03, 05, 06, 07, and 08, 108px down for 04's drop, and each of the four lanes at its own endpoint for 09. This post exists to show the shape of a curve, so turning off motion removes exactly the thing being demonstrated, but the layout itself never shifts, since the final position is already written into transform either way.
What else to check
Browser support is the other limit worth knowing before copying any of this: cubic-bezier() has worked everywhere since CSS transitions shipped, but linear() only reached Chrome and Edge at version 113, Firefox at 112, and Safari at 17.2 — an older engine just falls back to whatever timing function sits earlier in the cascade instead of breaking outright. More keyframe basics live in 9 CSS keyframe animations, and the token pattern item 08 reproduces is covered in full in 9 Tailwind v4 animations.
FAQ
What's the real difference between cubic-bezier() and linear()?
cubic-bezier() draws one smooth curve from four numbers, and that curve can only bend once — it can never make a value rise, fall, and rise again. linear() takes as many points as it's given, so a curve sampled straight from a real formula, like items 04 through 06 here, carries over exactly instead of getting approximated.
Does CSS have a built-in spring easing keyword?
No, not yet — a dedicated spring() easing function hasn't landed in browsers. Until it does, the practical way to get spring motion in CSS is to solve a damped-spring equation directly, the way items 05 and 06 do here, and sample the result into linear().
Is there a css easing generator that gets these values without the math?
For cubic-bezier(), a named-preset list like easings.net covers most everyday curves, and both Chrome's and Firefox's DevTools animation panels let you drag a curve and read off the numbers live. linear() values sampled from a physical formula, like the springs and bounce in this post, are more accurately produced by solving the equation in Python or JavaScript than by eyeballing a curve editor. For the spring feel without the derivation, the CSS bounce animations arrive with these curves already applied.









