✨ Tailwind 8장: 애니메이션과 트랜지션

2026년 3월 5일 수정됨

📋 개요

Tailwind transition 과 animate 유틸리티로 생동감 있는 UI 만들기 — 성능까지 고려한 애니메이션 설계

📋 목차


📌 이 문서를 읽기 전에

⏱️ 예상 읽기 시간: 15분

🎯 이 문서를 다 읽으면 할 수 있는 것

  • transition-*, duration-*, ease-* 로 부드러운 상태 전환을 구현할 수 있다
  • animate-spin, animate-pulse, animate-bounce 를 적재적소에 활용할 수 있다
  • 성능을 고려해 transform, opacity 속성 위주로 애니메이션을 설계할 수 있다

🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'

  • 🎨 영숙 (디자이너): "영철 님, 버튼 클릭할 때 살짝 눌리는 느낌이랑, 데이터 로딩 중에 스켈레톤 애니메이션 넣고 싶어요."
  • 🐣 영철: "CSS 애니메이션이요? @keyframes 써야 하나요?"
  • 🦁 영호 (리드): "Tailwind 내장 유틸리티로 80% 는 해결돼요. animate-pulse 하나면 스켈레톤도 완성이에요."
  • 🎨 영숙: "그리고 카드 hover 할 때 부드럽게 올라오는 효과도요!"
  • 🦁 영호: "transition-all hover:-translate-y-1 이면 돼요."

🤔 왜 알아야 하는가

애니메이션과 트랜지션은 단순한 "이쁨" 이 아니야. 사용자에게 상호작용 피드백을 주고, 상태 변화를 자연스럽게 안내하는 UX 도구야.

버튼을 클릭했을 때 즉각적인 시각 반응이 없으면 사용자는 "눌렸나?" 라고 의문을 가지고 다시 클릭해. 로딩 중에 스켈레톤 없이 빈 화면이 뜨면 앱이 멈춘 것처럼 느껴져.

영수가 "UX 개선해주세요" 라고 했을 때, 영철이가 할 수 있는 가장 효과적이고 빠른 방법 중 하나가 적절한 트랜지션 추가야.


🏗️ 트랜지션 vs 애니메이션

트랜지션 = 에스컬레이터 (A 상태에서 B 상태로 이동 시 부드럽게 연결)
애니메이션 = 회전목마 (시작점 없이 반복 실행)

구분트리거반복사용 사례
Transition상태 변화 (hover, focus 등)없음버튼 색상 전환, 카드 이동
AnimationCSS @keyframes무한 가능로딩 스피너, 스켈레톤

⚡ Transition 유틸리티 완전 정복

기본 트랜지션 클래스

<!-- 어떤 속성을 트랜지션할지 지정 -->
<div class="transition">           <!-- 일반적인 속성들 (color, bg, border 등) -->
<div class="transition-all">       <!-- 모든 속성 (성능 주의!) -->
<div class="transition-colors">    <!-- 색상 관련 속성만 -->
<div class="transition-opacity">   <!-- opacity 만 -->
<div class="transition-shadow">    <!-- box-shadow 만 -->
<div class="transition-transform"> <!-- transform 만 (가장 성능 좋음) -->
 
<!-- 트랜지션 시간 (duration) -->
<div class="duration-75">    <!-- 75ms -->
<div class="duration-100">   <!-- 100ms -->
<div class="duration-150">   <!-- 150ms ← 기본값 -->
<div class="duration-200">   <!-- 200ms ← 인터랙션 기본 추천 -->
<div class="duration-300">   <!-- 300ms ← 카드/모달 추천 -->
<div class="duration-500">   <!-- 500ms ← 페이지 전환 -->
<div class="duration-700">   <!-- 700ms -->
<div class="duration-1000">  <!-- 1000ms (1초) — 보통 너무 느림 -->
 
<!-- 타이밍 함수 (ease) -->
<div class="ease-linear">    <!-- 일정한 속도 -->
<div class="ease-in">        <!-- 처음 느리게, 나중 빠르게 -->
<div class="ease-out">       <!-- 처음 빠르게, 나중 느리게 ← 가장 자연스러움 -->
<div class="ease-in-out">    <!-- 처음 느리게, 중간 빠르게, 나중 느리게 -->
 
<!-- 딜레이 -->
<div class="delay-75">   <!-- 75ms 후 시작 -->
<div class="delay-150">  <!-- 150ms 후 시작 -->
<div class="delay-300">  <!-- 300ms 후 시작 — 순차 애니메이션에 활용 -->

Transform 유틸리티 (트랜지션과 함께 자주 씀)

