Shopify Builds>Nudestix>Accessible play/pause web component for autoplaying hero video

Accessible Play/Pause Web Component for Autoplaying Hero Video

A <play-pause> custom element that gives a muted, autoplaying hero video a real keyboard-reachable control — self-wiring from a selector in its own markup, so Swiper can clone and reorder slides without any script knowing how many there are.

An autoplaying, looping hero video with no way to stop it fails WCAG 2.2.2, and a control bound to a slide by index breaks the moment a carousel clones or reorders slides. This <play-pause> custom element reads a CSS selector from its own data-video attribute, resolves its video when it connects, mirrors the video's real play, pause and ended events, and unbinds everything on disconnect.

The Problem

Shopify's video_tag filter renders a <video> element with the attributes you pass it — autoplay, muted, loop — and nothing else. It does not render a control. A hero that autoplays motion with no way to stop it fails WCAG 2.2.2, which requires a mechanism to pause, stop or hide any moving content that starts automatically and lasts more than five seconds. A muted loop is exactly that content.

The homepage hero on this storefront autoplays muted, looping video inside a carousel. The obvious fix — a button per slide, wired up at page load — had already been tried and had broken in practice: a control instantiated per slide against fixed selectors is correct for the number of slides it was written for and wrong for every other number. Add a slide in the theme editor and the control on the new slide drives the wrong video, or nothing.

The Constraint

The videos live inside a Swiper carousel, and Swiper does not leave slide markup alone. In loop mode it clones the first and last slides so the track can wrap, and it reorders nodes as the shopper swipes. A control bound by index at page load is bound to a DOM that no longer exists a moment later.

The theme editor adds a second source of churn. When a merchandiser edits the hero, Shopify re-renders the section and swaps its markup in place. Anything bound to the old nodes is orphaned, and anything that registered listeners on them leaks. The control could not assume it would be created once, could not assume its video was a sibling, and could not assume it would survive to page unload.

The videos are Shopify-hosted and rendered by video_tag, so the <video> element is Shopify's, and the control had to work with whatever it emitted.

What We Built

header-video.js defines a PlayPauseButton class and registers it as the play-pause custom element. It is loaded once, globally, with defer from theme.liquid, not per section.

Self-wiring. The element carries a data-video attribute containing a CSS selector. In its connection lifecycle it resolves that selector to a <video> element, binds a click handler that toggles play() and pause(), and starts listening to the video. The relationship between button and video is declared in the markup, not computed in the script, which is why the script never needs to know how many slides exist or where they are.

Honest state. The icon does not track a toggled class. It tracks the video. The element listens for play, pause and ended on the video and swaps .icon-play and .icon-pause visibility in response. If the browser refuses autoplay, if the carousel pauses a slide that scrolled away, if the loop ends — the button shows what the video is actually doing, because the video told it.

Clean teardown. disconnectedCallback removes every listener the element added, so a theme-editor re-render that discards the old section markup takes the old handlers with it. The customElements.define() call is guarded by customElements.get('play-pause'), so a duplicate script tag — or a second load of the asset — is harmless rather than a thrown NotSupportedError.

Per-slide markup. video-play-pause-button.liquid emits the element with a uid built from the slide's index and the section id — desktop-<index>-<section.id> or mobile-<index>-<section.id> — as both the video's identifier and the button's data-video target. The desktop and mobile carousels documented on the homepage hero carousel page each render their own slides, and each slide's button points at its own video.

Why This Way

A custom element with a selector attribute is self-wiring. The markup declares the relationship, the browser calls the lifecycle when the element enters the document, and slides can be added, removed or cloned by Swiper without any JavaScript knowing how many there are. That is the property the previous approach lacked, and the reason this is an element rather than a function.

Driving the icon from the video's own events, rather than from a class the click handler toggles, keeps the button honest when playback is stopped by something other than the button — the browser, the carousel, the end of the file.

There is a trade, and it is in the selector. Because the element resolves data-video against the whole document, the uid on each side has to agree exactly, and that agreement is a Liquid convention rather than something the element can verify. A slide rendered with a mismatched uid gets a button that resolves nothing and quietly does nothing. We accepted that because the alternative — walking up to a shared ancestor and searching inside it — couples the element to the carousel's DOM structure, which is the coupling the whole build exists to avoid.

