All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Micro-Interactions and Scroll Animation Without Wrecking Performance

Scroll animation and micro-interactions that keep 60fps: compositor-only properties, CSS scroll-driven timelines, LCP traps and real measurement budgets.

A real working moment illustrating the theme of an article about scroll animation. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

A client handed us a marketing site last year that scored 96 on desktop Lighthouse and felt like treacle on a real phone. The cause wasn’t images or a bloated theme. It was 94 elements wearing a .reveal class, a scroll listener calling getBoundingClientRect() on every one of them, and a smooth-scroll library sitting between the user’s thumb and the browser. Interaction to Next Paint on a mid-range Android: 480ms. The design was fine. The implementation was the problem.

Scroll animation and micro-interactions are not inherently expensive. Done properly, a fade-and-rise reveal costs the compositor almost nothing and never touches the main thread. Done the way most templates and tutorials do it, every frame triggers layout and the phone drops to 20fps while the user is trying to read.

Here’s the version we actually use on production builds, including the parts that go wrong.

Key Takeaways

  • Only transform, opacity and (in most cases) filter can be animated on the compositor. Animating height, top, margin, box-shadow or clip-path forces layout or paint on every frame and is the usual reason a reveal feels sticky.
  • CSS scroll-driven animations (animation-timeline: view()) run off the main thread and replace both scroll listeners and IntersectionObserver for most reveal effects. Chromium has shipped them since 115, Safari added support in the 26 cycle, Firefox is the laggard, so keep a @supports fallback.
  • An element at opacity: 0 is not an LCP candidate in Chrome. Fading in your hero headline on scroll or on load pushes your Largest Contentful Paint out by the length of the animation plus the delay.
  • Micro-interactions need to acknowledge input within about 100ms to feel instant, and they must be interruptible. A 400ms hover transition that can’t be cancelled mid-flight reads as lag, not polish.
  • will-change is not a performance hint you sprinkle on everything. Each promoted layer costs roughly width times height times 4 bytes of GPU memory, and 200 promoted cards will hurt more than the animation ever helped.

What actually costs you frames

The browser’s rendering pipeline goes style, layout, paint, composite. Where your animation enters that pipeline decides what it costs.

Animate width or top and you re-run layout for that element and potentially its siblings, then repaint, then composite. Every frame. Animate background-color or box-shadow and you skip layout but still repaint: on a large blurred shadow that means the GPU rasterising a big soft-edged rectangle sixty times a second. Animate transform or opacity on an element that already has its own compositor layer and the main thread does nothing at all. The compositor thread interpolates the matrix and the frame ships even if your JavaScript is busy.

That last point is the whole game. At 60Hz you have 16.67ms per frame, and on a 120Hz display 8.33ms. Your app’s JavaScript, third-party tags and React re-renders are already eating most of it. Animations that live on the compositor survive a busy main thread. Animations that don’t stutter exactly when the page is most active: during hydration, during scroll, during the moments the user is judging you.

One trap worth naming: filter: blur() and backdrop-filter are composited but not cheap. A full-viewport backdrop blur on a sticky header will melt older Android GPUs. We’ve measured 30fps to 45fps drops on a Pixel 4a from a single frosted-glass nav. If you want glass, cap the blur radius around 12px and keep the blurred surface small.

Scroll animation without a scroll listener

For years the correct answer to scroll animation was IntersectionObserver, because it does the intersection maths off the main thread and hands you a callback. It’s still the right answer for anything that fires once and triggers side effects: lazy mounting, analytics, playing a video.

For pure visual effects tied to scroll position, CSS now does it natively with no JavaScript at all:

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

@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal linear both;
    / drive the animation by the element's own position in the scrollport /
    animation-timeline: view();
    / start when the top edge is 15% up from the bottom, finish 40% before centre /
    animation-range: entry 15% cover 40%;
  }
}

/ progress bar tied to document scroll, no JS, no rAF loop /
.progress {
  transform-origin: 0 50%;
  animation: grow linear both;
  animation-timeline: scroll(root block);
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }

This runs on the compositor. Block the main thread with a 2-second script and the progress bar still tracks your finger. No library matches that, because every JavaScript library is by definition main-thread bound unless it also delegates to animation-timeline or the Web Animations API with a ScrollTimeline.

The caveat: Firefox still requires layout.css.scroll-driven-animations.enabled at the time of writing, so anything inside @supports needs a defined non-animated end state outside it. Never put the hidden state in base CSS and the visible state inside the feature query. That’s how you ship an invisible page to a browser you didn’t test.

