# Pausable looping video in an Astro blog — WCAG 2.2.2 rules out GIF, iOS rules out VP9

> A GIF cannot be paused, so WCAG 2.2.2 ruled it out. MP4 then beat the smaller VP9 on iOS hardware decode. The encode recipe, the guards, and the tests.

- Source: https://oharu121.com/blog/astro-looping-video-figure-gif-pause-wcag-vp9-ios/
- Published: 2026-09-02T21:41:49+09:00
- Tags: Astro, Web API, iOS, Accessibility

---
## Introduction

I had two screen recordings of slide decks sitting in my Downloads folder, and a
draft article that claimed in-slide animation was the decisive difference between
Marp and Reveal.js while showing nothing at all. So I asked what looked like a
formatting question: for a looping clip with a play and pause button, should the
blog use `<video>` directly, or convert to a GIF first? And were video files
something this repository should be holding in the first place?

The answer to the first half turned out not to be a preference. **A GIF cannot be
paused**, so the button I had asked for eliminated the format before any
comparison of file sizes began. The second half went the same way one level down:
MP4 beat the 35%-smaller VP9 because **iOS has no VP9 hardware decoder**, which
matters far more for a clip that loops than for one you watch once.

This article covers why an accessibility requirement decided the container, why
the standard advice about codecs is wrong for looping content, the encode recipe
and the two things Homebrew's `ffmpeg` will not do, and then the less flattering
half: three bugs that were all in guards, and two tests that reported results
they had not earned.

## Prerequisites and environment

- macOS (Apple Silicon), Node.js 26 locally
- Astro 7.2, TypeScript 6.0
- `ffmpeg` 8.0 from Homebrew
- `sharp` 0.35, already a dependency of this site
- An existing `Figure` component that frames every diagram and screenshot here,
  and opens each one in a `<dialog>` on click

## Why a GIF could not do the job at all

The pause button was the requirement, and that is what settled the format.

Browsers expose no handle on a GIF's playback. There is no `pause()`, no
`paused`, no `play` event, nothing to bind a button to. Pausing one means
swapping in a static frame or repainting it onto a canvas, which is not pausing
the animation but replacing it with a different picture. So the control I wanted
was not merely awkward in that format. It was **not implementable**.

That turns a preference into a compliance question. WCAG 2.2 Success Criterion
2.2.2 (Pause, Stop, Hide) requires that moving content which starts
automatically and runs for more than five seconds can be paused, stopped, or
hidden by the user. Both clips run longer than five seconds. A GIF therefore
fails that criterion **by construction**, not by degree, and no amount of care
in how it is placed recovers it.

The file sizes only confirmed what was already decided. A GIF carries a
256-colour palette and no real inter-frame prediction, so the same recording
lands **somewhere between 10 and 20 times larger** than an H.264 encode of it,
and looks worse doing so.

## Why MP4 beat VP9, against web.dev's own advice

web.dev's guidance on replacing animated GIFs is explicit: provide both WebM and
MP4, and **list WebM first**, because browsers do not speculate about which
source is optimal and will take the first one they support. VP9 in WebM is
roughly 35% smaller than H.264 at comparable quality. On that advice this site
should be shipping two files per clip, WebM leading.

**It ships one, and the file is MP4.**

| | MP4 / H.264 | WebM / VP9 |
| --- | --- | --- |
| Size, these two clips | 820 KB, measured | ~530 KB, estimated from the 35% |
| Hardware decode on iOS | yes | **none** |
| Chroma constraint in Safari | none | 4:2:0 or 4:2:2 only |
| Version floor | none in practice | iOS 15 |
| Files to keep per clip | 1 | 1, or 2 with a fallback |

The reason is that **iOS has no VP9 hardware decoder**. Developers testing
`VTIsHardwareDecodeSupported(kCMVideoCodecType_VP9)` on current devices get
`false` back, and WebKit implements its own WebM demuxer with software VP9
decoding behind it. For a video you press play on once, software decoding is a
battery cost you pay and forget. For a clip that **loops for as long as it is on
screen**, you pay it continuously, and these clips exist specifically to sit in
an article and repeat.

Two smaller VP9 constraints came out of the same reading. Safari decodes VP9 only
at 4:2:0 and 4:2:2 chroma subsampling, so a 4:4:4 encode plays in Firefox and
Chromium and silently fails in Safari, and that issue was still open when the
agent checked it. WebM in `<video>` also carries an iOS 15 floor, which is negligible in
2026 but is a floor H.264 does not have at all.