<!-- 이동 (translate) -->
<div class="translate-x-4">        <!-- translateX(16px) -->
<div class="translate-y-4">        <!-- translateY(16px) -->
<div class="-translate-y-1">       <!-- translateY(-4px) ← hover 시 위로 살짝 -->
<div class="hover:-translate-y-2"> <!-- hover 시 위로 8px -->
 
<!-- 크기 (scale) -->
<div class="scale-95">             <!-- 95%로 축소 ← 클릭 눌림 효과 -->
<div class="scale-100">            <!-- 기본 크기 -->
<div class="scale-105">            <!-- 105%로 확대 ← hover 확대 효과 -->
<div class="active:scale-95">      <!-- 클릭 시 95% -->
 
<!-- 회전 (rotate) -->
<div class="rotate-45">            <!-- 45도 회전 -->
<div class="rotate-180">           <!-- 180도 회전 ← 드롭다운 화살표 반전 -->
<div class="hover:rotate-12">      <!-- hover 시 12도 회전 -->

실전 패턴: 버튼 트랜지션

// 🦁 영호: "버튼의 트랜지션은 200ms, ease-out 이 가장 자연스러워요."
 
// 기본 CTA 버튼
function Button({ children, onClick }: Props) {
  return (
    <button
      onClick={onClick}
      className="
        rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white
        transition-all duration-200 ease-out
        hover:bg-blue-700 hover:shadow-lg
        active:scale-95
        focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
        disabled:cursor-not-allowed disabled:opacity-50
      "
    >
      {children}
    </button>
  );
}
 
// 카드 (hover 시 올라오는 효과)
function Card({ children }: Props) {
  return (
    <div className="
      rounded-xl border border-gray-200 bg-white p-5 shadow-md
      transition-all duration-300 ease-out
      hover:-translate-y-1 hover:shadow-xl
    ">
      {children}
    </div>
  );
}

🎬 Animation 유틸리티 완전 정복

기본 내장 Animation 클래스

<!-- spin: 무한 회전 (로딩 스피너) -->
<div class="animate-spin">🔄</div>
 
<!-- ping: 바깥으로 확산하며 사라짐 (알림 뱃지) -->
<div class="animate-ping">🔴</div>
 
<!-- pulse: 페이드 인/아웃 반복 (스켈레톤 UI) -->
<div class="animate-pulse">████</div>
 
<!-- bounce: 위아래 통통 튀기 (스크롤 유도 화살표) -->
<div class="animate-bounce">↓</div>

내장 Animation 의 CSS 정의

/* animate-spin */
@keyframes spin {
  to { transform: rotate(360deg); }
}
.animate-spin { animation: spin 1s linear infinite; }
 
/* animate-ping */
@keyframes ping {
  75%, 100% { transform: scale(2); opacity: 0; }
}
.animate-ping { animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; }
 
/* animate-pulse */
@keyframes pulse {
  50% { opacity: 0.5; }
}
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
 
/* animate-bounce */
@keyframes bounce {
  0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); }
  50% { transform: none; animation-timing-function: cubic-bezier(0, 0, 0.2, 1); }
}
.animate-bounce { animation: bounce 1s infinite; }

💻 실전: 영수네 커뮤니티 애니메이션 UI

로딩 스피너

// 다양한 로딩 스피너 패턴
function Spinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
  const sizeClass = { sm: 'h-4 w-4', md: 'h-6 w-6', lg: 'h-8 w-8' }[size];
 
  return (
    <div
      className={`${sizeClass} animate-spin rounded-full border-2 border-gray-200 border-t-blue-600`}
      aria-label="로딩 중"
    />
  );
}
 
// 버튼 안에 스피너 — 제출 중 상태 표현
function SubmitButton({ loading }: { loading: boolean }) {
  return (
    <button
      disabled={loading}
      className="flex items-center gap-2 rounded-xl bg-blue-600 px-6 py-3 font-semibold text-white disabled:opacity-70"
    >
      {loading && <Spinner size="sm" />}
      {loading ? '처리 중...' : '스터디 신청하기'}
    </button>
  );
}

스켈레톤 UI (animate-pulse)

// 🦁 영호: "스켈레톤은 실제 컴포넌트와 레이아웃이 동일해야 Layout Shift 가 없어요."
 
