Ever seen videos on popular (social media) platform sites being automatically paused when you scroll them out view (and resume again when in view)? I find this an elegant UX and recently was asked, via my Rails UI Consultancy work, to create such a feature as part of a larger learning platform.
It is fairly straight-forward with JavaScript’s Intersection Observer, but there are still some interesting techniques used.
If you want to check out the full set up, check out this repo.
As often when building something with Stimulus, let’s start with the HTML:
<video
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
controls
preload="metadata"
data-controller="video"
data-video-percentage-visible-value="50">
</video>
Then create the Stimulus controller: bin/rails generate Stimulus video
.
Let’s write the controller piece by piece:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
playing: { type: Boolean, default: false },
percentageVisible: { type: Number, default: 20 } // percentage of video that must be visible (0-100)
}
The controller needs two values:
playing
: whether the video was playing when it left the viewport;percentageVisible
: how much of the video must be visible to be considered “in view” (defaults to20
%).
connect() {
this.#detectViewport()
}
disconnect() {
this.observer?.disconnect()
}
When the controller connects, it calls the private #detectViewport()
method to set up the visibility detection. When disconnecting, it cleans up the observer with optional chaining (?.
) to avoid errors if the observer wasn’t created (could happen if the video was immediately removed from the DOM).
#detectViewport() {
this.observer = new IntersectionObserver(
([entry]) => this.#adjustPlayback(entry),
{ threshold: this.#thresholdValue }
)
this.observer.observe(this.element)
}
The #detectViewport()
method creates an IntersectionObserver
that watches when the video enters or exits the viewport (the visible area of a webpage in the browser). The first parameter is a callback function that receives an array of entries. Using array destructuring ([entry])
cleanly extracts the first entry without needing a separate line of code.
The second parameter configures the observer with a threshold/percentage of the element that must be visible to trigger the callback. This value comes from the private thresholdValue
getter that converts the user-friendly percentage to the decimal format the API expects.
get #thresholdValue() {
return this.percentageVisibleValue / 100
}
This getter converts the percentage (like 50%) to a decimal (0.5) for the IntersectionObserver API.
#adjustPlayback(entry) {
if (!entry) return
if (!entry.isIntersecting) {
this.#pauseWhenOutOfView()
return
}
this.#resumeIfPreviouslyPlaying()
}
When the visibility changes, #adjustPlayback()
is called. It first checks if there’s a valid entry and returns early if not. Then it checks if the video is intersecting (visible) in the viewport. If not visible, it pauses the video. If visible, it potentially resumes playback.
The early returns create a clean flow without nested if/else statements.
#pauseWhenOutOfView() {
if (this.element.paused) return
this.element.pause()
this.playingValue = true
}
The #pauseWhenOutOfView()
method first checks if the video is already paused. If the video is playing, it pauses it and sets playingValue
to true
to remember that it was playing when it left the viewport.
#resumeIfPreviouslyPlaying() {
if (!this.playingValue) return
this.#attemptToPlay().catch(() => this.playingValue = false)
}
If the video returns to the viewport, #resumeIfPreviouslyPlaying()
checks if it was playing before (using the playingValue
). If not, it does nothing. If it was playing, it attempts to resume playback.
#attemptToPlay() {
return this.element.play() || Promise.reject()
}
The #attemptToPlay()
method handles a quirk of the HTML5 video API. The play()
method returns a Promise that resolves if playback starts successfully or rejects if autoplay is prevented (like when browser policies block autoplay). Some older browsers might not return a Promise, so the || Promise.reject()
ensures we always have a Promise to work with.
If playback fails (the Promise rejects), the catch
handler in the calling method sets playingValue
to false, preventing future auto-play attempts for this video.
And that’s it! A clean, focused controller that pauses videos when they’re scrolled out of view and resuming them when they return to view.
The controller uses modern JavaScript features like private methods, array destructuring, and optional chaining for clean, maintainable code. Want to learn more about writing modern and readable JavaScript code? Check out JavaScript for Rails Developers where this is all covered. 😊