CSS는 모션을 위한 두 가지 도구를 제공합니다: 트랜지션과 @keyframes 애니메이션. 대부분의 개발자는 둘 다 존재한다는 것을 알지만 먼저 배운 것, 보통 트랜지션에 먼저 손이 가고 나서 로딩 스피너가 왜 루프하지 않는지, 알림이 왜 원래 위치로 뚝 튀어 돌아가는지 의아해합니다. 각각이 실제로 무엇을 위한 것인지 이해하면 결정은 복잡하지 않습니다. 트랜지션은 반응입니다: 무언가 상태가 바뀌면 브라우저가 변화를 부드럽게 처리합니다. @keyframes 애니메이션은 독립된 스크립트입니다: 자체적으로 실행되고, 원하면 루프하고, 상태 트리거가 필요 없습니다. 이 가이드는 두 가지 모두, 가장 중요한 속성들, 그리고 부드러운 60fps 애니메이션과 버벅이는 것을 가르는 성능 세부사항을 다룹니다. CSS를 많이 작성한다면 CSS Formatter를 북마크해 두세요 — 지저분한 단축어를 빠르게 정리해 줄 것입니다.

CSS 트랜지션 — 간단한 경우

트랜지션은 브라우저에게 이렇게 말합니다: "이 속성이 바뀌면 즉시 점프하지 말고 시간을 들여 변화를 애니메이션화해." 그게 전부입니다. 가장 흔한 사용 예는 호버 효과 — 색상 변화, 불투명도 페이드, 미묘한 스케일 변환입니다. MDN 트랜지션 가이드에서 전체 스펙을 다루지만, 신경 써야 할 네 가지 속성은 transition-property, transition-duration, transition-timing-function, transition-delay입니다.

css
/* 단축어: 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 같은 도구로 커스텀 곡선을 시각적으로 만들고 미리 볼 수 있습니다.
css
/* 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 속성으로 요소에 적용합니다.

css
/* 애니메이션 정의 */
@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가지 속성을 하나의 선언으로 묶습니다. 매번 모두 필요한 것은 아니지만, 각각이 무엇을 제어하는지 알면 무언가 예상대로 동작하지 않을 때 문서를 뒤지지 않아도 됩니다.

css
/*
  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는 애니메이션이 끝난 후 요소를 마지막 키프레임 상태로 유지해서 이 문제를 해결합니다.

css
@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와 애니메이션 속성의 특정 조합을 보여줍니다.

css
/* --- 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%; }
css
/* --- 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;
}
css
/* --- 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;
}
css
/* --- 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. 매 프레임이 전체 레이아웃 재계산을 트리거합니다 — 복잡한 페이지에서 보장된 버벅임.
실용적인 규칙: transformopacity만으로 애니메이션화하세요. 무언가를 이동하려면 left/top 대신 translate를 사용하세요. 크기를 조정하려면 width/height 대신 scale을 사용하세요. CSS Triggers는 각 속성이 어떤 렌더링 단계에 영향을 주는지 정확히 나열한 참조 자료입니다.
css
/* 나쁨 — 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을 넣으면 대부분의 기기에서 성능이 저하됩니다 — 의도와 반대입니다.

css
/* 올바른 방법 — 애니메이션 시작 직전에 추가하고 이후 제거 */
.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 참조에서 브라우저 지원(이제 보편적)을 보여줍니다. 패턴은 간단합니다: 표준 미디어 쿼리 안에 애니메이션을 감싸고, 축소 모션 내부에 모션 없음 폴백을 제공합니다.

css
/* 모션에 문제없는 사용자를 위해 정상적으로 애니메이션 정의 */
@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를 사용하세요.
사람들이 걸려 넘어지는 조합: 배경색을 부드럽게 페이드하는 호버 효과(트랜지션)를 원하지만 같은 요소에 "new" 배지가 지속적으로 펄싱(@keyframes)되기를 원합니다. 같은 요소에 둘 다 사용할 수 있습니다 — 서로 다른 속성을 제어하며 서로 간섭하지 않습니다.

마무리

트랜지션은 상태 기반 모션을 처리합니다. @keyframes는 나머지를 처리합니다. 성능을 위해 항상 transformopacity를 애니메이션화하세요 — width, height, top, left는 제외하세요. 요소가 최종 상태를 유지해야 할 때는 animation-fill-mode: forwards를 사용하세요. 무언가를 출시하기 전에 prefers-reduced-motion 재정의를 추가하세요 — 실제 사용자에게 많은 것을 의미하는 10줄 추가입니다. 합성 모델과 레이아웃을 트리거하는 것에 대한 자세한 내용은 web.dev의 렌더링 성능 문서가 가장 실용적인 자료입니다. CSS를 작성한 후 CSS Formatter를 통해 단축어를 읽기 쉽게 유지하거나, CSS Minifier를 통해 프로덕션 출시 전 공백을 제거하세요.