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