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.

The Astro rocket logo with a pink flame and the astro wordmark in white on a black card
On this page

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.264WebM / VP9
Size, these two clips820 KB, measured~530 KB, estimated from the 35%
Hardware decode on iOSyesnone
Chroma constraint in Safarinone4:2:0 or 4:2:2 only
Version floornone in practiceiOS 15
Files to keep per clip11, 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:

Terminal window
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.

An observer pause resumes on re-entry; a reader's pause does notTwo tracks across the same three states. On the top track the observer pauses a clip that scrolled out of view without writing the userPaused flag, so scrolling back in resumes it. On the bottom track the reader presses the badge, which sets userPaused to true, so scrolling out and back leaves the clip paused. The rule beneath both reads: only the badge writes the flag, the observer reads it.Observer pauses itplayingscrolls out of viewflag untouchedpausedscrolls back inplayingresumesReader pauses itplayingscrolls out of viewuserPaused = truepausedscrolls back inpausedstays pausedOnly the badge writes the flag. The observer reads it.

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:

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.

The searched element contains the control doing the searchingAn indented tree. A figure element contains a figure-zoom element, which contains a div with class frame. querySelector runs on that frame. Inside it, two children sit side by side: a slot holding the figure's own media, and a magnifier button whose icon is an svg. The svg inside the button is marked as the element the selector matches first, so the test can never return null.figurefigure-zoom.framequerySelector runs here<slot />the figure's own media<p> → <img>button.figzoom-badgethe enlarge control<svg>matches firstSo the test can never return null.

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:

#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.

A fullscreen clip at intrinsic size, and filling the screenTwo identically scaled 1920 by 1080 screens. In the left one, labelled width auto, the clip sits in the middle at 1324 by 682 with 56.5% of the screen empty around it. In the right one, sized against the clip's own aspect ratio, the clip spans the full width at 1920 by 989 and only two thin letterbox bands remain.width: autoresolves to the file's own size1324 × 6821920 × 1080 screen56.5% emptywidth: min(100vw, 100vh × ratio)fills whichever axis binds1920 × 9891920 × 1080 screen100% of width

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:

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.

A test that passed without earning it, and one that failed without a defectTwo columns. The left, headed False pass, asserted that the hidden attribute was present on all four badges; in reality an author display grid declaration overrode it and four controls rendered, yet the run reported the component as verified. The right, headed False failure, asserted that a cloned element kept hidden equals false; in reality the guard worked and the clone had been upgraded while empty, yet the run reported the guard as broken. Beneath both: assert the outcome, and run a positive control beside it.False passWhat it assertedhasAttribute("hidden")on all four badgesWhat was truedisplay: grid wonfour controls renderedReported verifiedFalse failureWhat it asserteda clone kepthidden = falseWhat was truethe guard worked;the clone was upgraded emptyReported brokenThe fix for both: assert the outcome, and run a positive control beside it.

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

Share this article