ViewportVideo is a viewport-aware video controller for websites.
It turns a normal HTML <video> into a polished showcase video for product demos, feature loops, blogs, and other scroll-driven surfaces.
See it powering every video on appvideostudio.com.
If the page has one video, or a very simple autoplay rule, you may not need ViewportVideo.
In many cases a normal <video> element, a good MP4 with faststart, playsinline, muted, loop, and sensible preload choices are enough.
You may still want ViewportVideo with one video if you want the first frame ready before the video becomes visible, if playback should respond to viewport position instead of raw autoplay, if window blur and focus state should be handled consistently, if reduced motion should be respected automatically, if idle time should stop long-running playback, if loop count should be capped, or if you want one place to keep those rules instead of rebuilding them on each page.
ViewportVideo starts to make the clearest case when the page has multiple videos, scroll-driven playback, a need to warm the next demo, or page-level rules about which video should be active.
That is where the video logic stops being a simple element setup and starts turning into coordination code.
The value proposition is simple:
- Keep autoplay behavior disciplined across the page so only the right video plays.
- Reduce awkward start delay when the user scrolls toward the next demo by warming one upcoming video.
- Have the next video's paused first frame ready before it starts peeking on screen, so there is no visible first-frame pop-in.
- Make playback feel faster by shifting bandwidth toward the next needed video and away from paused ones when fastmode-style abort behavior is enabled.
- Avoid wasteful network overlap by never warming while another managed video is already loading or downloading.
- Work with a normal
<video>element and familiar video attributes instead of requiring a bespoke player setup.
npm install viewport-videoimport { bindViewportVideo } from "viewport-video";<script type="module">
import { bindViewportVideo } from "https://cdn.jsdelivr.net/npm/viewport-video/dist/viewport-video.js";
</script>import { bindViewportVideo } from "./lib/viewport-video.js";<video
class="demo-video"
src="/videos/product-demo.mp4"
muted
playsinline
loop
preload="none"
></video>import { bindViewportVideo } from "viewport-video";
const video = document.querySelector(".demo-video");
bindViewportVideo(video, {
playbackMode: "viewport",
visibilityThreshold: 0.5,
pauseOnTabBlur: true,
idleTimeoutMs: 60 * 60 * 1000,
abortDownloadWhilePaused: false,
ignoreReducedMotion: false,
offStateUi: "off",
maxLoopCount: 3000
});The main productivity win here is not needing to wire up the binding manually.
If you do not want the manual binding step, ViewportVideo can also be used as a declarative custom-element:
<viewport-video
src="/videos/product-demo.mp4"
width="1280"
height="720"
></viewport-video>The custom-element owns the viewport-aware behavior and the video object.
Author-provided video attributes such as src are passed through to the underlying video so the wrapper can evolve with the platform without needing a maintained allowlist, while library options map declaratively through attributes on the custom-element.
Required runtime attributes are applied and enforced by the component, and system defaults like loop are handled internally.
You only need to add behavior attributes when you want to override the defaults. The normal wrapper-mode setup is meant to stay short.
If you need direct control over the actual <video> object or more complex child markup rules, use slotted video mode instead of wrapper mode:
<viewport-video visibility-threshold="0.5">
<video
slot="video"
src="/videos/product-demo.mp4"
></video>
</viewport-video>bindViewportVideo(video, options)This is a vanilla JS binding for a normal <video> element.
The binding returns a small controller object:
const controller = bindViewportVideo(video, options);
controller.update(nextOptions);
controller.destroy();The controller has two methods:
-
update(nextOptions)Merges and reapplies runtime options.
-
destroy()Removes observers, listeners, timers, and any library-owned UI so the video returns to normal host control.
-
The binding should not replace the caller's
<video>element.
bindViewportVideo(video, options) accepts the options below.
In declarative usage:
- Boolean attributes parse from explicit
"true"/"false"strings. - Numeric attributes parse as numbers.
- Invalid values warn and fall back to defaults.
- Omitted attributes use defaults.
- Type:
"viewport" | "manual" - Default:
"viewport" - Declarative attribute:
playback-mode
Controls who decides when playback should start and stop.
Use "viewport" when the library should arbitrate playback based on visibility and viewport center.
Use "manual" when the host page already has its own timing system.
Notes:
"manual"does not disable the rest of the library. Blur pausing, reduced-motion behavior, blocked-autoplay recovery, idle timeout, warmup policy, and loop limiting still apply.- Host code can change playback intent later through
controller.update(...).
Example:
bindViewportVideo(video, {
playbackMode: "manual"
});<viewport-video playback-mode="manual"></viewport-video>- Type:
number - Range:
0to1 - Default:
0.5 - Declarative attribute:
visibility-threshold
Defines how much of the video must be visible before it counts as in-range for viewport-driven playback decisions.
Notes:
- Lower values make playback trigger earlier.
- Higher values make playback wait until the video is more fully on screen.
- This matters for viewport arbitration, not host-driven manual timing.
Example:
bindViewportVideo(video, {
visibilityThreshold: 0.65
});- Type:
boolean - Default:
true - Declarative attribute:
pause-on-tab-blur
Pauses playback when the tab or window loses focus, then allows resume behavior when focus returns.
Notes:
- In viewport mode, focus return resumes only for the current viewport winner.
- In manual mode, the controller should remember whether the video was actively playing when blur happened.
- Type:
number - Default:
3600000 - Special value:
0disables the timeout - Declarative attribute:
idle-timeout-ms
Stops playback after a long period of page inactivity.
Notes:
- Key presses, pointer or mouse movement, and window focus reset the idle timer.
- Once the timeout is hit, new activity alone does not restart autoplay. The user must restart playback manually.
Example:
bindViewportVideo(video, {
idleTimeoutMs: 15 * 60 * 1000
});- Type:
boolean - Default:
false - Declarative attribute:
abort-download-while-paused
Enables the fastmode-oriented behavior where pausing may stop an in-flight download by clearing src.
Notes:
- This is only intended for fastmode encoding workflows.
- Enabling it creates a resume requirement: the implementation must track playback position, restore the source, and seek back accurately.
Example:
<viewport-video abort-download-while-paused="true"></viewport-video>- Type:
boolean - Default:
false - Declarative attribute:
ignore-reduced-motion
Overrides the default reduced-motion behavior and allows normal viewport-triggered autoplay even when prefers-reduced-motion: reduce matches.
Notes:
- The library default stays conservative and does not ignore reduced motion.
- Our own landing-page usage often opts into
trueexplicitly because autoplay matters on those surfaces.
- Type:
"default" | "off" - Default:
"default" - Declarative attribute:
off-state-ui
Controls whether off states show the shared play / message UI or stay visually silent.
Notes:
"default"shows the shared SVG play affordance and any related waiting-state messaging."off"suppresses the visible UI for blocked autoplay, reduced motion, idle timeout, and max-loop waiting states.
Example:
bindViewportVideo(video, {
offStateUi: "off"
});- Type:
number | undefined - Default: omitted
- Behavior when omitted: unlimited looping
- Declarative attribute:
max-loop-count
Stops playback after a fixed number of completed loops instead of starting the next one.
Notes:
- This is mainly a battery / energy saving limit for long-running loops.
- When the limit is reached, the video enters the same manual-start waiting state used by the other off paths.
Example:
<viewport-video max-loop-count="3000"></viewport-video>ViewportVideo supports two declarative custom-element modes in addition to the JS binding.
For markup-driven setup without manually creating or binding a video:
<viewport-video
src="/videos/product-demo.mp4"
width="1280"
height="720"
></viewport-video>This custom-element acts as a true video wrapper. In the common case, authors should not need to declare every behavior attribute. Most of the viewport behavior uses recommended defaults already.
The main author-supplied inputs in wrapper mode are usually:
srcwidthheight
Behavior attributes such as playback-mode, visibility-threshold, pause-on-tab-blur, and the rest are optional overrides, not required declarations.
For example, a more explicit wrapper setup can still override defaults when needed:
<viewport-video
src="/videos/product-demo.mp4"
width="1280"
height="720"
visibility-threshold="0.65"
off-state-ui="off"
max-loop-count="3000"
></viewport-video>Wrapper mode uses a mix of prop drilling and prop policy management:
- Author-provided video attributes like
src,poster,aria-*, anddata-*forward onto the underlying video object. - Library attributes configure viewport behavior on the wrapper itself.
- Required runtime attributes are enforced by the component itself.
- System defaults like
loopare applied internally.
Wrapper attributes remain the source of truth for wrapper mode.
If host code updates an attribute like src, that change should forward to the underlying video object immediately instead of drifting into disconnected internal state.
In wrapper mode, host-provided video attributes that are not owned by the library forward to the underlying video element. Library-owned behavior attributes stay on the custom-element and are interpreted there, rather than being mirrored onto the video element as host-controlled state.
The JS binding remains the primary API, but declarative option declarations can still be used even when you initialize behavior with bindViewportVideo(video, options).
For direct ownership of the underlying <video> element and more complex child markup rules, the custom-element also supports a slotted video:
<viewport-video visibility-threshold="0.5">
<video
slot="video"
src="/videos/product-demo.mp4"
muted
playsinline
loop
preload="none"
></video>
</viewport-video>In slotted mode:
- The host owns the video object and any complex child structure.
- The component does not do prop drilling into a separate internal video.
- The component only applies policy enforcement and viewport behavior to the slotted video.
- If no slotted video is present, wrapper mode applies instead.
These behaviors are part of the core contract:
- The video auto-pauses when it is scrolled out of the viewport.
- The video never shows native controls.
- The video ignores clicks during normal autoplay behavior.
- The video stays muted and inline for safe autoplay behavior.
- In declarative usage, author-provided video attributes such as
srcare forwarded to the underlying<video>element. - The binding relies on and enforces
preload="none",playsinline,muted, and an initialsrc;loopis applied as a system default. preload="none"remains the normal resting state, even when the library is deciding which video should play next.- The library silently enforces the runtime attributes it owns; it only warns for problems it cannot fix on its own, such as a missing initial
src. - Only one video plays at a time across the page.
- When multiple bound videos are candidates to play, the one nearest the viewport center wins.
- Manual mode disables viewport-driven play timing so the host page can decide when playback should start and stop.
- While scrolling, the library may warm one upcoming paused video in the current scroll direction so it is closer to ready when it becomes the next playback winner, even if another managed video is currently playing.
- Warmup never runs while any managed video is already loading or downloading media data, and any current warmup must stop immediately if another video begins loading.
- If autoplay is blocked, the library detects that state and shows an inline SVG play button.
- When that blocked-autoplay play button is shown, the whole video area becomes clickable and playback should proceed through the native video
playevent rather than a separate library event surface. - The library owns the default play button styling while still allowing customization.
The inline play button is the exception to the normal non-interactive rule.
Viewport playback is not "all visible videos may play." The contract is stricter:
- Only one bound video should be playing at once.
- If several videos are in range, the one closest to the viewport center should play.
- Scrolling away from that target pauses playback.
Manual mode is for pages that already have their own playback timing system, such as a custom scroll choreography.
In manual mode:
- The host page decides when playback is requested.
- The library still manages preload behavior, warmup, blur pausing, reduced-motion behavior, blocked-autoplay recovery, idle timeout, max loop count, and optional paused-download abort behavior.
- Viewport winner selection does not run on top of that host-controlled timing.
- Host code can update playback intent live without rebinding the controller.
- Manual mode keeps the host page's existing timing math and scroll choreography intact.
When manual mode enters an off state such as blocked autoplay, reduced motion, idle timeout, or max loop limit, it uses the same play affordance and paused waiting state as the rest of the library.
To reduce the delay before the next video starts:
- The library may warm one upcoming video in the current scroll direction while the page is scrolling and all managed videos are paused.
- This does not change the normal resting policy of
preload="none"for managed videos. - Warmup is speculative only, so it is blocked whenever any managed video is already loading or downloading media data.
- If a managed video starts loading or downloading, any existing warmup must stop right away.
When tab-blur pausing is enabled, it takes priority over viewport eligibility:
- A video stays paused while the window is blurred, even if it is still in the viewport.
- If the user scrolls while the window is blurred, the controller treats that scroll as active user presence and enters the focused state.
- When focus returns, only the in-viewport video closest to the center resumes.
In manual mode, the controller should remember whether the video was actively playing when blur happened. When focus returns, it only resumes if that same video was playing at the moment blur happened.
Idle detection is based on page / window activity such as key presses, pointer or mouse movement, and window focus.
When the idle timeout is reached:
- Playback stops and enters the same clickable SVG off state used by the other manual-start paths.
- New activity alone does not restart autoplay; the user must manually restart playback.
- If
offStateUiis"off", this idle off state stays visually silent with no play button and no error message.
If autoplay is blocked by the browser:
- Show the inline SVG play button.
- Make the full video area clickable for that recovery path.
- Playback should proceed through the native video
playevent when the user starts it through that affordance.
If offStateUi is "off", suppress that visible blocked-autoplay UI entirely so there is no play button and no error message.
This manual-start state is the explicit override to the normal "ignore clicks" rule.
If prefers-reduced-motion: reduce matches, viewport-triggered autoplay should not begin by default.
Instead, the same inline play button used for blocked autoplay should be shown so the user can choose to start playback manually.
If offStateUi is "off", that reduced-motion waiting state should stay visually silent with no play button and no error message.
After a manual start:
- The normal viewport pause rules still apply.
- The normal tab blur pause rules still apply.
Reduced motion also keeps a manual stop path:
- If the user clicks the video again, playback can pause and normal viewport autoplay remains off.
- The SVG play button returns and waits for the user to resume motion manually.
Once reduced-motion playback has entered this manual-only mode, it persists until the page reloads.
For our own website and landing-page usage, we usually set ignoreReducedMotion or ignore-reduced-motion to true even though the library default stays off.
That is a deliberate product choice on our side: autoplay is extremely important on those pages, so we opt into it explicitly instead of baking that preference into the library default.
This reduced-motion behavior can be opted out of with the ignore-reduced-motion setting.
This is a fastmode-only behavior and is off by default.
When enabled, pausing may force the browser to stop downloading by setting src = ''.
That creates a resume requirement:
- The implementation must track playback position before clearing
src. - When playback resumes, it must restore the source and resume from the last position as accurately as possible.
If maxLoopCount or max-loop-count is provided, playback should stop after that many completed loops instead of starting the next one.
This is mainly a battery / energy saving limit for very long-running loops.
When that limit is reached, the video should enter the same clickable SVG off state used by the other manual-start paths, so playback can be started again explicitly. That means reusing the same play affordance and paused waiting state instead of inventing a separate loop-limit-only state model.
If offStateUi is "off", that loop-limit waiting state should stay visually silent with no play button and no error message.
If it is omitted, looping stays unlimited.
- Vanilla JS
- Primary API is
bindViewportVideo(video, options) - Optional declarative API uses a custom-element
- Library attributes should map cleanly to the JS options
playbackModein JS maps toplayback-modein declarative usagemaxLoopCountin JS maps tomax-loop-countin declarative usage- Author-provided video attributes should pass through from the custom-element to the underlying video object
- Required runtime attributes should be set and enforced by the custom-element itself
- System defaults like
loopshould be applied by the custom-element itself - Uses viewport observation / scroll observation to decide playback state
- Debounces where possible for performance
- No special fallback behavior is required when
IntersectionObserveris unavailable
Every substantial change to library code must bump the patch version in both src/constants.js and package.json, and add a datetime-stamped entry to CHANGELOG.md. Bump immediately when the code change is made, not deferred to a later step. Maintain at least 30 entries.
- How to Serve Video on Your Website Without Killing Your Bandwidth - covers compression, faststart, preload policy, autoplay behavior, and viewport timing fundamentals
ViewportVideo is developed by AppVideoStudio and released under the MIT License.
See LICENSE.