DEV Community

Rails Designer
Rails Designer

Posted on Originally published at railsdesigner.com on

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>

Enter fullscreen mode Exit fullscreen mode

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");

Enter fullscreen mode Exit fullscreen mode

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());

Enter fullscreen mode Exit fullscreen mode

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";

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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. ❤️

Top comments (0)