Autoplaying video previews for the web

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