The agent's first argument for MP4-only was **the wrong one**, and it retracted
it a message later. It had reasoned from git weight: every revision of a binary
is stored whole with no delta, so a second format doubles what the repository
keeps forever. Then it put numbers on that claim and they did not support it.
Twenty clips is about 5 MB as H.264 against about 3.2 MB as VP9, measured against
a 33 MB `.git`. Neither figure matters. **Git weight was not the deciding factor
and should not have been offered as one**; hardware decode was.

## The encode, and the two things Homebrew's ffmpeg will not do

One recipe, applied to both clips:

```bash
ffmpeg -i raw.mov -vf scale=1324:-2 \
  -c:v libx264 -crf 28 -preset slow \
  -pix_fmt yuv420p -movflags +faststart -an \
  clip.mp4
```

`1324` is twice the reading column's 662px content box, so a retina display gets
two device pixels per CSS pixel. `-2` rounds the height to an even number, which
`libx264` requires. `-an` drops audio: these are silent, and a file with no audio
track has no autoplay policy to negotiate. `+faststart` moves the `moov` atom to
the front so the file streams instead of downloading first.

The Constant Rate Factor was measured rather than chosen. The agent encoded both
clips at 24 and at 28, extracted the same frame from each, and compared them at
1:1. On a slide showing a comparison table in small type they were
indistinguishable, and **28 was 29.6% smaller**: 156 KB against 204 KB for the
shorter clip, 664 KB against 960 KB for the longer one. Guessing at 24 would have
cost 344 KB across two files.

Then Homebrew's `ffmpeg` declined to make the poster images:

```
[vost#0:0 @ 0xc54c24300] Unknown encoder 'libwebp'
[vost#0:0 @ 0xc54c24300] Error selecting an encoder
Error opening output file .../marp-demo-poster.webp
Error opening output files: Encoder not found
```

That reads like a bad flag rather than a missing codec, which is what makes it
worth writing down. **The Homebrew build simply has no WebP encoder compiled in**,
and Homebrew removed per-formula build options years ago, so there is no
`--with-libwebp` to reach for. The fix was to extract the frame as PNG with
`ffmpeg` and convert it with `sharp`, which this site already depends on for
image optimisation.

**The poster earns its place twice over**, which is why it is a tracked file
rather than a nicety. It is what a reader sees when autoplay does not happen, and
because it is imported as `ImageMetadata` its `width` and `height` land on the
`<video>` element and reserve the layout box before a single byte of the clip has
loaded.

## Autoplay, scoped to what the reader can see

I chose autoplay-with-a-pause-button over poster-first, so the clip starts on its
own. What makes that defensible is everything bounding it.

An `IntersectionObserver` at a 25% threshold plays a clip when it enters the
viewport and pauses it when it leaves. Two clips decoding continuously through a
long article is the cost that would make an autoplaying loop hard to defend, and
this removes it: **below the fold, nothing decodes**. Paired with
`preload="none"`, nothing downloads either. The second clip on the page reported `readyState: 0`
until it was scrolled to.

The part that took actual thought is what happens when the reader presses pause
and then scrolls. A single boolean does it, and the rule is that **only the badge
ever sets it**. The observer pauses an offscreen clip without touching the flag,
so it can resume that clip later; a reader who pressed pause has asked for
something the observer is not allowed to overrule.

*Figure — PauseOutranksObserver: Both paths pause the clip and only one of them survives the round trip. The
    observer never writes the flag, which is what lets it resume a clip it paused
    itself.*

`prefers-reduced-motion: reduce` initialises that same flag to true, which makes
"this reader asked for less motion" and "this reader pressed pause" one state,
both recoverable by pressing play. And a rejected `play()` is treated as ordinary
rather than exceptional, because iPhone low-power mode refuses autoplay outright.
The badge reads its label from the `play` and `pause` events instead of from
having made the call, so a refusal leaves it saying "Play", which is the truth.

## The guard that matched its own chrome

Video needed a control the existing `Figure` component does not have, and had to
give up one it does. The enlarge dialog clones an `<svg>` or an `<img>` into a
`<dialog>`; a clip cannot be cloned that way without losing its playback
position, so a video figure carries a fullscreen badge instead of a magnifier.

