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 seconddefine(). - Loaded once, globally, with
deferfromtheme.liquid, not from the section. The element definition exists before any section that uses it renders. - The element resolves its video with
document.querySelectoron thedata-videoselector, built from a per-slide uid ofdesktop-<index>-<section.id>ormobile-<index>-<section.id>. - Icon state is derived from the video's
play,pauseandendedevents, 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. disconnectedCallbackremoves 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 whoseconnectedCallbackanddisconnectedCallbackdo 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
- Homepage hero carousel v2 - video and split-image slides with per-slide typography control — the carousel this control lives in. That page covers the slides; this one covers the button on a video slide.
- Responsive video hero with independent desktop/mobile sources and single-source-of-truth playback controls — a different storefront's answer to the same WCAG requirement, where the control and the two-source video are one element.
- Product-card hover video and a reusable deferred-media playback refactor — video playback on a product card, where the trigger is hover rather than a button.
- Accessible link-label helpers for repeated CTAs — the same storefront's other accessibility helper, applied to repeated CTAs rather than to video.
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'svideo_tagfilter. - 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.