diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c38cd..7c47013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fd7291f..8bfb59a 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. @@ -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 diff --git a/package-lock.json b/package-lock.json index cdbd9c4..38a6153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.0.1", "license": "MIT", "dependencies": { - "video-worker": "^3.0.1" + "video-worker": "^3.1.0" }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -4518,9 +4518,9 @@ } }, "node_modules/video-worker": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/video-worker/-/video-worker-3.0.1.tgz", - "integrity": "sha512-J5a5E2/E3eZfIw+A/uBsEKGmxsIQR3kaw0kOiBsQKyeU8WR+9zFufu6ZFIn4QKhmsAN5Yi2TUxLmBRRQYGfPeg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/video-worker/-/video-worker-3.1.0.tgz", + "integrity": "sha512-OyhVS4mmMpopD24eECvmhsXURm5rpofDyTIRIiDfzD2JrdufFer0YaxnR4dXwWzk6k75fTBNp8cY9OuQabHfew==", "license": "MIT" }, "node_modules/vite": { diff --git a/package.json b/package.json index 76f6305..148f220 100644 --- a/package.json +++ b/package.json @@ -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", @@ -105,6 +105,6 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "video-worker": "^3.0.1" + "video-worker": "^3.1.0" } } diff --git a/src/core.ts b/src/core.ts index ad3a284..1ac649e 100644 --- a/src/core.ts +++ b/src/core.ts @@ -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, @@ -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 ); diff --git a/src/defaults.ts b/src/defaults.ts index 299d587..eaf6d27 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -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, diff --git a/src/ext-video.ts b/src/ext-video.ts index a0d5e3d..2a341c8 100644 --- a/src/ext-video.ts +++ b/src/ext-video.ts @@ -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') { @@ -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; @@ -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); @@ -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, @@ -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); @@ -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; diff --git a/src/types.ts b/src/types.ts index acd4afb..f12ab5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; @@ -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; diff --git a/src/utils/prefersReducedMotion.ts b/src/utils/prefersReducedMotion.ts new file mode 100644 index 0000000..6f1cbcb --- /dev/null +++ b/src/utils/prefersReducedMotion.ts @@ -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; +} diff --git a/src/video-worker.d.ts b/src/video-worker.d.ts index fbcaa73..7c79fd0 100644 --- a/src/video-worker.d.ts +++ b/src/video-worker.d.ts @@ -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'; diff --git a/tests/reduced-motion.test.js b/tests/reduced-motion.test.js new file mode 100644 index 0000000..8f051bf --- /dev/null +++ b/tests/reduced-motion.test.js @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createJarallaxBlock } from './test-helpers.js'; + +const videoWorkerState = vi.hoisted(() => ({ + instances: [], +})); + +vi.mock('video-worker', () => { + class FakeVideoWorker { + constructor(source, options) { + this.source = source; + this.options = options; + this.handlers = new Map(); + this.type = source.startsWith('mp4:') + ? 'local' + : source.includes('vimeo.com') + ? 'vimeo' + : 'youtube'; + this.videoID = 'mru3Q5m4lkY'; + this.videoWidth = 1280; + this.videoHeight = 720; + videoWorkerState.instances.push(this); + } + + isValid() { + return true; + } + + on(event, callback) { + this.handlers.set(event, callback); + } + + getImageURL(callback) { + callback('https://img.youtube.com/vi/mru3Q5m4lkY/maxresdefault.jpg'); + } + + getVideo(callback) { + const hidden = document.createElement('div'); + const node = document.createElement(this.type === 'local' ? 'video' : 'iframe'); + hidden.appendChild(node); + document.body.appendChild(hidden); + callback(node); + } + + play = vi.fn(); + + pause = vi.fn(); + } + + return { default: FakeVideoWorker }; +}); + +// Only `(prefers-reduced-motion: reduce)` is answered; everything else stays false, which is +// what jsdom would report if it implemented matchMedia at all. +function stubReducedMotion(reduce) { + vi.stubGlobal( + 'matchMedia', + vi.fn((query) => ({ + media: query, + matches: reduce && query === '(prefers-reduced-motion: reduce)', + addEventListener() {}, + removeEventListener() {}, + })) + ); +} + +describe('prefers-reduced-motion', () => { + beforeEach(() => { + vi.resetModules(); + videoWorkerState.instances.length = 0; + }); + + it.each([ + [true, false], + [false, true], + ])('reduce=%s -> parallax container created: %s', async (reduce, expectContainer) => { + stubReducedMotion(reduce); + + const { default: jarallax } = await import('../src/core.ts'); + const block = createJarallaxBlock({ mode: 'background' }); + + jarallax(block); + + expect(Boolean(block.querySelector('.jarallax-container'))).toBe(expectContainer); + expect(block.jarallax.options.disableParallax()).toBe(reduce); + }); + + it.each([ + [true, false], + [false, true], + ])('reduce=%s -> background video inserted: %s', async (reduce, expectVideo) => { + stubReducedMotion(reduce); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'img' }); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'https://youtu.be/mru3Q5m4lkY' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + expect(Boolean(block.querySelector('iframe'))).toBe(expectVideo); + // `disableVideo` keeps reporting only what the author asked for. + expect(block.jarallax.options.disableVideo()).toBe(false); + }); + + it('keeps the poster visible instead of the player when motion is reduced', async () => { + stubReducedMotion(true); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'background' }); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'https://youtu.be/mru3Q5m4lkY' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + // The author's own background survives, and no provider player was ever asked for. + expect(block.jarallax.image.bgImage).toContain('https://via.placeholder.com/100x50'); + expect(block.querySelector('iframe')).toBeNull(); + expect(videoWorkerState.instances[0].play).not.toHaveBeenCalled(); + }); + + // A self-hosted video with no fallback image has nothing else to show, so it is inserted + // paused. The placeholder poster must stay off or it would hide the frame. + it('inserts a self-hosted video paused when the element has no fallback image', async () => { + stubReducedMotion(true); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = document.createElement('div'); + block.className = 'jarallax'; + document.body.appendChild(block); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'mp4:../demo/video/video.mp4' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + const inserted = block.querySelector('video'); + + expect(inserted).toBeTruthy(); + expect(inserted.getAttribute('poster')).toBeNull(); + expect(inserted.style.objectFit).toBe('cover'); + expect(videoWorkerState.instances[0].options.autoplay).toBe(false); + expect(videoWorkerState.instances[0].play).not.toHaveBeenCalled(); + }); + + it('skips the self-hosted video when the element already has a fallback image', async () => { + stubReducedMotion(true); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'background' }); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'mp4:../demo/video/video.mp4' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + expect(block.querySelector('video')).toBeNull(); + expect(block.jarallax.image.bgImage).toContain('https://via.placeholder.com/100x50'); + }); + + it('keeps autoplay and the poster when motion is not reduced', async () => { + stubReducedMotion(false); + + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = document.createElement('div'); + block.className = 'jarallax'; + document.body.appendChild(block); + + jarallaxVideo(jarallax); + jarallax(block, { videoSrc: 'mp4:../demo/video/video.mp4' }); + + block.jarallax.isElementInViewport = true; + jarallax(block, 'onScroll'); + + const inserted = block.querySelector('video'); + + expect(inserted).toBeTruthy(); + expect(inserted.getAttribute('poster')).toContain('data:image/gif;base64,'); + expect(videoWorkerState.instances[0].options.autoplay).toBe(true); + }); +}); diff --git a/tests/video-dom.test.js b/tests/video-dom.test.js index fa62be7..eb78b3d 100644 --- a/tests/video-dom.test.js +++ b/tests/video-dom.test.js @@ -137,4 +137,24 @@ describe('jarallax video DOM integration', () => { expect(worker.options.mute).toBe(expectedMute); expect(worker.options.volume).toBe(Number(attr)); }); + + it('forwards the player host options and leaves them undefined when unset', async () => { + const { default: jarallax } = await import('../src/core.ts'); + const { default: jarallaxVideo } = await import('../src/ext-video.ts'); + const block = createJarallaxBlock({ mode: 'img' }); + const other = createJarallaxBlock({ mode: 'img' }); + + jarallaxVideo(jarallax); + jarallax(block, { + videoSrc: 'https://youtu.be/mru3Q5m4lkY', + videoYoutubeHost: 'https://www.youtube-nocookie.com', + }); + jarallax(other, { videoSrc: 'https://youtu.be/mru3Q5m4lkY' }); + + const [withHost, withoutHost] = videoWorkerState.instances; + + expect(withHost.options.youtubeHost).toBe('https://www.youtube-nocookie.com'); + expect(withoutHost.options.youtubeHost).toBeUndefined(); + expect(withoutHost.options.vimeoHost).toBeUndefined(); + }); });