Guides
Video playback
Use zoom events to play a video over an enlarged poster. Medium Zoom handles the image animation; your code positions and cleans up the video player.
'use client'
import mediumZoom, { type Zoom } from 'medium-zoom'
import { useEffect, useRef, type ComponentPropsWithoutRef } from 'react'
import './video.css'
type ZoomableVideoProps = ComponentPropsWithoutRef<'img'> & {
videoSrc: string
}
export function ZoomableVideo({
videoSrc,
alt,
...imageProps
}: ZoomableVideoProps) {
const triggerRef = useRef<HTMLButtonElement>(null)
const imageRef = useRef<HTMLImageElement>(null)
const zoomRef = useRef<Zoom | null>(null)
useEffect(() => {
const trigger = triggerRef.current!
const poster = imageRef.current!
const zoom = mediumZoom(poster, { margin: 24 })
zoomRef.current = zoom
let player: HTMLVideoElement | undefined
const removePlayer = () => {
if (!player) {
return
}
const video = player
player = undefined
video.pause()
video.remove()
video.removeAttribute('src')
video.load()
}
const restoreFocus = () => trigger.focus({ preventScroll: true })
const startPlayer = () => {
const video = document.createElement('video')
player = video
video.className = 'zoom-video-player'
video.controls = true
video.muted = true
video.playsInline = true
video.loop = true
video.poster = poster.currentSrc || poster.src
video.src = videoSrc
video.hidden = true
video.setAttribute('aria-label', poster.alt || 'Video')
video.addEventListener(
'loadeddata',
() => {
video.hidden = false
},
{ once: true }
)
video.addEventListener('error', removePlayer, { once: true })
// Start within the gesture; append the player after the image finishes opening.
void video.play().catch(() => {
if (player === video) {
video.hidden = false
}
})
}
const positionPlayer = () => {
const image = document.querySelector('.medium-zoom-image--opened')
if (!player || !image) {
return
}
const { left, top, width, height } = image.getBoundingClientRect()
Object.assign(player.style, {
left: `${left + window.scrollX}px`,
top: `${top + window.scrollY}px`,
width: `${width}px`,
height: `${height}px`,
})
document.body.append(player)
}
zoom.on('open', startPlayer)
zoom.on('opened', positionPlayer)
zoom.on('close', removePlayer)
zoom.on('closed', restoreFocus)
return () => {
zoomRef.current = null
zoom.off('open', startPlayer)
zoom.off('opened', positionPlayer)
zoom.off('close', removePlayer)
zoom.off('closed', restoreFocus)
removePlayer()
if (zoom.getZoomedImage()) {
zoom.on('closed', () => zoom.detach(), { once: true })
void zoom.close()
} else {
zoom.detach()
}
}
}, [videoSrc])
return (
<button
ref={triggerRef}
className="video-poster-trigger"
onClick={event => {
event.stopPropagation()
void zoomRef.current?.open()
}}
type="button"
aria-label={alt ? `Play video: ${alt}` : 'Play video'}
>
<img {...imageProps} ref={imageRef} alt={alt} />
</button>
)
}.video-poster-trigger {
cursor: zoom-in;
}
.video-poster-trigger:focus-visible {
outline: 2px solid var(--ring, currentColor);
outline-offset: 4px;
}
.zoom-video-player {
position: absolute;
z-index: 52;
border-radius: 6px;
}Activate the poster to zoom and play. The Code view contains the React component and its styles. Pass your poster and video URLs as props:
import { ZoomableVideo } from './examples/video'
export function FlowerVideo() {
return (
<ZoomableVideo
src="/flower-poster.jpg"
videoSrc="/flower.mp4"
alt="A pink flower opening"
width={960}
height={540}
/>
)
}The standalone JavaScript example shows the same lifecycle without React. Its source (opens in a new tab) includes the event handlers and styles.
Prepare the poster
Use an uncropped poster with the same aspect ratio as the video. Wrap it in a button so users can open it with Enter or Space:
<button id="play" type="button" aria-label="Play flower video">
<img
id="poster"
src="/flower-poster.jpg"
alt="A pink flower opening"
/>
</button>Let the button open the zoom and stop its click from also reaching the library's document listener:
import mediumZoom from 'medium-zoom'
const trigger = document.getElementById('play')
const poster = document.getElementById('poster')
if (trigger && poster) {
const zoom = mediumZoom(poster, { margin: 24 })
trigger.addEventListener('click', event => {
event.stopPropagation()
zoom.open()
})
}Start playback
Register handlers on the zoom instance for these events:
- On
open, create a video withmuted,controls, andplaysInline. Set its source and callplay()during the user gesture. Keep the player detached while the poster animates. - On
opened, measure.medium-zoom-image--openedwithgetBoundingClientRect(). Addwindow.scrollXandwindow.scrollYto those coordinates and useposition: absolutewith a higherz-index. The player then moves with the poster during scrolling. Append it to the page. - Reveal the player when
loadeddatasupplies a frame. Ifplay()rejects, show its poster and native controls so the user can start playback manually.
Both event.target and zoom.getZoomedImage() refer to the original image. The single-image example queries the animated clone after opening; events do not expose a clone reference.
Muted playback and playsInline do not guarantee autoplay. Browser policies and codec support still apply. If loading fails, leave the zoomed poster visible.
Close and clean up
Click outside the video or press Esc to close it. Native controls handle clicks on the player. Resizing the window or scrolling beyond scrollOffset also closes the zoom.
On close, pause and remove the player, clear its source, and call load() to stop loading it. This handler also runs when the user closes the zoom during the opening animation.
On closed, restore focus to the poster button. Create a new player on each opening to start playback from the beginning.