Animation testing

Happo normally freezes animations and takes a single screenshot. With animated snapshots, it steps an animation through a series of times, takes a screenshot at each one, and stores the frames as one animated PNG. A change to the motion shows up as a diff.

Two of our own animations

We capture Happo's own animations this way.

Loading indicator

A CSS @keyframes animation that loops forever. Happo finds it by itself and captures one iteration, so the snapshot loops without a stutter.

Hippo loader

Loading…

Drawn on a canvas by a web worker, in its own frame loop. Happo can't see into that, so a small driver hands it the grid's own seek. It holds still if you prefer reduced motion.

A diff of an animation

This is the diff view from a Happo report, showing the hippo loader before and after a small change: the dots that make up the hippo now pulse green once per loop. Some frames are identical, and the strip under the diff marks the ones that changed, darker the more they did. Pause it and step through them.

Concepts

Seeking
Happo pauses each animation and asks it to render at exact times, rather than recording it as it plays. The same animation gives the same frames on every run.
Auto mode
mode: 'auto' captures an animated snapshot when the story has an animation Happo can drive, and a still image when it doesn't.
Triggers
A CSS transition needs something to start it. A trigger adds a class, clicks, focuses or hovers an element right before the capture.
Discovery and stages
discovery is for animations that start late, like a staggered list, and stages for sequences where each step starts the next.
Virtual clock
clock: 'virtual' replaces the page's clock so requestAnimationFrame loops can be stepped one frame at a time.
Expectations
expect says what a capture has to find (animations, frames, stages), so a broken animation shows up in the report instead of becoming a still image.
Sampling
Frames are spread evenly at fps by default. With sampling they can be placed where the motion is, or at exact times.

An animated snapshot counts as three snapshots, however many frames it holds. A story that comes out as a still image counts as one.

What can be captured

KindCaptured
CSS animations (@keyframes)Yes
CSS transitionsYes, with a trigger to start them
element.animate() (Web Animations API)Yes
SVG SMIL animationsYes
requestAnimationFrame loopsYes, with the virtual clock
Lottie, canvas engines, anything with its own frame loopYes, with a driver
<video>Yes, with the built-in video driver
Animated GIF, WebP and APNG imagesNo
Scroll-driven animationsNo
View transitionsNot yet

Animated snapshots work in Chrome, Edge, Firefox and Safari targets, and with the Storybook, custom, pages, Cypress and Playwright integrations. iOS Safari targets don't support them.

Bring your own driver

An animation that runs its own frame loop, like Lottie or a canvas engine, never goes through the browser's animation APIs, so Happo can neither find nor seek it. A driver teaches it how: it has a name and a discover function that returns a handle for each animation, with its length and a seek that renders it at a given time.

This is the driver for the hippo loader above:

import { registerAnimationDriver } from 'happo/storybook/register';

import { pixelGridsIn } from './pixelGrid';

registerAnimationDriver({
  name: 'pixel-grid',
  discover: (root) =>
    pixelGridsIn(root).map((grid) => ({
      target: grid,
      element: grid.canvas,
      durationMs: grid.loopMs,
      repeats: true,
      seek: grid.seek,
      pause: grid.pause,
      release: grid.release,
    })),
});

Happo seeks a driver's handles on the same timeline as everything else on the page. A video driver is built in.

Get started

Opt in one story at a time, with the animate parameter on a story that shows an animation:

export const Default = {
  parameters: {
    happo: {
      animate: {
        mode: 'auto',
        prefersReducedMotion: false,
      },
    },
  },
};

Every other story keeps its still screenshot. Happo's targets prefer reduced motion by default, which is why prefersReducedMotion: false is there. The animated snapshots docs cover the rest: every option, the other integrations, hooks, drivers and troubleshooting.

Try it on your own animations

Sign up to get started, or go to the docs if you already have an account.

Need help?