Shopify Builds>LUS Brands>Responsive video hero with independent desktop/mobile sources and single-source-of-truth playback controls

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.

HTML has no picture element for video, so a hero with a landscape cut and a portrait cut needs two video elements, and both stay in the DOM. This custom element promotes sources only for the breakpoint that matches, runs one play/pause and one mute control against the active video, and pauses the other when the viewport crosses over — so the hidden cut never buffers and never leaks audio.

The Problem

Dawn ships no video hero. Its image banner takes images only, and its deferred-media pattern is click-to-play — a poster with a button, which is the right default for a product video and the opposite of what a hero is. A hero plays.

The kids landing page needed a full-bleed autoplaying video hero where the desktop cut and the mobile cut are genuinely different files: a 16:9 landscape edit for a wide screen, a 9:16 portrait edit for a phone, not one file letterboxed into both. It needed an editable heading, description and button overlaid on top, with type scale and spacing the merchandising team could set separately for each breakpoint. None of that is a settings change on a section Dawn already has.

The Constraint

Art-directing an image is a solved problem: <picture> with a <source> per breakpoint, and the browser fetches one. Video has no equivalent. The only way to serve a different cut per breakpoint is two <video> elements, and both stay in the DOM at all times. Whichever one CSS hides will still buffer if it has a src, and once a shopper unmutes it will still play audio from behind a display: none. A rotation from portrait to landscape swaps which one is visible without the browser telling either video anything.

Autoplay has its own rules — muted only, subject to browser policy — and the section had to respect prefers-reduced-motion without a second set of controls.

And because the heading, description and button are theme-editor blocks with per-breakpoint type settings, the responsive typography couldn't be hard-coded in the stylesheet. It had to be emitted at render time as CSS custom properties scoped to each block's id.

What We Built

video-hero.liquid renders a <video-hero> element containing up to two <video> tags. The modifier classes video-hero__video--desktop and video-hero__video--mobile are added only when both videos are configured; a single-video setup carries neither and stays active at every width. Each video's sources are emitted as <source data-src> rather than <source src>, so nothing is requested until the script decides which set is live.

One active set. video-hero.js resolves the active set through matchMedia('(min-width: 750px)'), promotes that set's data-src to src, and loads it. The play/pause button and the mute button operate on the active set only. A change listener on the same media query pauses the now-inactive video and resumes the newly active one if something was playing — so a rotation or a resize doesn't leave audio running behind a hidden element.

Server-rendered control state. The is-playing / is-paused class and the button's aria-label and aria-pressed are rendered in Liquid from the autoplay setting, so the control shows the right state before the deferred script executes. The component corrects it at runtime: the reduced-motion branch pauses the video and flips the state to match. The autoplay setting reaches the script through a data-autoplay attribute rather than Liquid inlined in a <script>, which keeps the JavaScript a static, cacheable, minifiable asset.

Covers and copy. Cover images are captured in a single Liquid block that falls back through the desktop cover, the mobile cover and each video's own preview_image. The heading, description and button blocks each emit their own custom properties — --font-size, --line-height, --vh-h-mt, --rt-btn-* — for desktop and mobile. Heading highlight uses the [[word]] token convention, parsed by the shared highlight-text snippet documented on the marketing banner page.

<video-hero> · BOTH VIDEOS ALWAYS IN THE DOM Desktop <video> · 16:9 <source data-src> promoted to src only if active Mobile <video> · 9:16 <source data-src> never fetched while inactive One play/pause · one mute state rendered in Liquid, corrected at runtime matchMedia('(min-width: 750px)') resolves the active set change listener: pause inactive, resume active matches: promote + play no match: leave data-src, pause operates on the active set not bound to the inactive set on rotation the roles swap — the listener re-runs
Both videos are in the document all the time; the media query decides which one is real. Only the active set has its sources promoted, only the active set is bound to the controls, and a breakpoint change pauses the other one before it can be heard.

Why This Way

Rendering the control state in Liquid, from the autoplay setting, is what makes the hero honest before JavaScript arrives. The button says "pause" on an autoplaying hero and "play" on a paused one in the initial HTML; the script only has to correct that if the browser or the shopper's motion preference disagrees. Carrying the setting across on data-autoplay instead of inlining Liquid inside a <script> block keeps the script an ordinary asset — built once, cached across pages, minified by the theme's build step.

Holding sources in data-src and promoting them in the script is the decision the rest of the design depends on. It means the inactive cut is never fetched, and that's what makes two full-bleed video files affordable on a landing page.

What you accept in return is a component that only works when its script runs: until it does, no source has been promoted and the hero shows its cover image. The rotation code path — pause one, resume the other — exists for a case a phone in a shopper's hand exercises and a desktop almost never does, so it's a branch that's easy to break without noticing. And there are two video files to produce and keep in step for every campaign, which is a content cost as much as an engineering one.

