Document animated snapshots - #347
Conversation
Adds an "Animated snapshots" guide covering every animate option (with types, defaults and the version each arrived in), where animate can be set, triggers, the virtual clock, discovery, chained stages, sampling, root, expectations, reduced motion, the motion environment, story hooks and the verify trace, and how to write a driver, with examples, plus the built-in video driver, determinism guarantees, support matrix and troubleshooting. configuration.md gets a short `animate` target option pointing to it, and a note under `prefersReducedMotion` about the per-capture override. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| - **`maxDuration`** — the ceiling for a derived duration, and the window used by | ||
| `mode: 'always'`. | ||
| - **`fps`** — samples per _second_ of animation. 500 ms at `fps: 6` is 3 frames, | ||
| not 6. |
There was a problem hiding this comment.
Remove the "500 ms at ..." part. It's confusing. People understand fps.
There was a problem hiding this comment.
Removed in 7eaea34. The bullet now just says "samples per second of animation".
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved release-timing and animated snapshot documentation inaccuracies remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds user-facing documentation for Happo animated snapshots, including configuration, hooks, drivers, and troubleshooting.
Changes:
- Adds the animated snapshots guide.
- Links animation configuration from
configuration.md. - Adds the guide to the Guides sidebar.
File summaries
| File | Summary | Final review findings |
|---|---|---|
sidebars.json |
Adds the animated snapshots guide to Guides. | None. |
docs/configuration.md |
Documents target animation settings and reduced-motion overrides. | None. |
docs/animated-snapshots.md |
Provides comprehensive animated snapshot documentation. | Moderate (2 votes): gate v6.17.0 guidance until its dependencies ship or mark it unreleased; this also applies at line 134. Moderate (1 vote): correct the SMIL mode: 'always' guidance. Moderate (1 vote): avoid requiring explicit duration for SMIL. Moderate (1 vote): clarify that maxDuration is the fallback when duration is 'auto'. Nit (1 vote): use StoryAnimateConfig instead of StoryAnimateOptions. Moderate (1 vote): convert milliseconds to seconds in the Lottie example. |
Review details
Suppressed comments (6)
docs/animated-snapshots.md:134
- The type cell includes the function form of
trigger, but this row dates all forms to v6.15.0. The function form is story-only and introduced with the v6.17.0 story-hook API (also noted in the section below), so users on v6.15/v6.16 may try to use a callback that is not available. Split the row or annotate the function form's v6.17.0 version.
| `trigger` | `AnimateTrigger` \| function \| `null` | `null` | v6.15.0 |
docs/animated-snapshots.md:153
- These bullets say
mode: 'always'is needed for SMIL, but theAnimateOptions.modecontract lists SMIL among the animations handled bymode: 'auto';alwaysis needed for animations with no reported duration, notably a virtual-clockrequestAnimationFrameloop. As written, this sends SMIL users to an unnecessary mode and window.
- **`mode`** — `'off'` takes a still. `'auto'` captures an animation when there
is one Happo can drive, and a still otherwise. `'always'` captures even when
nothing on the page reports how long it runs, using `maxDuration` as the
window — needed for `requestAnimationFrame` loops and SMIL.
docs/animated-snapshots.md:205
- This guidance also tells users to hand-set
durationfor SMIL, although the API describes SMIL as a duration-bearing animation thatmode: 'auto'can capture. That conflicts with the automatic-duration default and makes the recommendation unnecessarily brittle; reserve it for animations such asrequestAnimationFrameloops and long videos, or document the actual SMIL exception.
- **`duration`** — leave it at `'auto'`; a hand-set duration goes stale when the
CSS changes. Set it for SMIL, `requestAnimationFrame` loops, and long videos,
which can't report a useful one.
docs/animated-snapshots.md:157
durationis documented as the explicit capture window, and the virtual-clock example below suppliesduration: 2000together withmode: 'always'. Saying thatmode: 'always'usesmaxDurationas its window without qualifying this makes that example contradictory and can lead users to capture the 4000 ms default instead of the requested 2000 ms. DescribemaxDurationas the fallback whendurationremains'auto'.
- **`maxDuration`** — the ceiling for a derived duration, and the window used by
`mode: 'always'`.
docs/animated-snapshots.md:852
- The TypeScript section says
StoryAnimateOptionsis the type accepted byparameters.happo.animate, but the exported field type isStoryAnimateConfig, which also includes the documentedtrue,false, and'auto'shorthands. Please name the config type here so typed users do not miss those accepted forms.
`StoryAnimateOptions` is `AnimateOptions` plus the story-only hooks, and is what
`parameters.happo.animate` accepts.
docs/animated-snapshots.md:664
- The handle contract below says
seek(ms)receives milliseconds, butlottie-web'sgoToAndStop(value, false)interpretsvalueas seconds. PassingtimeMsdirectly seeks 1000× too far, so this example will render the end state for nearly every sampled frame; convert to seconds or pass a frame number withtrue.
seek: timeMs => animation.goToAndStop(timeMs, false),
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| _Available since happo v6.15.0. Options marked below as v6.17.0 need that | ||
| version or later._ |
There was a problem hiding this comment.
Agreed that this can't go live before the release. Rather than gating it in the page, the plan is to merge this PR only once happo/happo#594–#596 have shipped, and to adjust the v6.17.0 labels if they land in a different version. The PR description says so.
| chrome: { | ||
| type: 'chrome', | ||
| viewport: '1024x768', | ||
| animate: { clock: 'virtual' }, |
There was a problem hiding this comment.
Can I use this setting in a test suite with both requestAnimationFrame driven animations and pure CSS animations?
There was a problem hiding this comment.
Yes. While the clock steps a story's requestAnimationFrame loop, the same capture still seeks its CSS animations, transitions and element.animate() animations to each frame's time. A story with both is captured in step, and a CSS-only story works too, through the clock's capture path.
The catch is where you turn capture on. If the target sets mode: 'auto' alongside clock: 'virtual', every story gets a maxDuration window, because the clock counts as something to drive. Static stories still end up as stills, but only after being captured frame by frame. So arm the clock with capture off on the target and turn capture on per story. A story can also opt back into plain seeking with clock: 'off', which it needs for stages. I added a "Mixing clock-driven and CSS animations" section saying this, and fixed the bullet that claimed a requestAnimationFrame story needs mode: 'always': under the clock, 'auto' falls back to maxDuration too (7eaea34).
| chrome: { | ||
| type: 'chrome', | ||
| viewport: '1024x768', | ||
| animate: 'auto', |
There was a problem hiding this comment.
Should we put prefersReducedMotion: false here as well?
There was a problem hiding this comment.
Oh, wait, that gets auto-overridden when there is an animation story, correct?
There was a problem hiding this comment.
It isn't auto-overridden. animate's prefersReducedMotion defaults to null, which inherits the target's setting, and targets prefer reduced motion by default. That was deliberate in happo/happo-snap#1321, because the reduced-motion version is worth a snapshot too. Only a story that sets it explicitly gets it applied before render.
So yes, it belongs in the quick start. Without it, a site whose components respect the preference gets nothing but stills from animate: 'auto', which is confusing on first try. I added it with a paragraph on why, noting that it only affects animated captures and that you can leave it out to capture the reduced-motion version (7eaea34).
- Quick start sets prefersReducedMotion: false, with why, since pages that honour reduced motion otherwise leave nothing to capture. - The trigger function form gets its own v6.17.0 row. - mode/maxDuration/duration describe what actually happens: SMIL and the virtual clock capture under 'auto' with maxDuration as the window when duration is 'auto', and maxDuration caps an explicit duration too. - Drop the '500 ms at fps: 6' aside. - New section on mixing clock-driven and CSS animations on one target. - TypeScript: parameters.happo.animate takes a StoryAnimateConfig. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Went through the suppressed findings from the Copilot review (7eaea34):
|
lencioni
left a comment
There was a problem hiding this comment.
Partial review, flushing feedback now and I'll continue reviewing.
|
|
||
| `animate` can be set on a target, on a Storybook story, and on a page in the | ||
| [pages integration](full-page.mdx). Story and page options merge over the | ||
| target's, field by field. |
There was a problem hiding this comment.
It isn't clear to me exactly what this field by field merging means
edit: I see now that the code below is an example of this merging, but it isn't super clear that the document is set up like this. Maybe add some copy here that says see example below?
There was a problem hiding this comment.
Rewrote this. It now says options merge "one field at a time" and points to a new How options merge subsection with a worked example. (9ac88b2)
| ## Where to configure it | ||
|
|
||
| `animate` can be set on a target, on a Storybook story, and on a page in the | ||
| [pages integration](full-page.mdx). Story and page options merge over the |
There was a problem hiding this comment.
What if you are using the custom integration type? What options exist for you besides target?
There was a problem hiding this comment.
Added In the custom integration: animate goes on each happoCustom.registerExample() call, and the client already passes it through. The page also notes that Cypress and Playwright only take a target-level animate. All three are in the support table, linking to their docs. Hooks and the motion environment are marked as Storybook-only. (9ac88b2)
|
|
||
| A story can add to the target's options — a target with | ||
| `{ mode: 'auto', fps: 15 }` and a story with `{ maxFrames: 60 }` capture with | ||
| all three — or opt out with `animate: false`. |
There was a problem hiding this comment.
This could more clearly show the resulting configuration that would be used instead of just saying "all three"
There was a problem hiding this comment.
The new How options merge example spells out the result: target { mode: 'auto', fps: 15 } plus story { maxFrames: 60 } gives { mode: 'auto', fps: 15, maxFrames: 60 }. (9ac88b2)
|
|
||
| What each one does: | ||
|
|
||
| - **`mode`** — `'off'` takes a still. `'auto'` captures an animation when there |
There was a problem hiding this comment.
Instead of having a table followed by a list that duplicates each of the values, I think we should structure this more like we document similar things like on the configuration page https://docs.happo.io/docs/configuration where each option is a heading.
There was a problem hiding this comment.
Done. The table and the list are gone, and each option has its own heading with its version, type and default. The details for trigger, discovery, stages, sampling, root and expect now live under their option. The larger topics (the virtual clock, the motion environment, hooks and drivers) are still their own sections. (9ac88b2)
| ## Choosing values | ||
|
|
||
| Start with `animate: 'auto'` and change nothing else. The defaults are meant to | ||
| be boring, and most animations don't need tuning. |
There was a problem hiding this comment.
Reworded to: "The defaults work for typical UI animations like toasts, menus and spinners, and most animations don't need tuning." (9ac88b2)
| ```js title="ProgressRing.stories.js" | ||
| export const Filling = { | ||
| parameters: { | ||
| happo: { animate: { mode: 'always', duration: 2000, fps: 10 } }, |
There was a problem hiding this comment.
It might be nice to show an example of how someone might tie an animation duration in the story configuration to the actual duration in the component so they don't drift.
There was a problem hiding this comment.
Added under duration. The component exports ENTER_DURATION_MS and uses it in its animation, and the story imports the same constant for animate.duration. (9ac88b2)
|
|
||
| ```js | ||
| // 7 frames across the first half of the window, 3 across the rest | ||
| animate: { sampling: { split: 0.5, front: 7, tail: 3 } } |
There was a problem hiding this comment.
I have thoughts about this API--I think we could make this more flexible, but I'm not sure if that's important or useful.
There was a problem hiding this comment.
animate: { sampling: [
{ stop: 0.5, frames: 7 },
{ stop: 1, frames: 3 },
] }animate: { sampling: [
{ stop: 0.25, frames: 7 },
{ stop: 0.75, frames: 3 },
{ stop: 1, frames: 7 },
] }Something like this maybe? We could take inspiration from the CSS linear gradient API.
There was a problem hiding this comment.
I like this. A list of stops is more general than split/front/tail (which is just the two-stop case), and it will feel familiar from linear-gradient. sampling hasn't shipped yet (it's in happo/happo-snap#1323 and happo/happo#594, aimed at v6.17.0), so now is the cheapest time to change it. I haven't changed it in this PR: the API needs deciding first, then changes in happo-snap and the client, and the docs would follow.
|
|
||
| `root` scopes the animations, not the image: the snapshot still shows the whole | ||
| story. Animations outside `root` are held on their first frame, as in a still. | ||
| If `root` matches nothing, nothing is captured and you get a still. |
There was a problem hiding this comment.
This option is a bit confusing to me, and I think it could benefit from an example.
There was a problem hiding this comment.
Added an example: a story renders an app shell with a spinner in its header, and root points at the notification panel. The page explains what happens: the panel is stepped through the capture, the spinner is held on its first frame, and the image still covers the whole story. (9ac88b2)
|
|
||
| _Available since happo v6.17.0._ | ||
|
|
||
| A capture that finds nothing falls back to a still. That's what makes |
There was a problem hiding this comment.
Here's another usage of "a still". See my feedback earlier about this. I think we could improve this throughout the document.
There was a problem hiding this comment.
Fixed throughout. It now says "still image", or "still screenshot" where it's about the capture itself. (9ac88b2)
|
|
||
| A capture that finds nothing falls back to a still. That's what makes | ||
| `mode: 'auto'` safe across a whole target, but for a story that exists to show | ||
| an animation it's the wrong kind of quiet: the animation broke, the snapshot is |
There was a problem hiding this comment.
"the wrong kind of quiet" is a weird thing to say
There was a problem hiding this comment.
Rewrote it: "it's not what you want for a story that exists to show an animation: if the animation breaks, the snapshot becomes a perfectly plausible still image and nothing fails." (9ac88b2)
| after a click, content that arrives after a fetch — isn't there yet, and would | ||
| be missed. | ||
|
|
||
| `discovery` keeps looking for a while, one frame at a time, and takes over each |
There was a problem hiding this comment.
Instead of "for a while" we should probably say "keeps looking for settleMs milliseconds" or something.
There was a problem hiding this comment.
It now reads: "Happo keeps looking for settleMs milliseconds after the capture starts, checking once per frame". (9ac88b2)
|
|
||
| | Field | Default | Meaning | | ||
| | ----------- | ------- | ------------------------------------------------------------- | | ||
| | `settleMs` | `0` | How long to keep looking, in ms (up to 10000). `0` looks once | |
There was a problem hiding this comment.
If discovery finds something new, does that reset the timer, or is this a one-time hard limit? With a name like "settle" I would expect that the timer would reset every time something is found, but the documentation here slightly suggests that may not be the case.
There was a problem hiding this comment.
It's a hard limit. The deadline is set when the capture starts, and finding something doesn't extend it. The docs now say that explicitly. Your reading of "settle" is fair, though. If we'd rather the timer reset on each new find, that's a change in happo-snap to make before this ships.
| _Available since happo v6.17.0._ | ||
|
|
||
| Some animations are a sequence where each step starts the next: a panel slides | ||
| in, and when it's done a list staggers in, and then a Lottie plays. The next |
There was a problem hiding this comment.
"and when it's done a list staggers in" would be better as "then a list staggers in"
There was a problem hiding this comment.
Applied: "a panel slides in, then a list staggers in, and then a Lottie plays". (9ac88b2)
| - **`'image'`** (default) — the snapshot is replaced with an image describing | ||
| the failure: what was expected, and every animation that was found. It shows | ||
| up in the report as a diff that explains itself. | ||
| - **`'fail'`** — the run fails with the same description. |
There was a problem hiding this comment.
What does it mean for a run to fail here? What is the experience like?
There was a problem hiding this comment.
The job fails like any other "Failed on worker" failure, with the expectation's description as the error, and you see it in the Happo dashboard under Snap-requests. The docs now say this and link to the "Failed on worker" section of the debugging page. (9ac88b2)
| the failure: what was expected, and every animation that was found. It shows | ||
| up in the report as a diff that explains itself. | ||
| - **`'fail'`** — the run fails with the same description. | ||
| - **`'warn'`** — the description goes to the run log and the capture is kept. |
There was a problem hiding this comment.
What is the run log and how can I find it? This might be a separate document that we need to write and link off to.
There was a problem hiding this comment.
Good point. We don't document anywhere where users can see the worker's log, so I dropped "run log" from the page (it now just says Happo logs a warning). Documenting where to find those logs would be a separate page.
|
|
||
| _Available since happo v6.17.0._ | ||
|
|
||
| Some animations never go through the browser's animation APIs — anything that |
There was a problem hiding this comment.
The em-dashes here make this sentence confusing. A little rewrite that avoids the dashes would be good.
There was a problem hiding this comment.
Rewrote it without the dashes. (9ac88b2)
| ``` | ||
|
|
||
| A driver named there that isn't registered is reported in the run log. To make a | ||
| capture fail when a driver finds nothing — a Lottie that failed to load, say — |
There was a problem hiding this comment.
The em-dash section here is awkard too. Use parens with "e.g." instead
There was a problem hiding this comment.
Changed to "(e.g. a Lottie that failed to load)". (9ac88b2)
| animate: { mode: 'auto', expect: { drivers: { lottie: 1 } } } | ||
| ``` | ||
|
|
||
| ### The built-in `video` driver |
There was a problem hiding this comment.
Is it worth mentioning that this is probably going to be useful for animations that use the video tag, and not full on videos that people will sit and watch?
There was a problem hiding this comment.
Yes, added. It's meant for short clips that are part of the UI, like a looping background or an animated illustration shipped as a video, not for long-form video people sit and watch. (9ac88b2)
| | `accessibility` | Ignored — accessibility snapshots aren't images | | ||
| | Storybook | Yes | | ||
| | Pages | Yes | | ||
| | Plain HTML/CSS snapshots | Yes, except the virtual clock (no scripts run) | |
There was a problem hiding this comment.
We should add custom integration type to this page. Also these should maybe link off to their respective docs when available.
There was a problem hiding this comment.
Added the custom integration, Cypress and Playwright to the support table, and each integration row links to its docs. (9ac88b2)
| AnimateConfig, | ||
| AnimateOptions, | ||
| AnimateTrigger, | ||
| AnimateTrace, | ||
| AnimationDriver, | ||
| AnimationDriverHandle, | ||
| StoryAnimateConfig, | ||
| StoryAnimateOptions, |
There was a problem hiding this comment.
I worry a little about this list drifting from the code and I'm not sure how useful it is to explicitly list each of these types in the docs anyway.
There was a problem hiding this comment.
Agreed. I replaced the list with a short note naming the three types people are most likely to need. (9ac88b2)
|
We should probably address animated GIFs and similar types of animations that could exist on a page. Either it will work and yay, or it won't work well and we should be explicit about that. |
- One heading per option, like the configuration page, instead of a table plus a list repeating it. The Drivers section is now Animation drivers, so it doesn't collide with the drivers option. - Where to configure: the custom integration (per example), Cypress and Playwright (target only), and a worked example of how options merge. - Examples for tying duration to the component and for root. - discovery: settleMs is a fixed limit, not reset by finding something. - Animated GIFs, WebP and APNG images, and what the video driver is for. - onExpectationFailure 'fail' links to failed-on-worker debugging. - Reframe choosing values around the part of the animation that matters, back the file size claim with the one measurement we have, say still image rather than a still, and drop the dashes that made sentences hard to follow. - Link to the freezeAnimations CSS, and replace the type list with a note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Re animated GIFs (and similar): the page now covers them (9ac88b2). Happo shows animated GIFs on their first frame, the same as in any snapshot, whether or not |
Why
animate(animated APNG snapshots) shipped in happo v6.15.0 but has no user docs yet. The options, hooks and driver API from the work adapting it to Airbnb's motion-capture findings are about to land as well.What changed
docs/animated-snapshots.md, under Guides in the sidebar.animatecan be set (target, Storybook story, page) plus the shorthands.fps,duration,maxDurationandmaxFrames.discovery), chained animations (stages),sampling, limiting the capture withroot, expectations andonExpectationFailure, and reduced motion.data-happo-animate, and scoping your own "disable animations" CSS), the story hooks (setup, functiontrigger,verify) with the full trace reference, and how to write a driver (handle reference, guidelines, examples for Lottie, a custom frame loop, an async renderer and finishing a stage), plus the built-invideodriver..apng,frameCount,durationMs), browser and integration support, troubleshooting, and TypeScript types.configuration.md: a short### Target animatesection pointing to the guide, and a note underprefersReducedMotionabout the per-capture override. I left the file's existing formatting untouched. It doesn't currently passprettier --checkonmain, and reformatting it would have added about 100 lines of unrelated churn.Versions
Options available today are labelled v6.15.0 (
animate) and v6.16.0 (the nestedprefersReducedMotion). Everything else (discovery,sampling,root,expect,onExpectationFailure,stages,drivers, the hooks andregisterAnimationDriver) is labelled v6.17.0, on the assumption that happo/happo#594, happo/happo#595 and happo/happo#596 ship in the next minor release. Please adjust if they land elsewhere, and hold this PR until then. The worker side is happo/happo-snap#1322 through #1326.Testing
docusaurus buildsucceeds. None of the broken-link or broken-anchor warnings it reports involve the new page orconfiguration.md; they all come from existing pages.prettier --checkpasses on the new page andsidebars.json.🤖 Generated with Claude Code