Carousel (Video)
Same carousel mechanics plus video orchestration: auto-advance on clip end, deferred cleanup to avoid compositor black frames, lazy preload.
Live Demo
The currently-playing clip auto-advances to the next on ended. Off-screen videos have no src, so nothing is downloaded until it's needed.
Source
HTML + JavaScript
<div data-component="video-carousel" data-cloak>
<button data-action="prev" data-bind-attr="{ disabled: !canPrev }">‹</button>
<div class="viewport" data-bind-style="{ width: viewportWidth }">
<div class="track" data-bind-style="{ transform: trackTransform }" data-list="enrichedItems">
<template>
<div class="slide">
<video muted playsinline
data-bind-attr="{ src: loaded ? src : '', preload: loaded ? 'auto' : 'none' }"></video>
<div data-bind="label"></div>
</div>
</template>
</div>
</div>
<button data-action="next" data-bind-attr="{ disabled: !canNext }">›</button>
</div>
<script>
wildflower.component('video-carousel', {
state: {
offset: 0,
current: 0,
visibleCount: 2,
slideWidth: 200,
gap: 12,
items: [
{ src: '/videos/a.mp4', label: 'A' },
{ src: '/videos/b.mp4', label: 'B' },
{ src: '/videos/c.mp4', label: 'C' },
{ src: '/videos/d.mp4', label: 'D' }
]
},
computed: {
maxOffset() { return Math.max(0, this.items.length - this.visibleCount); },
canPrev() { return this.offset > 0; },
canNext() { return this.offset < this.maxOffset; },
viewportWidth() {
const n = this.visibleCount;
return (this.slideWidth * n + this.gap * (n - 1)) + 'px';
},
trackTransform() {
return 'translateX(-' + (this.offset * (this.slideWidth + this.gap)) + 'px)';
},
enrichedItems() {
const start = this.offset - 1;
const end = this.offset + this.visibleCount;
return this.items.map((item, i) => ({
...item,
loaded: i >= start && i <= end
}));
}
},
init() {
// Bind the 'ended' event on any video rendered by the list.
// Delegated handler keeps it simple as the list re-renders.
this.$el.addEventListener('ended', (e) => {
if (e.target.tagName === 'VIDEO') this._advance();
}, true);
this._playAt(0);
},
prev() {
if (!this.canPrev) return;
this.offset--;
this._syncPlaying();
},
next() {
if (!this.canNext) return;
this.offset++;
this._syncPlaying();
},
_advance() {
const nextIdx = (this.current + 1) % this.items.length;
this.current = nextIdx;
// Scroll viewport so the playing clip stays visible
if (nextIdx < this.offset) this.offset = nextIdx;
else if (nextIdx >= this.offset + this.visibleCount) {
this.offset = nextIdx - this.visibleCount + 1;
}
// Defer play until after the CSS slide animation completes, so the
// browser doesn't drop decoded frames mid-transform (causes black flash)
setTimeout(() => this._playAt(nextIdx), 400);
},
_syncPlaying() {
// When user arrows a playing clip off-screen, jump to first visible
if (this.current < this.offset || this.current >= this.offset + this.visibleCount) {
setTimeout(() => { this.current = this.offset; this._playAt(this.offset); }, 400);
}
},
_playAt(idx) {
const videos = this.$el.querySelectorAll('video');
videos.forEach(v => { try { v.pause(); } catch (e) {} });
const target = videos[idx];
if (target && target.src) {
try { target.currentTime = 0; target.play(); } catch (e) {}
}
}
});
</script>
Key Points
- The same
enrichedItemslazy-load pattern as the image carousel: off-screen videos get nosrc, so zero bytes download until they're needed preloadis bound reactively too:'auto'when the slide is in the loaded window,'none'when it isn't. The browser aggressively discards data for videos set to'none'- A delegated
endedlistener on the component root handles rotation; it survives list re-renders without manual per-video wiring setTimeout(..., 400)after a scroll defers the nextplay()until the CSS slide transition finishes. Without this, the browser's compositor can drop decoded frames mid-transform and the video flashes black- If the user arrows the playing clip off-screen, rotation jumps to the first visible slide after the animation
- Touching
currentTime = 0beforeplay()ensures each clip starts from frame 0 on its turn, even on repeat