CSS는 모션을 위한 두 가지 도구를 제공합니다: 트랜지션과 @keyframes 애니메이션. 대부분의 개발자는 둘 다 존재한다는 것을 알지만 먼저 배운 것, 보통 트랜지션에 먼저 손이 가고 나서 로딩 스피너가 왜 루프하지 않는지, 알림이 왜 원래 위치로 뚝 튀어 돌아가는지 의아해합니다. 각각이 실제로 무엇을 위한 것인지 이해하면 결정은 복잡하지 않습니다. 트랜지션은 반응입니다: 무언가 상태가 바뀌면 브라우저가 변화를 부드럽게 처리합니다. @keyframes 애니메이션은 독립된 스크립트입니다: 자체적으로 실행되고, 원하면 루프하고, 상태 트리거가 필요 없습니다. 이 가이드는 두 가지 모두, 가장 중요한 속성들, 그리고 부드러운 60fps 애니메이션과 버벅이는 것을 가르는 성능 세부사항을 다룹니다. CSS를 많이 작성한다면 CSS Formatter를 북마크해 두세요 — 지저분한 단축어를 빠르게 정리해 줄 것입니다.
CSS 트랜지션 — 간단한 경우
트랜지션은 브라우저에게 이렇게 말합니다: "이 속성이 바뀌면 즉시 점프하지 말고 시간을 들여 변화를 애니메이션화해." 그게 전부입니다. 가장 흔한 사용 예는 호버 효과 — 색상 변화, 불투명도 페이드, 미묘한 스케일 변환입니다. MDN 트랜지션 가이드에서 전체 스펙을 다루지만, 신경 써야 할 네 가지 속성은 transition-property, transition-duration, transition-timing-function, transition-delay입니다.
/* 단축어: property duration easing delay */
.btn-primary {
background-color: #4f46e5;
color: #fff;
padding: 0.625rem 1.25rem;
border-radius: 6px;
border: none;
cursor: pointer;
transition: background-color 200ms ease, transform 150ms ease;
}
.btn-primary:hover {
background-color: #4338ca;
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
/* 불투명도 페이드 — 뷰로 들어오는 카드 */
.notification-card {
opacity: 0;
transition: opacity 300ms ease-in-out;
}
.notification-card.is-visible {
opacity: 1;
}위의 버튼 예제처럼 쉼표로 구분하여 여러 트랜지션을 나열하고 각각 고유한 지속 시간과 이징을 줄 수 있습니다. 각 속성은 독립적으로 애니메이션화됩니다.
transition: all은 사용하지 마세요. 모든 것을 잡는 느낌에 끌리지만, 해당 요소의 모든 속성 변경이 의도하지 않은 것까지 애니메이션화된다는 의미입니다. 또한 매 프레임마다 브라우저가 더 많은 작업을 하게 됩니다. 트랜지션할 특정 속성을 나열하세요. MDN transition-property 문서에서 어떤 속성이 애니메이션 가능한지 설명합니다.transition-timing-function — 이징 설명
타이밍 함수는 애니메이션의 가속 곡선을 제어합니다 — 빠르게 시작해서 느려지는지, 일정한 속도로 움직이는지, 커스텀 곡선으로 바운스하는지. 기본 제공 키워드가 대부분의 상황을 커버합니다.
ease— 천천히 시작해서 가속되다가 끝 근처에서 느려집니다. 기본값. 대부분의 UI 모션에 적합합니다.linear— 전체적으로 일정한 속도. 스피너와 진행 표시줄에 좋지만 대부분의 다른 것에는 나쁩니다 — 기계적으로 보입니다.ease-in— 천천히 시작해서 빠르게 끝납니다. 속도를 올리는 느낌. 화면을 떠나는 요소에 좋습니다.ease-out— 빠르게 시작해서 천천히 끝납니다. 화면으로 들어오는 요소에 자연스럽습니다(예: 슬라이드인되는 알림).ease-in-out— 양쪽 끝이 느립니다. 뷰에서 시작하고 멈추는 요소에 세련되어 보입니다.cubic-bezier(x1, y1, x2, y2)— 자신만의 곡선을 정의합니다. cubic-bezier.com 같은 도구로 커스텀 곡선을 시각적으로 만들고 미리 볼 수 있습니다.
/* cubic-bezier를 이용한 커스텀 스프링 이징 */
.drawer {
transform: translateX(-100%);
transition: transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.drawer.is-open {
transform: translateX(0);
}
/* 위의 cubic-bezier 값은 약간 오버슈트합니다 (y > 1)
열릴 때 만족스러운 스프링 효과를 줍니다 */@keyframes — 트랜지션으로 부족할 때
트랜지션에는 명확한 한계가 있습니다: 두 가지 상태와 트리거가 필요합니다. 무언가를 무한히 루프하거나, 어떤 인터랙션 없이 페이지 로드 시 실행하거나, 두 단계 이상으로 애니메이션화하고 싶다면 @keyframes가 필요합니다. MDN @keyframes 참조에서 전체 문법을 다룹니다. 키프레임을 별도로 정의한 다음 animation 속성으로 요소에 적용합니다.
/* 애니메이션 정의 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 요소에 적용 */
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 700ms linear infinite;
}
/* 다단계 — 두 개 이상의 정지점 */
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
.badge-live {
animation: pulse-ring 1.5s ease-in-out infinite;
}animation 단축어 — 8가지 속성 모두
animation 단축어는 8가지 속성을 하나의 선언으로 묶습니다. 매번 모두 필요한 것은 아니지만, 각각이 무엇을 제어하는지 알면 무언가 예상대로 동작하지 않을 때 문서를 뒤지지 않아도 됩니다.
/*
animation: name | duration | timing-function | delay | iteration-count | direction | fill-mode | play-state
*/
.toast {
animation: slide-in-right 350ms ease-out 0s 1 normal forwards running;
}
/* 더 일반적으로 필요한 값만 작성: */
.toast {
animation: slide-in-right 350ms ease-out forwards;
}
/* 명확성을 위해 개별 속성으로 나누기: */
.confetti-piece {
animation-name: float-down;
animation-duration: 2s;
animation-timing-function: ease-in;
animation-delay: calc(var(--i) * 150ms); /* CSS 커스텀 속성으로 스태거 */
animation-iteration-count: 1;
animation-direction: normal;
animation-fill-mode: forwards;
animation-play-state: running;
}
/* JavaScript로 애니메이션 일시 정지: 클래스 토글 */
.spinner.is-paused {
animation-play-state: paused;
}animation-fill-mode — 뚝 튀어 돌아가는 문제 해결
이것은 언젠가 모든 사람이 걸려 넘어지는 속성입니다. 기본적으로 애니메이션이 끝나면 요소는 애니메이션 이전 스타일로 뚝 돌아갑니다 — 마치 애니메이션이 전혀 일어나지 않은 것처럼. 토스트 알림이 오른쪽에서 슬라이드인 되도록 애니메이션화하면 애니메이션이 끝나는 순간 화면 밖으로 다시 뚝 돌아갑니다. animation-fill-mode: forwards는 애니메이션이 끝난 후 요소를 마지막 키프레임 상태로 유지해서 이 문제를 해결합니다.
@keyframes slide-in-right {
from {
transform: translateX(110%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* forwards 없이: 토스트가 슬라이드인되다가 화면 밖으로 뚝 돌아감 */
.toast-bad {
animation: slide-in-right 350ms ease-out;
}
/* forwards 사용: 토스트가 슬라이드인되고 제자리에 머뭄 */
.toast {
animation: slide-in-right 350ms ease-out forwards;
}
/*
fill-mode 값:
- none (기본값) — 전후 스타일 적용 안 함
- forwards — 애니메이션 끝난 후 마지막 키프레임 유지
- backwards — 딜레이 기간 동안 첫 번째 키프레임 적용
- both — forwards + backwards 결합
*/실제 예제 — 스켈레톤 로더, 스피너, 토스트, 펄싱 배지
항상 사용하게 될 네 가지 패턴입니다. 각각 @keyframes와 애니메이션 속성의 특정 조합을 보여줍니다.
/* --- 1. 스켈레톤 로딩 시머 --- */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite linear;
border-radius: 4px;
}
.skeleton-title { height: 20px; width: 60%; margin-bottom: 12px; }
.skeleton-text { height: 14px; width: 100%; margin-bottom: 8px; }
.skeleton-text:last-child { width: 80%; }/* --- 2. 회전 로더 --- */
@keyframes spin {
to { transform: rotate(360deg); }
}
.loader-spinner {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid rgba(79, 70, 229, 0.2);
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 600ms linear infinite;
}/* --- 3. 슬라이드인 알림 토스트 --- */
@keyframes toast-enter {
from {
transform: translateX(calc(100% + 1.5rem));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes toast-exit {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(calc(100% + 1.5rem));
opacity: 0;
}
}
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
padding: 0.875rem 1.25rem;
background: #1f2937;
color: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
animation: toast-enter 350ms ease-out forwards;
}
.toast.is-dismissing {
animation: toast-exit 300ms ease-in forwards;
}/* --- 4. 펄싱 알림 배지 --- */
@keyframes badge-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
.notification-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 700;
border-radius: 999px;
animation: badge-pulse 2s ease-in-out infinite;
}성능 — GPU 합성 vs 레이아웃 트리거 속성
애니메이션 성능에 있어서 모든 CSS 속성이 동일하지 않습니다. 여기서 중요한 브라우저 렌더링 파이프라인에는 세 단계가 있습니다: 레이아웃(요소 크기와 위치 계산), 페인트(픽셀 채우기), 합성(GPU에서 레이어 결합). 레이아웃을 트리거하는 속성을 애니메이션화하면 브라우저가 매 프레임마다 전체 문서 기하학을 재계산하게 됩니다 — 이는 비용이 많이 들고 버벅임의 원인입니다. web.dev 애니메이션 성능 가이드에서 이 내용을 자세히 다룹니다.
- 안전하게 애니메이션화 가능 (GPU 합성):
transform(translate, scale, rotate)과opacity. 이것들은 GPU 합성기 스레드에서 완전히 실행됩니다 — 레이아웃이나 페인트를 트리거하지 않습니다. - 페인트 유발 (가능하면 피하기):
color,background-color,border-color,box-shadow. 레이아웃은 건너뛰지만 매 프레임마다 리페인트를 트리거합니다. 짧은 트랜지션(300ms 미만)은 보통 괜찮습니다. - 레이아웃 유발 (절대 애니메이션화하지 마세요):
width,height,top,left,margin,padding. 매 프레임이 전체 레이아웃 재계산을 트리거합니다 — 복잡한 페이지에서 보장된 버벅임.
transform과 opacity만으로 애니메이션화하세요. 무언가를 이동하려면 left/top 대신 translate를 사용하세요. 크기를 조정하려면 width/height 대신 scale을 사용하세요. CSS Triggers는 각 속성이 어떤 렌더링 단계에 영향을 주는지 정확히 나열한 참조 자료입니다./* 나쁨 — width 애니메이션화는 매 프레임마다 레이아웃 트리거 */
.progress-bar-bad {
transition: width 300ms ease;
}
/* 좋음 — 대신 scaleX transform 사용 */
.progress-bar {
transform-origin: left center;
transition: transform 300ms ease;
}
/* JS에서: progressBar.style.transform = 'scaleX(0.75)' (75%용) */
/* 나쁨 — top/left 애니메이션화 (레이아웃으로 위치 지정) */
.tooltip-bad {
position: absolute;
top: 0;
transition: top 200ms ease;
}
/* 좋음 — 대신 translateY 사용 */
.tooltip {
position: absolute;
top: 0;
transform: translateY(0);
transition: transform 200ms ease;
}will-change — 아껴서 사용하기
will-change는 특정 속성이 곧 애니메이션화될 것이라는 힌트를 브라우저에게 주어 미리 요소를 자체 합성기 레이어로 승격해야 한다는 것을 알려줍니다. 이것은 저사양 하드웨어에서 애니메이션 시작 시 가끔 나타나는 짧은 버벅임을 제거할 수 있습니다. 그러나 실제 비용이 있습니다: 승격된 각 레이어는 GPU 메모리를 소모합니다. 앱의 모든 애니메이션 요소에 will-change: transform을 넣으면 대부분의 기기에서 성능이 저하됩니다 — 의도와 반대입니다.
/* 올바른 방법 — 애니메이션 시작 직전에 추가하고 이후 제거 */
.modal-overlay {
/* 여기에 기본적으로 will-change 설정 안 함 */
}
/* 사용자가 모달 열기를 트리거할 때만 JavaScript로 추가 */
/* overlay.style.willChange = 'opacity'; */
/* overlay.addEventListener('transitionend', () => { */
/* overlay.style.willChange = 'auto'; */
/* }); */
/* 자주 애니메이션화되는 요소에는 CSS에서 허용 가능
(예: 스크롤 시 지속적인 플로팅 액션 버튼) */
.fab {
will-change: transform; /* OK — 이 요소는 실제로 스크롤 시 애니메이션화됨 */
transition: transform 200ms ease;
}
/* 이렇게 하지 마세요 — 아무 이득 없이 GPU 메모리 낭비 */
.card {
will-change: transform; /* 나쁨 — 카드는 항상이 아니라 호버 시에만 애니메이션화됨 */
}prefers-reduced-motion — 건너뛸 수 없는 접근성
상당수의 사용자들이 전정 장애나 모션이 불편함이나 메스꺼움을 유발하는 다른 상태를 가지고 있습니다. prefers-reduced-motion 미디어 쿼리를 사용하면 시스템 수준의 "모션 줄이기" 설정을 존중할 수 있습니다. WCAG 2.1 가이드라인 2.3.3이 이 요건을 다루며, MDN의 prefers-reduced-motion 참조에서 브라우저 지원(이제 보편적)을 보여줍니다. 패턴은 간단합니다: 표준 미디어 쿼리 안에 애니메이션을 감싸고, 축소 모션 내부에 모션 없음 폴백을 제공합니다.
/* 모션에 문제없는 사용자를 위해 정상적으로 애니메이션 정의 */
@keyframes slide-in-right {
from { transform: translateX(110%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.toast {
animation: slide-in-right 350ms ease-out forwards;
}
.spinner {
animation: spin 600ms linear infinite;
}
/* 모션 축소를 선호하는 사용자를 위한 재정의 */
@media (prefers-reduced-motion: reduce) {
.toast {
/* 슬라이딩 없음 — 그냥 나타남 */
animation: none;
opacity: 1;
transform: none;
}
.spinner {
/* 속도를 크게 줄이거나 완전히 정지 */
animation-duration: 4s;
}
/* 핵옵션 — 사이트 전체의 모든 애니메이션 비활성화 */
/* 각 애니메이션을 개별적으로 감사하지 않은 경우에만 사용 */
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}해당 스니펫 끝의 "핵옵션"은 모든 애니메이션을 감사하지 않은 기존 코드베이스에서 흔한 패턴입니다. 없는 것보다는 낫지만, 각 애니메이션을 개별적으로 감사하면 더 많은 제어권을 갖습니다 — 일부 애니메이션은 상태를 전달하고(진행 표시줄, 로딩 스피너) 유지되어야 하며, 단지 속도를 낮추면 됩니다.
트랜지션 vs @keyframes — 결정 가이드
어떤 것을 선택해야 할지 확신이 없을 때, 만들고 있는 애니메이션에 대해 두 가지 질문을 해보세요:
- 상태 변화에 의해 트리거되나요? (호버, 포커스, 클래스 토글, 체크박스) → 트랜지션을 사용하세요. 그것이 바로 트랜지션이 설계된 것입니다.
- 무한히 루프하나요? →
animation-iteration-count: infinite를 사용한 @keyframes를 사용하세요. - 두 단계 이상이 있나요? (시작 → 끝만이 아니라, 시작 → 중간 → 끝 또는 그 이상) → @keyframes를 사용하세요.
- 어떤 사용자 인터랙션 없이 페이지 로드 시 실행되어야 하나요? → @keyframes를 사용하세요.
- 요소가 완료 후 최종 상태에 머물러야 하나요? →
animation-fill-mode: forwards를 사용한 @keyframes를 사용하세요.
마무리
트랜지션은 상태 기반 모션을 처리합니다. @keyframes는 나머지를 처리합니다. 성능을 위해 항상 transform과 opacity를 애니메이션화하세요 — width, height, top, left는 제외하세요. 요소가 최종 상태를 유지해야 할 때는 animation-fill-mode: forwards를 사용하세요. 무언가를 출시하기 전에 prefers-reduced-motion 재정의를 추가하세요 — 실제 사용자에게 많은 것을 의미하는 10줄 추가입니다. 합성 모델과 레이아웃을 트리거하는 것에 대한 자세한 내용은 web.dev의 렌더링 성능 문서가 가장 실용적인 자료입니다. CSS를 작성한 후 CSS Formatter를 통해 단축어를 읽기 쉽게 유지하거나, CSS Minifier를 통해 프로덕션 출시 전 공백을 제거하세요.