Integrating Scroll Timelines with Vue keep-alive Components

Vue’s <KeepAlive> wrapper caches component instances instead of destroying them, which makes route switches feel instant — but the cached DOM subtree is physically detached from the document and reinserted on every activation cycle. That detach/reinsert round trip silently resets scroller offsets to 0 and restarts every CSS animation bound to a scroll timeline, so progress bars snap back to empty and parallax layers jump on return navigation. This page fixes all of it with a reusable composable, building on the scroll-restoration patterns from the SPA page swap animations guide in the Scroll-Driven & View Transition Implementation Patterns section.

When to Use This Approach

Use the patterns on this page when:

  • Your Vue 3 app wraps <router-view> in <KeepAlive> and cached routes contain animation-timeline: scroll() or view() bindings — progress indicators, reveal effects, parallax layers.
  • Scroll position inside a cached component resets to the top when the user navigates back to it, even though the component’s reactive state survived.
  • Scroll-driven animations restart from 0% progress — or freeze entirely — after a component is reactivated from cache.
  • You already handle route-change choreography (for example with the companion pattern for view transitions in React Router) and need the Vue-specific caching layer to cooperate.

Do not use these patterns when:

  • The timeline’s source is the document scroller (animation-timeline: scroll(root)). The root scroller is never detached by <KeepAlive>, so its offset survives caching — any reset you see comes from vue-router’s scrollBehavior, not from the cache.
  • You are not using <KeepAlive> at all. A freshly mounted component starts at scrollTop: 0 by design; there is nothing to restore.
  • The animation is time-driven rather than scroll-driven. Restarting a 300 ms entrance animation on reactivation is usually acceptable, and often desirable.

The Detach/Reinsert Lifecycle Problem

<KeepAlive> preserves the component instance — refs, reactive state, computed caches — but browsers do not preserve two things across DOM detachment: the scrollTop/scrollLeft of scroll containers inside the subtree, and the playback state of CSS animations declared on it. When the subtree is reinserted, scrollers read 0 and CSS animations are created afresh. For a scroll-driven animation that means the browser re-resolves progress against a scroller that is momentarily at the top.

KeepAlive detach/reinsert cycle and the onActivated repair step Three boxes across the top show the lifecycle: active route (scrollTop 1240, timeline progress 62 percent), then cached by KeepAlive (DOM detached, state kept, timeline inactive), then reinserted into the DOM (scrollTop resets to 0, animation restarts at 0 percent). A dashed arrow drops to a repair box labelled onActivated, which restores the saved scrollTop and rebinds animation-timeline, returning progress to 62 percent. Active route scroller scrollTop: 1240 timeline progress: 62% deactivated Cached by <KeepAlive> DOM detached, state kept timeline inactive activated Reinserted into DOM scrollTop resets to 0 animation restarts at 0% onActivated() repair 1. restore saved scrollTop (instant) 2. rebind animation-timeline → progress returns to 62% Component state survives the cache — scroll offsets and timeline bindings do not

There is a second, subtler failure mode: reads and writes performed at the wrong lifecycle moment. onDeactivated fires after the subtree has been moved out of the document, so reading scrollTop there returns 0 — the value you wanted is already gone. The steps below work around both problems.

Implementation

Step 1 — Reproduce the stale-timeline state

Start from a minimal cached-route setup so you can verify each fix in isolation:

<!-- App.vue — every matched route component is cached -->
<template>
  <router-view v-slot="{ Component }">
    <KeepAlive>
      <component :is="Component" />
    </KeepAlive>
  </router-view>
</template>

Give one route an internal scroller with a scroll-driven progress bar (Step 2 shows the full styles). Then: scroll the gallery halfway, navigate to another route, and navigate back. You will observe some combination of:

  • scrollTop is 0, even though the component’s list data, filters, and refs were preserved.
  • The progress bar reads 0% because the freshly re-created animation resolved against a scroller at the top.
  • In some engine versions the animation appears frozen at its pre-cache progress and no longer responds to scrolling — the animation object survived, but its timeline reference went stale during detachment.

