Combining Svelte Transitions with animation-timeline

Svelte’s transition:, in:, and out: directives are JavaScript-driven: during every enter and exit, the compiler-generated runtime injects a one-off @keyframes rule and writes an inline animation shorthand onto the element (or, for tick-based transitions, mutates inline styles frame by frame on the main thread). CSS scroll-driven animations live in the opposite world — a stylesheet-declared animation-timeline binding that the browser advances off the main thread. Put both on the same element and the inline shorthand resets animation-timeline to auto at inline specificity, silently stopping the scroll-driven effect for the duration of the transition. This page shows the wrapper-split pattern that lets both systems coexist, extending the route-change choreography from the SPA page swap animations guide in the Scroll-Driven & View Transition Implementation Patterns section.

When to Use This Approach

Reach for the wrapper split when:

  • A SvelteKit route or {#if} block uses transition:fade, transition:fly, or a custom transition on content that also carries animation-timeline: scroll(), view(), or a named timeline — progress bars, parallax layers, scroll-linked reveals.
  • The scroll-driven effect visibly “pops” to its unanimated pose the moment an enter or exit transition starts, then snaps back when the transition ends.
  • A custom transition using tick fights the scroll-driven keyframes over transform or opacity, producing per-frame flicker as inline styles and compositor output disagree.
  • You already coordinate route swaps in other stacks — the React Router view-transition integration or the Vue keep-alive scroll-timeline pattern — and need the Svelte-specific equivalent.

Skip it when:

  • The element carries only a Svelte transition, or only a scroll-driven animation. One system per element never conflicts.
  • The transition and the scroll-driven animation target different elements already. The collision is strictly per-element; siblings and ancestors are safe.
  • The “transition” is really scroll progress in disguise. An element that should fade in as it scrolls into view wants animation-timeline: view() alone — wrapping it in in:fade just duplicates the work on the main thread.

Why the Two Systems Collide

Svelte’s CSS-based transitions compute a keyframe track from the transition function’s css(t, u) callback, insert it into a runtime stylesheet, and start it by appending a generated entry — something like svelte-x1f3z 300ms linear 0ms 1 both — to the element’s inline style.animation. Two casualties follow. First, the animation shorthand cannot express animation-timeline, but per the cascade it still resets it, so the element’s stylesheet-declared timeline binding loses to auto at inline specificity. Second, the inline shorthand replaces the element’s effective animation-name list, so the scroll-driven keyframes stop applying at all until Svelte removes the inline value when the transition completes — at which point the stylesheet re-applies and the scroll-driven animation is created afresh.

Same-element conflict versus the wrapper split Left panel: one element carries both transition:fly and animation-timeline. During enter and exit, Svelte writes an inline animation shorthand, which resets animation-timeline to auto and stops the scroll-driven animation. Right panel: a wrapper div carries transition:fade, so Svelte's inline styles land on the wrapper, while the inner element keeps its animation-timeline binding untouched. One element — broken .hero transition:fly (JS-driven) animation-timeline: --hero-scroll enter / exit inline style written by Svelte animation: svelte-x1f3z 300ms … shorthand resets animation-timeline to auto — scroll binding severed Wrapper split — safe .hero-gate — transition:fade inline animation lands here only .hero (inner element) animation-timeline: --hero-scroll (untouched) no inline styles ever written each system owns one element: fade on the wrapper, scroll inside The animation shorthand cannot set animation-timeline — but it always resets it

The visible symptom depends on what the scroll-driven keyframes animate. A parallax transform collapses to identity during the transition and jumps back afterwards; a view()-driven reveal restarts from the wrong progress; a progress bar flashes empty. tick-based transitions are worse still: they write arbitrary inline properties every frame on the main thread, so even a surviving compositor-driven scroll animation gets visually overruled once per frame.

Implementation

Step 1 — Reproduce the inline shorthand conflict

Start from the broken shape so every later step is verifiable. This component binds a named scroll timeline and a Svelte transition to the same element:

<!-- Hero.svelte — BROKEN: two animation systems on one element -->
<script>
  import { fly } from 'svelte/transition';
  export let visible = true;
</script>

{#if visible}
  <!-- transition:fly writes an inline `animation` shorthand
       during enter/exit — resetting animation-timeline below -->
  <div class="hero" transition:fly={{ y: 24, duration: 300 }}>
    <h2>Scroll-linked hero</h2>
  </div>
{/if}

<style>
  .hero {
    /* Longhands: the shorthand would reset animation-timeline
       even inside the stylesheet, so never use it here. */
    animation-name: hero-depth;
    animation-duration: auto;
    animation-timing-function: linear;
    animation-fill-mode: both;
    animation-timeline: --hero-scroll;
  }

  @keyframes hero-depth {
    from { transform: translateY(0) scale(1); }
    to   { transform: translateY(-80px) scale(0.94); }
  }
</style>

Toggle visible while the page is scrolled partway. For the 300 ms of the fly transition, the computed animation-timeline on .hero reads auto and hero-depth stops applying entirely — the element snaps to its untransformed pose, plays the fly, then jumps back to scroll-resolved progress when Svelte removes the inline value. The stylesheet was never wrong; it was outranked.

Step 2 — Split enter/exit transitions onto a wrapper element

The fix is structural, not stylistic: give each animation system its own element. The wrapper owns the directive, so Svelte’s injected keyframes and inline shorthand land there; the inner element’s animation-timeline is never touched:

<!-- Hero.svelte — FIXED: wrapper owns the transition -->
<script>
  import { fade } from 'svelte/transition';
  export let visible = true;
</script>

{#if visible}
  <!-- Svelte may write style="animation: …" here — harmless,
       because this element carries no timeline binding -->
  <div class="hero-gate" transition:fade={{ duration: 250 }}>
    <div class="hero">
      <h2>Scroll-linked hero</h2>
    </div>
  </div>
{/if}

Choose wrapper transitions that do not visually fight the inner effect. fade composes cleanly with any inner transform, because opacity on the wrapper multiplies with whatever the compositor renders inside. A fly on the wrapper also works — the wrapper’s inline transform and the inner element’s scroll-driven transform live on different boxes, so they nest rather than overwrite. What you must not do is move the scroll-driven keyframes up onto the wrapper “to save a div”: that recreates the single-element conflict this page exists to remove.

The wrapper is layout-neutral by default (display: block, no styles required), but if the inner element uses position: sticky against a scrolling ancestor, make the wrapper display: contents-free — sticky positioning resolves against the nearest scrollport, and an intermediate wrapper does not break it as long as the wrapper does not clip (overflow: visible).

Step 3 — Declare named timelines in scoped styles

Anonymous scroll() references work inside a single component, but page-swap code usually animates against a scroller declared elsewhere, so use named timelines. Svelte’s style scoping rewrites selectors (appending a generated .svelte-hash class); it never rewrites dashed idents, so --hero-scroll declared in one component’s scoped block resolves against matching names anywhere on the ancestor chain — the resolution model covered in the named timelines and timeline-scope guide:

<!-- Scroller.svelte — owns the scroll container and the name -->
<div class="feed">
  <slot />
</div>

<style>
  .feed {
    height: 100dvh;
    overflow-y: auto;
    /* Dashed idents are NOT scoped by Svelte's compiler:
       descendants in any component can bind to this name. */
    scroll-timeline-name: --hero-scroll;
    scroll-timeline-axis: block;
  }
</style>

Prefix names per component (--hero-scroll, not --scroll) exactly as you would in any framework with scoped styles — the scoping protects selectors from collisions, not timeline names. And keep using animation-* longhands for the binding, as in Step 1: a stylesheet animation shorthand written after animation-timeline in the same rule resets the timeline just as thoroughly as Svelte’s inline one.

Step 4 — Hoist timelines across components with :global timeline-scope

Timeline-name resolution only walks ancestors. When the scroller and the animated element are siblings — a common SvelteKit shape, with the scroller in +layout.svelte and the animated hero inside a route component rendered through a slot — hoist the name to a shared ancestor with timeline-scope. Declared on an element inside the layout’s own markup, no :global is needed:

<!-- +layout.svelte — the shell is an ancestor of both -->
<div class="shell">
  <slot />
</div>

<style>
  .shell {
    /* Hoists --hero-scroll so any descendant scroller can
       attach it and any descendant animation can consume it. */
    timeline-scope: --hero-scroll;
  }
</style>

Two :global considerations bite here. First, if the only viable ancestor is outside the component’s markup — html or body, or an app shell rendered by a parent you do not control — a scoped selector cannot reach it, so escape the scoping explicitly:

/* Inside any component's <style> block */
:global(html) {
  timeline-scope: --hero-scroll;
}

Second, Svelte’s compiler prunes unused selectors: a scoped rule whose selector matches nothing in the component’s own markup is removed at build time (with an “unused CSS selector” warning that is easy to ignore). A timeline-scope rule targeting a slotted or parent-rendered element silently disappears from the output CSS — the timeline stays inactive and the animation simply never runs. Wrapping the selector in :global() exempts it from pruning. If you see the binding work in a quick prototype but die in the built app, check the compiled CSS for the missing rule before debugging the timeline itself.

Step 5 — Coordinate prefers-reduced-motion across both systems

The split gives you two independent motion sources, and both must respect prefers-reduced-motion — Svelte transitions do not honor it automatically. Gate the directive’s parameters in JavaScript and the scroll-driven keyframes in CSS:

<!-- Hero.svelte — reduced-motion aware on both axes -->
<script>
  import { fade } from 'svelte/transition';
  export let visible = true;

  // Evaluated per transition run, so a live OS toggle is
  // picked up on the next enter/exit without a reload.
  const reduced = () =>
    matchMedia('(prefers-reduced-motion: reduce)').matches;
</script>

{#if visible}
  <div
    class="hero-gate"
    transition:fade={{ duration: reduced() ? 0 : 250 }}
  >
    <div class="hero"><h2>Scroll-linked hero</h2></div>
  </div>
{/if}
/* Scroll-driven half: hold the resting pose instead */
@media (prefers-reduced-motion: reduce) {
  .hero {
    animation-name: none;
    transform: none;
  }
}

duration: 0 is deliberately chosen over conditionally omitting the directive: the element still mounts and unmounts through the same code path, introend/outroend events still fire, and any logic awaiting them keeps working. Zeroing durations rather than deleting animations is the same principle the parent guide applies to view-transition pseudo-elements.

Step 6 — Verify the split survives a transition

Freeze the moment of maximum danger — mid-transition — and assert on the inner element (details in the next section). The one-line health check from the console, with .hero selected as $0:

// Must stay true even while the wrapper is mid-fade:
const anims = $0.getAnimations();
console.log(anims.length === 1 &&
  anims[0].timeline instanceof ScrollTimeline); // true

If a second animation ever appears in that list during enter/exit, a directive has crept back onto the timeline-carrying element.

Verification

Computed-style check during the transition window

The conflict only exists for the few hundred milliseconds of an enter or exit, so verification has to catch that window. Slow it down: temporarily set the transition duration to 5000, trigger the toggle, and while the wrapper is animating inspect the inner .hero in DevTools → Elements → Computed:

  • animation-timeline must read --hero-scroll throughout. If it flips to auto mid-transition, the directive is still on the timeline element.
  • animation-name must remain hero-depth and never show a generated svelte-* entry. Generated names appearing here mean the wrapper split regressed — usually a refactor that merged the two divs.
  • The wrapper .hero-gate, by contrast, should show the inline animation shorthand in the Styles pane (struck through nothing, since it conflicts with nothing).

Timeline progress probe

Confirm the scroll binding is not just present but live:

const hero = document.querySelector('.hero');
const animation = hero.getAnimations()[0];

// Percentage-based CSSUnitValue for scroll timelines:
console.log(animation.timeline.currentTime?.value); // e.g. ≈ 37

// Scroll a bit, then read again — the value must move
// even while the wrapper's enter transition is running.

In Chromium DevTools the Animations drawer shows the scroll-driven animation with a scrubber pinned to scroll progress, while the wrapper’s transition appears as an ordinary time-based animation — seeing them listed as two separate entries on two separate nodes is itself confirmation of the split.

Build-output check for pruned rules

Because Step 4’s failure mode is a compile-time deletion, verify the built CSS too: search the production bundle for timeline-scope. If the declaration is missing, the compiler pruned the selector — re-wrap it in :global(). This is the one check DevTools cannot do for you, since the dev server and the production build can disagree.

Edge Cases and Gotchas

The animate: directive has the same failure mode. Svelte’s FLIP-based animate:flip on {#each} items writes inline animation values exactly like transition: does. A list item that carries view()-driven reveal styles needs the same wrapper split: animate:flip on the keyed {#each} element, scroll-driven styles on a child.

style: directives can re-open the hole. style:animation — or a style:transform fighting scroll-driven keyframes that animate transform — puts inline styles right back on the inner element. Route dynamic values through custom properties (style:--hero-hue={hue}) so the cascade, not inline specificity, carries them.

Outgoing elements keep their timelines only while attached. During an out: transition the element remains in the DOM, so its scroll-driven animation stays live — but if the scroller is being removed in the same block, the named timeline goes inactive and a both-fill animation freezes at its last resolved pose. That freeze is usually the least-bad outcome; just avoid keyframes that look broken when halted midway.

Global transitions widen the blast radius. With the |global modifier (Svelte 4+; the default in Svelte 3), an ancestor block being added or removed plays the transition too — meaning inline shorthands can appear at moments no local reasoning predicts. Keep transitions local (the Svelte 4+ default) on any subtree containing timeline bindings.

Both route copies can be in the DOM at once. A crossfade between outgoing and incoming pages briefly duplicates every scroll-timeline-name the routes declare. Sibling duplicates are harmless under ancestor-walk resolution, but if a shared timeline-scope hoists the name, two attaching scrollers make the reference ambiguous and the timeline can go inactive for both. Suffix hoisted names per route, or scope the hoist below the crossfade container.

A zero-duration transition still writes inline styles. Even duration: 0 briefly assigns and removes the inline shorthand. On the wrapper that is invisible; on the inner element it would still cause a one-frame reset — another reason the split, not parameter tuning, is the real fix.

Browser-Specific Notes

Chrome 115+ / Edge 115+animation-timeline, scroll-timeline-name, and the WAAPI ScrollTimeline interface shipped in 115, with timeline-scope following in Chrome 116. Everything on this page works as written, and the Animations drawer distinguishes scroll-driven from time-driven entries, which makes the two-element split directly observable.

Firefox — scroll-driven animations sit behind the layout.css.scroll-driven-animations.enabled pref in Nightly. With the pref off, the inner element simply never animates while the Svelte transition keeps working — the wrapper split degrades cleanly, since the two systems no longer share anything that could break.

Safari — scroll-driven animation support is in active development in Safari Technology Preview. Until it reaches stable releases, guard the timeline styles with @supports (animation-timeline: --x) and let the Svelte transition carry the enter/exit experience alone. If you backfill with a polyfill, remember it drives ordinary time-based animations from scroll events, so the instanceof ScrollTimeline probe in Step 6 will not match — feature-detect and fall back to checking animation-name.

All engines — the shorthand-resets-animation-timeline behavior is specified cascade behavior, not a Svelte bug or a browser quirk. Any library that writes element.style.animation — FLIP helpers, older carousel libraries, inline-style animation utilities — severs timeline bindings the same way, and the same wrapper discipline fixes all of them.


← SPA Page Swap Animations