Skip to main content

Animation performance in product tours: requestAnimationFrame vs CSS

Compare requestAnimationFrame and CSS animations for product tour tooltips. Learn the two-layer architecture that keeps tours at 60fps without jank.

DomiDex
DomiDexCreator of Tour Kit
April 8, 202611 min read
Share
Animation performance in product tours: requestAnimationFrame vs CSS

Animation performance in product tours: requestAnimationFrame vs CSS

Your product tour works fine in isolation. Tooltips glide into view, spotlights pulse gently, and step transitions feel crisp. Then you ship it into a real app with 300 components, three analytics scripts, and a WebSocket connection, and frames start dropping on every step change.

The reason isn't that your animation code is "wrong." It's that you picked the wrong animation engine for the wrong job. Product tours sit in a unique position: they animate on top of someone else's app, compete for the same rendering budget, and must handle scroll-synced positioning that pure CSS can't touch.

npm install @tourkit/core @tourkit/react

What determines animation performance in a product tour?

Product tour animation performance depends on which browser thread handles the animation work. Browsers run two separate animation engines: a CPU-bound main thread responsible for JavaScript, React renders, and layout calculations, and a GPU compositor thread that can apply transform and opacity changes without touching the main thread at all. CSS animations using compositor-friendly properties run at consistent 60fps regardless of main-thread load, while requestAnimationFrame callbacks compete with every other piece of JavaScript for that same 16.67ms frame budget (web.dev).

That two-engine split matters more for product tours than for typical web animations. A marketing landing page controls its own rendering load. A tour library runs inside someone else's application, which might trigger a heavy React reconciliation at the exact moment the user clicks "Next step."

Why animation performance matters for product tours

Dropped frames during a product tour don't just look bad. They actively undermine onboarding. Users who complete an onboarding tour are 2.5x more likely to convert to paid (Appcues 2024 Benchmark Report), but janky animations signal "unpolished product" and increase early abandonment. A tour running at 30fps on a customer's budget laptop erodes the trust you're trying to build.

Product tours also face a unique constraint: they're a guest in someone else's DOM. Unlike application animations where you control the rendering budget, a tour tooltip competes with the host app's React renders, third-party scripts, and layout recalculations. Your animation budget isn't 16.67ms per frame. It's whatever the host app leaves you.

CSS animations: what they're good at in tours

CSS transitions and keyframes on transform and opacity run on the compositor thread with zero main-thread cost. Google's web.dev documentation puts it bluntly: "Use CSS when you have smaller, self-contained states for UI elements. CSS transitions and animations are ideal for bringing a navigation menu in from the side, or showing a tooltip" (web.dev). Product tour tooltips are exactly this kind of self-contained UI element.

For step transitions (fading a tooltip in, scaling it up from 95% to 100%, sliding it from one position to another), CSS is the right default. The animation runs on the GPU even if your host app is mid-render.

// src/components/TourTooltip.tsx
import { useTour } from '@tourkit/react';

function TourTooltip({ children }: { children: React.ReactNode }) {
  const { isActive } = useTour();

  return (
    <div
      className={`
        transition-all duration-200 ease-out
        ${isActive
          ? 'opacity-100 translate-y-0 scale-100'
          : 'opacity-0 translate-y-2 scale-95 pointer-events-none'
        }
      `}
    >
      {children}
    </div>
  );
}

Stick to transform, opacity, filter, and clip-path. These four properties are "guaranteed to neither affect nor be affected by the normal flow or DOM environment," so their animation can be completely offloaded to the GPU (Smashing Magazine). The moment you animate width, height, top, left, or box-shadow, you've dropped from S-tier to C or D-tier on the performance tier list (Motion Magazine).

What CSS can't do

CSS doesn't know where your anchor element is after a scroll. It can't read getBoundingClientRect(). It can't compute "position this tooltip 8px below the target button" when the button has moved since the last repaint. For anything that requires real-time position tracking, CSS alone isn't sufficient.

