9. 애니메이션과 전환
웹 애플리케이션의 사용자 경험을 크게 향상시키는 요소 중 하나는 부드럽고 자연스러운 애니메이션입니다. Svelte는 강력하면서도 간단한 애니메이션 시스템을 제공하여 복잡한 라이브러리 없이도 생동감 있는 UI를 구현할 수 있게 합니다. 이 장에서는 CSS 전환, 애니메이션 디렉티브, 모션 스토어를 통해 전문적인 수준의 애니메이션 효과를 만드는 방법을 완전히 마스터해보겠습니다.
9.1 CSS 전환
transition: 디렉티브
transition:
디렉티브는 요소가 DOM에 추가되거나 제거될 때 부드러운 전환 효과를 적용합니다.
Svelte가 자동으로 CSS 애니메이션을 생성하여 메인 스레드를 차단하지 않고 효율적으로 실행되도록 합니다.
React나 Vue의 전환 시스템보다 더 간단하면서도 강력한 기능을 제공합니다.
기본 전환 사용법
<script>
import { fade, fly, slide } from 'svelte/transition';
let visible = $state(true);
function toggle() {
visible = !visible;
}
</script>
<button onclick="{toggle}">토글</button>
{#if visible}
<div transition:fade>
<h2>페이드 전환</h2>
<p>부드럽게 나타나고 사라집니다</p>
</div>
{/if}
<style>
div {
padding: 1rem;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border-radius: 8px;
margin: 1rem 0;
}
</style>
전환 매개변수
전환 함수들은 다양한 매개변수를 받아 애니메이션을 세밀하게 제어할 수 있습니다.
각 전환마다 고유한 매개변수가 있으며, 공통 매개변수로는 duration
, delay
, easing
등이 있습니다.
{#if}
블록과 {#each}
블록을 함께 사용할 때는 개별 요소가 DOM에 추가/제거되도록 구조를 설계해야 전환이 올바르게 작동합니다.
<script>
import { fly, scale } from 'svelte/transition';
let showCards = $state(false);
let selectedCard = $state(null);
const cards = [
{ id: 1, title: '카드 1', color: '#FF6B6B' },
{ id: 2, title: '카드 2', color: '#4ECDC4' },
{ id: 3, title: '카드 3', color: '#45B7D1' }
];
</script>
<button onclick={() => (showCards = !showCards)}>
카드 {showCards ? '숨기기' : '보이기'}
</button>
<div class="card-container">
{#each cards as card, i}
{#if showCards}
<div
class="card"
style="background: {card.color}"
in:fly={{
y: 100,
duration: 500,
delay: i * 150
}}
out:fly={{
y: -100,
duration: 300
}}
onclick={() => (selectedCard = card)}
>
<h3>{card.title}</h3>
<p>클릭하여 선택</p>
</div>
{/if}
{/each}
</div>
{#if selectedCard}
<div
class="selected-card"
style="background: {selectedCard.color}"
transition:scale={{
start: 0.5,
opacity: 0.5,
duration: 400
}}
>
<h2 style="color: white">{selectedCard.title} 선택됨</h2>
<button onclick={() => (selectedCard = null)}>닫기</button>
</div>
{/if}
<style>
.card-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin: 1rem 0;
}
.card {
padding: 1.5rem;
color: white;
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.selected-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 2rem;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
color: white;
}
.selected-card button {
background: white;
color: #333;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
내장 전환 효과
Svelte는 다양한 내장 전환 효과를 제공하여 즉시 사용할 수 있습니다. 각 전환은 고유한 매개변수를 받아 세밀한 제어가 가능하며, 조합하여 복잡한 효과도 만들 수 있습니다. 모든 전환은 CSS 애니메이션으로 구현되어 성능이 뛰어납니다.
모든 내장 전환 예제
<script>
import {
fade,
blur,
fly,
slide,
scale,
draw,
} from 'svelte/transition';
import { quintOut } from 'svelte/easing';
let activeTransition = $state('fade');
let showElement = $state(false);
const transitions = [
{ name: 'fade', label: '페이드' },
{ name: 'blur', label: '블러' },
{ name: 'fly', label: '플라이' },
{ name: 'slide', label: '슬라이드' },
{ name: 'scale', label: '스케일' },
];
function getTransition(name) {
const configs = {
fade: { duration: 300 },
blur: { amount: 5, duration: 300 },
fly: { x: -200, duration: 400, easing: quintOut },
slide: { duration: 300, easing: quintOut },
scale: { start: 0.5, opacity: 0.5, duration: 300 },
};
const functions = { fade, blur, fly, slide, scale };
return { fn: functions[name], config: configs[name] };
}
$effect(() => {
if (showElement) {
setTimeout(() => (showElement = false), 2000);
}
});
</script>
<div class="controls">
{#each transitions as t}
<label>
<input
type="radio"
bind:group="{activeTransition}"
value="{t.name}"
/>
{t.label}
</label>
{/each}
<button onclick="{()" ="">
(showElement = true)}> 애니메이션 시작
</button>
</div>
{#if showElement} {@const { fn, config } =
getTransition(activeTransition)}
<div class="demo-box" transition:fn="{config}">
{activeTransition} 전환 효과
</div>
{/if}
<style>
.controls {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 2rem;
}
.demo-box {
padding: 2rem;
background: linear-gradient(45deg, #3b82f6, #8b5cf6);
color: white;
border-radius: 8px;
text-align: center;
font-size: 1.25rem;
}
</style>
접근성을 고려한 전환
<script>
import { prefersReducedMotion } from 'svelte/motion';
import { fly, fade } from 'svelte/transition';
let showNotification = $state(false);
let message = $state('');
function notify(msg) {
message = msg;
showNotification = true;
setTimeout(() => {
showNotification = false;
}, 3000);
}
</script>
<div class="button-group">
<button onclick={() => notify('성공!')}>
성공 알림
</button>
<button onclick={() => notify('경고!')}>
경고 알림
</button>
</div>
{#if showNotification}
<div
class="notification"
in:fly={{
y: prefersReducedMotion.current ? 0 : -50,
duration: prefersReducedMotion.current ? 0 : 300
}}
out:fade={{
duration: prefersReducedMotion.current ? 0 : 200
}}
>
{message}
</div>
{/if}
<p class="status">
모션 감소 설정: {prefersReducedMotion.current ? '켜짐' : '꺼짐'}
</p>
<style>
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 2rem;
background: #10b981;
color: white;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.button-group {
display: flex;
gap: 1rem;
}
.status {
margin-top: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
</style>
커스텀 전환 만들기
Svelte에서는 자신만의 커스텀 전환을 만들 수 있습니다. 전환 함수는 CSS 애니메이션을 생성하거나 JavaScript를 통한 애니메이션을 구현할 수 있습니다. 타입 정의와 easing 함수를 활용하여 독특하고 재사용 가능한 전환 효과를 만들 수 있습니다.
커스텀 CSS 전환
<script>
import { cubicOut } from 'svelte/easing';
let visible = $state(false);
function typewriter(node, { speed = 1 }) {
const valid = node.childNodes.length === 1 &&
node.childNodes[0].nodeType === Node.TEXT_NODE;
if (!valid) {
throw new Error('타입라이터는 텍스트 노드만 지원합니다');
}
const text = node.textContent;
const duration = text.length / (speed * 0.01);
return {
duration,
tick: t => {
const i = Math.trunc(text.length * t);
node.textContent = text.slice(0, i);
}
};
}
function spin(node, { duration = 1000 }) {
return {
duration,
css: t => {
const eased = cubicOut(t);
return `
transform: rotate(${eased * 360}deg) scale(${eased});
opacity: ${eased};
`;
}
};
}
</script>
<button onclick={() => (visible = !visible)}>
토글
</button>
{#if visible}
<h2 transition:typewriter={{ speed: 2 }}>
안녕하세요! Svelte입니다!
</h2>
<div
class="spinner"
transition:spin={{ duration: 800 }}
>
회전!
</div>
{/if}
<style>
h2 {
font-family: monospace;
font-size: 2rem;
}
.spinner {
width: 100px;
height: 100px;
background: linear-gradient(45deg, #f59e0b, #ef4444);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
margin: 2rem 0;
}
</style>
9.2 애니메이션
animate: 디렉티브
animate:
디렉티브는 요소의 위치가 변경될 때 부드러운 애니메이션을 적용합니다.
FLIP (First, Last, Invert, Play) 기법을 사용하여 성능이 뛰어난 애니메이션을 구현합니다.
리스트 재정렬이나 그리드 레이아웃 변경 시 특히 유용합니다.
FLIP 애니메이션 기초
<script>
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
let items = $state([
{ id: 1, name: '사과' },
{ id: 2, name: '바나나' },
{ id: 3, name: '체리' },
{ id: 4, name: '딸기' },
{ id: 5, name: '포도' }
]);
function shuffle() {
items = [...items].sort(() => Math.random() - 0.5);
}
function remove(id) {
items = items.filter(item => item.id !== id);
}
</script>
<button onclick={shuffle}>섞기</button>
<ul>
{#each items as item (item.id)}
<li
animate:flip={{ duration: 300, easing: quintOut }}
transition:fade={{ duration: 200 }}
>
{item.name}
<button onclick={() => remove(item.id)}>×</button>
</li>
{/each}
</ul>
<style>
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
margin: 0.5rem 0;
background: #f3f4f6;
border-radius: 4px;
}
button {
background: #ef4444;
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
}
</style>
리스트 재정렬 애니메이션
복잡한 리스트 재정렬 시나리오에서 애니메이션을 구현하는 방법입니다. 드래그 앤 드롭, 필터링, 정렬 등 다양한 상황에서 자연스러운 애니메이션을 제공합니다. 전환과 애니메이션을 조합하여 전문적인 UX를 구현할 수 있습니다.
<script>
import { flip } from 'svelte/animate';
import { crossfade } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
const [send, receive] = crossfade({
duration: 300,
fallback: (node) => {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;
return {
duration: 300,
easing: quintOut,
css: t => `
transform: ${transform} scale(${t});
opacity: ${t}
`
};
}
});
let todos = $state([
{ id: 1, text: 'Svelte 학습', done: false },
{ id: 2, text: '프로젝트 구현', done: false },
{ id: 3, text: '코드 리뷰', done: true }
]);
function add(text) {
todos = [...todos, {
id: Math.max(0, ...todos.map(t => t.id)) + 1,
text,
done: false
}];
}
function toggle(id) {
todos = todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
);
}
function remove(id) {
todos = todos.filter(t => t.id !== id);
}
let pending = $derived(todos.filter(t => !t.done));
let completed = $derived(todos.filter(t => t.done));
</script>
<div class="board">
<div class="column">
<h2>할 일 ({pending.length})</h2>
<ul>
{#each pending as todo (todo.id)}
<li
in:receive={{ key: todo.id }}
out:send={{ key: todo.id }}
animate:flip={{ duration: 300 }}
>
<label>
<input
type="checkbox"
onchange={() => toggle(todo.id)}
/>
{todo.text}
</label>
<button onclick={() => remove(todo.id)}>삭제</button>
</li>
{/each}
</ul>
</div>
<div class="column">
<h2>완료 ({completed.length})</h2>
<ul>
{#each completed as todo (todo.id)}
<li
in:receive={{ key: todo.id }}
out:send={{ key: todo.id }}
animate:flip={{ duration: 300 }}
class="completed"
>
<label>
<input
type="checkbox"
checked
onchange={() => toggle(todo.id)}
/>
{todo.text}
</label>
<button onclick={() => remove(todo.id)}>삭제</button>
</li>
{/each}
</ul>
</div>
</div>
<style>
.board {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.column {
background: #f8fafc;
padding: 1rem;
border-radius: 8px;
}
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
justify-content: space-between;
padding: 0.75rem;
margin: 0.5rem 0;
background: white;
border-radius: 4px;
}
.completed {
opacity: 0.6;
}
.completed label {
text-decoration: line-through;
}
</style>
9.3 모션과 스프링
Tween과 Spring 클래스
Svelte 5는 Tween
과 Spring
클래스를 제공하여 시간에 따라 부드럽게 변하는 값을 생성합니다.
Tween
은 고정된 시간 동안 값을 변경하고, Spring
은 물리 기반 애니메이션을 제공합니다.
이러한 모션 시스템은 수치 값뿐만 아니라 색상, 위치 등 다양한 속성에 적용할 수 있습니다.
Tween 사용법
<script>
import {
Tween,
prefersReducedMotion,
} from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
const progress = new Tween(0, {
duration: prefersReducedMotion.current ? 0 : 800,
easing: cubicOut,
});
let value = $state(0);
function updateProgress() {
value = Math.random() * 100;
progress.target = value;
}
// 반응형 바인딩 예제
let { score = 0 } = $props();
const animatedScore = Tween.of(() => score, {
duration: 1000,
});
</script>
<div class="progress-demo">
<button onclick="{updateProgress}">랜덤 값 설정</button>
<div class="progress-bar">
<div
class="progress-fill"
style="width: {progress.current}%"
>
{Math.round(progress.current)}%
</div>
</div>
<p>목표: {Math.round(value)}%</p>
{#if animatedScore}
<p>점수: {Math.round(animatedScore.current)}</p>
{/if}
</div>
<style>
.progress-bar {
width: 100%;
height: 30px;
background: #e5e7eb;
border-radius: 15px;
overflow: hidden;
margin: 1rem 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
color: white;
/* transition 제거 - Tween이 애니메이션 처리 */
}
</style>
Spring 물리 애니메이션
<script>
import {
Spring,
prefersReducedMotion,
} from 'svelte/motion';
const coords = new Spring(
{ x: 50, y: 50 },
{
stiffness: prefersReducedMotion.current ? 1 : 0.1,
damping: prefersReducedMotion.current ? 1 : 0.25,
}
);
const size = new Spring(10, {
stiffness: 0.3,
damping: 0.4,
});
function handleMouseMove(event) {
const rect =
event.currentTarget.getBoundingClientRect();
coords.target = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
function handleMouseDown() {
size.target = 30;
}
function handleMouseUp() {
size.target = 10;
}
// 모션 감소 설정 변경 시 spring 파라미터 업데이트
$effect(() => {
if (prefersReducedMotion.current) {
coords.stiffness = 1;
coords.damping = 1;
} else {
coords.stiffness = 0.1;
coords.damping = 0.25;
}
});
</script>
<svg
width="400"
height="400"
onmousemove="{handleMouseMove}"
onmousedown="{handleMouseDown}"
onmouseup="{handleMouseUp}"
onmouseleave="{()"
=""
>
(size.target = 10)} >
<circle
cx="{coords.current.x}"
cy="{coords.current.y}"
r="{size.current}"
fill="#8b5cf6"
/>
</svg>
<div class="controls">
<label>
강성 (Stiffness): {coords.stiffness}
<input
type="range"
bind:value="{coords.stiffness}"
min="0.01"
max="1"
step="0.01"
/>
</label>
<label>
감쇠 (Damping): {coords.damping}
<input
type="range"
bind:value="{coords.damping}"
min="0.01"
max="1"
step="0.01"
/>
</label>
<p>
모션 감소: {prefersReducedMotion.current ? '켜짐' :
'꺼짐'}
</p>
</div>
<style>
svg {
background: #f3f4f6;
border-radius: 8px;
cursor: pointer;
}
.controls {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
label {
display: flex;
align-items: center;
gap: 1rem;
}
input[type='range'] {
flex: 1;
}
</style>
부드러운 값 변화
모션 시스템을 활용하여 다양한 UI 요소에 부드러운 애니메이션을 적용하는 방법입니다. 숫자, 색상, 위치 등 다양한 속성을 자연스럽게 변경할 수 있습니다. 사용자 인터랙션에 즉각적으로 반응하는 매력적인 UI를 구현할 수 있습니다.
<script>
import {
Tween,
Spring,
prefersReducedMotion,
} from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
let activeTab = $state(0);
const tabPosition = new Spring(0, {
stiffness: 0.3,
damping: 0.6,
});
const stats = {
users: new Tween(0, {
duration: prefersReducedMotion.current ? 0 : 1000,
easing: cubicOut,
}),
revenue: new Tween(0, {
duration: prefersReducedMotion.current ? 0 : 1200,
easing: cubicOut,
}),
growth: new Tween(0, {
duration: prefersReducedMotion.current ? 0 : 1400,
easing: cubicOut,
}),
};
function selectTab(index) {
activeTab = index;
tabPosition.target = index * 120;
// 탭 변경 시 애니메이션 트리거
stats.users.target = Math.random() * 1000;
stats.revenue.target = Math.random() * 50000;
stats.growth.target = Math.random() * 100;
}
$effect(() => {
// 초기 값 설정
selectTab(0);
});
</script>
<div class="dashboard">
<div class="tabs">
<div
class="tab-indicator"
style:transform="translateX({tabPosition.current}px)"
></div>
<button
class:active="{activeTab"
=""
=""
="0}"
onclick="{()"
=""
>
selectTab(0)} > 대시보드
</button>
<button
class:active="{activeTab"
=""
=""
="1}"
onclick="{()"
=""
>
selectTab(1)} > 분석
</button>
<button
class:active="{activeTab"
=""
=""
="2}"
onclick="{()"
=""
>
selectTab(2)} > 리포트
</button>
</div>
<div class="stats">
<div class="stat-card">
<h3>사용자</h3>
<div class="value">
{Math.round(stats.users.current)}
</div>
</div>
<div class="stat-card">
<h3>수익</h3>
<div class="value">
${Math.round(stats.revenue.current).toLocaleString()}
</div>
</div>
<div class="stat-card">
<h3>성장률</h3>
<div class="value">
{Math.round(stats.growth.current)}%
</div>
</div>
</div>
<p class="a11y-note">
{prefersReducedMotion.current ? '애니메이션이 감소된
모드로 실행 중입니다.' : '완전한 애니메이션이 활성화되어
있습니다.'}
</p>
</div>
<style>
.dashboard {
padding: 2rem;
background: #f8fafc;
border-radius: 12px;
}
.tabs {
position: relative;
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.tab-indicator {
position: absolute;
bottom: 0;
width: 100px;
height: 3px;
background: #3b82f6;
border-radius: 2px;
transition: none;
}
button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
cursor: pointer;
color: #6b7280;
font-weight: 500;
}
button.active {
color: #3b82f6;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
}
.value {
font-size: 2rem;
font-weight: bold;
color: #3b82f6;
margin-top: 0.5rem;
}
.a11y-note {
margin-top: 1rem;
font-size: 0.875rem;
color: #6b7280;
}
</style>
정리
Svelte의 애니메이션과 전환 시스템을 완전히 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:
핵심 요약
- CSS 전환:
transition:
디렉티브를 통한 요소 추가/제거 시 부드러운 효과, 내장 전환과 커스텀 전환 구현 - 애니메이션:
animate:
디렉티브와 FLIP 기법으로 리스트 재정렬 시 자연스러운 모션 효과 - 모션과 스프링:
Tween
과Spring
클래스를 활용한 시간 기반 및 물리 기반 애니메이션 - 접근성:
prefersReducedMotion
을 활용한 사용자 설정 존중
실무 활용 팁
- 전환은 CSS로 구현되어 메인 스레드를 차단하지 않으므로 성능 걱정 없이 사용
- Spring은 자주 변경되는 값(마우스 위치 등)에, Tween은 단발성 변경에 적합
- crossfade와 flip을 조합하여 복잡한 리스트 애니메이션 구현 가능
- 항상
prefersReducedMotion
을 고려하여 접근성 있는 애니메이션 구현
다음 단계: 10장 "스토어와 상태 관리"에서는 Svelte의 반응형 스토어 시스템을 알아보겠습니다. 컴포넌트 간 상태 공유와 복잡한 상태 관리 패턴을 마스터해보세요!