The first attempt at suppressing the magnifier looked obvious. The dialog already
tested for its media, so the badge could test for the same thing:

```ts
if (!this.#frame.querySelector("svg, img")) return;
```

**It did nothing.** The magnifier came back on the video figures, and clicking it
would have opened the dialog on a **play triangle**, because the play and pause
icons in the new component *are* `<svg>` elements and that selector had found
one.

*Figure — FrameSubtree: The selector runs on the frame, and the frame contains the magnifier button.
    Any child that adds an icon of its own satisfies a test meant to describe the
    figure's media.*

The fix was to stop sniffing and let the child declare itself. A video figure
sets `data-figure-owns-controls`, and the badge script returns when it sees that
attribute. **An opt-out states intent; a media sniff guesses at it**, and the
guess is defeated by whatever markup the subtree gains next.

## The guard that could never fire

The `svg, img` test stayed in place as a second check, on the theory that it still
covered any figure wrapping something that was neither. The code review during
release found that it was **unreachable**.

The magnifier button lives inside the very element being searched, and it renders
an icon. So `querySelector("svg, img")` on that frame always matches something,
and can never return `null`. The line the agent had written to catch a case, and
the CHANGELOG entry describing it as fixed, were both describing a branch that
could not execute.

The bug it was meant to catch was also still there, and it had a different
shape than the CHANGELOG claimed.
A figure wrapping a table would have offered a badge that opened the dialog on
the magnifier glyph itself, drawn at full width. Not a control that did nothing,
which is what the CHANGELOG said. **A control that did something absurd.**

The repair is a small method that both call sites use:

```ts
#media(): SVGSVGElement | HTMLImageElement | null {
  const candidates =
    this.#frame?.querySelectorAll<SVGSVGElement | HTMLImageElement>("svg, img") ?? [];

  for (const candidate of candidates) {
    if (!this.#badge?.contains(candidate)) return candidate;
  }

  return null;
}
```

It filters in script rather than with `svg:not(.figzoom-badge svg)` for a
concrete reason: a raster in an article sits at `.frame > p > img`, because remark
wraps a lone markdown image in a paragraph. No `:scope >` form reaches it.

## Fullscreen was drawing at intrinsic size

This one surfaced while measuring something else. I had asked whether clicking
outside the video should exit fullscreen, the way the diagram dialog closes on a
backdrop click, and the agent went to measure how much "outside" there actually
was for a 1.94:1 clip.

The measurement came back with the letterbox geometry and with a number nobody
had gone looking for: on a 1920×1080 screen the clip was drawing at 1324×682, its
own stored size, leaving **56.5% of the screen empty**.

*Figure — FullscreenSizing: Against the 686px the clip already had inline, the broken control widened it
    1.9× where the fix widens it 2.8×, for a feature whose only purpose is
    reading small text.*

The cause is that `width: auto` on a `<video>` resolves to the file's intrinsic
size, and `max-width: 100%` only ever constrains it downward. Fullscreen exists
here so a reader can read 10px slide text, and 1324px of a 1920px screen is
barely more than the 686px the clip already had inline.

The fix sizes it against a ratio taken from the poster's own dimensions, so the
number cannot drift from the file:

```css
video-figure:fullscreen video {
  width: min(100vw, calc(100vh * var(--video-ratio)));
  max-width: 100vw;
  height: auto;
  max-height: 100vh;
}
```

This is deliberately the opposite of the rule the same repository applies to
rasters, where the enlarge dialog refuses to draw a screenshot past its intrinsic
width because upscaling only interpolates. The difference is what the reader is
doing. A diagram dialog is for inspecting detail, and a reader who wants more can
zoom the browser on top of it. A clip is being watched, at half size inline, with
no other route to a larger picture, so **angular size wins over per-pixel
sharpness**. Every video player ever shipped has reached the same conclusion.

There was a tempting wrong fix here too. `object-fit: contain` on a full-bleed
box looks equivalent and would have quietly removed the feature I had asked
about: the element would then cover the whole screen, the letterbox would become
part of the `<video>`, and a click outside the picture would have nowhere to
land.

## Two tests that reported the wrong answer

Both of the first two verification harnesses were wrong, in opposite directions.

*Figure — TestsThatLied: A false pass and a false failure from the same session. The first asserted a
    weaker property than the one that mattered; the second measured an object
    that had inherited its answer.*

