HTML animation on page load

A CSS keyframe animation with no trigger runs as soon as the element is parsed, which is the simplest page load animation there is. Everything beyond that is about timing, staggering, and not animating content the reader is already looking at.

An HTML animation runs on page load when a CSS animation is set on an element with no trigger. The browser starts it as soon as the element renders, and no JavaScript is involved.

@keyframes rise {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: none; }
}

.hero {
  animation: rise 500ms cubic-bezier(.2,.7,.3,1) both;
}
A page heading part way through a fade and rise animation immediately after the page renders.
A page heading part way through a fade and rise animation immediately after the page renders.

The keyword that removes the flash

both in that shorthand is animation-fill-mode. It matters more than the easing curve.

Fill mode Before the animation After it ends
none Element shows its normal styles Snaps back to normal styles
forwards Normal styles Holds the last keyframe
backwards Holds the first keyframe Snaps back
both Holds the first keyframe Holds the last

Without backwards or both, an element with a delay is fully visible during the delay, then jumps to opacity: 0 when the animation starts. That jump is the flash people ask about.

Staggering a list

Sequential delays turn a group arriving at once into a group arriving in order. Written in plain CSS for a known count:

.card { animation: rise 420ms ease-out both; }
.card:nth-child(1) { animation-delay: 0ms; }
.card:nth-child(2) { animation-delay: 60ms; }
.card:nth-child(3) { animation-delay: 120ms; }
.card:nth-child(4) { animation-delay: 180ms; }

For an unknown count, set a custom property from script once and let CSS do the arithmetic:

document.querySelectorAll('.card').forEach((el, i) => {
  el.style.setProperty('--i', String(i));
});
.card { animation-delay: calc(var(--i, 0) * 60ms); }

Keep the total under about 400 milliseconds. A stagger long enough to notice as waiting is too long.

Four cards mid stagger, the first fully settled and the last still faded and offset.
Four cards mid stagger, the first fully settled and the last still faded and offset.

Waiting for the page to be ready

Sometimes the animation should not begin until fonts and images have settled, or the first frames land on a half drawn layout.

document.addEventListener('DOMContentLoaded', () => {
  requestAnimationFrame(() => document.body.classList.add('ready'));
});
body:not(.ready) .hero { opacity: 0; }
body.ready .hero { animation: rise 500ms ease-out both; }

The nested requestAnimationFrame gives the browser one frame to apply the initial state before the class flips. Without it the transition is sometimes skipped entirely.

One risk with this pattern. If the script fails, the content stays at opacity: 0 and the page looks empty. Prefer a no-preference guard or a timeout fallback for anything important.

Below the fold, reveal on scroll

Animating content the reader has not reached wastes the effect. An intersection observer runs it at the right moment.

const io = new IntersectionObserver((entries) => {
  entries.forEach(e => {
    if (e.isIntersecting) {
      e.target.classList.add('in');
      io.unobserve(e.target);
    }
  });
}, { threshold: 0.2, rootMargin: '0px 0px -10% 0px' });

document.querySelectorAll('.reveal').forEach(el => io.observe(el));

unobserve after the first hit means the element animates once, not every time it scrolls back into view.

Reduced motion, written as opt in

Make motion the exception rather than the default. Then an unsupported case degrades to a still page, which is always acceptable.

.hero { opacity: 1; }

@media (prefers-reduced-motion: no-preference) {
  .hero { animation: rise 500ms ease-out both; }
}

This is the same shape as a dark mode query. The base rule is the safe state and the query adds the enhancement.

animation against transition

Both can run on load, and they are not interchangeable.

animation transition
Starts on its own Yes No, needs a value change
Multiple steps Yes, via keyframes Two endpoints only
Repeats Yes, animation-iteration-count No
Can be seeked from script Yes, through the returned object Not directly

For a page load effect, use animation, since nothing has changed yet for a transition to respond to. A transition is the right tool once the reader acts, which is what a hover transition does.

Counting up a number

A common load effect that is not CSS at all. Animate the value, not the element.

function count(el, to, ms) {
  const start = performance.now();
  function step(now) {
    const p = Math.min(1, (now - start) / ms);
    el.textContent = Math.round(to * (1 - Math.pow(1 - p, 3)));
    if (p < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

The cubic term is an ease out, so the number slows as it lands. Write the final value into the HTML first and let the script overwrite it, so a failed script leaves the correct figure on screen rather than a zero.

What to animate and what not to

  • Animate opacity and transform. The compositor handles both without recalculating layout.
  • Avoid width, height, top and margin. Each one forces layout on every frame. Transitioning height to auto covers the exception.
  • Never animate the page's main text block. Readers came for the words.
  • Keep it short. 300 to 500 milliseconds for entry motion. Anything longer is felt as lag on a second visit.
Browser settings with reduce motion enabled, and the same page rendering instantly in its final state.
Browser settings with reduce motion enabled, and the same page rendering instantly in its final state.

Timing values that read as considered

There is no correct number, but there are ranges that work and ranges that do not.

Motion Duration Easing
Hero heading entry 400 to 600ms ease-out or a custom bezier
Card or list item entry 300 to 450ms ease-out
Stagger step between items 50 to 80ms Not applicable
Small element, icon or badge 150 to 250ms ease-out
Anything the reader triggered 120 to 220ms ease

Entry motion eases out, meaning fast at the start and settling at the end. Easing in makes an element appear to hesitate before moving, which reads as lag.

Total time from first paint to everything settled should stay under about 800 milliseconds. Past that, a returning reader is waiting rather than watching.

Checking it honestly

You will see a load animation once and then stop noticing it. Open the page in a private window, and throttle the network in dev tools to see what the delay looks like when assets are slow.

Paste the HTML into a NOS document and open the link on a phone. The keyframes and scripts run as written, so the timing you see there is the timing a reader gets.

If you need a still copy for a ticket that only accepts images, turning the animation into a GIF covers that.

Questions people ask

Do I need JavaScript for a page load animation?

No. A CSS animation property with no trigger starts when the element renders. JavaScript is only needed when you want the animation to wait for a real event, such as images finishing, or to stagger a list whose length you do not know.

Why does my load animation flash before it starts?

The element is visible in its final state for a frame before the animation applies. Set animation-fill-mode to backwards, or use the both value, so the first keyframe is applied during the delay rather than after it.

Should content below the fold animate on load?

No. Animate what is visible and reveal the rest on scroll, with an intersection observer or the CSS animation timeline. Otherwise the reader scrolls down to content that has already finished animating, and only sees the end state.

How do I respect reduced motion preferences?

Wrap the animations in a prefers-reduced-motion no-preference media query, so motion is opt in rather than opt out. Readers with vestibular conditions get the page instantly and lose nothing, since the end state is the real content.

Keep reading