✨ Tailwind 8장: 애니메이션과 트랜지션
📋 개요
Tailwind transition 과 animate 유틸리티로 생동감 있는 UI 만들기 — 성능까지 고려한 애니메이션 설계
📋 목차
- 📌 이 문서를 읽기 전에
- 🤔 왜 알아야 하는가
- 🏗️ 트랜지션 vs 애니메이션
- ⚡ Transition 유틸리티 완전 정복
- 🎬 Animation 유틸리티 완전 정복
- 💻 실전: 영수네 커뮤니티 애니메이션 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 등) | 없음 | 버튼 색상 전환, 카드 이동 |
| Animation | CSS @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 = 저렴 */}📌 원칙:
transform과opacity는 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-pulse는opacity: 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 는 모든 사용자를 포용한다."
오늘 일찍 퇴근해서 오랜만에 책 읽어야겠다. 기술 서적 말고 소설. 개발자도 가끔은 비기술적인 거 읽어야 사고가 넓어진다고 영호 님이 그러셨거든. 📚