CSS 애니메이션 | Transition, Animation, Transform
이 글의 핵심
CSS 애니메이션: Transition, Animation, Transform. Transition (전환)·Transform (변형).
HTML/CSS 시리즈 일곱 번째. 이번엔 transition·animation이랑 그 옆에 붙는 transform, transform-origin을 이야기할게. 애니메이션을 “예쁘게”만 쓰면 끝이 아니고, 상태가 바뀐다는 걸 눈에 보이게 해 주는 쪽이야. 다만… 아래 써 둔 버벅 경험만큼은 진짜로 피하고 싶으면, 성능 쪽을 같이 읽어 주면 좋겠다.
transition은 이미 갖고 있는 두 값 사이를 부드럽게 잇는 쪽. 트리거는 :hover, :focus, 클래스 토글이 흔하고. animation은 @keyframes로 여러 시점을 박고, 자동 재생·반복·역재생까지 손댈 때 쓰는 편—스피너, 루프 배너 같은 거.
둘 다 “시간에 따라 CSS가 변한다”는 점은 같고, transition은 시작·끝이 한 쌍으로 딱 잡혀 있을 때 읽기 쉽다. animation은 0% → 50% → 100%처럼 단계가 있거나, 로드 직후부터 도는 게 편할 때.
transition vs animation을 표로 정리하는 글은 많은데, 나는 이 정도로만 기억해 둬도 실무에선 충분해 보였다. transition은 “규칙 + 상태별 CSS”이고, 키프레임은 사실상 암시적으로 시작/끝만 있다. animation은 키프레임이 명시적이고, 자동 실행·반복 횟수(animation-iteration-count)는 여기가 본가다. transition 쪽은 기본이 한 번 도착이고, 루프가 필요하면 animation을 쓰는 쪽이 의도에 맞는 경우가 많다.
transition은 어떤 속성이, 얼마나, 어떤 곡선으로, 얼마나 늦게 움직일지 한 줄에 묶는 축약이야. transition-property / duration / timing-function / delay로 쪼개서 써도 되고.
.card {
transition: transform 0.25s ease-out, opacity 0.25s ease-out;
}
.card.is-active {
transform: translateY(-4px);
opacity: 1;
}
.is-active가 붙는 순간 transform이랑 opacity는 목표값 쪽으로 0.25초에 걸쳐 가고, 떼면 같은 규칙이 있으면 역으로 돌아온다. transition: all은 손쉬운데, 생각지도 않게 width나 margin까지 건드리면 리플로우 나와서; 나는 그냥 전환시킬 애들만 나열하는 편이야.
transition-property에 transform, opacity, filter, box-shadow 같은 걸 두면 합성 쪽에 잘 올라가기 쉽고, width·height·top·left·margin은 레이아웃 다시 그리기 비싸니까, 꼭 필요할 때만.
여러 속성에 다른 duration을 줄 땐 쉼표 개수를 맞춰 주면 돼.
.el {
transition-property: opacity, transform;
transition-duration: 0.2s, 0.35s; /* opacity 0.2s, transform 0.35s */
}
타이밍은 ease, linear, ease-in, ease-out, ease-in-out, cubic-bezier(...), steps(...) 정도. 클릭 UI는 ease-out 많이 쓰고, 로딩 바·스피너는 linear가 무난하다. cubic-bezier는 DevTools에서 곡선 편집기 켜고 만지는 게 제일 빠름.
transition-delay로 순차 줄 수도 있어. nth-child마다 delay 조금씩 올리는 패턴. 근데 delay가 길면 “반응 느림”으로 느껴질 수 있으니, 핵심 버튼엔 너무 길게 잡지 마.
.staggered li {
transition: opacity 0.4s ease, transform 0.4s ease;
transition-delay: 0s;
}
.staggered li:nth-child(1) { transition-delay: 0.05s; }
.staggered li:nth-child(2) { transition-delay: 0.1s; }
.staggered li:nth-child(3) { transition-delay: 0.15s; }
@keyframes는 이름 붙이고, from/to나 %로 스타일을 찍는다. animation-name이 그걸 가리키고, animation-duration·iteration-count·direction·fill-mode·play-state 같은 걸로 한 사이클을 정밀하게 조절하는 게 transition이랑 다른 맛.
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes wobble {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px) rotate(-2deg); }
75% { transform: translateX(6px) rotate(2deg); }
}
animation-fill-mode는 none / forwards / backwards / both—딜레이 구간·끝난 뒤 어디 스타일에 머무를지 헷갈리기 쉬우니, 한 번 재생 + 끝에 고정이 중요하면 forwards랑 키프레임 끝 값을 같이 머릿속으로 검산해 봐. animation-play-state: paused로 호버할 때만 멈추는 식도 자주 쓰고.
transform은 레이아웃 박스를 직접 뜯는 게 아니라 시각 좌표를 바꾸는 쪽에 가깝다. translate·rotate·scale·skew를 한 줄에 나열할 때 곱해지는 순서에 결과(특히 이동+회전) 감이 달라지니, 팀이면 “우리는 항상 이 순서” 정도는 맞춰 두는 걸 권장.
transform-origin은 어느 점을 기준으로 돌릴지·커질지—문 여는 느낌 left center, 눌리는 버튼 bottom 같은 거.
.drawer {
transform: translateX(100%);
transition: transform 0.35s ease;
}
.drawer.is-open {
transform: translateX(0);
}
로딩 스피너는 rotate 한 바퀴 + linear + infinite 조합이 무난하다. 스크린리더엔 aria-label이나, 순수 장식이면 aria-hidden 쪽 검토.
<style>
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
</style>
<div class="spinner" role="status" aria-label="로딩 중"></div>
prefers-reduced-motion 켜진 사용자 쪽에선, 아래 같은 식으로 짧게 쳐주는 팀이 많다. (필수 정보를 애니메이션에만 싣지는 말 것—끄면 텍스트·포커스로도 전달돼야 함.)
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
애니메이션 때문에 앱이 버벅였던 적—진짜로 있다. 옛날에 리스트에 카드 50장 넘게 깔고, 스크롤할 때마다 box-shadow랑 filter 흔들고, width로 패널 열리는 거까지 transition에 넣은 화면이었는데, 개발 머신에선 “어? 괜찮은데?” 하다가 낮은 기기에서만 프레임이 무너진다. DevTools Performance 찍어 보면 Layout이나 Paint 쪽이 붉게 올라가고, 그때 transform/opacity로 흉내 낼 수 있는지 먼저 본 뒤에 애들만 남기고 정리했더니 체감이 달라졌다. 루프 애니메이션이 여러 겹 겹치면 합성 자체도 부담이니, “이거 없으면 UX가 깨지나?” 아니면 과감히 뺀다.
will-change는 남발하지 마. 브라우저에 “곧 이거 바뀌어” 힌트 주는 건데, 막 *에 will-change: transform 박아 두거나 카드마다 permanent로 걸어 두면 메모리랑 레이어만 불필요하게 쌓인다. 드래그 잠깐 하거나, 애니메이션 직전에만 잠깐 쓰고 끝나면 auto로 돌리는 패턀이 낫다. translateZ(0) / translate3d(0,0,0)로 “GPU 꼼수” 박는 것도, 예전엔 통했는데 남용하면 VRAM·합성만 늘 수 있으니, “최적화”라는 이름으로 전역에 퍼뜨리지 말고—필요할 때, 이유랑 같이. 나는 기본은 브라우저한테 맡기고, 측정에서 병목 찍힐 때만 손댄다 쪽.
Animate.css는 클래스만으로 빠르게 쓰기 좋고, GSAP은 타임라인·스크롤·스크롤트리거 쪽으로 복잡해지면 강하다. 순수 CSS로 끊기면 굳이 번들 안 늘리는 게 이득이고, 체이닝이 기니까 그때 가서 라이브러리를 본다.
전환이 안 먹으면—초기/목표 값이 셀렉터에 같이 잡혀 있는지, transition이 같은 요소에 먼저 선언돼 있는지, 다른 규칙이 덮는지. animation이 안 돌면 @keyframes 이름이랑 animation-name 철자, animation-duration: 0·none 덮기. 끝난 뒤 첫 키로 돌아가버리면 fill-mode랑 방향이 의도한 “멈출 프레임”이랑 맞는지 본다. transform/opacity 쓰다 z-index 기대가 어긋나면, 새 스태킹 컨텍스트 열리는지 생각해 보고 DOM이랑 z-index를 다시 짠다. 모션이 버벅이면—위에 쓴 대로 width/left 말고 transform으로 옮길 수 있는지, will-change·동시 루프·요소 수부터 줄이고, 스크롤이랑 섞이면 rAF·GSAP 쪽을 검토한다. cubic-bezier 튐이 과하면 y가 0~1 밖으로 나가서 UI가 흔들리는 느낌 나니, 절제된 곡선이랑 비교해 봐.
정리하면: transition은 두 상태 사이의 피드밵에, animation은 키프레임·반복·방향·필 쪽에, transform/transform-origin은 합성 친화 모션의 기본 축. 성능은 will-change 함부로 쓰지 말고, 측정한 뒤에만. 접근성은 prefers-reduced-motion. Chromium이면 DevTools → Animations이나 Performance로 타임라인·프레임 찍는 게, 합성 위주 원칙을 말이 아니라 숫자로 확인하는 데 제일 직관적이다. 배포 전엔 git 커밋·푸시한 다음, 이 repo 관습대로 npm run deploy 전에 워킹트리 한 번 보면 됨.
MDN 링크는 그냥 여기 두고 갈게.
시리즈 목차는 여기에서 이어 읽을 수 있어.