Video Preview on Hover with Stimulus

Building on the video recording feature from earlier, let’s add a nice touch to the presentation index page: video previews that play on hover. You know the experience—hover over a video thumbnail and get a quick preview of what’s inside. It’s the same interaction you see on YouTube, Netflix and every modern video platform.

The presentations index

After recording presentations, you need a way to browse them. A simple index page lists all presentations with video thumbnails. When you hover over a thumbnail, the video plays a preview. Move your cursor away and it returns to the poster image.

Here’s the basic setup:

<% @presentations.each do |presentation| %>
  <%= video_tag presentation.video,
    width: "160",
    poster: (presentation.video.representable? ? url_for(presentation.video.representation(resize: "160x120")) : nil),
    data: {
      controller: "preview",
      action: "mouseenter->preview#play mouseleave->preview#pause"
    }
  %>
<% end %>

Active Storage’s representable? method checks if a preview can be generated and representation() creates the thumbnail automatically.

The preview controller

All the logic happens in one Stimulus controller:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    segments: { type: Number, default: 3 },
    interval: { type: Number, default: 1000 },
    minDuration: { type: Number, default: 5 }
  }

  connect() {
    this.originalTime = 0
    this.wasPlaying = false
    this.previewTimer = null
    this.currentIndex = 0
    this.isReady = false
    this.timestamps = []

    this.element.addEventListener("loadedmetadata", () => {
      this.#calculateTimestamps()

      this.isReady = true
    })
  }
}

Understanding loadedmetadata

The loadedmetadata event is the key here. It fires when the browser has loaded enough of the video to know its duration, dimensions and other metadata. Without this information, you can’t calculate meaningful preview timestamps.

this.element.addEventListener("loadedmetadata", () => {
  this.#calculateTimestamps()
  this.isReady = true
})

Only after loadedmetadata fires can you access this.element.duration reliably. Try to use it before this event and you’ll get NaN or 0.

Smart timestamping

Instead of just playing from the beginning, the controller shows different parts of the video:

#calculateTimestamps() {
  const duration = this.element.duration

  if (duration < this.minDurationValue) {
    this.timestamps = [0]
    return
  }

  this.timestamps = []
  for (let i = 1; i <= this.segmentsValue; i++) {
    this.timestamps.push((duration / (this.segmentsValue + 1)) * i)
  }
}

For short videos (under 5 seconds), it just plays from the start. For longer videos, it divides the duration into segments and cycles through them. A 60-second video with 3 segments shows clips at 15, 30 and 45 seconds.

Product-minded Rails notes

Monthly roundup on what we're building, open source work and recent articles.

The element methods

Stimulus gives you direct access to the video element through this.element. Here are the key methods and properties used:

  • this.element.duration for the total video length in seconds
  • this.element.currentTime for current playback position
  • this.element.paused is a boolean indicating if video is paused
  • this.element.play() to start playback
  • this.element.pause() to pause playback
  • this.element.muted is a boolean to control audio during preview

The preview controller leverages these to create smooth hover interactions:

play() {
  if (!this.isReady || this.timestamps.length === 0) return

  this.originalTime = this.element.currentTime
  this.wasPlaying = !this.element.paused
  this.currentIndex = 0

  this.element.muted = true
  this.#showNextTimestamp()

  if (this.timestamps.length > 1) {
    this.previewTimer = setInterval(() => {
      this.#showNextTimestamp()
    }, this.intervalValue)
  }
}

When you hover, it saves the current state, mutes the audio and starts cycling through preview segments. When you leave, it restores everything exactly as it was.

Auto-looping previews

The preview automatically loops through different parts of the video:

#showNextTimestamp() {
  this.element.currentTime = this.timestamps[this.currentIndex]
  this.element.play()

  this.currentIndex = (this.currentIndex + 1) % this.timestamps.length
}

The modulo operator (%) creates the loop: when it reaches the last segment, it goes back to the first. Combined with setInterval, this creates a cycling preview that gives your users a real sense of the video content.


The key is waiting for the right moment (when metadata loads), calculating smart preview points and cleaning up properly when the interaction ends. This preview controller shows how a small Stimulus controller can create a nice UX. By understanding browser events like loadedmetadata and leveraging the video element’s built-in methods, you can get a lot done without writing a lot of JavaScript. 🥳

Product-minded Rails notes

Once a month: straightforward notes on improving UX in Rails—what to simplify, what to measure, and UI/frontend changes that move real usage.

Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Want to read me more?