document.getAnimations() is the most underused line in front-end debugging. It returns every animation the document currently knows about, each with a playState, a currentTime and the name of the keyframes driving it. One call tells you what is actually moving, as opposed to what your stylesheet says could move.
I ran it across all 76 public pages of Nakodo expecting a spread. The site is drawn rather than photographed, it has 24 @keyframes blocks in its stylesheet, and the landing page has a hero full of blowing leaves.
Here is the whole result:
| Pages | Animation objects |
|---|---|
| 75 | 0 |
| 1 | 15 |
Every guide, every glossary entry, every pricing table, every hub: nothing. One page animates, and it is the home page.
24 keyframes, 8 of which ever reach the public
The stylesheet declares 24 keyframe blocks:
live-pulse illo-river illo-current illo-pencil illo-ink illo-hop illo-alcy-hop illo-peck-body illo-peck-head illo-drum-body illo-drum-head illo-drum-chip-1 illo-drum-chip-2 grow-x grow-y draw-in illo-scan-head illo-blink illo-dive-bird illo-dive-ripple illo-notice-sheet illo-notice-roll stamp-land row-arrive Eight of those names appear in a live animation on a public page. The other sixteen belong to screens behind the login: a bird drumming on a log while a job runs, a blink, a dive with a ripple, a sheet of paper rolling into a notice, a stamp landing, a row arriving in a table. They cost nothing to a visitor beyond their share of the stylesheet, and they are the reason a census of the CSS would have told me the wrong thing. Declared motion and running motion are different populations, and only one of them is measurable from the outside.
4 running, 11 paused, before you scroll
On the home page at the top of the document:
document.getAnimations().length; // 15 document.getAnimations().filter((a) => a.playState === "running"); // 4 document.getAnimations().filter((a) => a.playState === "paused"); // 11 The four that run are two pairs: illo-peck-body and illo-peck-head, twice over. That is the kingfisher in the hero, pecking.
The eleven that exist but do not run are the illustrations further down the page, and they are paused by design. Each one is wrapped in a component whose only job is to decide whether its drawing should move:
export function IlloPlay({ className, children }: { className?: string; children: React.ReactNode }) { const ref = useRef<HTMLLIElement>(null); const [onScreen, setOnScreen] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting)); observer.observe(el); return () => observer.disconnect(); }, []); return ( <li ref={ref} data-play={onScreen ? "" : undefined} className={cn("illo-play", className)}> {children} </li> ); } The animations are declared paused in CSS and a data-play attribute set by an IntersectionObserver is what starts them. Hovering one pauses it again, on the theory that if you have stopped on a drawing you would like to look at it rather than watch it loop.
Scroll and you can watch the arithmetic change. Measured at 1280 by 900:
| Scroll position | Objects | Running |
|---|---|---|
| 0 | 15 | 4 |
| 900 | 15 | 15 |
| 1,800 | 32 | 18 |
| 2,700 | 34 | 6 |
| 4,309 (bottom) | 32 | 4 |
Two things in that table surprised me.
The first is that the object count goes up and does not come back down. At 1,800 pixels, seventeen new animations appear: seven grow-x and seven grow-y and a few others. Those are the bars of the two small example charts, which grow once when they come into view. They are one-shot animations with a fill, and a finished animation with a fill stays in getAnimations() rather than disappearing from it. So the list is a record of everything that has animated, not a snapshot of what is animating, and playState is the only part of it that answers the question you probably meant to ask.
The second is the bottom row. The hero is long gone off the top of the screen, and its four animations are still running. The pecking bird is the one drawing on the page that is not wrapped in the component above, so nothing ever tells it to stop. It is small, it is cheap, and it is still work being done for a bird nobody can see. That is going on the list.
Reduced motion takes it to zero, not to paused
With prefers-reduced-motion: reduce emulated, the same page returns:
document.getAnimations().length; // 0 Not fifteen paused objects. Zero objects, because they never exist. The stylesheet puts the rules that use those keyframes inside the positive query rather than switching them off in the negative one:
@media (prefers-reduced-motion: no-preference) { /* the rules that attach illo-* animations */ } There are 13 reduced-motion queries in the CSS the site serves, 10 of them no-preference and 3 of them reduce. Writing it this way round means reduced motion is the default state of the site and motion is the enhancement, which is both the accessible way round and the cheaper one: there is nothing to create and nothing to pause.
You can check this in a few seconds without any tooling. Open DevTools on the home page, open the Rendering panel, set "Emulate CSS prefers-reduced-motion" to reduce, reload, and run document.getAnimations().length.
The hero animation is not in any of these numbers
The thing a visitor is most likely to call "the animation on that site" is the gust of autumn leaves that whirls around the hero plate. It has 45 leaves at desktop width, 34 on a tablet and 22 on a narrow phone.
It appears in none of the counts above, because it is not a CSS animation and it is not a Web Animations API animation. It is 318 lines of component that writes transforms on a requestAnimationFrame loop, and getAnimations() has no idea it exists.
That is the catch worth knowing about the API. It lists animations the browser is driving. Anything you drive yourself is invisible to it, so "zero animations" never means "nothing is moving".
It does have the two gates that matter, written as plain early exits rather than as CSS:
if (!svg || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; and
frame = onScreen ? requestAnimationFrame(step) : 0; A reduced-motion visitor never starts the loop at all, and scrolling the hero out of view cancels it until an IntersectionObserver brings it back. The same two decisions as the CSS illustrations, taken twice in two different languages, which is the honest cost of having both kinds of motion in one page.
A note on what I could not measure
I wanted to put a frame rate in this post, so I ran a requestAnimationFrame loop on the home page and on a static page and compared the gaps. The numbers were nonsense: around 30 callbacks a second on the animating page and about 2 a second on the static one, with the tab visible and focused in both cases.
That is not the site. That is how a headless browser produces frames. Without anything damaging the screen there is no reason to produce a frame at all, so an idle page gets a trickle, and a page with a running animation gets whatever cadence the compositor settles on. A real display on a real machine would give you 60 of them a second on both pages.
So there is no frame rate in this post. The census is solid, because getAnimations() reports document state rather than compositor behaviour, and the moment a measurement starts describing the harness instead of the thing, it belongs in a paragraph like this one rather than in a table.
What came out of it
- Gate the hero bird. Four animations keep running after the hero has scrolled away. The component that fixes it already exists and is used eleven times on the same page.
-
playState, notlength. Anything that watches animation counts has to filter by state, or a finished bar chart will make a quiet page look busy for ever. - The 16 unused keyframes are fine. They are app motion, they are in the same stylesheet as everything else, and splitting the stylesheet to save a visitor a few hundred bytes of text they never parse into an animation would be the wrong trade.
The counts in this post are reproducible on nakodo.app as it stands. Load the home page, run document.getAnimations().length, scroll to the three-step section, run it again, and then try it on the pricing page for the number that 75 of the 76 pages give.