Shopify Builds>LUS Brands>Product-card hover video and a reusable deferred-media playback refactor

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.

A Shopify file-reference metafield can hold an image or a video, and nothing in the card decides which until render. This card reads custom.feature_hover_img, emits a muted, looping, preload="none" video when the file is one, and plays it only on pointer hover, on wide screens, when motion is allowed — alongside a refactor of Dawn's deferred media that gives hosted video, YouTube and Vimeo one playback path.

The Problem

A Shopify product has a media list, and a product card shows the first item of it. There's no "hover" slot in the platform: a card that reveals a second asset when the cursor arrives has to decide for itself what that asset is. On LUS Brands the card already showed a second image on hover. For some products — a texture, an application, curl movement — the right second asset is a few seconds of video, not a still. And which products get a clip has to be a merchandiser's choice, made per product in the admin, not a rule in code.

The Constraint

Autoplaying a video on every card of a sixteen-card grid would start sixteen downloads no shopper asked for, and it would be meaningless on a phone, where there is no hover. So playback had to be strictly opt-in on pointer entry, restricted to wide screens, and switched off entirely for anyone who has asked their browser for reduced motion.

The video also had to carry preload="none", so a grid of clips doesn't fetch a dozen files up front — which means play() has to tolerate a video element that hasn't loaded anything yet, and a hover that arrives before the browser has metadata.

Two things about the theme made it harder. The card is Dawn's card-product.liquid, shared by the collection grid, the search results and the home page, so anything added to it ships everywhere at once. And collection grids are re-rendered through AJAX — filtering, quick-add, theme-editor section reloads — so any binder attached to a card has to be safe to run again after the markup changes.

What We Built

The card. card-product.liquid reads product.metafields.custom.feature_hover_img. The metafield is a file reference, which is polymorphic: it resolves to an image or to a video, and the snippet branches on media_type == 'video'. On the video branch it emits a muted, looping, playsinline, preload="none" <video> with one <source> per encoded source, marked aria-hidden and carrying a motion-reduce class so it reads as decorative to assistive technology. On the image branch, or when the metafield is empty, the card falls back to product.media[1] as a still hover layer — the behavior it had before.

The binder. An IIFE in global.js finds every .card-wrapper containing a video.card-product__hover-video and binds mouseenter and mouseleave on the wrapper, not the video, so the hover target is the whole card. mouseenter plays, guarded by matchMedia('(min-width: 990px)') and by prefers-reduced-motion; mouseleave pauses and resets currentTime to zero, so a re-hover restarts the clip from its first frame. The binder is exposed as window.initCardHoverVideos(root) and re-runs on DOMContentLoaded and on shopify:section:load, marking each wrapper it has handled so a re-run after a filter or a section reload leaves already-bound cards alone.

The refactor. Separately, Dawn's DeferredMedia element was reworked so that loadContent() delegates to a new playLoadedMedia(focus). That function handles a <video>, an iframe.js-youtube and an iframe.js-vimeo through one path: it forces muted when autoplay is set without controls, swallows play() rejections, issues YouTube and Vimeo play commands through postMessage inside a requestAnimationFrame so the iframe has a contentWindow, and dispatches a bubbling deferred-media:loaded custom event when a player is ready. That event is what lets other components on the page — the product gallery's video controls, for one — react to a player mounting without knowing which element it came from.

Why This Way

Binding on the wrapper rather than the <video> is a small decision with a large effect on feel: the clip starts when the cursor reaches the card, not when it happens to cross the media area. The per-wrapper flag is what makes the binder safe to call again after any AJAX render, and exposing it on window gives the theme's other scripts a re-init hook without an import.

The metafield is the source of truth, and its polymorphism is a feature. A merchandiser uploads a video into the same field they'd upload a still into, and the card does the right thing. No second field, no "is this a video" checkbox to keep in step.

In exchange, the card now has two render branches to maintain, and the playback logic lives in the theme's global script rather than next to the card that uses it. The clip is also a desktop-only enrichment by design: a shopper on a phone gets the still, and the video is never requested for them. That's the right trade for a grid, and it means the video is never the only place a product's story is told.

Why Not an App

"Video on product card" apps sell exactly this, and their mechanism is to keep the mapping in the app and inject a player into the grid after paint. Here the source of truth is a native product metafield, editable in the admin beside the product it describes, and the video is a Shopify-hosted file. No third-party script runs on the collection page, and there's no mapping to sync.