function StudyCardSkeleton() {
  return (
    <div className="animate-pulse rounded-xl border border-gray-200 bg-white p-5 shadow-md">
      {/* 태그 배지 자리 */}
      <div className="mb-3 h-5 w-20 rounded-full bg-gray-200" />
 
      {/* 제목 자리 */}
      <div className="mb-2 h-6 w-3/4 rounded bg-gray-200" />
      <div className="mb-4 h-4 w-1/2 rounded bg-gray-200" />
 
      {/* 설명 자리 */}
      <div className="mb-1 h-4 w-full rounded bg-gray-200" />
      <div className="mb-4 h-4 w-4/5 rounded bg-gray-200" />
 
      {/* 하단 */}
      <div className="flex items-center justify-between">
        <div className="h-4 w-16 rounded bg-gray-200" />
        <div className="h-8 w-24 rounded-lg bg-gray-200" />
      </div>
    </div>
  );
}
 
// 그리드에 스켈레톤 표시
function StudyGridWithSkeleton({ loading, studies }: Props) {
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
      {loading
        ? Array.from({ length: 6 }).map((_, i) => <StudyCardSkeleton key={i} />)
        : studies.map((study) => <StudyCard key={study.id} {...study} />)
      }
    </div>
  );
}

알림 뱃지 (animate-ping)

// 새 알림이 있을 때 통통 튀기는 뱃지
function NotificationBell({ count }: { count: number }) {
  return (
    <button className="relative p-2">
      🔔
      {count > 0 && (
        <span className="absolute right-1 top-1">
          {/* 바깥으로 확산하는 ping 효과 */}
          <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
          {/* 실제 뱃지 */}
          <span className="relative inline-flex h-3 w-3 items-center justify-center rounded-full bg-red-500 text-[8px] font-bold text-white">
            {count > 9 ? '9+' : count}
          </span>
        </span>
      )}
    </button>
  );
}

스크롤 유도 화살표 (animate-bounce)

function ScrollDownIndicator() {
  return (
    <div className="flex flex-col items-center gap-2 text-gray-400">
      <span className="text-sm">스크롤해서 더 보기</span>
      <div className="animate-bounce text-2xl">↓</div>
    </div>
  );
}

순차 애니메이션 (delay 활용)

// 카드들이 순서대로 나타나는 효과 (stagger animation)
function StudyCardList({ studies }: { studies: Study[] }) {
  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
      {studies.map((study, index) => (
        <div
          key={study.id}
          className="animate-fade-in"  // 커스텀 애니메이션 (테마에 등록 필요)
          style={{ animationDelay: `${index * 100}ms` }}  // 100ms 씩 지연
        >
          <StudyCard {...study} />
        </div>
      ))}
    </div>
  );
}

🚨 성능 고려사항과 접근성

성능: GPU 가속 속성 우선 사용

{/* ❌ 성능 저하 유발 — 레이아웃 리플로우 발생 */}
<div className="transition-all hover:w-80 hover:h-40">  {/* width/height 변경 = 비쌈 */}
<div className="transition-all hover:top-10">           {/* position 변경 = 비쌈 */}
 
{/* ✅ GPU 가속 — Composite 레이어에서 처리 */}
<div className="transition-transform hover:-translate-y-1">  {/* transform = 저렴 */}
<div className="transition-opacity hover:opacity-80">        {/* opacity = 저렴 */}

📌 원칙: transformopacity 는 CPU 연산 없이 GPU 에서 처리돼서 매우 빨라. width, height, top, left, margin, padding 변경은 레이아웃 계산을 다시 하기 때문에 비싸.

접근성: 애니메이션 줄임 설정 존중

<!-- prefers-reduced-motion: 사용자가 애니메이션 줄임 설정한 경우 -->
<div class="animate-spin motion-reduce:animate-none">
<div class="transition-all duration-300 motion-reduce:transition-none">
<div class="hover:-translate-y-1 motion-reduce:hover:translate-y-0">
// 실전: 모든 애니메이션에 motion-reduce 고려
function AnimatedCard({ children }: Props) {
  return (
    <div className="
      transition-all duration-300 ease-out
      hover:-translate-y-1 hover:shadow-lg
      motion-reduce:transition-none   {/* 애니메이션 선호 안 함 설정 시 비활성 */}
      motion-reduce:hover:translate-y-0
    ">
      {children}
    </div>
  );
}

💡 왜 중요한가: 전정계 장애(Vestibular Disorder)가 있는 사용자들은 과도한 애니메이션에 어지럼증이나 메스꺼움을 느낄 수 있어. prefers-reduced-motion 미디어 쿼리를 존중하는 것은 접근성 표준 이야.


🏁 이번에 배운 내용 총정리