requestAnimationFrame: when JavaScript animation is the right call

requestAnimationFrame synchronizes JavaScript calculations with the browser's paint cycle, roughly 60 times per second on a standard display and 120 on high-refresh panels. For product tours, rAF earns its place in one specific scenario: repositioning a tooltip that must track a moving target during scroll, resize, or layout shifts.

The key insight most articles miss: rAF and CSS aren't competitors in a production tour. They form a two-layer architecture. rAF handles position calculation (reading the anchor element's current coordinates), and CSS handles the visual transition (applying the new position smoothly via transform).

// src/hooks/useAnchorTracking.ts
import { useRef, useCallback, useEffect } from 'react';

function useAnchorTracking(anchorEl: HTMLElement | null) {
  const tooltipRef = useRef<HTMLDivElement>(null);
  const rafId = useRef<number>(0);

  const updatePosition = useCallback(() => {
    if (!anchorEl || !tooltipRef.current) return;

    // READ phase — measure anchor position
    const rect = anchorEl.getBoundingClientRect();
    const x = rect.left + rect.width / 2;
    const y = rect.bottom + 8;

    // WRITE phase — apply via transform (compositor-friendly)
    tooltipRef.current.style.transform = `translate(${x}px, ${y}px)`;

    rafId.current = requestAnimationFrame(updatePosition);
  }, [anchorEl]);

  useEffect(() => {
    rafId.current = requestAnimationFrame(updatePosition);
    return () => cancelAnimationFrame(rafId.current);
  }, [updatePosition]);

  return tooltipRef;
}

Notice the "read then write" pattern inside the callback. Reading getBoundingClientRect() then immediately writing to a layout property like style.left inside rAF causes layout thrashing because the browser has to recalculate layout mid-frame. Writing to style.transform bypasses this entirely since transforms don't trigger layout (DebugBear).

The React render cycle problem

Here's a gotcha specific to React-based tours. When you call setCurrentStep(step + 1), React runs a synchronous reconciliation on the main thread. On a complex app, that can consume 5-15ms. Your rAF callback runs after React finishes, leaving maybe 1-2ms of the 16.67ms frame budget. If the tooltip position update is also expensive, you drop a frame.

Tour Kit addresses this by computing tooltip positions outside the React render cycle and applying them through refs, not state. The position update never triggers a React re-render.

The CSS variable spotlight antipattern

Some tour libraries animate spotlight overlays by updating CSS custom properties every frame:

/* Don't do this — F-tier performance */
.spotlight-overlay {
  --spotlight-x: 0px;
  --spotlight-y: 0px;
  --spotlight-radius: 0px;
  background: radial-gradient(
    circle at var(--spotlight-x) var(--spotlight-y),
    transparent var(--spotlight-radius),
    rgba(0, 0, 0, 0.5) 0
  );
}
// Updating CSS variables in rAF — forces full style recalc
function animateSpotlight(x, y, radius) {
  requestAnimationFrame(() => {
    overlay.style.setProperty('--spotlight-x', `${x}px`);
    overlay.style.setProperty('--spotlight-y', `${y}px`);
    overlay.style.setProperty('--spotlight-radius', `${radius}px`);
  });
}

Every element that inherits from those variables recalculates styles on every single frame. Motion Magazine documented a case where updating a global CSS variable per frame triggered recalculations on 1,300+ elements, consuming 8ms per frame, which is the entire 120fps budget on a single style recalc (Motion Magazine).

Use an SVG cutout or a clip-path on the overlay element instead, positioned with transform. Tour Kit's highlighting approach avoids CSS variables entirely and uses a box-shadow spread on a positioned element, keeping the animation on compositor-safe properties.

Implicit compositing: the production-only jank you won't catch in dev

A tour tooltip animated with transform gets promoted to its own GPU compositing layer. So far so good. But here's the trap: any non-animated element positioned above that tooltip in the z-index stacking order also gets forcibly promoted to its own layer. The browser does this to maintain correct visual ordering.

