GODRICH

cubic-bezier css 이징 커브·스프링 9종 — 복붙용 애니메이션 곡선

버튼을 누르거나 카드가 나타날 때, 움직임이 일정한 속도면 딱딱해 보이고 처음엔 빠르다가 점점 느려지거나 끝에서 살짝 튕기면 자연스럽다. 이 속도 변화를 CSS 에서 숫자 네 개로 적는 방법이 cubic-bezier css 값이고, 통통 튀는 움직임은 스프링 방정식을 곡선으로 바꾼

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 인기순이 아니라 값을 다루는 방법이 늘어나는 순서로 놓았다. 화면에 처음 등장하는 요소가 겪는 감속 곡선 세 개(01~03)로 시작해, cubic-bezier 로는 못 만드는 물리적인 튕김 세 개(04~06)를 지나, 그 값을 코드에서 재사용하는 두 가지 방법(07·08)을 거쳐, 마지막으로 넷을 한 화면에 놓고 눈으로 비교하는 데모(09)로 끝난다.

01ease-out-quart 곡선

cubic-bezier(.25, 1, .5, 1)translateX 에 물리면 처음부터 빠르게 튀어나가 관성 없이 목표 지점 근처에서 부드럽게 멈춘다. 모달이나 드로어가 열릴 때, 카드가 화면에 등장할 때 쓰기 좋다.

cubic-bezier(.25, 1, .5, 1)transform
$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을 벗어난다. 그래서 출발 전 살짝 뒤로 당겼다가, 도착 뒤 목표를 넘었다 되돌아오는 두 번의 예비동작이 생긴다. 토스트·배지가 튀어나오듯 등장할 때 쓴다.

cubic-bezier오버슈트transform
$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) 은 첫 몇 프레임 만에 이동 대부분을 끝내고, 남은 구간은 육안으로 거의 안 움직일 만큼 천천히 기어가 도착을 길게 늘인다. 사이드바가 훅 펼쳐지는 속도감을 강조하고 싶을 때 쓴다.

cubic-bezier급감속transform
$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() 에 넣으면 값이 커지다 줄었다를 반복하는 진짜 튕기는 곡선이 나온다. 장바구니에 아이콘이 담기거나 알림 배지가 새로 뜰 때 쓴다.

linear()표본점 25개transform
$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에 가까울수록 오버슈트가 줄어든다. 설정 패널이 옆에서 미끄러져 들어오는 것처럼 평소 쓰는 UI 전환에 맞는다.

linear()감쇠 스프링transform
$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) 지나쳤다가 몇 번을 더 오가는 탄력이 뚜렷한 값이 된다. 좋아요 버튼을 눌렀을 때, 드래그 후 손을 놓아 카드가 튕겨 돌아올 때 쓴다.

linear()감쇠 스프링오버슈트
$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 으로 꺼내 쓰게 했다. 여러 컴포넌트가 같은 브랜드 이징을 공유해야 하는 디자인 시스템에서 쓰는 패턴이다.

Sass map@mixinmap.get
$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-* 를 등록하면 이징을 유틸리티 클래스처럼 이름으로 쓸 수 있다. 이 zip 은 외부 의존을 안 넣으려고 Tailwind 를 직접 불러오지 않지만, 같은 패턴을 :root 커스텀 프로퍼티만으로 그대로 재현했다.

CSS 커스텀 프로퍼티var()Tailwind v4
$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초·같은 거리로 나란히 돌리면, 감속의 모양이 눈으로 바로 갈린다. 값을 새로 만들지 않고 앞선 네 항목의 값을 그대로 재사용했다.

cubic-bezierlinear()비교
.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;
}

어디서 깨지는가 — 세로 트랙은 가로보다 먼저 넘친다

04번(linear() 바운스)은 처음엔 공이 132px 가 아니라 180px 를 떨어지게 만들었다. 480×300 데스크톱 화면에선 멀쩡했다. 문제는 이 사이트가 모바일 대응을 재는 방식이다 — 폭 320px 로 줄일 때 높이도 같은 비율(320×300÷480)로 200px 까지 같이 줄어든다. 세로로 쌓이는 트랙 180px 에 위아래 여백을 더하면 200px 를 넘겨, 실제로 렌더해서 재 보니 폰넘침: true 가 나왔다. 가로로 눕는 트랙은 폭 320px 안에서 여유가 있어 안 걸렸지만, 세로 트랙은 높이 200px 라는 더 좁은 예산을 바로 만난다. 트랙을 132px 로 줄이고 나서야 넘침이 사라졌다. zip 을 열 때 묻는 비밀번호는 wnxge56e 이고, 안내 그대로 입력하면 풀린다. 세로 방향 애니메이션을 넣을 땐 가로 기준으로만 반응형을 확인하고 넘어가기 쉬운데, 이 편처럼 모바일 뷰포트의 높이도 따로 재야 한다.

접근성

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() 와 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 애니메이션은 사이트 검색 에서 찾아볼 수 있고, 이 블로그가 데모를 만드는 방식은 소개 페이지 에 있다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.