Build an accessible paused-GIF using an Custom Element

A looping GIF is a practical running joke in accessibility (a11y). It moves whether you want it to or not, you can’t stop it, and screen readers have nothing to grab onto. I built a small custom element that fixes all three at once.

The whole thing is paused-gif. Drop it in your markup and it renders a still frame with a button to play the animation:

<paused-gif src="/celebration.gif" alt="A confetti cannon going off at a launch party"></paused-gif>

That’s it. Let me walk through why each accessibility decision in there matters, because that’s the interesting bit.

Pause by default

The “poster frame” (the initial image) is captured the moment the element mounts, so nothing animates until someone clicks “play”. This is the most important a11y win.

Why does it matter? Animated content triggers vertigo and balance disorders for a real chunk of people. WCAG 2.3.3 says anything that moves for more than five seconds needs a pause control. By showing a still image first, I’m compliant before the user even clicks. I also grab the first frame onto a canvas so there’s no flash of a half-loaded GIF:

canvas.getContext("2d").drawImage(frame, 0, 0);

this.#posterUrl = canvas.toDataURL("image/png");

If the canvas read fails (cross-origin issue, mostly), I fall back to the source URL. The poster still shows, the animation stays paused.

A real button, not a div with a click handler (yes, I have seen this)

The toggle is a <button type="button">. This matters more than it looks:

this.#button = document.createElement("button");
this.#button.type = "button";

this.#button.addEventListener("click", () => this.toggle());

Setting type="button" stops it from accidentally submitting a form it happens to live inside. Because it’s a native button, keyboard users get Tab, Enter and Space for free. If you use any other element (e.g. <div>), you’ve just created a problem that already has a solution.

State that screen readers can hear

A play/pause button is a toggle, so it needs aria-pressed. The render method keeps it in sync with the actual state:

this.#button.setAttribute("aria-pressed", String(this.#playing));
this.#button.setAttribute("aria-label", `${this.#playing ? "Pause" : "Play"} animation: ${this.alt}`);

this.#button.textContent = this.#playing ? "Pause" : "Play";

Two things are happening. aria-pressed tells assistive tech this is a toggle and whether it’s on. The dynamic aria-label does something cool: it bakes the image’s alt text right into the control. A screen reader user hears “Play animation: a confetti cannon going off at a launch party” instead of a bare “Play”. The visible text says “Play” or “Pause” for sighted users, while the label carries the context.

And the alt isn’t an afterthought. It flows straight through to the underlying <img>:

this.#img.alt = this.alt;

If you forget the alt attribute, it becomes an empty string, which is the correct signal that the image is decorative (the button label already describes it).

decoding = "async" on the image keeps the poster from blocking the main thread. And because src, poster, alt and button-class are all observed attributes, the element re-renders correctly when any of them change after mount.


I keep coming back to web components/elements: the platform already solved accessibility for buttons, images and toggles. Build the control out of native elements, mirror state into ARIA and pause the motion by default. Everything else is detail.

As always, the code can be found on GitHub. If you ship it somewhere, I’d love to see how you theme the button. ❤️

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Want to read me more?