A typical product tour sets z-index: 9999 on its tooltip. If the host application has elements at z-index: 10000 or above (modal backdrops, notification badges, cookie banners), each one silently becomes its own compositing layer. This causes "unexpected repaints, data transfer delays, and potential flickering, especially problematic on low-end devices" (Smashing Magazine).

We measured this in Chrome DevTools using the Layers panel. A tour overlay running in isolation promoted 3 layers. The same overlay inside a production dashboard with multiple stacked UI elements promoted 14. Each 320x240 layer costs roughly 307KB of GPU memory (width x height x 4 bytes). On a 3x high-DPI mobile screen, that multiplies by 9.

How to mitigate it

  1. Use the lowest possible z-index that still renders above the target element
  2. Apply will-change: transform only during the active animation, then remove it
  3. Check the Layers panel in Chrome DevTools. If you see unexpected layer count spikes when the tour activates, you have implicit compositing
// src/components/TourOverlay.tsx
function TourOverlay({ isAnimating }: { isAnimating: boolean }) {
  return (
    <div
      style={{
        // Only promote to compositor layer during animation
        willChange: isAnimating ? 'transform, opacity' : 'auto',
        zIndex: 1000, // Not 9999 — use the minimum viable z-index
      }}
    />
  );
}

The Web Animations API: an overlooked middle ground

The Web Animations API (WAAPI) gives you scriptable control (pause, reverse, adjust playback rate) while still running on the compositor thread for transform and opacity properties. CSS-Tricks calls it "one of the most performant ways to animate on the Web, letting the browser make its own internal optimizations without hacks, coercion, or window.requestAnimationFrame()" (CSS-Tricks).

WAAPI fits a specific niche in product tours: step transitions that need programmatic control (pause on hover, skip animation, reverse on "Back" click) but shouldn't run on the main thread.

// src/hooks/useStepTransition.ts
function animateStepIn(element: HTMLElement) {
  return element.animate(
    [
      { opacity: 0, transform: 'translateY(8px) scale(0.96)' },
      { opacity: 1, transform: 'translateY(0) scale(1)' },
    ],
    {
      duration: 200,
      easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
      fill: 'forwards',
    }
  );
}

// Later — pause, reverse, or cancel programmatically
const animation = animateStepIn(tooltipEl);
animation.pause();    // On hover
animation.reverse();  // On "Back" click
animation.cancel();   // On tour exit

WAAPI has full browser support as of 2024. It's the cleanest path for animations that need to be both performant and controllable from JavaScript.

The will-change memory budget on mobile

Tour overlays on budget Android devices deserve specific attention. A single compositing layer for a 320x240 element costs about 307KB of GPU memory. Sounds trivial until you account for high-DPI screens: a 3x device multiplies that by 9, bringing a single small element to ~2.7MB. A tour with a backdrop overlay, spotlight cutout, and tooltip (three promoted layers) can consume 5-10MB of scarce GPU RAM (Smashing Magazine).

Budget Android phones (Snapdragon 680, 2-3GB total RAM) have roughly 200-300MB of GPU memory after OS overhead. Three tour layers won't crash the browser, but they crowd out GPU memory that the host app's own animations need. Frame drops show up on real user devices, never on developer MacBooks.

Apply will-change only during the active animation phase. Remove it the moment the step transition completes. Never apply it to persistent elements like the overlay backdrop that stays visible throughout the entire tour.

prefers-reduced-motion: not optional for tours

WCAG 2.2.2 (Level AA) requires a mechanism to pause, stop, or hide animation that starts automatically and lasts more than 5 seconds. A multi-step product tour with animated transitions between steps absolutely qualifies. The prefers-reduced-motion media query is the standard implementation path (W3C WCAG Technique C39).

For product tours specifically, here's what to reduce and what to keep:

