Persisting Animation State Across Page-Views In React.js

A walkthrough of how to persist the state of an animation across page-views with seeded randomization, deterministic state, and CSS Parameter interpolation.

by Andrew Magill

Published on

I see the hero animation on my website often enough that the tiny imperfections started to drive me crazy. Given that my site is built with Next.js SSG (Static Site Generation), the animation would reset to its "Day 1" state when a user navigates to a new page 😭. In contrast to the smooth animations and persistent state of single page apps, my hero animation felt choppy and repetitive.

So what does a better way look like? For me it was a mix of local storage, seeded randomization, and CSS variables. Here’s how I pulled it all together.

Tracking Styles with Local Storage

To keep things consistent, I needed to track randomized values and current appearance with local storage. Instead of saving every frame, I just store the elapsed duration and some initial presets (colors, positions, etc.) generated by a seededRandom helper.

Each radial-gradient "particle" in the animation gets its own base hue, size, and—crucially—a negative baseDelay, to position it in the animation timeline. By storing the exact moment the animation first started, I can calculate exactly how much time has passed since the user first landed on the site. The component calculates elapsedSeconds and subtracts that from the animation delay.

I use requestAnimationFrame to wrap these updates. It keeps React from yelling at me about synchronous renders while ensuring the animation stays in sync.

State Variables into Style Variables

Once JavaScript figures out where the animation should be, it passes those values to CSS custom properties. I use useMemo to keep this efficient:

const styleVars = useMemo(() => {
  if (!animationState || elapsedMs === null) return {};

  return {
    '--animation-offset-1': `${animationState.offsets.o1}%`,
    '--animation-color-1': `${animationState.colors.c1}%`,
    '--animation-delay': `-${elapsedMs}ms`, // The magic "rewind"
  } as React.CSSProperties;
}, [animationState, elapsedMs]);

The CSS takes it from here. Using @property rules and keyframes, the browser handles the heavy lifting of interpolating colors and movement. By setting a negative animation-delay, the browser effectively "fast-forwards" the animation to exactly where it should be.

@property --gradient-angle {
	syntax: '<angle>';
	initial-value: 160deg;
	inherits: false;
}

@property --gradient-stop-0-offset {
	syntax: '<percentage>';
	initial-value: 0%;
	inherits: false;
}

@property --gradient-stop-1-offset {
	syntax: '<percentage>';
	initial-value: 50%;
	inherits: false;
}

.heroAnimation {
	animation: gradient-animation 12s ease-in-out infinite;
	animation-delay: var(--animation-delay, 0ms);
	background: linear-gradient(
		var(--gradient-angle),
		var(--gradient-color-0)
			calc(var(--gradient-stop-0-base, 0%) + var(--gradient-stop-0-offset, 0%)),
		var(--gradient-color-1)
			calc(var(--gradient-stop-1-base, 60%) + var(--gradient-stop-1-offset, 0%))
	);
}

@keyframes gradient-animation {
	from {
		--gradient-angle: 160deg;
	}
	to {
		--gradient-angle: 42deg;
	}
}

@keyframes particle-drift {
	from {
		transform: translate3d(var(--particle-start, -50vw), 0, 0);
	}
	to {
		transform: translate3d(var(--particle-end, 120vw), 0, 0);
	}
}

Performance & Polish

I'm not trying to crash anyone's browser, so we need a smart approach to handling visual complexity. Persisting the start timestamp plus the seed means returning sessions don't replay the animation from zero—they simply subtract the elapsed clock and set a negative delay via CSS, mimicking an ongoing loop. Since the state values are seeded and calculated once, the background can render as fast as the browser can read local storage.

By using will-change, transform, and GPU-driven keyframes, the animation stays buttery smooth even on crusty old phones. JavaScript just handles the "math" at the start, and CSS handles the "art" for the rest of the session.

Moving Forward

If we take the time to get it right, there doesn't need to be a compromise between static, appealing, and performant animations. By using seeded randoms and local storage, we can give a static site the "soul" of a persistent application. The hero background on my site is no longer just a random loop; it's a continuous, evolving part of the user's journey.

Whether you're building a personal portfolio or a complex dashboard, remember that the best animations are the ones that respect the user's time, and the browser's main thread.

You can see the latest version of my persistent animation implementation here : https://github.com/andymagill/dev.magill.next/blob/master/app/components/global/HeroAnimation.tsx