How to fix this iOS video animation issue when setting currentTime dynamically with JS?
13:10 13 Apr 2025

I'm implementing a scroll-based animation in a Shopify storefront, where a background video plays step-by-step (frame-by-frame) as the user scrolls. It works perfectly across most platforms — including desktop (Chrome, Firefox, Safari), Android phones, MacBooks, and iPads — but performs very poorly on iPhones, especially in Safari.

Problem on iPhone:

  • The first video frame pops up randomly, not synced with scroll.
  • Scroll-scrubbing is laggy or stutters, and the video feels unresponsive.
  • Sometimes the video doesn't load properly at all, requiring a manual scroll nudge to start appearing.

Implementation Details:

  • I’m using a element and manually updating its currentTime based on scroll progress.

  • After a certain scroll threshold, the video plays automatically in a loop.

  • I preload the video, mute it, and ensure it uses playsInline for iOS compatibility.

  • On iOS, I “prime” the video with a silent play/pause on touchstart to comply with autoplay policies.

class ScrollVideoController {
  constructor() {
    this.video = document.querySelector('.animate-on-scroll');
    this.sticky = document.querySelector('.scroll-sticky');
    this.container = document.querySelector('.scroll-video-animation');
    this.progressBar = document.querySelector('.progress-bar');
    this.content = document.querySelector('.scroll-video-animation .content');
    this.contentHalfHalf = document.querySelector('.scroll-video-animation .content-half-half');
    this.header = document.querySelector("#new-fixed-header-styling");

    if (!this.video || !this.sticky || !this.container) return;

    this.isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent);
    this.isMobile = window.innerWidth <= 768;

    // 💡 Increase scroll threshold on mobile to show more frames
    this.scrollThreshold = this.isMobile ? 150 : 80;
    this.loopStartFrame = this.scrollThreshold + 25;
    this.isReady = false;
    this.isAutoplaying = false;
    this.rafId = null;
    this.frameRate = 30;
    this.lastScrollTime = Date.now();
    this.scrollDebounceTime = this.isMobile ? 150 : 0;

    this.init();
  }

  init() {
    this.setupLoadingUI();
    this.video.preload = 'auto';
    this.video.load();
    this.video.currentTime = 0;
    this.video.muted = true;
    this.video.playsInline = true;

    if (this.contentHalfHalf) {
      this.contentHalfHalf.style.opacity = '0';
    }

    this.setupEventListeners();
    this.animate();
  }

  setupLoadingUI() {
    const loadingDiv = document.createElement('div');
    loadingDiv.className = 'loading-overlay';
    loadingDiv.innerHTML = `
      
Loading experience... `; this.video.parentElement.appendChild(loadingDiv); const errorDiv = document.createElement('div'); errorDiv.className = 'error-overlay hidden'; errorDiv.innerHTML = `

Failed to load video. Please try again or use a different browser.

This browser may not support the video format or your connection is unstable.

`; this.video.parentElement.appendChild(errorDiv); this.loadingOverlay = loadingDiv; this.errorOverlay = errorDiv; } setupEventListeners() { this.video.addEventListener('loadeddata', () => { this.isReady = true; this.video.classList.add('ready'); this.loadingOverlay.classList.add('hidden'); }); this.video.addEventListener('canplay', () => { this.loadingOverlay.classList.add('hidden'); }); this.video.addEventListener('error', () => { this.loadingOverlay.classList.add('hidden'); this.errorOverlay.classList.remove('hidden'); }); this.video.addEventListener('ended', () => { const frameTime = this.loopStartFrame / this.frameRate; this.video.currentTime = frameTime; if (this.isAutoplaying) { this.video.play().catch(() => {}); } }); if (this.isIOS) { const primeVideo = async () => { try { await this.video.play(); this.video.pause(); this.video.currentTime = 0; } catch (error) { console.warn('Video priming failed:', error); } window.removeEventListener('touchstart', primeVideo); }; window.addEventListener('touchstart', primeVideo, { once: true }); } window.addEventListener('resize', () => { this.isMobile = window.innerWidth <= 768; this.scrollDebounceTime = this.isMobile ? 50 : 0; // Recalculate scrollThreshold based on new window size this.scrollThreshold = this.isMobile ? 150 : 80; this.loopStartFrame = this.scrollThreshold + 25; }); } getScrollProgress() { const containerRect = this.container.getBoundingClientRect(); const stickyRect = this.sticky.getBoundingClientRect(); const containerScrollLength = this.container.offsetHeight - window.innerHeight; const scrolledInsideContainer = Math.abs(containerRect.top); return { progress: Math.min(Math.max(scrolledInsideContainer / containerScrollLength, 0), 1), stickyTop: stickyRect.top, stickyRect }; } updateVideo(scrollData) { if (!this.isReady) return; const now = Date.now(); if (this.isMobile && now - this.lastScrollTime < this.scrollDebounceTime) { return; } this.lastScrollTime = now; // Header visibility if (this.header) { const windowHeight = window.innerHeight; const visibleHeight = Math.min(windowHeight, scrollData.stickyRect.bottom) - Math.max(0, scrollData.stickyRect.top); const visibleRatio = visibleHeight / scrollData.stickyRect.height; if (visibleRatio >= 0.9) { this.header.style.opacity = "0"; this.header.style.pointerEvents = "none"; } else { this.header.style.opacity = "1"; this.header.style.pointerEvents = "auto"; } } // Content opacity if (this.content) { const contentOpacity = Math.max(0, 1 - (scrollData.progress * (1 / 0.45))); this.content.style.opacity = contentOpacity.toString(); } // Content-half-half fade in if (this.contentHalfHalf) { const halfHalfOpacity = scrollData.progress >= 0.6 ? Math.min(1, (scrollData.progress - 0.6) * (1 / 0.2)) : 0; this.contentHalfHalf.style.opacity = halfHalfOpacity.toString(); } if (scrollData.stickyTop > 0) { this.isAutoplaying = false; this.video.pause(); this.video.currentTime = 0; return; } if (scrollData.progress < 0.55) { const frameNumber = Math.floor(scrollData.progress * (this.scrollThreshold / 0.55)); const frameTime = frameNumber / this.frameRate; if (this.isAutoplaying) { this.isAutoplaying = false; this.video.pause(); } const timeDiff = Math.abs(this.video.currentTime - frameTime); if (timeDiff > (this.isMobile ? 0.5 : 0.1)) { this.video.currentTime = frameTime; } } else if (!this.isAutoplaying) { this.isAutoplaying = true; const startFrame = this.scrollThreshold / this.frameRate; this.video.currentTime = startFrame; this.video.play().catch(() => {}); } if (this.progressBar) { this.progressBar.style.width = `${scrollData.progress * 100}%`; const progressBarContainer = this.progressBar.parentElement; if (progressBarContainer) { if (scrollData.progress >= 0.98) { progressBarContainer.style.opacity = '0'; progressBarContainer.style.transition = 'opacity 0.5s ease-out'; } else if (scrollData.progress < 0.95) { progressBarContainer.style.opacity = '1'; progressBarContainer.style.transition = 'opacity 0.3s ease-in'; } } } } animate() { const scrollData = this.getScrollProgress(); this.updateVideo(scrollData); this.rafId = requestAnimationFrame(() => this.animate()); } }
//ONLY TEXT CONTENT HERE
//ONLY TEXT CONTENT HERE
//ONLY TEXT CONTENT HERE

javascript html html5-video