The IntersectionObserver version, done once, correctly

// 18 lines that replace a 14 KB animate-on-scroll library
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.classList.add('is-in');
    io.unobserve(entry.target); // one-shot: stop paying for it forever
  }
}, {
  // fire slightly before the element reaches the fold so the motion isn't cut off
  rootMargin: '0px 0px -12% 0px',
  threshold: 0.01
});

// mark the hidden state from JS so no-JS users still see content
document.documentElement.classList.add('js-reveal');
document.querySelectorAll('[data-reveal]').forEach((el) => io.observe(el));

Two details people skip. Call unobserve after the reveal, otherwise you keep 90 live observations for the life of the page. And apply the hidden state only once JavaScript has run, either with that root class or with @media (scripting: enabled), which all three engines now support. If your bundle 404s behind a corporate proxy, your content should still be readable.

Reveals that don’t wreck LCP or CLS

This is where animation performance stops being about frame rate and starts costing you Core Web Vitals.

Chrome will not treat an element with opacity: 0 as a Largest Contentful Paint candidate. If your hero heading or hero image fades in over 600ms with a 200ms delay, you’ve added 800ms to LCP for free. We’ve seen this single mistake take a 1.9s LCP to 2.7s. Rule: nothing above the fold animates its opacity from zero. Animate the below-fold content only, or animate the hero with transform alone and leave opacity at 1.

Cumulative Layout Shift is the reverse problem, and the good news is that transform-based movement is excluded from CLS by design. Shifts caused by translateY don’t count. Shifts caused by an element going from display: none to display: block, or from height: 0 to auto, absolutely do. If you’re animating accordions or reveals that change flow, reserve the space first.

Worth combining with content-visibility: auto on long pages, but give it an intrinsic size or you’ll trade animation jank for scrollbar jitter:

.section-deep { content-visibility: auto; contain-intrinsic-size: auto 600px; }

If you’re auditing a slow WordPress build, animation is rarely the first cause but it’s frequently the last 300ms. Our diagnostic method for slow WordPress sites covers the ordering: server, then payload, then main thread, then paint.

Micro-interactions web work: the 100ms rule

The research on response time is old and still correct. Under roughly 100ms, feedback feels like direct manipulation. Past about a second, attention drifts. For micro interactions web UI, that means hover, focus and press feedback should land between 80ms and 150ms, not 400ms. Entrance choreography can run 200ms to 400ms because it isn’t responding to a click.

Most template hover effects animate box-shadow, which repaints. Animate it on a pseudo-element’s opacity instead and the shadow is rasterised once:

.card { position: relative; transition: transform 120ms ease-out; }
.card::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  box-shadow: 0 12px 32px rgb(0 0 0 / .18);
  opacity: 0; transition: opacity 120ms ease-out;
  pointer-events: none;
}
.card:hover::after, .card:focus-within::after { opacity: 1; }
.card:hover, .card:focus-within { transform: translateY(-3px); }
/ press state must be faster than hover: acknowledge input immediately /
.card:active { transform: translateY(-1px); transition-duration: 60ms; }

Note :focus-within alongside :hover. Keyboard users get the same affordance, which is one of the cheapest accessibility wins available and something auditors check. If that’s an area you’re weak on, the five failures auditors always find covers the rest.

Interruptibility matters more than easing curves. CSS transitions handle it natively: move the mouse away mid-transition and it reverses from the current value. Bespoke JS animations usually don’t, and you get the queue-up effect where three hovers play back to back. If you’re writing JS animation, use the Web Animations API and cancel() the existing animation before starting a new one.

When a library earns its bytes

GSAP went fully free in 2025, ScrollTrigger included, which removed the licensing argument against it. The byte argument stands: core plus ScrollTrigger is roughly 35 KB gzipped, and on a 4G connection that’s real time spent before anything animates.

Our rule after a lot of client builds:

  • Reveal on scroll, parallax, sticky progress, simple hover states: CSS only. A library here is 35 KB to replace 20 lines.
  • Timeline choreography with a dozen coordinated elements, pinning, scrubbed SVG morphs, physics: GSAP or Motion. Hand-rolling this is how you lose a week.
  • Anything React-heavy where layout animations follow state: Motion’s layout animations use FLIP correctly and are hard to beat by hand.

Smooth-scroll libraries like Lenis are the one category we push back on nearly every time. They replace native scroll with a rAF-driven transform, which adds a frame of latency, breaks the platform scroll physics your OS spent a decade tuning, and puts scrolling on the main thread. On a fast laptop it looks luxurious. On a mid-range Android with a busy main thread it feels broken, and it interacts badly with position: sticky, anchor links and screen readers. Use it only when the entire design concept depends on scrubbed scroll choreography, and never on a content site.