Why Not an App

Video hero apps exist, and they bring a render-blocking third-party script plus their own editor surface to do what is, here, one section and under a hundred lines of JavaScript. The bigger difference is where the video lives. This section's video is a native Shopify file — a setting of type video — served from Shopify's own CDN with no external player and no extra request chain. An app's video lives with the app.

Implementation Notes

  • The custom element is registered defensively — if (!customElements.get('video-hero')) before define — so a duplicated section or a theme-editor re-render can't throw.
  • <source> elements carry data-src and are promoted on load, so the inactive breakpoint's file is never requested.
  • Section height is merchandiser-controlled at both breakpoints through a select (custom, full, full-minus-header, 16-9, 9-16) plus a numeric custom value, emitted as min-height in a scoped {% style %} block.
  • Play, pause, sound and muted icons are inlined with inline_asset_content (icon-video-play.svg, icon-video-pause.svg, icon-video-sound.svg, icon-video-muted.svg) rather than fetched as separate assets.
  • The schema uses Shopify's visible_if conditional settings to hide the numeric height input unless the height type is custom.
  • Blocks are heading, description and button; each carries its own font size, line height and block-level padding for desktop and for mobile.
  • video-hero.min.js is generated by the theme's esbuild script, build-theme-assets.mjs, not hand-minified.

Edge Cases

  • prefers-reduced-motion: reduce pauses the video even when autoplay is on, and syncs the button state to match.
  • video.play() rejections are swallowed, so a browser that blocks autoplay produces a paused hero rather than a console error.
  • Crossing the 750px breakpoint pauses the now-inactive video and resumes the active one only if something was already playing — a paused hero stays paused after a rotation.
  • No video configured at all falls back to the cover images, and then to a placeholder_svg_tag, so an unconfigured section still lays out.
  • The mute toggle reads its state from the first video and applies it to every video, so the two breakpoints can't drift into one muted and one not.
  • aria-pressed and aria-label are maintained on both control buttons through every state change.

Platform Primitives Used

  • liquid-schema-settings — two video settings, two cover images, the height select and visible_if conditionals are all section schema; the control state is rendered from the autoplay setting.
  • theme-blocks — heading, description and button are blocks, each publishing its own per-breakpoint custom properties scoped to its block id.
  • custom-elements<video-hero> owns source promotion, the media-query listener and the controls in its lifecycle callbacks.

Where It Runs

On the landing page surface, mounted by one template: the kids landing page, which is built entirely from sections rather than from a page's body content. The section is registered against the home page surface as well, since nothing in it is specific to the template that mounts it today.

What This Demonstrates

  • Video and shoppable media modules — the primary capability. Self-hosted video with deferred source loading, accessible controls and a motion preference honored without a second code path.
  • Art-directed hero and banner system — genuinely separate desktop and mobile artwork, here for video, where the platform gives you no <picture> to lean on.

How We Know

One section file and one JavaScript asset with its compiled output, roughly 1,000 lines together, plus four client working-session records and a scoping document from April and May 2026. The records establish the requirement rather than the mechanism: a hero that could hold video, with a play button and a sound button, was called for before it was mocked up, and separate mobile and desktop assets were a recorded decision. The records also show that an embedded-player approach was kept open at launch for performance reasons while the section had to support hosted media — which is part of why the sources here are deferred. The two-element design and the media-query switch are read from the code.

Related Builds

The Buy-vs-Build Question

A video hero app buys a hero with its own player and its own editor, and puts a third-party script in front of your landing page's largest element. Building it bought a native Shopify video, served from Shopify's CDN, with controls that read correctly before any script runs — and cost two files per campaign and a component that depends on its script for the switch. Where a player is the right buy is the subject of Shoppable video and media: buy or build?

Provenance & Evidence

  • Client: LUS Brands — loveurcurls.com
  • Surfaces: Landing page, home page
  • Templates served: one — the kids landing page template
  • Complexity: Medium
  • Attribution: Deploi-authored. The section, its block schema and the <video-hero> element are ours. They run in a theme built on Dawn 15.4.1, which ships no video hero of its own.
  • Status: Live, verified 2026-09-06
  • Evidence: One Liquid section and one JavaScript asset with its compiled build, plus four client working-session records and a scoping document from April and May 2026
  • Confidence: Strong — the code and the client record describe the same hero
  • Primary capability: Video and shoppable media modules

Ready for a Video Hero That Plays the Right Cut?

You dream it. We build it. If your landing page hero is one video letterboxed into two shapes, Contact us today and we'll show you what a portrait cut and a landscape cut look like when the browser only ever fetches one.

More builds