The false pass came from checking the wrong property. With JavaScript disabled,
every control is supposed to stay hidden, and the check asserted
`hasAttribute("hidden")` on each badge. All four returned true and the run was
reported as verified. But the component's own CSS said `.badge { display: grid }`
with no `:not([hidden])` qualifier, and **an author declaration beats the user
agent stylesheet's `[hidden] { display: none }`**. The attribute was present and
being ignored. Four dead controls rendered over a poster on any page whose script
never ran, and the test had confirmed the attribute rather than the outcome. The
existing `Figure` component carries `.figzoom-badge:not([hidden])` for exactly
this reason, and its comment says so.

The false failure came next, in the test written to prove the `#media()` fix
worked. It cloned an already-hydrated element, stripped the media out, appended
it, and checked whether the badge stayed hidden. It did not, and the run reported
a broken guard. **The guard was fine.** Cloning a custom element runs its constructor
on an empty node, so the field initialisers found nothing and
`connectedCallback` returned early without changing anything, while the clone
carried `hidden = false` and `data-ready` inherited from the element it was
copied from.

What made the re-test trustworthy was **a positive control**. It builds through
`insertAdjacentHTML`, the parser path, so children exist before the element
upgrades, exactly as they do on a server-rendered page. It resets the two
attributes hydration had changed. And it runs the media-present case alongside
the media-absent one, because only the pair is evidence:

```
positive control (media present): {"badgeHidden":false,"frameReady":true,"svgsInFrame":2}
guard under test (no media):      {"badgeHidden":true,"frameReady":false,"svgsInFrame":1}
```

**Two svgs in the frame is the diagram plus the magnifier, and the badge
appears. One svg is the magnifier alone, and it does not.** **A guard test without a
positive control cannot distinguish a working guard from a broken harness**, and
in this session each failure mode happened once.

## What could not be verified, and stayed that way

**Three things resisted checking**, and saying so is cheaper than implying
otherwise.

**Escape closing fullscreen.** That binding lives in browser chrome, not in the
page, and a CDP-injected `Escape` does not reach it. Not in headless Chromium and
not in real Chrome driven through the DevTools protocol. It is standard browser
behaviour that nothing in this component implements or can break, but it was
never observed working here. A test that presses Escape and assumes it worked
also leaves the page in fullscreen, which is how one run failed later with a
confusing `subtree intercepts pointer events` against an unrelated element.

**The Vercel preview.** It sits behind deployment protection, so a headless fetch
redirects to a login page and only a signed-in browser can open it.

**The clips in production.** The article carrying them is still `status: 'draft'`,
which `astro build` skips, so a green build exercises none of this. The change
that did reach production is the shared close control on every enlarged figure,
which is the higher-risk half anyway: 86 existing figures rather than one draft.

## Summary

The container was decided by an accessibility requirement rather than by taste. A
GIF has no pause API, so WCAG 2.2 SC 2.2.2 rules it out for any clip over five
seconds, and that eliminated the format before file size entered the argument.

The codec then went against the standard recommendation. web.dev says WebM first
with an MP4 fallback, and for a clip that loops on screen the absence of VP9
hardware decoding on iOS reverses it. **The advice is right and its scope is
narrower than it looks.** A one-shot video and a looping figure are not the same
delivery problem.

The rest of the session was a lesson in where the bugs actually live. Three of
them were in code written to prevent bugs: a guard defeated by an icon it had not
anticipated, the same guard left in place after it became unreachable, and a
fullscreen rule that constrained a size it should have set. Two test harnesses
reported the wrong answer before any of that was caught, one passing without
earning it and one failing without a defect. Measuring the encode saved 344 KB;
measuring the guards is what turned three plausible fixes into three real ones.

## References

- [Replace animated GIFs with video for faster page loads, which is where the both-formats-with-WebM-first recommendation comes from](https://web.dev/articles/replace-gifs-with-videos)
- [Going beyond images with basic video for the web](https://web.dev/articles/video-basics)
- [WCAG 2.2 Success Criterion 2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html)
- [Apple Developer Forums thread where VTIsHardwareDecodeSupported returns false for VP9 on iOS](https://developer.apple.com/forums/thread/664770)
- [caniuse issue 7541, still open, on Safari failing to decode VP9 at 4:4:4 chroma](https://github.com/Fyrd/caniuse/issues/7541)
- [WebM video format support table, including the iOS 15 floor](https://caniuse.com/webm)