If you’re starting from a component library rather than a blank file, check what the reveal system actually compiles to. The reveal utilities in Canvas sit on transform and opacity with a single observer, which is the shape you want before you add anything custom on top.

Reduced motion is not an off switch

prefers-reduced-motion: reduce means vestibular safety, not “no feedback”. Killing all transitions makes an interface feel dead and removes the state-change cues some users rely on. Keep colour and opacity changes, remove large-distance movement, parallax and anything that scales or rotates.

@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; opacity: 1; transform: none; }
  .parallax { transform: none !important; }
  / keep short opacity/colour feedback: it communicates state /
  • { animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
.card, .card::after { transition-duration: 100ms !important; } }

Test it properly. macOS System Settings has Reduce Motion under Accessibility, Windows has it under Settings, Accessibility, Visual effects, and Chrome DevTools can emulate it from the Rendering panel.

Measuring it, with numbers you hold yourself to

Lighthouse won’t catch animation jank, because it doesn’t scroll. You have to look.

  1. Open DevTools, Rendering panel, enable Paint flashing. Scroll. Green flashes over animating elements mean you’re repainting every frame. Transform-only animations flash nothing.
  2. Enable Frame Rendering Stats for a live fps and GPU memory readout. Watch the memory figure while you scroll past a hundred promoted cards.
  3. Record a Performance trace with CPU throttling at 4x or 6x, scroll for five seconds, then check the Frames track. You’re looking for frames over 16ms and, in the Long Animation Frames entries, main-thread blocks over 50ms.
  4. Check field data, not lab data. INP at the 75th percentile should be under 200ms. If your lab trace is clean and CrUX says otherwise, the problem is on devices you don’t own.

Budget we hold on client work: zero layout events during scroll, main-thread work under 5ms per frame while scrolling, no more than about 20 composited layers on screen at once, and total animation-related JavaScript under 10 KB gzipped unless there’s a named requirement that justifies more.

Frequently Asked Questions

Are CSS scroll-driven animations production-ready in 2026?

Yes, with a fallback. Chromium-based browsers have supported animation-timeline since version 115 and Safari added it in the 26 release cycle, which covers the large majority of traffic on most sites. Firefox still needs a flag, so wrap the scroll-driven rules in @supports (animation-timeline: view()) and make sure the default state outside that block is fully visible content.

Does animating on scroll hurt Core Web Vitals?

It hurts LCP if any above-the-fold element starts at opacity: 0, because Chrome excludes zero-opacity elements from LCP candidacy until they paint. It rarely hurts CLS, since layout shifts caused by transforms are not counted. It can hurt INP if you’re using scroll listeners that read layout properties, which forces synchronous reflow on the main thread during the exact moment the user is interacting.

Should I use will-change on every animated element?

No. will-change promotes an element to its own compositor layer, and each layer costs GPU memory roughly equal to its pixel area times 4 bytes. Apply it just before an animation starts and remove it after, or skip it entirely, because Chrome already auto-promotes elements with running transform and opacity animations.

Is IntersectionObserver still worth using?

Absolutely, for anything with side effects: lazy-mounting components, triggering video playback, firing analytics or adding a class that a non-scroll-driven animation depends on. For purely visual effects tied to scroll progress, CSS scroll-driven animations are better because they run off the main thread and survive heavy JavaScript. Many production sites end up using both.

How long should a hover or button animation last?

Between 80ms and 150ms for direct feedback on hover, focus and press, with the press state faster than the hover state. Entrance and reveal animations can run 200ms to 400ms because nobody is waiting on them. Anything over 400ms for a response to user input starts reading as lag rather than craft.

Where to start tomorrow

Open your own site on a throttled profile, turn on paint flashing, and scroll. If the page lights up green, you have a repaint problem and the fix is mechanical: move every animated property onto transform and opacity, delete the scroll listener, and put the reveal logic behind either a one-shot IntersectionObserver or animation-timeline: view().

Then remove the animation from anything above the fold. That single change is usually worth more in measured LCP than a week of image optimisation, and no one has ever complained that a hero headline appeared too quickly.

css animation performance compositor transform opacity css scroll driven animations animation-timeline intersection observer reveal on scroll pattern micro interactions web hover timing 100ms opacity 0 hurting largest contentful paint prefers-reduced-motion implementation css scroll animation scroll animation without javascript listener