Which symptom you get depends on the browser’s timing of style resolution versus reinsertion, which is exactly why the fix below both restores the offset and forcibly rebinds the timeline rather than trusting either to recover on its own.

Step 2 — Declare named timelines in scoped styles

Named timelines are more robust than anonymous scroll() references inside cached components, because the name re-resolves along the ancestor chain every time the subtree re-enters the document. Declare everything in the SFC’s scoped style block:

<!-- Gallery.vue -->
<template>
  <div ref="scrollerRef" class="gallery">
    <div class="gallery-progress" aria-hidden="true"></div>
    <article v-for="item in items" :key="item.id" class="gallery-item">
      {{ item.title }}
    </article>
  </div>
</template>

<style scoped>
.gallery {
  height: 100dvh;
  overflow-y: auto;
  /* Prefix the ident with the component name: Vue's scoped-style
     compiler rewrites SELECTORS with [data-v-hash] attributes but
     never rewrites dashed idents, so timeline names resolve
     globally along the ancestor chain. */
  scroll-timeline-name: --gallery-scroll;
  scroll-timeline-axis: block;
}

.gallery-progress {
  position: sticky;
  top: 0;
  height: 4px;
  background: currentColor;
  transform-origin: left;
  /* Longhands only — the `animation` shorthand RESETS
     animation-timeline back to its initial value, so a later
     shorthand declaration would sever the binding. */
  animation-name: gallery-progress;
  animation-duration: auto;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: --gallery-scroll;
}

@keyframes gallery-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* Motion-sensitive users get a static bar */
@media (prefers-reduced-motion: reduce) {
  .gallery-progress {
    animation-name: none;
    transform: none;
  }
}
</style>

Two details matter here. First, animation-timeline is declared after the animation-* longhands; had the shorthand been used, it would reset animation-timeline to auto in the same declaration block. Second, the reduced-motion override follows the guard patterns from implementing prefers-reduced-motion — a cached component re-runs its animations on every activation, so respecting the preference matters twice as much.

If the scroller lives in a parent layout component rather than inside the cached child, hoist the name with timeline-scope on the shared ancestor — the resolution rules are covered in depth in the named timelines and timeline-scope guide.

Step 3 — Restore scroll position in onActivated

The critical trap: you cannot read the offset in onDeactivated, because that hook fires after the subtree has already been detached and the scroller reports 0. Record the value continuously with a passive listener instead, and write it back in onActivated, which fires after reinsertion:

// useKeepAliveScroll.js
import {
  ref, onMounted, onBeforeUnmount,
  onActivated, onDeactivated, nextTick,
} from 'vue';

export function useKeepAliveScroll(scrollerRef, onRestored) {
  const savedTop = ref(0);
  let tracking = true;

  // Passive listener: captures the last REAL offset before
  // detachment. Reading scrollTop in onDeactivated is too late.
  const record = () => {
    if (tracking) savedTop.value = scrollerRef.value?.scrollTop ?? 0;
  };

  onMounted(() => {
    scrollerRef.value?.addEventListener('scroll', record, { passive: true });
  });

  onBeforeUnmount(() => {
    scrollerRef.value?.removeEventListener('scroll', record);
  });

  onDeactivated(() => {
    // The subtree is already out of the document here.
    // Do not read scrollTop — just pause tracking while cached.
    tracking = false;
  });

  onActivated(async () => {
    // Wait one tick so reinserted content has laid out; a scroller
    // with zero content height clamps any scrollTo() to 0.
    await nextTick();
    const scroller = scrollerRef.value;
    if (!scroller) return;

    // 'instant' avoids a smooth scroll that would visibly sweep
    // every scroll-driven animation through its whole range.
    scroller.scrollTo({ top: savedTop.value, behavior: 'instant' });
    tracking = true;

    // Give the engine one frame to see the restored offset, then
    // let the caller rebind timelines (Step 4).
    requestAnimationFrame(() => onRestored?.(scroller));
  });

  return { savedTop };
}

