Video
VideoEngine makes a single <video> considerate. It watches that one element and:
- Pauses it when it scrolls out of view, then resumes it when it comes back — unless the user paused it deliberately, in which case it stays paused.
- Pauses it while the tab is hidden, resuming when the page comes back — a backgrounded tab keeps decoding video for nobody.
- Steps aside in Picture-in-Picture — a floating video is visible wherever the page scrolled, so neither rule applies until it goes back inline.
- Respects
prefers-reduced-motion— disables looping and pauses the video if it runs longer than five seconds, reacting live as the setting changes. - Fades out your own poster — hand it any element and it stands in for the video until there is a frame to show.
- Handles decorative clips — one
decorativeflag applies everything a background video needs, and keeps its responsive<source media>honest as breakpoints change.
It only drives native playback (play() / pause()) and the loop property, so your <video> keeps
its own controls, muted, poster and styling. The behaviour is written once and shared across
every framework.
Pause it yourself and scroll away — it stays paused on the way back.
- Threshold
- 0.4 · default 0
- Auto-paused
- 0×
- Auto-resumed
- 0×
- Last reason
- —
This demo sets threshold=0.4 (resume once 40% is visible). The engine default is 0 — a single visible pixel.
Honoring playback and loop.
Pause it yourself and scroll away — it stays paused on the way back.
- Threshold
- 0.4 · default 0
- Auto-paused
- 0×
- Auto-resumed
- 0×
- Last reason
- —
This demo sets threshold=0.4 (resume once 40% is visible). The engine default is 0 — a single visible pixel.
Honoring playback and loop.
Scroll the video out of the panel and back. Pause it yourself first to see it stay paused. Toggle Simulate to preview the reduced-motion policy without changing your OS setting.
Usage
Point the hook at a single <video> and it takes over the polite-playback behaviour. An autoplay,
muted, looping hero becomes “play only while it’s on screen”:
import { useRef } from 'react';
import { useVideo } from '@60fps/ui-react/video-engine';
function Hero() {
const ref = useRef<HTMLVideoElement>(null);
useVideo(ref);
return <video ref={ref} src="/hero.mp4" muted loop autoPlay playsInline />;
}
<script setup lang="ts">
import { ref } from 'vue';
import { useVideo } from '@60fps/ui-vue/video-engine';
const video = ref<HTMLVideoElement | null>(null);
useVideo(video);
</script>
<template>
<video ref="video" src="/hero.mp4" muted loop autoplay playsinline />
</template>
How the viewport rule works
The engine tracks who paused the video so it never fights the user:
- If the video is playing when it leaves the viewport, the engine pauses it and resumes it on re-entry.
- If the user paused it (via the controls), it is left alone — it will not auto-resume when it scrolls back in.
- A video that was already paused/idle is never auto-played.
If the user takes over — pressing play again — the engine hands back control and re-acquires it only on the next time the video leaves the viewport while playing.
The page-visibility rule
Switching tabs (or minimising the window) follows the same logic: the engine pauses a playing video
while document.hidden is true and resumes it when the page becomes visible again. It applies at mount
too — a video that hydrates in a background tab won’t start.
Playback that starts while the tab is in the background is held back the same way — a browser
honouring a native autoplay attribute there, OS media controls, third-party code. The intent is
remembered, so the video plays as soon as the page comes back.
Both gates must be open for the engine to resume: coming back to a tab whose video meanwhile scrolled out of view leaves it paused until it scrolls back in. And, as everywhere else, a video the user paused deliberately is never resumed. This rule is always on — there is no option to switch it off.
Picture-in-Picture wins over both
A video the user popped out keeps playing no matter what the page does: while it floats, the viewport
and page-visibility rules are suspended, so scrolling away or switching tabs won’t pause it. The engine
keeps tracking both in the background (visibility still fires), and applies them again the moment the
video goes back inline — a video that scrolled off screen meanwhile is paused right then.
Entering PiP never starts playback on its own; the engine only stops holding the video back.
The reduced-motion rule
When prefers-reduced-motion: reduce is active, the engine:
- sets
loop = false(restoring the original value when the preference is lifted), and - pauses the video when it is longer than
reducedMotionMaxDuration(5 seconds by default). A shorter, decorative clip keeps playing.
Duration is read once metadata is available, and the whole policy re-applies live when the media query flips — no reload required.
Your own poster element
The native poster attribute takes a single image URL — no styling, no markup, no gradient, no blurred
placeholder, no skeleton. Pass a poster element instead and the engine treats whatever you put there
as the placeholder: it stays visible while the video loads, then fades out as soon as the video has a
frame to show.
The gradient is a plain <div> over the <video>. The engine fades it out on the first frame.
The gradient is a plain <div> over the <video>. The engine fades it out on the first frame.
Hit Replay to refetch the clip and watch the spinner poster hand over to the video again. The slower fade makes the hand-off obvious; the poster stops taking clicks the moment it starts fading, so the controls underneath are reachable straight away.
import { useRef } from 'react';
import { useVideo } from '@60fps/ui-react/video-engine';
function Hero() {
const videoRef = useRef<HTMLVideoElement>(null);
const posterRef = useRef<HTMLDivElement>(null);
useVideo(videoRef, { poster: posterRef, autoPlay: true });
return (
<div className="relative">
<video ref={videoRef} src="/hero.mp4" muted loop playsInline className="w-full" />
{/* Anything at all: an <img>, a blurhash canvas, a gradient, a skeleton. */}
<div ref={posterRef} className="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-600" />
</div>
);
}
<script setup lang="ts">
import { ref } from 'vue';
import { useVideo } from '@60fps/ui-vue/video-engine';
const video = ref<HTMLVideoElement | null>(null);
const poster = ref<HTMLElement | null>(null);
useVideo(video, { poster, autoPlay: true });
</script>
<template>
<div class="relative">
<video ref="video" src="/hero.mp4" muted loop playsinline class="w-full" />
<div ref="poster" class="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-600" />
</div>
</template>
You own the layout — overlay the poster on the video however you like (the example above uses an
absolutely positioned sibling in a relative wrapper). The engine only:
- sets
opacity: 0andpointer-events: none, so a poster covering the video never swallows a click on the native controls, - plays the fade from the poster’s current opacity down to 0 over
posterFadeDuration(300ms;0skips it) with the Web Animations API, - sets
visibility: hiddenwhen that animation finishes, which also takes the poster out of the accessibility tree, - restores all three inline styles, exactly as you authored them, on teardown.
It hides on the first of loadeddata, canplay or playing — whichever the browser gets to first —
and immediately if the video already has a decoded frame when the engine attaches (a cached clip, late
hydration). The posterhide event fires at the same moment — and since a cached clip is renderable
before you can subscribe, engine.posterHidden tells a late subscriber where things stand.
Decorative videos
A background clip is a different animal from a video someone came to watch: it is silent, it takes no
input, it says nothing to a screen reader, and nobody should be able to pop it out over the page. That
is five or six attributes people forget one at a time. decorative applies them together:
useVideo(ref, { decorative: true });
| What it applies | Type | Default | Description |
|---|---|---|---|
muted | true | — | Silent — and the only way a browser lets the engine start it. |
playsInline | true | — | iOS otherwise takes a background clip fullscreen. |
controls | false | — | There is nothing to operate. |
disablepictureinpicture | attribute | — | Nothing worth floating over the page. |
disableremoteplayback | attribute | — | No AirPlay/cast button on wallpaper. |
aria-hidden | 'true' | — | The alt="" of videos: decoration has nothing to announce. |
autoPlay | true | — | A clip nobody can start has to start itself. Overridable. |
reloadOnMediaChange | true | — | Keeps responsive <source media> honest. Overridable. |
The element keeps all of it after clean(). These are the values your markup would have carried, not
overrides the engine imposes while it is attached — a video is no less decorative once the engine lets
go of it, and re-applying them on a remount changes nothing. (loop and the poster styles are put
back, precisely because those override markup that says otherwise.)
loop is deliberately not in the list: decoration says nothing about whether a clip repeats, and
forcing it would fight both your markup and the reduced-motion policy.
Responsive sources that stay responsive
A <video> picks its source once. Unlike <picture>, it never revisits the decision, so this
markup does the right thing on first load and nothing at all afterwards — rotate the phone or unfold
the window, and the clip chosen in portrait stays:
<video>
<source src="/hero-portrait.mp4" media="(orientation: portrait)" />
<source src="/hero.mp4" />
</video>
reloadOnMediaChange fixes it: the engine watches the media conditions your markup declared — not
resize, so one callback per real breakpoint crossing — and when a different <source> would now win,
it calls load() and emits sourcechange.
The engine keeps the cost down: it reloads only when the winning source actually changes, so a resize
inside one breakpoint costs nothing, and it defers while the tab is hidden rather than refetching for
nobody. Playback state carries over — a clip that was playing resumes, a clip the user paused stays
paused. A page whose sources carry no media condition never sets up a single listener.
Try it
Two clips behind one breakpoint, with decorative: true doing the rest:
<video ref={ref}>
<source src="/big-buck-bunny.mp4" media="(min-width: 900px)" type="video/mp4" />
<source src="/flower.webm" type="video/webm" />
</video>
- Playing
- —
- Reloads
- 0×
Drag the window across 900px. Without reloadOnMediaChange the file would never change after the first load — resizing inside one breakpoint costs nothing either way.
Read back off the element after mount. loop is not in the list — that one stays yours.
- Playing
- —
- Reloads
- 0×
Drag the window across 900px. Without reloadOnMediaChange the file would never change after the first load — resizing inside one breakpoint costs nothing either way.
Read back off the element after mount. loop is not in the list — that one stays yours.
Drag your window across 900px: the clip swaps and the reload counter goes up. Reload the page at a given width and then resize with the option off (any other demo on this page) to see the difference — the file stays put. The wide clip is a 61 MB Big Buck Bunny served with range requests, so only the played part is fetched.
useVideo(ref, options?)
| Option | Type | Default | Description |
|---|---|---|---|
threshold | number | number[] | 0 | IntersectionObserver threshold for the in-view check. |
rootMargin | string | '0px' | IntersectionObserver root margin. |
root | Element | Document | null | — | IntersectionObserver root. Defaults to the viewport. |
autoPlay | boolean | false | Start playback (engine-driven, once in view) instead of the native autoplay attribute. |
pauseOutOfView | boolean | true | Pause off-screen videos and resume them on re-entry. |
respectReducedMotion | boolean | true | Apply the reduced-motion policy (disable loop, pause long videos). |
reducedMotionMaxDuration | number | 5 | Seconds above which reduced motion pauses a video. |
forceReducedMotion | boolean | — | Force the policy on/off, ignoring the media query. Omit to follow the OS setting. |
poster | HTMLElement | Ref | — | Element standing in for the video until it has a frame, then faded out. |
posterFadeDuration | number | 300 | Fade-out duration (ms) of the poster element, played with the Web Animations API. 0 hides it instantly. |
decorative | boolean | false | Apply the background-clip preset: silent, inline, no controls/PiP, hidden from AT, autoplayed. |
reloadOnMediaChange | boolean | false | Reload when a breakpoint hands the win to another <source media>. Decorative clips only. |
To react to the engine, subscribe to its events with engine.on(...) (see below) — the hook exposes no
on* callback props.
Engine events
The hook returns the engine — an emitter. Subscribe with engine.on(event, cb) to react to it, and
engine.off(event, cb) to clean up:
| Event | Type | Default | Description |
|---|---|---|---|
visibility | (visible, entry) | — | The video entered or left the viewport. |
autopause | (reason) | — | The engine paused the video on its own — 'viewport', 'document-hidden', 'reduced-motion' or 'source-change'. |
autoplay | (reason) | — | The engine resumed the video on its own, with the same reasons. |
reducedmotionchange | (reduced) | — | The effective reduced-motion state changed. |
posterhide | (poster) | — | The video became renderable, so the poster element started fading out. |
sourcechange | (src) | — | A breakpoint flipped, another <source> won, and the engine reloaded the video. |
Driving reduced motion yourself
Wire the policy to your own settings UI with engine.setReducedMotion(forced) — pass true/false to
override the media query, or null to fall back to it:
const engine = useVideo(ref);
// e.g. a "reduce animations" switch in your app
const onToggle = (enabled: boolean) => engine.setReducedMotion(enabled ? true : null);