diff --git a/src/widgets/landing/video-section/VideoSection.tsx b/src/widgets/landing/video-section/VideoSection.tsx
index 5986d7e2..a3a79c46 100644
--- a/src/widgets/landing/video-section/VideoSection.tsx
+++ b/src/widgets/landing/video-section/VideoSection.tsx
@@ -7,8 +7,10 @@
* Only the video for the active viewport is mounted — no double-fetch.
* Aspect-ratio box reserved to prevent CLS during SSR → client transition.
* Reduced motion: autoplay suppressed; poster shown with native controls.
+ * Mobile: autoplay disabled entirely so preload="none" actually defers the
+ * 2.7MB fetch. Poster shown with native controls for tap-to-play affordance.
* Off-screen: IntersectionObserver pauses video when scrolled out of view
- * and resumes when scrolled back in (skipped under reduced-motion).
+ * and resumes when scrolled back in (skipped under reduced-motion or mobile).
*/
'use client'
@@ -37,9 +39,10 @@ export function VideoSection() {
}, [])
// Pause the off-screen video to avoid wasting data/battery.
- // Skip entirely under reduced-motion — videos aren't autoplaying anyway.
+ // Skip under reduced-motion or mobile — videos aren't autoplaying in those cases.
useEffect(() => {
if (prefersReducedMotion) return
+ if (isMobile) return
if (typeof window === 'undefined') return
const section = sectionRef.current
@@ -62,7 +65,7 @@ export function VideoSection() {
observer.observe(section)
return () => observer.disconnect()
- }, [prefersReducedMotion])
+ }, [prefersReducedMotion, isMobile])
const videoSrc = isMobile ? '/video/voidpay-9x16-v2.mp4' : '/video/voidpay-16x9-v2.mp4'
const wrapperClassName = isMobile
@@ -98,11 +101,11 @@ export function VideoSection() {
src={videoSrc}
poster="/video/poster-scene5.webp"
muted
- autoPlay={!prefersReducedMotion}
+ autoPlay={!prefersReducedMotion && !isMobile}
loop
playsInline
preload="none"
- controls={prefersReducedMotion}
+ controls={prefersReducedMotion || isMobile}
aria-label="VoidPay product walkthrough: creating and paying a crypto invoice"
onPlay={handlePlay}
/>
diff --git a/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx b/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx
index fdd34eba..6fe4e9d0 100644
--- a/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx
+++ b/src/widgets/landing/video-section/__tests__/VideoSection.test.tsx
@@ -172,6 +172,25 @@ describe('VideoSection', () => {
expect(video).toHaveAttribute('controls')
})
+ it('mobile path: should NOT have autoplay attribute', () => {
+ stubMobileViewport()
+ render()
+ const video = document.querySelector('video')
+ // On mobile, autoPlay is disabled so preload="none" actually defers the fetch
+ expect(video).not.toHaveAttribute('autoplay')
+ })
+
+ it('mobile path: should show controls for tap-to-play affordance', () => {
+ stubMobileViewport()
+ render()
+ const video = document.querySelector('video')
+ expect(video).toHaveAttribute('controls')
+ })
+
+ // Desktop autoplay when reduced-motion is off cannot be tested in this suite:
+ // useReducedMotion is globally mocked to return true (vitest.setup.ts).
+ // The mobile-specific cases above cover the new branching logic.
+
it('should have an accessible aria-label on the video', () => {
render()
const video = document.querySelector('video')
@@ -236,7 +255,8 @@ describe('VideoSection', () => {
it('should register an IntersectionObserver when reduced-motion is off', () => {
// Note: global mock returns prefersReducedMotion=true, so observer is NOT registered.
- // This test documents that the observer is skipped in reduced-motion mode.
+ // Observer is also skipped on mobile. This test documents that the observer is
+ // skipped in reduced-motion mode (global mock).
render()
// Under global reduced-motion mock (true), IntersectionObserver should NOT be called
expect(IntersectionObserver).not.toHaveBeenCalled()