Animated Routing in Svelte with {#key}: Transitions Without Manual Cleanup

Animated navigation often becomes more complicated than the animation itself.

A user moves from one view to another. The outgoing content needs to leave, the next content needs to enter, and the application has to know when each animation has finished before cleaning up the DOM. Add interrupted navigation, component state, focus management, and reduced-motion preferences, and a relatively modest interface effect can accumulate a surprising amount of lifecycle code.

Svelte has a small piece of template syntax that changes that equation: {#key}.

A key block tells Svelte that when a particular expression changes, everything inside the block represents a new instance of that interface. Svelte destroys the old contents and creates the new ones. If those contents have in:, out:, or transition: directives, those transitions become part of that lifecycle automatically. The Svelte documentation specifically calls out replaying a transition whenever a value changes as one of the uses for {#key}.

For applications with route-like state changes, particularly guided workflows, questionnaires, configurators, and embedded applications, that gives us a useful form of animated routing without building an animation lifecycle alongside the application's navigation lifecycle.

We recently used this pattern in a Svelte application for the State of Colorado DMV.

{#key} turns a state change into a view boundary

A normal reactive update tells Svelte to keep an existing component or DOM element and update the values that changed.

A key block changes the meaning of that update.

{#key currentStepId}
  <Step />
{/key}

As long as currentStepId stays the same, Svelte treats the contents as the same instance. When currentStepId changes, Svelte destroys those contents and creates them again.

That distinction matters for animation.

Svelte transitions are tied to elements entering and leaving the DOM as a result of state changes. When an element transitions out, Svelte keeps the relevant block in the DOM until its transitions finish. Developers do not need to coordinate a timeout with DOM removal or manually listen for animation completion before replacing the old interface.

A simple version looks like this:

<script>
  import { fade, fly } from 'svelte/transition';
 
  let currentStepId = $state('start');
</script>
 
{#key currentStepId}
  <div
    in:fly={{ x: 24, duration: 180 }}
    out:fade={{ duration: 120 }}
  >
    <Step id={currentStepId} />
  </div>
{/key}

Change currentStepId, and the relationship is straightforward:

  1. The existing keyed content becomes the outgoing view.
  2. Its out: transition runs.
  3. New keyed content is created.
  4. Its in: transition runs.
  5. Svelte handles the associated DOM lifecycle.

That is a useful model for developers new to Svelte because the animation follows the application's state instead of sitting beside it as another state machine.

The same principle is what makes {#key} particularly interesting when people ask whether a framework supports "animated routing."

Strictly speaking, {#key} is not a router. SvelteKit provides URL-based routing, and many Svelte applications have navigation states that never need to become URLs. But the problem animation code needs to solve is often the same: one identifiable view is leaving and another identifiable view is arriving.

A key block gives that change an explicit boundary.

A DMV wizard where application state acts like routing

For the Colorado DMV project, we built a guide application driven by structured content.

Source data originates in CSV, is transformed into JSON, and is consumed by a Svelte application distributed as an IIFE for integration into the larger site. The application presents a series of questions, static informational steps, and final results based on the visitor's answers.

From a visitor's perspective, each step behaves much like a route:

  • Start
  • Question
  • Another question
  • Supporting information
  • Result

But creating a URL route for every question would add architecture without necessarily adding value. The meaningful navigation state is already represented by the wizard's current step.

At the application shell, that looks roughly like this:

<script>
  import config from './data/wizard-config.json';
  import { state as wizardState } from './lib/wizard.svelte.js';
 
  import StepStart from './components/StepStart.svelte';
  import StepQuestion from './components/StepQuestion.svelte';
  import StepResult from './components/StepResult.svelte';
  import StepStatic from './components/StepStatic.svelte';
 
  const currentStep = $derived(
    config.steps[wizardState.currentStepId]
  );
 
  const maxWidthClass = $derived(
    currentStep?.type === 'result'
      ? 'maxw-desktop-lg'
      : 'maxw-tablet-lg'
  );
</script>
 
<section
  class={['cdmv-wizard', 'padding-5', 'width-full', maxWidthClass]}
  aria-label="DMV Wizard"
>
  {#key wizardState.currentStepId}
    {#if wizardState.currentStepId === config.startId}
      <StepStart step={currentStep} />
    {:else if currentStep?.type === 'question'}
      <StepQuestion step={currentStep} />
    {:else if currentStep?.type === 'static'}
      <StepStatic step={currentStep} />
    {:else if currentStep?.type === 'result'}
      <StepResult step={currentStep} />
    {/if}
  {/key}
</section>

The important line is not a transition at all:

{#key wizardState.currentStepId}

That establishes the lifecycle boundary.

When a visitor answers a question and wizardState.currentStepId changes, Svelte knows the contents of the block belong to a different step. Adding a transition inside that boundary then gives us animation on every forward navigation, backward navigation, and transition to a result without having to call an animation function from each navigation path.

For example:

{#key wizardState.currentStepId}
  <div
    in:fly={{ x: 24, duration: 180 }}
    out:fade={{ duration: 120 }}
  >
    {#if wizardState.currentStepId === config.startId}
      <StepStart step={currentStep} />
    {:else if currentStep?.type === 'question'}
      <StepQuestion step={currentStep} />
    {:else if currentStep?.type === 'static'}
      <StepStatic step={currentStep} />
    {:else if currentStep?.type === 'result'}
      <StepResult step={currentStep} />
    {/if}
  </div>
{/key}

Svelte provides built-in transitions including fade, fly, slide, and others through svelte/transition. It also supports custom transition functions when an interface needs behavior beyond the built-ins. The transition API supplies normalized t and u values, easing, duration, and either generated animation CSS or a JavaScript tick callback.

For most navigational transitions, though, the built-ins are enough.

That restraint is useful. The purpose of animation in a wizard is not to demonstrate that the application can animate. It is to reinforce that one step has ended and another has begun.

The cleanup you do not have to write

Without a mechanism like {#key}, route animation implementations often grow a secondary lifecycle.

Code begins tracking things such as:

isLeaving = true;
nextStep = targetStep;
 
setTimeout(() => {
  currentStep = nextStep;
  isLeaving = false;
}, 200);

Or there are animation-complete listeners, temporary CSS classes, duplicated handlers for forward and backward navigation, and defensive logic for users who interact before an animation finishes.

That code is not inherently wrong. Sometimes fine-grained choreography requires it.

But it carries an operational cost.

The application now has at least two representations of navigation: where the user actually is and where the animation thinks the user is. Those states can become unsynchronized. Timing values can drift between JavaScript and CSS. A new navigation path may forget to invoke the correct cleanup. Tests need to account for intermediate animation states that exist only because the animation implementation requires them.

With a keyed block, component identity is the animation trigger.

The application already needs to know its current step. It does not need a second state variable that means "the previous step is currently fading out."

Svelte's transition system is designed around DOM creation and destruction. The framework keeps transitioning content around long enough to finish its outro, and transition: directives can even reverse smoothly when state changes while a bidirectional transition is running. Separate in: and out: directives are available when entering and leaving should have different behavior.

This is one of the more valuable characteristics of Svelte for interaction-heavy applications: animation can often remain declarative.

The markup describes when an interface should be considered new and how that interface should enter or leave. It does not need to describe the mechanics of cleaning up after the animation.

Destruction is useful, but it has consequences

The same behavior that makes {#key} useful also deserves some caution.

When the key changes, Svelte is not merely repainting the existing component. It is recreating it.

That means local component state inside the keyed boundary is recreated too.

For a guided application, that is another reason to keep durable workflow state outside the individual step components. In the DMV implementation, wizardState owns information such as the current step, answers, and navigation history. A question component can therefore be destroyed when the visitor leaves the step without losing information the broader workflow needs.

That separation is useful beyond animation.

A component such as StepQuestion should primarily be responsible for rendering and interacting with the current question. It should not become the only place where the application remembers the visitor's entire journey.

Keying the step boundary makes that architectural expectation more visible.

It can also expose accidental state dependencies. If destroying and recreating a step causes important data to disappear, the problem may not be {#key}. The application may be storing workflow state too low in the component hierarchy.

There are also places where keying a large subtree is unnecessary. If only one small region needs to replay an animation when its value changes, key that region rather than recreating an entire page.

Component identity is a useful tool, but it should have a meaningful boundary.

Animated navigation still needs an accessibility model

Removing animation cleanup code does not remove the need to think about what navigation means for people who cannot see the animation, do not want motion, or are navigating with assistive technology.

There are at least three separate concerns.

Respect reduced-motion preferences

Svelte's current transition implementation uses the Web Animations API. As a result, a global CSS rule that sets animation-duration or transition-duration to zero under prefers-reduced-motion does not automatically disable Svelte transitions. Svelte provides prefersReducedMotion from svelte/motion specifically so applications can adapt their transition behavior.

For example:

<script>
  import { fade, fly } from 'svelte/transition';
  import { prefersReducedMotion } from 'svelte/motion';
</script>
 
{#key wizardState.currentStepId}
  <div
    in:fly={{
      x: prefersReducedMotion.current ? 0 : 24,
      duration: prefersReducedMotion.current ? 0 : 180
    }}
    out:fade={{
      duration: prefersReducedMotion.current ? 0 : 120
    }}
  >
    <!-- current step -->
  </div>
{/key}

This keeps the lifecycle behavior of the key block intact while removing unnecessary motion for visitors who have expressed that preference.

Announce meaningful state changes

Visual movement also communicates something: "you are looking at new content now."

A screen reader user needs an equivalent signal.

For a wizard, a small persistent live region can announce the newly active step:

<section
  class="cdmv-wizard"
  aria-label="DMV Wizard"
>
  <p
    class="sr-only"
    aria-live="polite"
    aria-atomic="true"
  >
    {currentStep?.title}
  </p>
 
  {#key wizardState.currentStepId}
    <div
      in:fly={{ x: 24, duration: 180 }}
      out:fade={{ duration: 120 }}
    >
      <!-- current step component -->
    </div>
  {/key}
</section>

There is a subtle implementation detail here: the live region sits outside the keyed block.

We want the live-region element itself to remain stable while its text changes. Recreating the announcement container on every navigation is unnecessary and can make announcements less predictable. The animated content can have a replacement lifecycle while the assistive-technology communication channel remains persistent.

We should also be conservative about what goes into that region. Making an entire form aria-live can result in excessive announcements. A concise step title or status is usually more useful.

Treat focus as navigation, not animation

A key block can coordinate visual transition lifecycle. It does not decide where keyboard or screen reader focus belongs after navigation.

For a small content update, leaving focus on the button that initiated the change and announcing the new state may be appropriate. For an interaction that behaves more like loading a new page, moving focus to the new step heading may provide a clearer experience.

That decision should come from the interaction model, not from the animation implementation.

This distinction is important: {#key} eliminates manual animation cleanup. It should not become an excuse to ignore navigation semantics, focus order, meaningful headings, error messaging, or other accessibility requirements.

Why this pattern fits embedded and institutionally complex applications

The DMV wizard is also a useful example of why framework decisions cannot be reduced to whether a tool has a router, an animation library, or a particular component syntax.

This application needed to operate inside a larger digital ecosystem. Its content starts in a format people can manage, is transformed into structured application data, and is delivered through a focused Svelte application rather than requiring the surrounding platform to own every part of the interaction.

That creates several boundaries:

  • content management and application behavior
  • persistent wizard state and individual step components
  • the host website and the embedded IIFE application
  • visual transitions and accessible navigation feedback

{#key} works well because it reinforces one of those boundaries instead of creating another system beside it.

The step ID already determines application state. It can also determine component identity. Component identity can then determine when transitions occur.

That is simpler than asking every button, every answer handler, and every back-navigation path to remember that an animation needs to happen.

For organizations maintaining digital services over years rather than campaign cycles, those reductions in incidental state matter. Small abstractions become valuable when they make the system easier for the next developer to understand.

Animated routing does not need to mean more routing code

When evaluating a framework for animated navigation, it is tempting to look for a large routing-animation API.

Svelte's answer can be considerably smaller.

Use application or route state to identify the current view. Put the replaceable interface inside a {#key} block. Attach transitions to the elements that enter and leave. Keep durable application state above that replacement boundary. Then handle reduced motion, live announcements, and focus according to the actual navigation experience.

The result is not animation without lifecycle.

It is animation whose lifecycle is the same lifecycle the framework already uses to manage the interface.

For applications built around questions, decisions, steps, and results, that alignment can remove a surprising amount of code.

And usually, the animation code you never have to maintain is the most useful kind.

JavaScript

Read This Next