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 containanimation-timeline: scroll()orview()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 fromvue-router’sscrollBehavior, not from the cache. - You are not using
<KeepAlive>at all. A freshly mounted component starts atscrollTop: 0by 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.
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:
scrollTopis0, 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
- Load the cached route, scroll partway, and note the progress bar position.
- Navigate away and back.
- In DevTools → Elements, select
.gallery-progressand check Computed →animation-timeline. It must read--gallery-scroll— if it showsauto, 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.
Related
- Combining Svelte Transitions with animation-timeline — the sibling framework problem: JS-driven enter/exit transitions clobbering scroll-driven bindings
- Sharing a Scroll Timeline Across Components with timeline-scope — hoisting a scroller’s timeline to an ancestor when the animated element lives in another component
- Creating a Reading Progress Bar with Scroll Timeline — the full progress-bar pattern this page’s example is built on
- Browser Support & Progressive Enhancement — layered
@supportsstrategies for shipping timeline features today