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.durationfor the total video length in seconds -
this.element.currentTimefor current playback position -
this.element.pausedis a boolean indicating if video is paused -
this.element.play()to start playback -
this.element.pause()to pause playback -
this.element.mutedis 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. 🥳
Want to read me more?
-
Record video in Rails with Stimulus
Build a video recording feature using the MediaRecorder API. Learn how to capture webcam, screen and picture-in-picture recordings with clean Stimulus controller organization. -
Auto-pause Video Player with Stimulus
This article explores how to auto-pause videos when they out of the viewport, like media platforms like Twitter and so on do. -
Auto-pause YouTube Videos with Stimulus
This article explores how to auto-pause an embedded YouTube video player when it is outside of the viewport using Stimulus.
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
{{comment}}