유틸리티용도예시
transition상태 전환 트랜지션 활성화transition-colors duration-200
duration-*트랜지션 시간duration-200 (200ms)
ease-*타이밍 함수ease-out (자연스러움)
animate-spin무한 회전로딩 스피너
animate-pulse페이드 반복스켈레톤 UI
animate-ping확산 반복알림 뱃지
animate-bounce튀기기스크롤 유도
motion-reduce:애니메이션 줄임 존중접근성

📝 마무리 퀴즈

Q1. 영숙이 "API 로딩 중에 카드 자리를 잿빛 직사각형들로 채워주세요. 살살 깜빡이게요" 라고 요청했다. 어떤 Tailwind 클래스를 사용해야 하는가?

정답: animate-pulse 를 스켈레톤 컨테이너에 적용하고, 내부에 bg-gray-200 인 직사각형 div 들을 실제 컴포넌트 레이아웃과 동일하게 배치한다.

💡 상세 해설:

  • animate-pulseopacity: 1 → 0.5 → 1 을 2초 주기로 반복해서 "살살 깜빡이는" 효과를 만들어.
  • 중요한 것은 스켈레톤의 레이아웃이 실제 컴포넌트와 동일해야 한다는 거야. 그래야 데이터가 로드될 때 레이아웃 이동(Layout Shift) 없이 자연스럽게 전환돼.
  • animate-pulse 는 부모에 한 번만 붙이면 모든 자식에 영향을 주므로, 컨테이너에 적용하면 효율적이야.
  • 📌 핵심 기억법: "스켈레톤 = animate-pulse + bg-gray-200 + 실제 레이아웃 모방."

Q2. 버튼 hover 시 색상 전환을 부드럽게 만들고 싶다. 가장 성능에 친화적이고 자연스러운 Tailwind 클래스 조합은?

정답: transition-colors duration-200 ease-out

💡 상세 해설:

  • transition-colors: 색상 관련 속성(background-color, color, border-color 등)만 트랜지션 대상으로 지정. transition-all 보다 가볍고 명확해.
  • duration-200: 200ms 는 버튼 인터랙션에서 가장 자연스럽게 느껴지는 시간. 100ms 이하는 너무 빠르고, 300ms 이상은 느리게 느껴져.
  • ease-out: 처음 빠르게 시작해서 부드럽게 마무리. 사용자의 클릭/호버 동작이 이미 완료된 후 스타일이 자연스럽게 안착하는 느낌을 줘.
  • 📌 핵심 기억법: "인터랙션 기본 = transition-colors duration-200 ease-out."

Q3. 전정계 장애가 있는 사용자를 배려해 애니메이션을 선택적으로 비활성화하는 Tailwind 접근법은?

정답: motion-reduce: variant 를 사용해 prefers-reduced-motion 미디어 쿼리를 존중한다. 예: animate-spin motion-reduce:animate-none.

💡 상세 해설:

  • 운영체제(macOS: 손쉬운 사용 → 동작 줄이기, Windows: 접근성 → 애니메이션 효과 끄기)에서 애니메이션 줄임 설정을 켜면 prefers-reduced-motion: reduce 미디어 쿼리가 활성화돼.
  • motion-reduce:animate-none 은 이 설정이 켜진 경우에만 animation: none 을 적용해.
  • motion-safe: 는 반대로 "애니메이션 줄임 설정이 안 켜진 경우에만" 적용해 — motion-safe:animate-spin 처럼 쓸 수 있어.
  • 📌 핵심 기억법: "줄임 설정 존중 = motion-reduce:. 좋은 UX 는 선택권을 준다."

🐣 영철이의 퇴근 일기

오늘 animate-pulse 로 스켈레톤 UI 만든 게 제일 뿌듯하다. 예전엔 로딩 중에 그냥 흰 화면이었는데, 이제 카드 모양 스켈레톤이 깜빡깜빡하니까 훨씬 완성도 있어 보인다고 영수 님이 칭찬해주셨다.

그리고 motion-reduce: 는 몰랐는데 영호 님이 "접근성 놓치면 결국 사용자를 놓치는 거예요" 라고 하셔서 인상 깊었다. 그냥 예쁘게 만드는 것만이 아니라 모든 사용자를 위한 UI 를 고민해야 한다는 걸 다시 새겼다.

💡 오늘의 교훈: "애니메이션은 UX 도구지 장식이 아니다. 그리고 좋은 UX 는 모든 사용자를 포용한다."

오늘 일찍 퇴근해서 오랜만에 책 읽어야겠다. 기술 서적 말고 소설. 개발자도 가끔은 비기술적인 거 읽어야 사고가 넓어진다고 영호 님이 그러셨거든. 📚


🔗 더 알아보기