Implementation Notes

  • Registered lazily behind if (!customElements.get('play-pause')), so a duplicate script tag cannot throw on a second define().
  • Loaded once, globally, with defer from theme.liquid, not from the section. The element definition exists before any section that uses it renders.
  • The element resolves its video with document.querySelector on the data-video selector, built from a per-slide uid of desktop-<index>-<section.id> or mobile-<index>-<section.id>.
  • Icon state is derived from the video's play, pause and ended events, never from the click, so it stays correct when playback is interrupted externally.
  • Click calls stopPropagation() so the control does not trigger the slide's wrapping anchor. Pausing a video is not the same as tapping the slide.
  • disconnectedCallback removes every listener the element added, so theme-editor re-renders and carousel teardown do not leak handlers.

Edge Cases

  • No data-video, or a selector that resolves nothing: the click handler returns early instead of throwing. The button is inert, and the page is unaffected.
  • Missing icon elements are tolerated by the icon-update routine. A slide rendered without one of the two icons still toggles playback; it just does not swap a glyph it cannot find.
  • The click is stopped from propagating, so a video slide whose whole surface is a link does not navigate when the shopper pauses it.
  • Listeners are removed on disconnect. A merchandiser editing the hero in the theme editor can re-render the section repeatedly without accumulating handlers on videos that no longer exist.

Platform Primitives Used

  • custom-elements — the control is a registered custom element whose connectedCallback and disconnectedCallback do the wiring and the teardown.
  • liquid-schema-settings — the video, its poster and whether a slide is a video at all are block settings of the hero section; the button snippet renders from them.
  • liquid-snippets — the button is emitted by a snippet that takes the slide's index and section id and builds the matching uid.

Integrations in Play

  • Swiper — the carousel the hero runs on. It clones and reorders slides; the element is designed so that does not matter.

Where It Runs

The home page, on the index template, where the hero carousel renders video slides. The element definition is global — loaded from the theme layout on every page — so any future section that renders a <play-pause> with a data-video gets the same control without loading anything new.

What This Demonstrates

  • Video and shoppable media modules — the primary capability: accessible play/pause on autoplaying video, with state driven by the video rather than the control.
  • Art-directed hero and banner system — the control that makes a video hero permissible under WCAG without giving up autoplay.
  • Accessibility remediation — a keyboard-reachable button for motion that starts automatically, which is the specific control WCAG asks for.

How We Know

One JavaScript asset from the theme, roughly 100 lines, plus three client working records: a specification document and two task-register entries. The specification for the homepage hero states that a video background should have a pause and play button; the task register logs the homepage video banner and a follow-up fix to its audio behavior. The custom-element design is not described in those documents. The requirement is the client's; the shape of the control is a code-side decision, read from the asset.

Related Builds

The Buy-vs-Build Question

There is no app for this. A video app brings its own player and its own controls, and this hero uses Shopify's own video_tag. What was needed was a control, not a player. Where a player is the right buy — and where the theme's own <video> is enough — is the question on the shoppable video and media decision page.

Provenance & Evidence

  • Client: Nudestix — nudestix.com
  • Surfaces: Home page; the element definition is loaded globally
  • Templates served: one — index
  • Complexity: Low
  • Attribution: Deploi-authored. The element, its snippet and the uid convention are ours. It runs in a theme built on Dawn 6.0.2 and controls a <video> rendered by Shopify's video_tag filter.
  • Status: Live, verified 2026-09-06
  • Evidence: One JavaScript asset from the theme, plus a specification document and two task-register entries from client working sessions
  • Confidence: Strong — the client record names the requirement; the code shows how it was met
  • Primary capability: Video and shoppable media modules

Ready for Motion That Every Shopper Can Stop?

You dream it. We build it. If your hero autoplays and nothing on the page can stop it, Contact us today and we'll add the control without giving up the video.

More builds

Homepage hero carousel v2 - video and split-image slides with per-slide typography control

The live homepage hero: two independent carousels over one authored slide list, video slides and split-image slides, typography controlled per slide, and promotion events fired from the slide's own copy.

Nudestix

Responsive video hero with independent desktop/mobile sources and single-source-of-truth playback controls

A `<video-hero>` custom element that holds a 16:9 cut and a 9:16 cut, promotes only the active breakpoint's sources to `src`, and wires one play/pause and one mute button to whichever video is live — with server-rendered control state so the buttons read correctly before the script runs.

LUS Brands

Product-card hover video and a reusable deferred-media playback refactor

The shared product card's hover slot can hold a short clip instead of a second still: a per-product metafield decides which, the card emits a video that fetches nothing until asked, and a pointer-only, motion-aware binder plays it on hover of the whole card — plus a refactor of Dawn's deferred-media element that handles every player kind through one function.

LUS Brands

Accessible link-label helpers for repeated CTAs

Two Liquid snippets that resolve a real conflict between two link-naming rules — identical link text on different destinations versus an accessible name that must contain the visible label — by deriving a name from the link's href and emitting it only when it differs from what the shopper can see.

Nudestix