If a picture is worth a thousand words, and a video is made of several pictures in a sequence, then a video is worth several thousand words.
A great way to show a preview of online content is by using video thumbnails – an ambiguous term that can both refer to a static image, usually depicting one frame of the video (also known as poster image), or, more interestingly, a video preview that’s a smaller and shorter version of the referenced content.
If you arrived to this page through the homepage of my website, you might have noticed that every post on it features an animated thumbnail. It was a natural choice since most of my work on display is some form of animated media, or at least accompanied by video assets.
For a long time my thumbnails have been simple animated GIFs – one of those ancient formats that managed to maintain popularity for longer than expected – but there now is a good selection of modern, more efficient formats to display motion content on the web.
WebM for the win
I researched a few options: animated PNG (APNG), WebM, animated WebP, MP4. After evaluating the pros and cons of each container, I finally settled for WebM
because of the smallest file size (for a quality that’s visually similar to other solutions tested), a wider browser support, and because – I’m betting my money on it – it’s a future-proof file format. (I’m joking. I never bet any money.)
WebM is an open-source container format, while the actual video encoding format I am using is VP9, which has very good browser support even across the mobile and iOS landscape.
I would have loved to lazily set all videos to play on load and in a loop, but the state of the technology told me we we’re not there yet.
Having many looping videos playing simultaneously is resource-intensive. For this reason if on a page there are multiple videos with the autoplay attribute, most devices will let only the first one play automatically.
So I had to come up with my own method to dynamically control which video is playing, based on how far the user has scrolled the page. I do this in JavaScript. An additional benefit of controlling video with scripting is the ability to only load a resource as the user is about to display it (which saves my bandwitdh and users’ metered data). There’s also room for a fallback poster.
Generating WebM files
My editing video software can output WebM, but in case yours doesn’t, you can use a PNG sequence as intermediary format, or an MP4 video with lossless or near-lossless compression settings. Then you’d batch transcode your source videos with the open-source tool ffmpeg and the following command:
for f in *.mp4; do
echo "Processing file $f."
ffmpeg -i "$f" -c:v libvpx-vp9 -b:v 500K -pass 1 -an -f null /dev/null && \
ffmpeg -i "$f" -c:v libvpx-vp9 -b:v 500K -pass 2 -an "$f".webm
done
This is what I’d use instead to convert a PNG image sequence to WebM:
ffmpeg -i '%.png' -pix_fmt yuv420p -r 24 -c:v libvpx-vp9 -b:v 500K -pass 1 -an -f null /dev/null && ffmpeg -i '%.png' -pix_fmt yuv420p -r 24 -c:v libvpx-vp9 -b:v 500K -pass 2 -an output.webm
Note the need to perform two encoding passes, and the use of the flag “an” (audio=none) to suppress any audio channel.
The JavaScript code
This is the code that does all the magic. Just include it on a page with video elements and it will automatically control their playback. The first video in the DOM will play immediately. As new ones become fully visible and start playing, one at a time, previous ones will stop.
/**
* Video Autoplay Implementation using Intersection Observer
* Optimized for portfolio sites with muted videos
*/
class VideoPortfolioManager {
constructor() {
this.videos = []
this.currentVideo = null
this.loadedVideos = new Set()
this.playingVideos = new Set()
this.init()
}
init() {
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.setup())
} else {
this.setup()
}
}
setup() {
this.videos = Array.from(document.querySelectorAll('video'))
if (this.videos.length === 0) return
// Set up all videos with proper attributes
this.videos.forEach(video => {
video.muted = true
video.playsInline = true
video.preload = 'none'
// Add loading state handling
video.addEventListener('loadstart', () => {
video.classList.add('loading')
})
video.addEventListener('canplay', () => {
video.classList.remove('loading')
this.loadedVideos.add(video)
})
video.addEventListener('error', (e) => {
console.warn('Video failed to load:', video.dataset.source || video.src, e)
video.classList.add('error')
})
})
this.setupIntersectionObserver()
this.preloadFirstVideo()
}
setupIntersectionObserver() {
// Observer for loading videos when they're about to come into view
const loadObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadVideo(entry.target)
}
})
}, {
rootMargin: '50px 0px', // Start loading 50px before video enters viewport
threshold: 0.1
})
// Observer for playing videos when they're prominently visible
const playObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
// Video is more than 50% visible
this.playVideo(video)
} else if (this.playingVideos.has(video)) {
// Video is no longer prominently visible
this.pauseVideo(video)
}
})
}, {
threshold: [0.3, 0.5, 0.7], // Multiple thresholds for smooth transitions
rootMargin: '-10px 0px' // Slight negative margin for better UX
})
// Observe all videos
this.videos.forEach(video => {
loadObserver.observe(video)
playObserver.observe(video)
})
}
loadVideo(video) {
if (this.loadedVideos.has(video) || !video.dataset.source) return
video.src = video.dataset.source
video.load()
delete video.dataset.source
}
async playVideo(video) {
if (this.playingVideos.has(video) || !this.loadedVideos.has(video)) return
// Pause all other videos first
this.pauseAllVideos()
try {
await video.play()
this.playingVideos.add(video)
this.currentVideo = video
video.classList.add('playing')
// Dispatch custom event for analytics/tracking
video.dispatchEvent(new CustomEvent('videoStarted', {
detail: { videoIndex: this.videos.indexOf(video) }
}))
} catch (error) {
console.warn('Video autoplay failed:', error)
// Fallback: try again on user interaction
this.setupUserInteractionFallback(video)
}
}
pauseVideo(video) {
if (!this.playingVideos.has(video)) return
video.pause()
this.playingVideos.delete(video)
video.classList.remove('playing')
}
pauseAllVideos() {
this.playingVideos.forEach(video => {
video.pause()
video.classList.remove('playing')
})
this.playingVideos.clear()
}
preloadFirstVideo() {
// Immediately load and play the first video for better UX
if (this.videos.length > 0) {
const firstVideo = this.videos[0]
this.loadVideo(firstVideo)
// Play when ready
firstVideo.addEventListener('canplay', () => {
this.playVideo(firstVideo)
}, { once: true })
}
}
setupUserInteractionFallback(video) {
// If autoplay fails, try again on first user interaction
const playOnInteraction = () => {
this.playVideo(video)
document.removeEventListener('click', playOnInteraction)
document.removeEventListener('touchstart', playOnInteraction)
}
document.addEventListener('click', playOnInteraction, { once: true })
document.addEventListener('touchstart', playOnInteraction, { once: true })
}
// Public API methods
getCurrentVideo() {
return this.currentVideo
}
getPlayingVideos() {
return Array.from(this.playingVideos)
}
// Method to manually trigger video at specific index (useful for navigation)
playVideoAtIndex(index) {
if (index >= 0 && index < this.videos.length) {
const video = this.videos[index]
video.scrollIntoView({ behavior: 'smooth', block: 'center' })
this.loadVideo(video)
setTimeout(() => this.playVideo(video), 100)
}
}
}
// Initialize when script loads
const videoManager = new VideoPortfolioManager()
// Expose to global scope for debugging/external control
window.videoPortfolioManager = videoManager
That’s it! Now everything moves, but only when we need it to.
Sources:
https://trac.ffmpeg.org/wiki/Encode/VP9
https://corydowdy.com/blog/apng-vs-webp-vs-gif