behavior: 'instant' is load-bearing: a smooth restore animates the scroll offset itself, which drives every bound timeline through its full progress range and produces a flash of unwanted motion on each return navigation.

Step 4 — Rebind animation-timeline after reactivation

Restoring the offset fixes progress values, but not the stale-binding case where the re-created animation no longer tracks its timeline. Force a rebind by cancelling and replaying every scroll-driven animation in the subtree — a cheap, idempotent operation:

// rebindScrollTimelines.js
export function rebindScrollTimelines(rootEl) {
  // getAnimations returns [] while the subtree is detached,
  // so this must run after reinsertion (see Step 3's rAF).
  const animations = rootEl.getAnimations({ subtree: true });

  for (const animation of animations) {
    // ViewTimeline subclasses ScrollTimeline, so this test
    // catches both scroll() and view() driven animations while
    // leaving ordinary time-driven animations untouched.
    if (typeof ScrollTimeline !== 'undefined' &&
        animation.timeline instanceof ScrollTimeline) {
      animation.cancel();
      animation.play(); // re-resolves progress from current offset
    }
  }
}

Wire it into the component:

<!-- Gallery.vue, script block -->
<script setup>
import { ref } from 'vue';
import { useKeepAliveScroll } from './useKeepAliveScroll';
import { rebindScrollTimelines } from './rebindScrollTimelines';

const scrollerRef = ref(null);
useKeepAliveScroll(scrollerRef, rebindScrollTimelines);
</script>

If you need a JavaScript-free fallback, toggling animation-name: none and back (with a forced reflow between the writes) achieves the same rebind, but the WAAPI route is more precise because it only touches animations that are actually scroll-driven.

Step 5 — Keep inline style bindings off timeline properties

Vue’s :style bindings compile to inline styles, and inline styles outrank the scoped stylesheet. The most common way teams accidentally destroy a timeline binding is a dynamic animation shorthand:

<!-- BROKEN: the inline `animation` shorthand resets
     animation-timeline to auto at inline specificity, so the
     progress bar detaches from --gallery-scroll and becomes a
     runaway 1-second time-driven animation. -->
<div
  class="gallery-progress"
  :style="{ animation: `gallery-progress 1s linear ${playState}` }"
></div>

The animation shorthand cannot express animation-timeline, but it still resets it — so any inline shorthand silently rebinds the animation to the document timeline. The same applies to third-party libraries that write element.style.animation. Route all dynamic values through custom properties instead, which cascade into the scoped rules without touching the animation longhands:

<!-- SAFE: custom properties carry the dynamic values -->
<div
  class="gallery-progress"
  :style="{ '--progress-hue': hue, '--progress-height': `${height}px` }"
></div>
.gallery-progress {
  height: var(--progress-height, 4px);
  background: hsl(var(--progress-hue, 220) 80% 50%);
  /* animation-* longhands stay untouched in the stylesheet */
}

If you genuinely must control playback from JavaScript, bind only animation-play-state inline — it is a longhand and leaves animation-timeline intact.

Verification

Computed-style round trip

  1. Load the cached route, scroll partway, and note the progress bar position.
  2. Navigate away and back.
  3. In DevTools → Elements, select .gallery-progress and check Computedanimation-timeline. It must read --gallery-scroll — if it shows auto, an inline shorthand or a later declaration severed the binding (Step 5).

Console timeline probe

With the progress bar selected as $0:

const animation = $0.getAnimations()[0];

// Both must hold after a cache round trip:
console.log(animation.timeline instanceof ScrollTimeline); // true
console.log(animation.timeline.currentTime?.value);        // ≈ 62, not 0

// Cross-check against the restored offset:
const scroller = $0.closest('.gallery');
const expected =
  scroller.scrollTop / (scroller.scrollHeight - scroller.clientHeight) * 100;
console.log(Math.abs(animation.timeline.currentTime.value - expected) < 1);

