On a Centra fashion storefront, merchandisers can attach an optional product video to a display item in Sanity Studio: file, poster, and where it sits in the PDP gallery. Shoppers see those clips on PLP cards, search hits, and related-product rails. I wanted muted loops on listing cards without hijacking the tap that opens the lightbox.
Putting autoPlay muted playsInline on the server-rendered <video> looked finished in dev. In production the card either stayed frozen, started twice after hydration, or ignored prefers-reduced-motion: reduce. SSR and browser autoplay policy do not agree on who goes first.
A Centra display item is the commerce row; Sanity enriches it with media the sync must not wipe when Centra refreshes stock. On the PLP the video asset prepends the card stack. On the PDP it inserts at the Studio-configured gallery index. Same file, two layouts, one playback rule.
Why the JSX attribute is the wrong owner
Browsers treat autoplay as a client decision. React still renders the tag on the server, so the first paint can show a paused frame, a poster, or nothing useful. Hydration then races play() against layout and lazy loading. Reduced-motion users get motion anyway because nobody paused the element when the OS asked.
I moved playback into a tiny client island. The <video> ships without autoPlay. After mount, an effect sets muted, calls play(), and swallows the promise rejection when the tab blocks autoplay. When autoplay should stop, the effect pauses and returns.
PLP card: muted loop after mount
Listing cards get a looping product clip from Sanity. Autoplay starts in a client effect, not on the server render. Toggle reduced motion to see the compact play control that does not steal the card click.
On the PLP the video asset prepends the card media stack. On the PDP it lands at the Studio gallery index. Centra sync must keep the Sanity video object when display items refresh.
Reduced motion without killing the card click
Listing cards are one big hit target. A full-width play overlay on top would steal the click that opens the gallery lightbox. When matchMedia('(prefers-reduced-motion: reduce)') matches, I pause the loop and show a compact play chip in the corner. That button calls stopPropagation so starting the clip does not open the overlay.
Merchandisers still upload once in Studio. I do not fork the card component per breakpoint. The hook reads the media query on the client; SSR renders the paused poster until the effect runs.
Search results and related-product rails reuse the same card molecule, so fixing playback once fixed three surfaces. Algolia hits and CMS-driven carousels both mount the client island; only the data source changes.
function useMutedAutoplay(enabled: boolean) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const node = videoRef.current;
if (!node) return;
if (!enabled) {
node.pause();
return;
}
node.muted = true;
void Promise.resolve(node.play()).catch(() => {});
}, [enabled]);
return videoRef;
}enabled is false under reduced motion until the shopper taps the chip. Everyone else gets the loop after hydration without fighting the lightbox gesture.
Same asset, two gallery positions
Card and PDP share the Sanity video object. Only the sort order changes: prepend on cards, insert at galleryPosition on the product page. Centra sync jobs that rebuild display items used to drop the video block when SKUs changed. Keeping that enrichment stable is a separate post; here the lesson is playback timing.
If your cards feel dead after ship, check whether autoplay still lives in JSX. The attribute is a hint, not a contract.
Happy coding! Sander