Transitions & Animations
Transitions animate elements entering and leaving
Section titled “Transitions animate elements entering and leaving”When an element is added to or removed from the DOM (via {#if}, {#each}, etc.), a transition animates that change. Apply one with the transition: directive, using a built-in from svelte/transition:
<script> import { fade } from 'svelte/transition'; let visible = $state(true);</script>
<button onclick={() => (visible = !visible)}>toggle</button>
{#if visible} <p transition:fade>Fades in when added, fades out when removed</p>{/if}transition: applies to both enter and leave. When you want different animations for each, use in: and out: separately:
<script> import { fly, fade } from 'svelte/transition';</script>
{#if visible} <div in:fly={{ y: 20 }} out:fade>Flies in, fades out</div>{/if}The built-ins include fade, fly, slide, scale, blur, and draw (for SVG). Each accepts parameters — duration, delay, easing, and transition-specific options like fly’s x/y:
<p transition:fly={{ y: 50, duration: 400, delay: 100 }}>…</p>Local by default — and the |global modifier
Section titled “Local by default — and the |global modifier”A transition is local by default: it only plays when the block it directly belongs to is created or destroyed, not when a block further up the tree is. This became the default in Svelte 4 (to avoid every nested transition replaying on unrelated page navigations) and Svelte 5 keeps the same rule. Add |global to make a transition replay whenever any ancestor block is created or destroyed, not just its own:
<script lang="ts"> import { fade } from 'svelte/transition'; let { outer, inner, onIntro }: { outer: boolean; inner: boolean; onIntro: (which: string) => void } = $props();</script>{#if outer} {#if inner} <p transition:fade onintrostart={() => onIntro('local')}>local</p> <p transition:fade|global onintrostart={() => onIntro('global')}>global</p> {/if}{/if}Toggling the outer block off and back on — without ever touching inner — only replays the |global paragraph’s intro. The local one is skipped, because the block it directly belongs to (the inner {#if}) wasn’t what changed:
// jsdom has no Web Animations API; Svelte's transition engine creates a// zero-duration "dummy" animation via element.animate() and waits for its// onfinish before running the real one. This stub resolves on a microtask,// which is enough to drive the transition lifecycle (onintrostart etc.) in// a test without a real browser.if (typeof Element !== 'undefined' && !('animate' in Element.prototype)) { // @ts-ignore Element.prototype.animate = function () { const anim: any = { onfinish: null, oncancel: null, cancel() {}, finish() { anim.onfinish?.(); } }; queueMicrotask(() => anim.onfinish && anim.onfinish()); return anim; };}import './animate-polyfill';import { render } from '@testing-library/svelte';import { tick } from 'svelte';import { expect, test } from 'vitest';import LocalVsGlobal from './LocalVsGlobal.svelte';
async function settle() { await new Promise((r) => setTimeout(r, 0)); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await tick();}
test('a local transition only replays when its OWN block is (re)created; |global also replays when an ancestor block is', async () => { const seen: string[] = []; const onIntro = (which: string) => seen.push(which); const { rerender } = render(LocalVsGlobal, { props: { outer: true, inner: true, onIntro } }); await settle(); seen.length = 0; // discard the initial-mount intros
// Toggle the OUTER block off then on again WITHOUT touching `inner`. await rerender({ outer: false, inner: true, onIntro }); await settle(); await rerender({ outer: true, inner: true, onIntro }); await settle();
expect(seen).toEqual(['global']); // local was skipped; only the |global one replayed});✓ a local transition only replays when its OWN block is (re)created; |global also replays when an ancestor block is
Test Files 1 passed (1) Tests 1 passed (1){#key} to re-trigger a transition
Section titled “{#key} to re-trigger a transition”A {#key} block destroys and recreates its contents whenever the key expression changes — the standard way to replay a transition on a value change, not just on add/remove:
<script lang="ts"> import { fade } from 'svelte/transition'; let { value, onIntro }: { value: number; onIntro: () => void } = $props();</script>{#key value} <p transition:fade onintrostart={onIntro}>{value}</p>{/key}import './animate-polyfill';import { render } from '@testing-library/svelte';import { tick } from 'svelte';import { expect, test } from 'vitest';import KeyReset from './KeyReset.svelte';
async function settle() { await new Promise((r) => setTimeout(r, 0)); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await tick();}
test('{#key} re-triggers the intro transition only when the key expression actually changes', async () => { const intros: number[] = []; const { rerender } = render(KeyReset, { props: { value: 1, onIntro: () => intros.push(1) } }); await settle(); await rerender({ value: 1, onIntro: () => intros.push(1) }); // same value: no new block, no re-intro await settle(); await rerender({ value: 2, onIntro: () => intros.push(1) }); // changed value: {#key} tears down + rebuilds await settle(); expect(intros.length).toBe(1);});✓ {#key} re-triggers the intro transition only when the key expression actually changes
Test Files 1 passed (1) Tests 1 passed (1)animate: for reordering lists
Section titled “animate: for reordering lists”Transitions handle add/remove, but not movement. When items in a keyed {#each} reorder, the animate: directive smoothly slides them to their new positions. The built-in flip (First-Last-Invert-Play) from svelte/animate does exactly this:
<script> import { flip } from 'svelte/animate'; let items = $state([1, 2, 3, 4]); function shuffle() { items = items.toSorted(() => Math.random() - 0.5); }</script>
<button onclick={shuffle}>shuffle</button>
{#each items as item (item)} <div animate:flip={{ duration: 300 }}>{item}</div>{/each}animate: requires a keyed each block — the key (item) is how Svelte knows which element moved where. It’s easy to assume transition: alone will smoothly slide a reordered item — it won’t, because reordering a keyed each block reuses the same DOM node rather than removing and re-adding it, so no intro/outro is ever triggered for the move:
<script lang="ts"> let { items }: { items: number[] } = $props(); function noop(_node: HTMLElement) { return {}; }</script><ul> {#each items as item (item)} <li data-item={item} transition:noop>{item}</li> {/each}</ul>import { render } from '@testing-library/svelte';import { flushSync } from 'svelte';import { expect, test } from 'vitest';import ReorderNoFlip from './ReorderNoFlip.svelte';
test('reordering a keyed each reuses the same DOM node — no add/remove happens, so transition: never fires for the move', () => { const { container, rerender } = render(ReorderNoFlip, { props: { items: [1, 2, 3] } }); const nodeFor1 = container.querySelector('[data-item="1"]'); rerender({ items: [3, 2, 1] }); flushSync(); const nodeFor1After = container.querySelector('[data-item="1"]'); expect(nodeFor1After).toBe(nodeFor1); // same DOM node instance, just moved — proves no add/remove});✓ reordering a keyed each reuses the same DOM node — no add/remove happens, so transition: never fires for the move
Test Files 1 passed (1) Tests 1 passed (1)Combine animate:flip with transition: on the same list to animate additions, removals, and reordering all at once — animate: is the piece that’s easy to leave out because everything still compiles and “mostly” works (add/remove still animate) without it.
Custom transitions: css and tick
Section titled “Custom transitions: css and tick”A custom transition function receives the node and its parameters, and returns an object describing the animation — either a css(t, u) function (Svelte turns this into a Web Animation, so it can run off the main thread) or a tick(t, u) function (runs on every frame, for effects css can’t express):
<script lang="ts"> import { elasticOut } from 'svelte/easing'; let visible = $state(true);
function whoosh(node: HTMLElement, params: { delay?: number; duration?: number } = {}) { const existingTransform = getComputedStyle(node).transform.replace('none', ''); return { delay: params.delay ?? 0, duration: params.duration ?? 400, easing: elasticOut, css: (t: number, u: number) => `transform: ${existingTransform} scale(${t})`, }; }</script>{#if visible} <div in:whoosh>whooshes in</div>{/if}t goes from 0 to 1 on intro (1 to 0 on outro); u is 1 - t. Prefer css whenever the effect is expressible in CSS — it’s what lets Svelte hand the animation to the browser’s compositor instead of running your function on every frame.
Respecting prefers-reduced-motion
Section titled “Respecting prefers-reduced-motion”Transitions run through the Web Animations API, not CSS — so a page-wide @media (prefers-reduced-motion: reduce) rule that zeroes animation-duration has no effect on them. Svelte’s own fix is prefersReducedMotion (from svelte/motion, added in Svelte 5.7): a reactive value backed by the media query, so you read .current and adjust the transition yourself:
<script lang="ts"> import { prefersReducedMotion } from 'svelte/motion'; import { fly } from 'svelte/transition'; let visible = $state(true);</script>{#if visible} <p transition:fly={{ y: prefersReducedMotion.current ? 0 : 200 }}>hi</p>{/if}With y: 0, the “fly” becomes a plain, near-instant transition instead of a large motion — respecting the user’s system-level preference without disabling the transition mechanism entirely.
Transitions inside a pending boundary
Section titled “Transitions inside a pending boundary”A <svelte:boundary> with a pending snippet shows placeholder content while await expressions inside it resolve, then swaps to the real content once (covered fully in the Async Svelte lesson, which is experimental behind compilerOptions.experimental.async). That swap is an ordinary DOM add — a transition: on the newly-revealed content plays exactly like it would after any other {#if}:
<script lang="ts"> import { fade } from 'svelte/transition'; async function loadMessage() { await new Promise((r) => setTimeout(r, 10)); return 'loaded!'; }</script><svelte:boundary> <p transition:fade>{await loadMessage()}</p> {#snippet pending()} <p>loading…</p> {/snippet}</svelte:boundary>flowchart LR add["element added to DOM"] --> intro["in: / transition: plays the intro"] remove["element removed"] --> outro["out: / transition: plays the outro"] outro --> gone["element leaves after the animation"] reorder["keyed each reorders (same node, no add/remove)"] --> flip["only animate:flip moves it smoothly"]
Mental Model
Section titled “Mental Model”A transition is triggered by a DOM add or remove, full stop — not by “the value changed” or “the list order changed.” {#if}/{#each}/{#await}/{#key} are the only things that add and remove nodes, so those are the only places transition:/in:/out: ever fire. animate: exists precisely because reordering a keyed each block is the one common case where Svelte deliberately reuses the existing node instead of destroying and recreating it (for performance and to preserve focus/scroll/input state) — so it needs its own, separate animation mechanism. Once you see “transitions = add/remove, animate = reposition,” the rest (local vs. global, {#key} forcing a fresh add/remove) follows from that one rule.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Local transition (default) | A nested transition doesn’t replay on unrelated ancestor changes (e.g. page navigation) | Easy to be surprised when a transition you expected to replay doesn’t, if it’s nested deeper than you think |
|global transition | Replays on any ancestor’s create/destroy — matches “this should always animate when it appears” | Can over-trigger: every ancestor toggle replays it, including ones unrelated to this element’s own meaning |
{#key} to force a replay | Simple, explicit way to say “treat this as a brand-new element when X changes” | Destroys and recreates the whole block’s state (any local component state inside is lost), not just its DOM |
animate:flip alongside transition: | Smooth, correct reordering with almost no code | Requires a keyed each block — an unkeyed list can’t use animate: at all |
Custom css transition | Runs off the main thread (Web Animations API) | Must express the effect as CSS properties — tick is required for anything CSS can’t do |
Common Misconceptions
Section titled “Common Misconceptions”- “
transition:will smoothly animate a reordered item in a keyed{#each}.” It won’t — proven above: reordering reuses the same DOM node (no add/remove), so no intro/outro ever fires for the move. Onlyanimate:fliphandles reordering. - “A global
prefers-reduced-motionCSS rule automatically tones down Svelte transitions.” It doesn’t — transitions run via the Web Animations API, not CSSanimation/transitionproperties, so that media query has no effect on them by default. UseprefersReducedMotionfromsvelte/motioninstead. - “Local is just the old default —
|globalis the modern way to write transitions.” The opposite: local has been the default since Svelte 4 specifically to avoid transitions replaying on unrelated ancestor changes;|globalis the deliberate opt-in for the less common case. - “
{#key}is only useful for{#each}lists.” It works on any expression — a common use is forcing a full remount (and a transition replay) when route params or a selected item id changes, with no list involved at all.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated component uses transitions or animations, check for these before trusting it:
- Does a keyed
{#each}rely ontransition:alone to animate reordering, with noanimate:flip? - Is a transition marked
|globalwhen it’s actually meant to play only when its own immediate block changes (over-triggering on unrelated ancestor toggles)? - Does the code assume a page-wide
prefers-reduced-motionCSS rule silences Svelte transitions, instead of readingprefersReducedMotionfromsvelte/motion? - Is
{#key}used where a plain{#if}/reactive prop update would do — needlessly destroying and recreating component state just to replay a transition? - Does a custom transition’s
cssfunction ignore theu(1 - t) parameter when the effect actually needs both (e.g. combining two properties that move in opposite directions)?
🐛 Spot the bug
<script>import { fade } from 'svelte/transition';let items = $state([ { id: 1, label: 'Alpha' }, { id: 2, label: 'Beta' }, { id: 3, label: 'Gamma' },]);function sortAlpha() { items = items.toSorted((a, b) => a.label.localeCompare(b.label));}</script>
<button onclick={sortAlpha}>Sort A→Z</button>{#each items as item (item.id)}<div transition:fade>{item.label}</div>{/each}Show the answer
The list is correctly keyed ((item.id)), so Svelte knows exactly which row moved where — but transition:fade only ever fires on add and remove. Sorting reorders existing rows; none of them are added or removed, so sortAlpha() produces an instant, jarring jump with no animation at all, even though the code “has a transition.” Proven above (reorder.test.ts): the same DOM node survives a reorder untouched, so no intro/outro is ever triggered for that move.
The fix is to add animate:flip (the piece that actually handles repositioning):
<script> import { flip } from 'svelte/animate';</script>{#each items as item (item.id)} <div transition:fade animate:flip={{ duration: 250 }}>{item.label}</div>{/each}Now adds/removes still fade (transition:), and every reorder glides to its new position (animate:) — the two directives are complementary, not redundant.