AnimationNormal motionReduced motion
Step transition (in/out)Fade + translate + scale (200ms)Instant opacity crossfade (100ms)
Spotlight/highlightAnimated resize to targetInstant resize, no animation
Beacon/hotspot pulseContinuous pulse loopStatic indicator, no pulse
Progress barAnimated fillKeep (conveys information, not decoration)
Focus ringAnimated outlineKeep (essential for keyboard navigation)

CSS handles this more reliably than JavaScript because it's evaluated before any JS executes:

@media (prefers-reduced-motion: reduce) {
  .tour-tooltip {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }

  .tour-beacon {
    animation: none;
  }
}

Tour Kit checks prefers-reduced-motion at the provider level and passes it through context. Components receive a reducedMotion boolean and adjust their behavior accordingly. No visual builder or library-level toggle required. It respects the user's OS-level preference by default.

The decision tree for tour developers

Based on the current state of web animation APIs, Google's recommendations, and what we've measured building Tour Kit's own animation layer:

  1. Start with CSS transitions for tooltip appear/disappear, step transitions, and overlay fades. Use only transform, opacity, filter, or clip-path. This covers 80% of tour animation needs at zero main-thread cost.

  2. Use rAF for scroll-synced repositioning when the tooltip must track an anchor element during scroll or resize. Batch reads before writes. Never write to layout properties inside rAF.

  3. Use WAAPI for controllable transitions where step animations need pause/resume/reverse without dropping to the main thread. Full browser support since 2024.

  4. Avoid animating layout properties (width, height, top, left) in any tour animation. Refactor to transform: scale() and transform: translate().

  5. Never update CSS variables per frame for spotlight positioning. Use transform on a positioned element instead.

Tour Kit follows this exact decision tree internally. Step transitions use CSS transitions. Anchor tracking uses rAF with transform writes. The reduced-motion path is CSS-first. No library can make a layout-triggering animation fast; the only fix is choosing the right property.

One honest limitation: Tour Kit doesn't include a visual timeline editor for animations. You write CSS classes or pass animation config as props. If your team needs drag-and-drop animation authoring, Appcues or Userflow are a better fit. Teams that want control over animation performance get a code-first approach where you can profile and fix exactly what's slow.

FAQ

Is requestAnimationFrame faster than CSS animations for product tours?

requestAnimationFrame runs on the main thread, competing with JavaScript and React renders for the 16.67ms frame budget at 60fps. CSS animations on transform and opacity run on a separate GPU compositor thread with zero main-thread cost. CSS wins for product tour step transitions. Use rAF only for scroll-synced position tracking where CSS has no equivalent.

What causes product tour animations to drop frames in production but not in demos?

Implicit compositing. A tour tooltip animated at z-index: 9999 forces the browser to promote every higher-stacked element to its own GPU layer. In a demo with 10 elements, no problem. In a production dashboard with dozens of stacked UI components, it causes memory spikes and frame drops. Check Chrome DevTools Layers panel to spot unwanted promotions.

How should product tours handle prefers-reduced-motion?

Tour libraries should respect prefers-reduced-motion by replacing slide/scale transitions with instant opacity crossfades and removing beacon pulse animations. Keep informational animations like progress bars. CSS media queries are more reliable than JavaScript checks because they evaluate before JS runs. WCAG 2.2.2 (Level AA) requires this for animations lasting more than 5 seconds.

Should I use Framer Motion or CSS for tour tooltip animations?

Framer Motion's animate prop uses the Web Animations API internally (since Motion v11), achieving compositor-thread performance. Its layout prop triggers main-thread DOM measurement first. CSS transitions are lighter for simple tooltips with zero runtime cost. Framer Motion adds convenience for complex sequences at a 12-20KB bundle increase. Tour Kit uses CSS transitions to minimize bundle impact.

What is the Web Animations API and should tour libraries use it?

The Web Animations API (WAAPI) lets you create and control animations from JavaScript while still running on the compositor thread, combining rAF's scriptability with CSS's performance. For tour step transitions that need programmatic control (pause on hover, reverse on "Back" click), WAAPI is the best current option with full browser support.


Ready to try userTourKit?

$ pnpm add @tour-kit/react