Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

- added automatic `prefers-reduced-motion` support, which stops the parallax movement and background video playback, falling back to the provider thumbnail, the author's image, or the video's own first frame
- added `videoYoutubeHost` and `videoVimeoHost` options, passed through to video-worker
- updated `video-worker` to 3.1.0, which fixes Vimeo backgrounds playing muted in Chrome and moves YouTube off the nocookie host
- fixed `data-video-volume="0"` leaving the video unmuted, which blocked background autoplay on mobile

## [3.0.1] - Jun 9, 2026
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Parallax scrolling for modern browsers. Supported <img> tags, background i
- [A. JavaScript way](#a-javascript-way-1)
- [B. Data attribute way](#b-data-attribute-way-1)
- [Options](#options)
- [Reduced motion](#reduced-motion)
- [Disable on mobile devices](#disable-on-mobile-devices)
- [Additional options for video extension](#additional-options-for-video-extension)
- [Events](#events)
Expand Down Expand Up @@ -418,6 +419,23 @@ jarallax(document.querySelectorAll('.jarallax'), {
});
```

### Reduced motion

Nothing to configure. When the reader's system asks for reduced motion, Jarallax stops on its own: the image is still covered and positioned, it just does not move with the scroll, and background video never plays. The block always keeps something to look at:

| Background | Reduced motion shows |
| :--- | :--- |
| Image | The image, positioned as usual, not moving |
| YouTube / Vimeo | The provider thumbnail; the player iframe is never requested |
| Self-hosted, with a fallback image | That image; the video is never requested |
| Self-hosted, no fallback image | The video's own first frame, inserted paused |

The parallax side matches what `disableParallax: true` already does, so the layout is the one that path has always produced. `disableVideo` keeps meaning only what you asked for — reduced motion is decided separately, because what replaces the video depends on the provider.

A fallback image (`imgSrc`, a `.jarallax-img` element, or a CSS `background-image`) is still worth adding for a self-hosted video: it gives you control over the frame and it loads faster than the video does.

Read from `(prefers-reduced-motion: reduce)` when the instance is created; changing the system setting applies on the next page load.

### Additional options for video extension

Requires the video extension bundle. In package-based apps call `jarallaxVideo()` after importing from `jarallax`. In script-tag usage load `dist/jarallax-video(.min).js` after the core bundle.
Expand All @@ -431,6 +449,8 @@ videoEndTime | float | `0` | End time in seconds when video will be ended.
videoLoop | boolean | `true` | Loop video to play infinitely.
videoPlayOnlyVisible | boolean | `true` | Play video only when it is visible on the screen.
videoLazyLoading | boolean | `true` | Preload videos only when it is visible on the screen.
videoYoutubeHost | string | - | Origin the YouTube embed is loaded from, for example `https://www.youtube-nocookie.com`. Left empty, video-worker picks its own default.
videoVimeoHost | string | - | Origin the Vimeo embed is loaded from. Left empty, video-worker picks its own default.
disableVideo | boolean / RegExp / function | - | Disable video load on specific user agents (using regular expression) or with function return value. The image will be set on the background.

## Events
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@
],
"devDependencies": {
"@biomejs/biome": "^2.4.16",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^6.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@types/jquery": "^4.0.1",
"@types/node": "^25.9.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.1.8",
"clean-css": "^5.3.3",
"cross-env": "^10.1.0",
Expand All @@ -105,6 +105,6 @@
"react-dom": ">=18.0.0"
},
"dependencies": {
"video-worker": "^3.0.1"
"video-worker": "^3.1.0"
}
}
11 changes: 10 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import extend from './utils/extend';
import getParents from './utils/getParents';
import getWindowSize from './utils/getWindowSize';
import { addObserver, removeObserver } from './utils/observer';
import prefersReducedMotion from './utils/prefersReducedMotion';
import type {
DisableOption,
JarallaxCoverImageData,
Expand Down Expand Up @@ -109,9 +110,17 @@ class Jarallax {
});

this.options.speed = Math.min(2, Math.max(-1, parseFloat(`${this.options.speed}`)));
this.options.disableParallax = resolveDisableOption(

// Readers who ask for reduced motion get the same treatment as `disableParallax: true`:
// the image is still covered and positioned, it just never moves with the scroll.
const disableParallax = resolveDisableOption(
userOptions?.disableParallax ?? this.options.disableParallax
);
this.options.disableParallax = () => prefersReducedMotion() || disableParallax();

// Reduced motion stops background video too, but what to show instead depends on the
// provider, so that decision lives in the video extension and this option keeps meaning
// only what the author asked for.
this.options.disableVideo = resolveDisableOption(
userOptions?.disableVideo ?? this.options.disableVideo
);
Expand Down
4 changes: 4 additions & 0 deletions src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const defaults: JarallaxOptions = {
videoStartTime: 0,
videoEndTime: 0,
videoVolume: 0,

// Empty means "whatever video-worker defaults to", so the host is not pinned in two places.
videoYoutubeHost: '',
videoVimeoHost: '',
videoLoop: true,
videoPlayOnlyVisible: true,
videoLazyLoading: true,
Expand Down
33 changes: 30 additions & 3 deletions src/ext-video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import VideoWorker from 'video-worker';

import type { JarallaxCoverImageData, JarallaxInstance, JarallaxStatic } from './types';
import global from './utils/global';
import prefersReducedMotion from './utils/prefersReducedMotion';

// Under reduced motion a provider iframe buys nothing — its thumbnail is already painted as the
// background — but a self-hosted video with no fallback image is the only thing that element has
// to show, so it is inserted paused and the browser paints its first frame.
function isPosterOnlyVideo(instance: JarallaxInstance): boolean {
return (
prefersReducedMotion() && instance.video?.type === 'local' && !instance.defaultInitImgResult
);
}

function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax): void {
if (typeof jarallax === 'undefined') {
Expand All @@ -15,11 +25,13 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax):
Jarallax.prototype.onScroll = function onScrollWithVideo(this: JarallaxInstance): void {
defOnScroll.apply(this);

const posterOnly = isPosterOnlyVideo(this);
const isReady =
!this.isVideoInserted &&
this.video &&
(!this.options.videoLazyLoading || this.isElementInViewport) &&
!this.options.disableVideo();
!this.options.disableVideo() &&
(!prefersReducedMotion() || posterOnly);

if (!isReady) {
return;
Expand Down Expand Up @@ -49,8 +61,13 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax):
this.$video = insertedVideo;

// Self-hosted video should keep using the image as a poster, just like the legacy extension did.
// In the poster-only case that image is the transparent placeholder, and setting it would
// cover the very frame this element was inserted to show, so it is left off and the video
// is stretched to cover by itself — `started` never fires to hand it to coverImage().
if (this.video?.type === 'local') {
if (this.image.src) {
if (posterOnly) {
this.css(insertedVideo, { objectFit: 'cover' });
} else if (this.image.src) {
insertedVideo.setAttribute('poster', this.image.src);
} else if (this.image.$item?.tagName === 'IMG') {
insertedVideo.setAttribute('poster', (this.image.$item as HTMLImageElement).src);
Expand Down Expand Up @@ -149,7 +166,9 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax):
}

const video = new VideoWorker(this.options.videoSrc, {
autoplay: true,
// Self-hosted players autoplay themselves once metadata lands, so this is what keeps a
// poster-only video paused.
autoplay: !prefersReducedMotion(),
loop: this.options.videoLoop,
showControls: false,
accessibilityHidden: true,
Expand All @@ -158,6 +177,9 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax):
// `data-video-volume` reaches us as a string, and "0" is truthy. Compare the number.
mute: !Number(this.options.videoVolume),
volume: Number(this.options.videoVolume || 0),
// video-worker ignores undefined, so an unset host keeps its own default.
youtubeHost: this.options.videoYoutubeHost || undefined,
vimeoHost: this.options.videoVimeoHost || undefined,
});

this.options.onVideoWorkerInit?.call(this, video);
Expand Down Expand Up @@ -206,6 +228,11 @@ function jarallaxVideo(jarallax: JarallaxStatic | undefined = global.jarallax):
}

video.on('ready', () => {
// Nothing starts on its own under reduced motion, so the visibility hook is never installed.
if (prefersReducedMotion()) {
return;
}

// Visibility-driven play/pause is applied by wrapping the existing onScroll implementation.
if (this.options.videoPlayOnlyVisible) {
const oldOnScroll = this.onScroll;
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export interface JarallaxOptions {
videoStartTime?: number | string;
videoEndTime?: number | string;
videoVolume?: number | string;
videoYoutubeHost?: string;
videoVimeoHost?: string;
videoLoop?: boolean;
videoPlayOnlyVisible?: boolean;
videoLazyLoading?: boolean;
Expand Down Expand Up @@ -93,6 +95,8 @@ export interface JarallaxResolvedOptions
videoStartTime: number | string;
videoEndTime: number | string;
videoVolume: number | string;
videoYoutubeHost: string;
videoVimeoHost: string;
videoLoop: boolean;
videoPlayOnlyVisible: boolean;
videoLazyLoading: boolean;
Expand Down
16 changes: 16 additions & 0 deletions src/utils/prefersReducedMotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import global from './global';

let query: MediaQueryList | null | undefined;

// `matches` stays live on the MediaQueryList, so the object is resolved once and read on demand.
// Resolving lazily keeps the module import-safe in SSR and in environments without matchMedia.
export default function prefersReducedMotion(): boolean {
if (typeof query === 'undefined') {
query =
typeof global.matchMedia === 'function'
? global.matchMedia('(prefers-reduced-motion: reduce)')
: null;
}

return query?.matches ?? false;
}
2 changes: 2 additions & 0 deletions src/video-worker.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ declare module 'video-worker' {
endTime?: number;
mute?: boolean;
volume?: number;
youtubeHost?: string;
vimeoHost?: string;
}

export type VideoWorkerEvent = 'ready' | 'started' | 'ended' | 'error';
Expand Down
Loading