SUMMER SALE Summer sale: 25% off UI Components and JavaScript for Rails Developers

Auto-pause YouTube Videos with Stimulus

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 to 20%).
  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: resets playingValue to false 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. 😊

Published at . Have suggestions or improvements on this content? Do reach out.

More articles like this on modern Rails & frontend? Get them first in your inbox.
JavaScript for Rails Developers
Out now

UI components Library for Ruby on Rails apps

$ 99 one-time
payment

View components