본문으로 건너뛰기

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는 TweenSpring 클래스를 제공하여 시간에 따라 부드럽게 변하는 값을 생성합니다. 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 기법으로 리스트 재정렬 시 자연스러운 모션 효과
  • 모션과 스프링: TweenSpring 클래스를 활용한 시간 기반 및 물리 기반 애니메이션
  • 접근성: prefersReducedMotion을 활용한 사용자 설정 존중

실무 활용 팁

  • 전환은 CSS로 구현되어 메인 스레드를 차단하지 않으므로 성능 걱정 없이 사용
  • Spring은 자주 변경되는 값(마우스 위치 등)에, Tween은 단발성 변경에 적합
  • crossfade와 flip을 조합하여 복잡한 리스트 애니메이션 구현 가능
  • 항상 prefersReducedMotion을 고려하여 접근성 있는 애니메이션 구현

다음 단계: 10장 "스토어와 상태 관리"에서는 Svelte의 반응형 스토어 시스템을 알아보겠습니다. 컴포넌트 간 상태 공유와 복잡한 상태 관리 패턴을 마스터해보세요!