
When OPTIKKA—a creative orchestration platform transforming traditional design workflows into intelligent, extensible systems—came to us at Zajno, we quickly defined a core visual metaphor: a dynamic, visually rich file system that expands as you scroll. Throughout design and development, we explored multiple iterations to ensure the website’s central animation was not only striking but also seamless and consistent across all devices.
In this article, we’ll explain why we moved away from using HTML5 video for scroll-synchronized animation and provide a detailed guide on creating similar animations using frame sequences.
The Initial Approach: HTML5 Video
Why It Seemed Promising
Our first idea was to use HTML5 video for the scroll-triggered animation, paired with GSAP’s ScrollTrigger plugin for scroll tracking. The approach had clear advantages:
// Initial approach with video element
export default class VideoScene extends Section {
private video: HTMLVideoElement;
private scrollTrigger: ScrollTrigger;
setupVideoScroll() {
this.scrollTrigger = ScrollTrigger.create({
trigger: '.video-container',
start: 'top top',
end: 'bottom bottom',
scrub: true,
onUpdate: (self) => {
// Synchronize video time with scroll progress
const duration = this.video.duration;
this.video.currentTime = self.progress * duration;
},
});
}
}- Simplicity: Browsers support video playback natively.
- Compactness: One video file instead of hundreds of images.
- Compression: Video codecs efficiently reduce file size.
In reality, this approach had significant drawbacks:
- Stuttering and lag, especially on mobile devices.
- Autoplay restrictions in many browsers.
- Loss of visual fidelity due to compression.
These issues motivated a shift toward a more controllable and reliable solution.
Transition to Frame Sequences
What Is a Frame Sequence?
A frame sequence consists of individual images played rapidly to create the illusion of motion—much like a film at 24 frames per second. This method allows precise control over animation timing and quality.
Extracting Frames from Video
We used FFmpeg to convert videos into individual frames and then into optimized web formats:
- Take the source video.
- Split it into individual PNG frames.
- Convert PNGs into WebP to reduce file size.
// Extract frames as PNG sequence
console.log('🎬 Extracting PNG frames...');
await execPromise(`ffmpeg -i "video/${videoFile}" -vf "fps=30" "png/frame_%03d.png"`);
// Convert PNG sequence to WebP
console.log('🔄 Converting to WebP sequence...');
await execPromise(`ffmpeg -i "png/frame_%03d.png" -c:v libwebp -quality 80 "webp/frame_%03d.webp"`);
console.log('✅ Processing complete!');Device-Specific Sequences
To optimize performance across devices, we created at least two sets of sequences for different aspect ratios:
- Desktop: Higher frame count for smoother animation.
- Mobile: Lower frame count for faster loading and efficiency.
// New image sequence based architecture
export default abstract class Scene extends Section {
private _canvas: HTMLCanvasElement;
private _ctx: CanvasRenderingContext2D;
private _frameImages: Map = new Map();
private _currentFrame: { contents: number } = { contents: 1 };
// Device-specific frame configuration
private static readonly totalFrames: Record = {
[BreakpointType.Desktop]: 1182,
[BreakpointType.Tablet]: 880,
[BreakpointType.Mobile]: 880,
};
// Offset for video end based on device type
private static readonly offsetVideoEnd: Record = {
[BreakpointType.Desktop]: 1500,
[BreakpointType.Tablet]: 1500,
[BreakpointType.Mobile]: 1800,
};
} We also implemented dynamic path resolution to load the correct image sequence depending on the user’s device type.
// Dynamic path based on current breakpoint
img.src = `/${this._currentBreakpointType.toLowerCase()}/frame_${paddedNumber}.webp`;Intelligent Frame Loading System
The Challenge
Loading 1,000+ images without blocking the UI or consuming excessive bandwidth is tricky. Users expect instantaneous animation, but heavy image sequences can slow down the site.
Stepwise Loading Solution
We implemented a staged loading system:
- Immediate start: Load the first 10 frames instantly.
- First-frame display: Users see animation immediately.
- Background loading: Remaining frames load seamlessly in the background.
await this.preloadFrames(1, countPreloadFrames);
this.renderFrame(1);
this.loadFramesToHash();Parallel Background Loading
Using a ParallelQueue system, we:
- Load remaining frames efficiently without blocking the UI.
- Start from a defined countPreloadFrames to avoid redundancy.
- Cache each loaded frame automatically for performance.
// Background loading of all frames using parallel queue
private loadFramesToHash() {
const queue = new ParallelQueue();
for (let i = countPreloadFrames; i <= totalFrames[this._currentBreakpointType]; i++) {
queue.enqueue(async () => {
const img = await this.loadFrame(i);
this._frameImages.set(i, img);
});
}
queue.start();
}Rendering with Canvas
Why Canvas
Rendering frames in an HTML element offered multiple benefits:
- Instant rendering: Frames load into memory for immediate display.
- No DOM reflow: Avoids repainting the page.
- Optimized animation: Works smoothly with requestAnimationFrame.
// Canvas rendering with proper scaling and positioning
private renderFrame(frameNumber: number) {
const img = this._frameImages.get(frameNumber);
if (img && this._ctx) {
// Clear previous frame
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
// Handle high DPI displays
const pixelRatio = window.devicePixelRatio || 1;
const canvasRatio = this._canvas.width / this._canvas.height;
const imageRatio = img.width / img.height;
// Calculate dimensions for object-fit: cover behavior
let drawWidth = this._canvas.width;
let drawHeight = this._canvas.height;
let offsetX = 0;
let offsetY = 0;
if (canvasRatio > imageRatio) {
// Canvas is wider than image
drawWidth = this._canvas.width;
drawHeight = this._canvas.width / imageRatio;
} else {
// Canvas is taller than image
drawHeight = this._canvas.height;
drawWidth = this._canvas.height * imageRatio;
offsetX = (this._canvas.width - drawWidth) / 2;
}
// Draw image with proper scaling for high DPI
this._ctx.drawImage(img, offsetX, offsetY, drawWidth / pixelRatio, drawHeight / pixelRatio);
}
}Limitations of
Elements
While possible, using for frame sequences presents issues:
- Limited control over scaling.
- Synchronization problems during rapid frame changes.
- Flickering and inconsistent cross-browser rendering.
// Auto-playing loop animation at the top of the page
private async playLoop() {
if (!this.isLooping) return;
const startTime = Date.now();
const animate = () => {
if (!this.isLooping) return;
// Calculate current progress within loop duration
const elapsed = (Date.now() - startTime) % (this.loopDuration * 1000);
const progress = elapsed / (this.loopDuration * 1000);
// Map progress to frame number
const frame = Math.round(this.loopStartFrame + progress * this.framesPerLoop);
if (frame !== this._currentFrame.contents) {
this._currentFrame.contents = frame;
this.renderFrame(this._currentFrame.contents);
}
requestAnimationFrame(animate);
};
// Preload loop frames before starting animation
await this.preloadFrames(this.loopStartFrame, this.loopEndFrame);
animate();
}Loop Animation at Page Start
Canvas also allowed us to implement looping animations at the start of the page with seamless transitions to scroll-triggered frames using GSAP.
// Smooth transition between loop and scroll-based animation
// Background loading of all frames using parallel queue
private handleScrollTransition(scrollProgress: number) {
if (this.isLooping && scrollProgress > 0) {
// Transition from loop to scroll-based animation
this.isLooping = false;
gsap.to(this._currentFrame, {
duration: this.transitionDuration,
contents: this.framesPerLoop - this.transitionStartScrollOffset,
ease: 'power2.inOut',
onComplete: () => (this.isLooping = false),
});
} else if (!this.isLooping && scrollProgress === 0) {
// Transition back to loop animation
this.preloadFrames(this.loopStartFrame, this.loopEndFrame);
this.isLooping = true;
this.playLoop();
}
}Performance Optimizations
Dynamic Preloading Based on Scroll Direction
We enhanced smoothness by preloading frames dynamically according to scroll movement:
- Scroll down: Preload 5 frames ahead.
- Scroll up: Preload 5 frames behind.
- Optimized range: Only load necessary frames.
- Synchronized rendering: Preloading happens in sync with the current frame display.
// Smart preloading based on scroll direction
_containerSequenceUpdate = async (self: ScrollTrigger) => {
const currentScroll = window.scrollY;
const isScrollingUp = currentScroll < this.lastScrollPosition;
this.lastScrollPosition = currentScroll;
// Calculate adjusted progress with end offset
const totalHeight = document.documentElement.scrollHeight - window.innerHeight;
const adjustedProgress = Math.min(1, currentScroll / (totalHeight - offsetVideoEnd[this._currentBreakpointType]));
// Handle transition between states
this.handleScrollTransition(self.progress);
if (!this.isLooping) {
const frame = Math.round(adjustedProgress * totalFrames[this._currentBreakpointType]);
if (frame !== this._currentFrame.contents) {
this._currentFrame.contents = frame;
// Preload frames in scroll direction
const preloadAmount = 5;
await this.preloadFrames(
frame + (isScrollingUp ? -preloadAmount : 1),
frame + (isScrollingUp ? -1 : preloadAmount)
);
this.renderFrame(frame);
}
}
};Results of the Transition
Benefits
- Stable performance across devices.
- Predictable memory usage.
- No playback stuttering.
- Cross-platform consistency.
- Autoplay flexibility.
- Precise control over each frame.
Technical Trade-offs
- Increased bandwidth due to multiple requests.
- Larger overall data size.
- Higher implementation complexity with caching and preloading logic.
Conclusion
Switching from video to frame sequences for OPTIKKA demonstrated the importance of choosing the right technology for the task. Despite added complexity, the new approach provided:
- Reliable performance across devices.
- Consistent, smooth animation.
- Fine-grained control for various scenarios.
Sometimes, a more technically complex solution is justified if it delivers a better user experience.
#Creating #Smooth #ScrollSynchronized #Animation #OPTIKKA #HTML5 #Video #Frame #Sequences #Codrops