Implementation Notes

  • custom.feature_hover_img is polymorphic — a file reference that may be an image or a video — and the Liquid branches on the resolved value's media_type.
  • The hover video is aria-hidden and carries motion-reduce, so it's decorative to a screen reader and honors the motion preference at the CSS layer as well as in the script.
  • deferred-media:loaded bubbles, so a section can react to a player mounting without knowing which card or gallery slide it came from.
  • The refactor fixed a latent Dawn bug: the former code touched the deferred element's style attribute without a null check when the template contained no video or iframe.
  • YouTube and Vimeo playback is issued via postMessage inside a requestAnimationFrame, so the iframe has a contentWindow by the time the command is sent.
  • playLoadedMedia forces muted when autoplay is set without controls, so browser autoplay policy is satisfied by construction rather than by hoping.

Edge Cases

  • Touch and small screens never play: the min-width: 990px gate fails before play() is called.
  • prefers-reduced-motion: reduce never plays, in the script and in the CSS.
  • currentTime is reset on mouseleave, so a re-hover restarts the clip rather than resuming mid-motion.
  • play() rejections are caught, so a video that hasn't loaded yet when the cursor arrives produces a still rather than a console error.
  • A product without the metafield falls back to product.media[1] as a still hover image, the pre-existing behavior.
  • The binder marks each wrapper it has handled, so re-running it after a filter or a section reload skips the cards it has already seen.

Platform Primitives Used

  • metafieldscustom.feature_hover_img, a file reference whose resolved media_type decides whether the card's hover layer is a still or a clip.
  • custom-elements — Dawn's DeferredMedia is a custom element; the refactor changes what its loadContent() does, not how it's registered.
  • deferred-media — Dawn's poster-then-player contract, extended with a uniform playback function and a deferred-media:loaded event.

Integrations in Play

  • YouTube — the refactor's playback path drives a YouTube iframe through postMessage, the same way it drives a hosted video through play().
  • Vimeo — handled identically; the iframe.js-vimeo selector routes it through the same function.

Where It Runs

On the collection listing through the standard collection template, where the shared card renders the grid. Because the card is Dawn's shared card-product.liquid, the same hover branch is available on the home page and on search wherever that card is used. The deferred-media refactor is theme-wide: any section that uses Dawn's deferred-media element gets the new playback path.

What This Demonstrates

  • Video and shoppable media modules — the primary capability. Self-hosted video, deferred loading, out-of-hover pausing and a motion preference honored — at card scale, and one playback function for every player kind.
  • Metafield-driven PDP content blocks — the storage decision applied to merchandising media: which products get a clip is a field on the product, not a rule in the theme.

How We Know

The relevant change is roughly 130 lines across global.js and card-product.liquid, read from the theme, plus a metafield export that lists custom.feature_hover_img as a field new to the 2026 redesign, and two client working-session records from spring 2026. The records establish the requirement: card code was to support every media type — images, motion graphics, video — and the placement of animation and video was to be decided page-wide rather than section by section, so playback here is deferred rather than preloaded. The binder's gates and the deferred-media refactor are read from the code.

Related Builds

The Buy-vs-Build Question

A card-video app buys a mapping held in the app and a player injected after paint. Building it bought a metafield the merchandiser already edits, a Shopify-hosted file and a card that requests nothing until hovered — and cost a second render branch and a binder that lives in the global script. Where a bought player is worth it, and where the theme's own <video> is enough, is the question on Shoppable video and media: buy or build?

Provenance & Evidence

  • Client: LUS Brands — loveurcurls.com
  • Surfaces: Collection listing, home page, search
  • Templates served: one — the collection template, through the shared card
  • Complexity: Medium
  • Attribution: Deploi-authored. The metafield branch in the card, the hover binder and the deferred-media playback refactor are ours. They run inside a theme built on Dawn 15.4.1 and modify Dawn's own card-product.liquid and DeferredMedia element.
  • Status: Live, verified 2026-09-06
  • Evidence: Two theme files, a metafield export and two client working-session records from spring 2026
  • Confidence: Strong — the export documents the metafield as new and the records document the media requirement; the binder and the refactor are documented from the code
  • Primary capability: Video and shoppable media modules

Ready to Show the Product Moving Before the Click?

You dream it. We build it. If your product grid could say more with three seconds of motion than with a second photo, Contact us today and we'll show you how to do it without a script on every listing page.

More builds