Skip to content
60fps/ui
@60fps/ui-videov0.0.5

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 decorative flag 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.

Scroll inside the panel ↓
Out of viewPaused
↑ scroll down to the video

Pause it yourself and scroll away — it stays paused on the way back.

↓ scroll up to the video
Viewport
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.

Reduced motionOff

Honoring playback and loop.

Duration
Scroll inside the panel ↓
Out of view Paused
↑ scroll down to the video

Pause it yourself and scroll away — it stays paused on the way back.

↓ scroll up to the video
Viewport
Threshold
0.4 · default 0
Auto-paused
Auto-resumed
Last reason

This demo sets threshold=0.4 (resume once 40% is visible). The engine default is 0 — a single visible pixel.

Reduced motion Off

Honoring playback and loop.

Duration —

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.

Poster → videoPoster showing
Loading the clip…

The gradient is a plain <div> over the <video>. The engine fades it out on the first frame.

Poster → video Poster showing
Loading the clip…

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: 0 and pointer-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; 0 skips it) with the Web Animations API,
  • sets visibility: hidden when 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 appliesTypeDefaultDescription
mutedtrueSilent — and the only way a browser lets the engine start it.
playsInlinetrueiOS otherwise takes a background clip fullscreen.
controlsfalseThere is nothing to operate.
disablepictureinpictureattributeNothing worth floating over the page.
disableremoteplaybackattributeNo AirPlay/cast button on wallpaper.
aria-hidden'true'The alt="" of videos: decoration has nothing to announce.
autoPlaytrueA clip nobody can start has to start itself. Overridable.
reloadOnMediaChangetrueKeeps 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>
Resize the window ↔Measuring…
Selected source
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.

What decorative applied

Read back off the element after mount. loop is not in the list — that one stays yours.

Resize the window ↔ Measuring…
Selected source
Playing
Reloads

Drag the window across 900px. Without reloadOnMediaChange the file would never change after the first load — resizing inside one breakpoint costs nothing either way.

What decorative applied

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?)

OptionTypeDefaultDescription
thresholdnumber | number[]0IntersectionObserver threshold for the in-view check.
rootMarginstring'0px'IntersectionObserver root margin.
rootElement | Document | nullIntersectionObserver root. Defaults to the viewport.
autoPlaybooleanfalseStart playback (engine-driven, once in view) instead of the native autoplay attribute.
pauseOutOfViewbooleantruePause off-screen videos and resume them on re-entry.
respectReducedMotionbooleantrueApply the reduced-motion policy (disable loop, pause long videos).
reducedMotionMaxDurationnumber5Seconds above which reduced motion pauses a video.
forceReducedMotionbooleanForce the policy on/off, ignoring the media query. Omit to follow the OS setting.
posterHTMLElement | RefElement standing in for the video until it has a frame, then faded out.
posterFadeDurationnumber300Fade-out duration (ms) of the poster element, played with the Web Animations API. 0 hides it instantly.
decorativebooleanfalseApply the background-clip preset: silent, inline, no controls/PiP, hidden from AT, autoplayed.
reloadOnMediaChangebooleanfalseReload 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:

EventTypeDefaultDescription
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);