Skip to main content
Summary

움직임은 장식이 아니라 상태 변화의 설명이다

화면이 갑자기 바뀌면 사용자는 무엇이 사라졌고 무엇이 새로 생겼는지 잠깐 놓칠 수 있다. 전환과 애니메이션은 이 틈을 줄여 준다. 예를 들어 저장 버튼을 눌렀을 때 알림이 부드럽게 나타났다가 사라지면, 사용자는 '방금 저장이 끝났구나'라고 자연스럽게 이해한다.

Svelte는 이런 움직임을 위해 별도의 큰 애니메이션 라이브러리를 먼저 설치하지 않아도 된다. svelte/transition, svelte/animate, svelte/motion, svelte/easing에 기본 도구가 들어 있다.1

먼저 가장 단순한 예제를 보자.

<script>
  import { fade } from 'svelte/transition';

  let visible = $state(false);
</script>

<button onclick={() => visible = !visible}>
  알림 토글
</button>

{#if visible}
  <p transition:fade>
    저장되었습니다.
  </p>
{/if}

핵심은 {#if}다. Svelte의 전환은 요소가 DOM에 들어오거나 나갈 때 실행된다. visible이 true가 되면 <p>가 생기면서 fade-in 되고, false가 되면 사라지기 전에 fade-out 된다. 단순히 CSS로 display: none만 바꾸는 것과는 다르다. 요소가 실제로 렌더링 흐름에 들어오고 나가는 순간에 전환이 연결된다.

전환, 애니메이션, 모션으로 생동감 주기AI 생성 이미지

transition directive로 나타남과 사라짐 다루기

이 장에서 자주 사용할 전환 함수는 다음 네 가지다.

  • fade: 투명도를 바꾸며 나타나거나 사라진다.

  • fly: 특정 방향에서 날아오듯 이동한다.

  • slide: 접히고 펼쳐지는 느낌을 준다.

  • scale: 크기가 커지거나 작아지며 나타난다.

사용법은 거의 같다. 필요한 함수를 import하고, 요소에 transition: 지시자를 붙인다.

<script>
  import { fly, scale, slide } from 'svelte/transition';

  let open = $state(false);
</script>

<button onclick={() => open = !open}>
  메뉴 열기
</button>

{#if open}
  <aside transition:fly={{ y: -12, duration: 180 }}>
    <a href='/'>홈</a>
    <a href='/cart'>장바구니</a>
  </aside>
{/if}

fly의 y: -12는 요소가 위쪽으로 12px 떨어진 위치에서 시작해 원래 자리로 온다는 뜻이다. duration: 180은 180밀리초 동안 움직인다는 뜻이다. 값이 클수록 느려지고, 값이 작을수록 빠르다.

모달에는 scale을 자주 쓴다.

<script>
  import { fade, scale } from 'svelte/transition';
  import { cubicOut } from 'svelte/easing';

  let modalOpen = $state(false);
</script>

<button onclick={() => modalOpen = true}>상품 자세히 보기</button>

{#if modalOpen}
  <div class='backdrop' transition:fade={{ duration: 120 }}>
    <section
      class='modal'
      transition:scale={{ start: 0.96, duration: 160, easing: cubicOut }}
    >
      <h2>가벼운 러닝화</h2>
      <p>매일 신기 좋은 기본형 러닝화입니다.</p>
      <button onclick={() => modalOpen = false}>닫기</button>
    </section>
  </div>
{/if}

배경은 fade로 조용히 나타나고, 모달 본문은 아주 작게 시작해 원래 크기로 커진다. 여기서 중요한 것은 start: 0.96처럼 변화 폭을 작게 잡는 것이다. 모달이 0에서 1까지 크게 튀어나오면 시선을 빼앗는다. 반대로 0.96에서 1로 움직이면 변화는 느껴지지만 과하지 않다.

들어올 때와 나갈 때를 다르게 만들 수도 있다.

{#if saved}
  <p
    in:fly={{ y: 8, duration: 160 }}
    out:fade={{ duration: 100 }}
  >
    변경 사항을 저장했습니다.
  </p>
{/if}

transition:은 들어올 때와 나갈 때 모두 같은 효과를 쓴다. in:과 out:을 나누면 나타날 때는 이동하고, 사라질 때는 조용히 흐려지게 만들 수 있다.

duration, delay, easing으로 움직임 조정하기

전환은 보통 세 가지 값으로 느낌을 조절한다.

  • duration: 움직임이 지속되는 시간

  • delay: 시작하기 전 기다리는 시간

  • easing: 속도가 변하는 방식

duration은 대부분 100~300ms 안에서 충분하다. 알림처럼 짧은 피드백은 120~180ms가 자연스럽고, 모달이나 카드 이동처럼 조금 더 큰 변화는 180~250ms 정도가 적당하다. 너무 긴 전환은 앱이 느리게 느껴질 수 있다.

delay는 여러 요소가 순서대로 나타날 때 유용하다. 하지만 남용하면 사용자가 기다려야 한다. 중요한 정보에는 지연을 길게 주지 않는 편이 좋다.

easing은 움직임의 속도 곡선이다. 현실의 물체는 처음부터 끝까지 같은 속도로 움직이지 않는다. 살짝 빠르게 시작했다가 부드럽게 멈추면 더 자연스럽다.

<script>
  import { fly } from 'svelte/transition';
  import { cubicOut } from 'svelte/easing';

  let show = $state(true);
</script>

{#if show}
  <article
    class='card'
    transition:fly={{ y: 16, duration: 220, easing: cubicOut }}
  >
    <h2>오늘의 추천 상품</h2>
    <p>가볍게 시작하기 좋은 기본 아이템입니다.</p>
  </article>
{/if}

처음에는 duration만 조절해도 충분하다. 그다음 움직임이 딱딱하게 느껴질 때 easing을 추가하자.

animate:flip으로 목록 이동을 부드럽게 만들기

전환은 요소가 생기고 사라질 때 강하다. 반면 목록의 순서가 바뀌는 상황에는 animate:가 어울린다.

예를 들어 상품 카드를 가격순으로 정렬한다고 하자. 애니메이션이 없으면 카드가 새 위치로 즉시 튄다. animate:flip을 쓰면 카드가 이전 위치에서 새 위치로 이동하는 과정을 보여 준다.

<script>
  import { flip } from 'svelte/animate';

  let products = $state([
    { id: 1, name: '키보드', price: 89000 },
    { id: 2, name: '마우스', price: 39000 },
    { id: 3, name: '모니터 받침대', price: 52000 }
  ]);

  function sortByPrice() {
    products = [...products].sort((a, b) => a.price - b.price);
  }
</script>

<button onclick={sortByPrice}>가격 낮은 순</button>

<div class='grid'>
  {#each products as product (product.id)}
    <article class='card' animate:flip={{ duration: 250 }}>
      <h2>{product.name}</h2>
      <p>{product.price.toLocaleString()}원</p>
    </article>
  {/each}
</div>

여기서 꼭 봐야 할 부분은 {#each products as product (product.id)}다. animate:flip은 각 항목의 정체성을 알아야 한다. 그래서 고유한 key가 필요하다.2 product.id가 있으면 Svelte는 '이 카드는 사라진 것이 아니라 위치가 바뀐 것'이라고 판단할 수 있다.

또 하나 기억할 점이 있다. animate: 지시자는 키가 있는 {#each} 블록 안에서, 그 블록의 직접 자식 요소에 붙어야 한다. 위 예제에서는 {#each} 바로 아래의 <article>에 animate:flip을 붙였기 때문에 제대로 동작한다.

animate:flip은 정렬, 필터 결과가 바뀌면서 남아 있는 항목의 위치가 이동하는 상황, 드래그 앤 드롭 후 재배치 같은 UI에 잘 맞는다. 다만 새로 추가되거나 삭제되는 요소 자체를 보여 주는 기능은 animate:flip의 역할이 아니다. 추가와 삭제에는 transition:을, 이미 있던 항목의 위치 이동에는 animate:flip을 쓴다고 나누어 생각하면 쉽다.

motion으로 숫자와 위치를 부드럽게 바꾸기

전환과 애니메이션이 DOM 요소의 등장과 이동을 다룬다면, motion은 값 자체가 부드럽게 변하도록 돕는다. 진행률, 숫자 카운터, 좌표, 크기 같은 값에 적합하다.

Svelte 5에서는 Tween과 Spring 클래스를 사용할 수 있다. 기존 자료에서는 tweened, spring store 함수도 자주 보인다. 이름은 다르지만 핵심은 같다. 현재값에서 목표값까지 한 번에 뛰지 않고 중간값을 거쳐 이동한다.

먼저 Tween은 정해진 시간 동안 목표값까지 이동한다.

<script>
  import { Tween } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';

  const progress = new Tween(0, {
    duration: 700,
    easing: cubicOut
  });

  function upload() {
    progress.target = 100;
  }
</script>

<button onclick={upload}>업로드 완료</button>

<progress max='100' value={progress.current}></progress>
<p>{Math.round(progress.current)}%</p>

progress.target = 100이라고 쓰면 화면의 값은 즉시 100이 되지 않는다. progress.current가 0에서 100까지 부드럽게 변한다. 그래서 진행률 막대나 숫자 증가 UI에 잘 맞는다.

기존 Svelte 예제에서는 같은 개념을 이렇게 볼 수 있다.

<script>
  import { tweened } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';

  const progress = tweened(0, {
    duration: 700,
    easing: cubicOut
  });

  function upload() {
    progress.set(100);
  }
</script>

<button onclick={upload}>업로드 완료</button>
<p>{Math.round($progress)}%</p>

$progress는 store 값을 자동으로 읽는 문법이다. 이 책에서는 Svelte 5의 runes 흐름을 중심으로 보지만, 기존 문서를 읽을 때 tweened를 만나면 '부드럽게 변하는 숫자 store'라고 이해하면 된다.

Spring은 조금 다르다. 정해진 시간보다 탄성 있는 움직임에 가깝다. 목표값에 다가가면서 약간 따라오는 느낌이 필요할 때 좋다.

<script>
  import { Spring } from 'svelte/motion';

  const size = new Spring(1, {
    stiffness: 0.12,
    damping: 0.35
  });

  function press() {
    size.target = 1.08;
  }

  function release() {
    size.target = 1;
  }
</script>

<button
  onpointerdown={press}
  onpointerup={release}
  onpointerleave={release}
  style:transform={`scale(${size.current})`}
>
  장바구니에 담기
</button>

버튼을 누르는 동안 살짝 커졌다가 손을 떼면 원래 크기로 돌아온다. 이런 작은 반응은 사용자의 입력이 처리되었다는 느낌을 준다. 다만 구매, 삭제, 결제 같은 중요한 버튼은 과장된 튕김 효과보다 차분한 피드백이 더 적합하다.

모달, 갤러리, 알림에 적용하는 기준

애니메이션은 많이 넣는다고 좋은 것이 아니다. 기준은 간단하다. 사용자가 상태 변화를 더 잘 이해하게 만들면 사용하고, 단지 화려해 보이기 위한 움직임이면 줄인다.

모달에는 보통 두 가지면 충분하다.

  • 배경: fade

  • 본문: 약한 scale 또는 아래에서 살짝 올라오는 fly

이미지 갤러리에서는 사진이 바뀔 때 fade를 짧게 주면 자연스럽다. 좌우 슬라이드는 다음 이미지와 이전 이미지의 방향이 중요할 때만 쓰자. 모든 이미지 전환을 크게 움직이면 사진보다 움직임이 더 눈에 띈다.

알림 메시지는 짧고 분명해야 한다.

{#if message}
  <p class='toast' in:fly={{ y: -8, duration: 140 }} out:fade={{ duration: 100 }}>
    {message}
  </p>
{/if}

알림은 사용자의 작업을 방해하지 않아야 한다. 화면 중앙을 크게 덮기보다, 상단이나 하단에 작게 나타났다가 사라지는 편이 좋다.

마지막으로 접근성도 생각해야 한다. 어떤 사용자는 OS에서 '움직임 줄이기'를 설정한다. Svelte가 모든 전환을 자동으로 꺼 주는 방식이 기본으로 보장되는 것은 아니므로, 움직임이 많은 앱이라면 prefers-reduced-motion 설정을 고려해 duration을 0에 가깝게 줄이거나 애니메이션을 건너뛰는 처리를 준비하는 것이 좋다. Svelte 5에서는 svelte/motion의 prefersReducedMotion을 이용해 사용자의 설정을 확인할 수 있다.3

이번 장에서 기억할 것

Svelte의 움직임 도구는 역할이 나뉘어 있다.

  • 요소가 생기고 사라진다: transition:fade, transition:fly, transition:slide, transition:scale

  • 목록 항목의 위치가 바뀐다: animate:flip

  • 숫자나 좌표 같은 값이 부드럽게 변한다: Tween, Spring, 기존 문법의 tweened, spring

움직임의 목적은 화면을 화려하게 만드는 것이 아니라 상태 변화를 이해하기 쉽게 만드는 것이다. 짧게, 적게, 의미 있게 쓰면 Svelte만으로도 충분히 부드러운 UI를 만들 수 있다.

참고

1Svelte Docs: svelte/transition, svelte/animate, svelte/motion, svelte/easing

2Svelte Docs: animate directive

3Svelte Docs: svelte/motion

책 목록

쉽게 배우는 Svelte 개발: Svelte 5와 SvelteKit으로 시작하는 프론트엔드

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기