A scroll timeline’s currentTime is a percentage-based CSSUnitValue; comparing it against the ratio computed from scrollTop catches both the reset-to-zero and frozen-binding failure modes in one assertion.

Automated regression test

Scroll timelines do not exist in jsdom, so assert in a real engine:

// keep-alive-scroll.spec.js — Playwright
test('progress survives a keep-alive round trip', async ({ page }) => {
  await page.goto('/gallery');
  await page.locator('.gallery')
    .evaluate(el => el.scrollTo({ top: 1200, behavior: 'instant' }));

  await page.click('a[href="/settings"]'); // deactivate → cache
  await page.click('a[href="/gallery"]');  // activate → reinsert

  const result = await page.locator('.gallery').evaluate(el => ({
    scrollTop: el.scrollTop,
    progress: el.querySelector('.gallery-progress')
      .getAnimations()[0]?.timeline?.currentTime?.value ?? null,
  }));

  expect(result.scrollTop).toBe(1200);
  expect(result.progress).toBeGreaterThan(0);
});

Edge Cases and Gotchas

Async content and <Suspense> defeat the nextTick wait. If the cached component re-fetches or lazily renders list items, the scroller’s content height at onActivated time may be shorter than the saved offset, and the restore gets clamped. Watch the data-ready flag and re-run the restore once content height exceeds savedTop, or persist item data in the component state so the cached height is correct immediately.

include/exclude props silently disable caching. If the component’s name option does not match the <KeepAlive include> pattern, the component unmounts normally, onActivated never fires, and your restore logic dead-ends. Script-setup SFCs infer their name from the filename — a rename can break the include match without any warning.

Timeline names can collide across sibling components. Scoped styles do not rename dashed idents. Two different components that both declare scroll-timeline-name: --scroll are fine as long as they never nest — name resolution walks ancestors only — but the moment one renders inside the other, the inner scroller shadows the outer name for its descendants. The component-prefixed naming convention from Step 2 avoids the problem entirely.

getAnimations() returns an empty array while cached. You cannot pre-emptively rebind timelines in onDeactivated; the detached subtree reports no animations. All rebind work must happen post-reinsertion, which is why Step 3 defers it behind requestAnimationFrame.

<Transition> around <KeepAlive> can reintroduce inline animations. The class-based enter/leave transitions are safe, but JavaScript transition hooks that assign el.style.animation will reset animation-timeline on whatever element they touch, exactly as in Step 5. Keep transition hooks on a wrapper element that carries no timeline bindings.

Multiple cached instances share one document. Only the active instance is in the DOM, so duplicate timeline names across cached copies never conflict at resolution time — but module-level state in a shared composable does. Keep savedTop inside the composable call (per-instance), never at module scope.

Browser-Specific Notes

Chrome 115+ / Edge 115+animation-timeline, named timelines, and the ScrollTimeline/ViewTimeline WAAPI interfaces shipped in 115; timeline-scope followed in Chrome 116. The rebind utility works as written, and DevTools’ Animations drawer shows scroll-driven animations with a scrubber locked to scroll progress — useful for spot-checking after reactivation.

Firefox — scroll-driven animations remain behind the layout.css.scroll-driven-animations.enabled pref in Nightly builds. With the pref off, the progress bar simply never animates; the scroll-restoration half of this page (Step 3) still works and is worth shipping unconditionally.

Safari — scroll-driven animation support is in active development in Safari Technology Preview. Until it ships in stable releases, guard the timeline styles with @supports (animation-timeline: --x) and lean on the scroll-timeline polyfill for Safari if the effect is essential. Note that the polyfill drives a time-based WAAPI animation from scroll events, so the instanceof ScrollTimeline check in Step 4 will not match polyfilled animations — feature-detect and rebind by animation name in that environment.

All engines — the scroll-position reset on detach/reinsert is standard platform behavior, not a Vue bug and not engine-specific. Any framework that moves cached DOM out of the document (Vue <KeepAlive>, custom offscreen caches) needs the same save/restore discipline.


← SPA Page Swap Animations