I recently published an article to auto-pause a video using Stimulus when it is outside of the viewport which I built for one of my Rails UI Consultancy clients. In this article I am exploring the same feature but using an embedded YouTube player using an iframe. While the implementation uses the same core concept as the previous video controller (the Intersection Observer API), working with YouTube’s iframe API adds some interesting complexity.
If you want to check out the full setup, check out this repo.
Again, let’s start with the HTML:
<div data-controller="youtube" data-youtube-percentage-visible-value="20">
<iframe
data-youtube-target="player"
src="https://www.youtube.com/embed/dQw4w9WgXcQ?enablejsapi=1"
frameborder="0"
allowfullscreen
></iframe>
</div>
Note a few important details:
- add
enablejsapi=1
to the iframe URL to allow JavaScript control; - wrap the iframe in a div with the controller (since you’ll need to observe the container);
- target the iframe with
data-youtube-target="player"
.
Now let’s generate the Stimulus controller: bin/rails generate stimulus youtube
.
Let’s break down the controller piece by piece:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["player"]
static values = {
playing: { type: Boolean, default: false },
percentageVisible: { type: Number, default: 20 } // percentage of video that must be visible (0-100)
}
The controller needs a target for the iframe and two values (the same as the previous controller):
player
: target for the YouTube iframe;playing
: tracks 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.#setup()
}
disconnect() {
this.observer?.disconnect()
}
When the controller connects, it calls the private #setup()
method to initialize the YouTube API. When disconnecting, it cleans up the observer with optional chaining to avoid errors if the observer wasn’t created.
#setup() {
if (window.YT) {
this.#createPlayer()
return
}
window.onYouTubeIframeAPIReady = () => this.#createPlayer()
const tag = document.createElement("script")
tag.src = "https://www.youtube.com/iframe_api"
document.head.appendChild(tag)
}
The #setup()
method handles loading the YouTube iframe API. If the API is already loaded (window.YT
exists), it creates the player immediately. Otherwise, it loads the API script and sets up a callback to create the player once the API is ready. With this approach you don’t load the YouTube API multiple times if you have several videos on the page.
#createPlayer() {
if (!this.playerTarget) return
this.player = new YT.Player(this.playerTarget, {
events: {
"onReady": () => this.#detectViewport(),
"onStateChange": (event) => {
if (event.data !== YT.PlayerState.PLAYING) return
this.playingValue = false
}
}
})
}
The #createPlayer()
method initializes the YouTube player with the iframe API. It sets up two event handlers:
onReady
: calls#detectViewport()
once the player is ready;onStateChange
: resetsplayingValue
tofalse
when the video starts playing (to avoid auto-resuming if the user manually paused).
#detectViewport() {
this.observer = new IntersectionObserver(
([entry]) => this.#adjustPlayback(entry),
{ threshold: this.#thresholdValue }
)
this.observer.observe(this.element)
}
Just like in the video controller, #detectViewport()
creates an IntersectionObserver to watch when the video enters or exits the viewport. The threshold comes from the percentageVisibleValue
converted to a decimal.
#adjustPlayback(entry) {
if (!entry) return
if (!entry.isIntersecting) {
this.#pauseWhenOutOfView()
return
}
this.#resumeIfPreviouslyPlaying()
}
When visibility changes, #adjustPlayback()
is called. If the video is not visible, it pauses. If visible, it potentially resumes playback. The early returns create a clean flow without nested if/else statements.
#pauseWhenOutOfView() {
if (!this.player || this.player.getPlayerState() !== YT.PlayerState.PLAYING) return
this.player.pauseVideo()
this.playingValue = true
}
The #pauseWhenOutOfView()
method first checks if the player exists and if the video is currently playing. If it is, it pauses the video and sets playingValue
to true
to remember it was playing when it left the viewport.
#resumeIfPreviouslyPlaying() {
if (!this.playingValue) return
this.#attemptToPlay()
}
If the video returns to the viewport, #resumeIfPreviouslyPlaying()
checks if it was playing before. If it was, it attempts to resume playback.
#attemptToPlay() {
if (!this.player) return
this.player.playVideo()
this.playingValue = false
}
The #attemptToPlay()
method plays the video if the player exists and resets the playingValue
to false
. Unlike the HTML5 video API, YouTube’s API doesn’t return a Promise from playVideo()
, so you don’t need the Promise handling from the previous controller.
get #thresholdValue() {
return this.percentageVisibleValue / 100
}
}
This getter converts the percentage (like 20%) to a decimal (0.2) for the IntersectionObserver API.
And that’s it! A clean, focused controller that pauses YouTube videos when they’re scrolled out of view and resumes them when they return to view.
The controller handles the complexity of the YouTube iframe API while maintaining the same core functionality as the native video controller.
Want to learn more about writing modern and readable JavaScript code for Rails apps? Check out JavaScript for Rails Developers where all these techniques are covered in depth. 😊