D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Video Only Shows Its Poster Frame? The Element Sits Off-Screen at First Paint

· · 7 min read
Video Only Shows Its Poster Frame? The Element Sits Off-Screen at First Paint

The homepage carousel section was built as a pinned sticky stage 330svh tall: three slides sliding horizontally as you scroll, one of them holding a product video. That video never played. All you ever saw was a single still frame, the poster I had prepared for that element myself, so at a glance the slide looked like a photo sitting between two other photos.

The confusing part was that nothing was missing from the markup:

<video autoplay muted loop playsinline poster="slide-poster_w1280.webp">
  <source src="slide-web.mp4" type="video/mp4" />
</video>

autoplay present, muted present, loop present, playsinline present so iOS would not force fullscreen. The same autoplay muted combination is used in this theme for a full-bleed 16:9 hero background loop on another page. So the easiest suspect, one forgotten attribute, was out from the start.

This bug did not arrive alone. It surfaced together with two other bugs in the same section, in a single session on 13 May 2026, after work on another page landed. All three went in as post-ship corrections inside v1.8.0, with no version bump.

Its position, not its attributes

What separates this video from the hero video on the other page is not its markup, it is where it sits when the page is first painted.

The carousel track is 300% of the viewport wide and holds three slides, so each slide takes 100%: 300 divided by 3. The video sits at a horizontal offset of 100 to 200%, the middle third of the track. Which means that at first paint the video element is not merely below the fold, it is a full screen width to the right of the visible area. It only enters the viewport once the user scrolls and the track shifts.

What I recorded as the cause at the time: Chrome and Edge defer autoplay for off-screen videos as a battery-saving heuristic, so the autoplay attribute never fires. I wrote that down as a hypothesis, not as a measurement. There is no number and no spec reference in my notes. What I actually had was one fact about shape: the element is physically off-screen from the beginning, and its attribute never went off.

The fix: call play() yourself

If the autoplay attribute cannot be relied on to fire, stop relying on it. I added a programmatic video.play() trigger inside the carousel init:

root.querySelectorAll('video').forEach((v) => {
  v.play().catch(() => {});
  v.addEventListener('canplay', () => v.play().catch(() => {}));
});

The selector is querySelectorAll('video') on the section root, so it sweeps every video element inside the section without needing to know how many there are. Play is attempted twice: once at init, and again on the canplay event. The first attempt covers the case where enough video data is already there when init runs; the second covers the opposite case, where init finishes before the browser has enough data to start playing.

The reasoning I leaned on while writing this fix: muted videos bypass the autoplay policy when played programmatically. That is also something I noted as an assumption rather than something I proved by measuring, and the practical evidence was simply that the video started playing.

The .catch(() => {}) there is not decoration. play() returns a Promise, and if the policy still blocks, that Promise rejects. The catch deliberately swallows policy-block rejections so nothing is left dangling. The desktop-only gate in this section's init had also been removed earlier so the scroll effects run on all viewports, which means this play trigger runs everywhere too.

Second bug: the thumb that should have been a video

To the left and right of the large slide there is a strip of preview thumbs. For the video slide, those two thumbs were not video at all, they were <img> elements holding the poster frame, frame 1 of the video as a static image. The poster itself I had extracted with ffmpeg from the mp4 at 0.5s into two webp sizes, and the w720 one was what the thumbs used.

Because the element type was <img>, there was no way to animate it. This was not a video failing to play, it was genuinely a picture. Tracing it back, the original gallery component this section was ported from did have video in that position. The video was lost during the port.

The fix swapped those two poster <img> elements for real video elements, in left strip slot 2 and right strip slot 1:

<!-- before -->
<img src="slide-poster_w720.webp" alt="" />
 
<!-- after -->
<video autoplay muted loop playsinline preload="auto" poster="slide-poster_w720.webp">
  <source src="slide-web.webm" type="video/webm" />
  <source src="slide-web.mp4" type="video/mp4" />
</video>

One thing I nearly missed: the CSS rule that positions the thumb contents only targeted img. This theme already had a precedent of a global img rule reaching into media elements, since img { max-width: 100% } once collapsed the header logo down to around 34px, so I checked the selector and sure enough video was getting none of it. I extended the rule to both element types with identical declarations:

.carousel__thumb img,
.carousel__thumb video {
  position: absolute;
  inset: 0;
  object-fit: cover;
  display: block;
}

The pleasant part: the JS fix from the first bug automatically covers these two new videos, because querySelectorAll('video') picks up whatever lives inside the section root. Zero extra lines.

Three video elements, one file

After the fix, the section holds three video elements: one main video and two strip videos, all pointing at the same file.

The reasoning I leaned on when deciding that was safe: the browser HTTP cache shares the file across all the video elements, so it is still a single fetch. I never opened the Network panel to prove it, so treat that as a consideration rather than a measured result.

What I can state about the shape of it: the strip is display: none at 1023px and below, so on mobile only the main video plays and those two extra elements are never present on small viewports. The videos in this theme were also prepared in two formats, mp4 and webm, through ffmpeg compression. The 16:9 one went from 167MB down to 6.6MB for MP4 and 8.5MB for WebM, roughly 25 times smaller for the MP4, from 167 divided by 6.6.

What never got finished

Autoplaying lifestyle videos on mobile is something I flagged myself as potentially janky, possibly needing disablepictureinpicture or pause-on-blur. It stopped at the follow-up list and I never got to it.

Not because I forgot. The client changed direction, the brief became a visual replica of a reference site, and all of the theme work up to v1.8.7 was declared scrapped, including the patterns, CSS and JS carrying every fix above. The bug was real, the fix worked, and the code is no longer in the repo.

What I took away