When OPTIKKA—a artistic orchestration platform remodeling conventional design workflows into clever, extensible techniques—got here to us at Zajno, we shortly outlined a core visible metaphor: a dynamic, visually wealthy file system that expands as you scroll. All through design and growth, we explored a number of iterations to make sure the web site’s central animation was not solely placing but additionally seamless and constant throughout all units.
On this article, we’ll clarify why we moved away from utilizing HTML5 video for scroll-synchronized animation and supply an in depth information on creating related animations utilizing body sequences.
The Preliminary Strategy: HTML5 Video
Why It Appeared Promising
Our first thought was to make use of HTML5 video for the scroll-triggered animation, paired with GSAP’s ScrollTrigger plugin for scroll monitoring. The strategy had clear benefits:
// Preliminary strategy with video aspect
export default class VideoScene extends Part {
non-public video: HTMLVideoElement;
non-public scrollTrigger: ScrollTrigger;
setupVideoScroll() {
this.scrollTrigger = ScrollTrigger.create({
set off: '.video-container',
begin: 'high high',
finish: 'backside backside',
scrub: true,
onUpdate: (self) => {
// Synchronize video time with scroll progress
const length = this.video.length;
this.video.currentTime = self.progress * length;
},
});
}
}
- Simplicity: Browsers assist video playback natively.
- Compactness: One video file as an alternative of tons of of photographs.
- Compression: Video codecs effectively scale back file measurement.
In actuality, this strategy had vital drawbacks:
- Stuttering and lag, particularly on cell units.
- Autoplay restrictions in lots of browsers.
- Lack of visible constancy on account of compression.
These points motivated a shift towards a extra controllable and dependable answer.
Transition to Body Sequences
What Is a Body Sequence?
A body sequence consists of particular person photographs performed quickly to create the phantasm of movement—very like a movie at 24 frames per second. This methodology permits exact management over animation timing and high quality.
Extracting Frames from Video
We used FFmpeg to transform movies into particular person frames after which into optimized internet codecs:
- Take the supply video.
- Cut up it into particular person PNG frames.
- Convert PNGs into WebP to scale back file measurement.
// Extract frames as PNG sequence
console.log('🎬 Extracting PNG frames...');
await execPromise(`ffmpeg -i "video/${videoFile}" -vf "fps=30" "png/frame_percent03d.png"`);
// Convert PNG sequence to WebP
console.log('🔄 Changing to WebP sequence...');
await execPromise(`ffmpeg -i "png/frame_percent03d.png" -c:v libwebp -quality 80 "webp/frame_percent03d.webp"`);
console.log('✅ Processing full!');
System-Particular Sequences
To optimize efficiency throughout units, we created at the least two units of sequences for various facet ratios:
- Desktop: Greater body depend for smoother animation.
- Cellular: Decrease body depend for quicker loading and effectivity.
// New picture sequence primarily based structure
export default summary class Scene extends Part {
non-public _canvas: HTMLCanvasElement;
non-public _ctx: CanvasRenderingContext2D;
non-public _frameImages: Map<quantity, HTMLImageElement> = new Map();
non-public _currentFrame: { contents: quantity } = { contents: 1 };
// System-specific body configuration
non-public static readonly totalFrames: Report<BreakpointType, quantity> = {
[BreakpointType.Desktop]: 1182,
[BreakpointType.Tablet]: 880,
[BreakpointType.Mobile]: 880,
};
// Offset for video finish primarily based on system kind
non-public static readonly offsetVideoEnd: Report<BreakpointType, quantity> = {
[BreakpointType.Desktop]: 1500,
[BreakpointType.Tablet]: 1500,
[BreakpointType.Mobile]: 1800,
};
}
We additionally applied dynamic path decision to load the right picture sequence relying on the person’s system kind.
// Dynamic path primarily based on present breakpoint
img.src = `/${this._currentBreakpointType.toLowerCase()}/frame_${paddedNumber}.webp`;
Clever Body Loading System
The Problem
Loading 1,000+ photographs with out blocking the UI or consuming extreme bandwidth is hard. Customers count on instantaneous animation, however heavy picture sequences can decelerate the positioning.
Stepwise Loading Answer
We applied a staged loading system:
- Speedy begin: Load the primary 10 frames immediately.
- First-frame show: Customers see animation instantly.
- Background loading: Remaining frames load seamlessly within the background.
await this.preloadFrames(1, countPreloadFrames);
this.renderFrame(1);
this.loadFramesToHash();
Parallel Background Loading
Utilizing a ParallelQueue system, we:
- Load remaining frames effectively with out blocking the UI.
- Begin from an outlined countPreloadFrames to keep away from redundancy.
- Cache every loaded body robotically for efficiency.
// Background loading of all frames utilizing parallel queue
non-public 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.begin();
}
Rendering with Canvas
Why Canvas
Rendering frames in an HTML <canvas> aspect provided a number of advantages:
- Immediate rendering: Frames load into reminiscence for fast show.
- No DOM reflow: Avoids repainting the web page.
- Optimized animation: Works easily with requestAnimationFrame.
// Canvas rendering with correct scaling and positioning
non-public renderFrame(frameNumber: quantity) {
const img = this._frameImages.get(frameNumber);
if (img && this._ctx) {
// Clear earlier body
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.peak);
// Deal with excessive DPI shows
const pixelRatio = window.devicePixelRatio || 1;
const canvasRatio = this._canvas.width / this._canvas.peak;
const imageRatio = img.width / img.peak;
// Calculate dimensions for object-fit: cowl conduct
let drawWidth = this._canvas.width;
let drawHeight = this._canvas.peak;
let offsetX = 0;
let offsetY = 0;
if (canvasRatio > imageRatio) {
// Canvas is wider than picture
drawWidth = this._canvas.width;
drawHeight = this._canvas.width / imageRatio;
} else {
// Canvas is taller than picture
drawHeight = this._canvas.peak;
drawWidth = this._canvas.peak * imageRatio;
offsetX = (this._canvas.width - drawWidth) / 2;
}
// Draw picture with correct scaling for top DPI
this._ctx.drawImage(img, offsetX, offsetY, drawWidth / pixelRatio, drawHeight / pixelRatio);
}
}
Limitations of <img> Parts
Whereas attainable, utilizing <img> for body sequences presents points:
- Restricted management over scaling.
- Synchronization issues throughout fast body adjustments.
- Flickering and inconsistent cross-browser rendering.
// Auto-playing loop animation on the high of the web page
non-public async playLoop() {
if (!this.isLooping) return;
const startTime = Date.now();
const animate = () => {
if (!this.isLooping) return;
// Calculate present progress inside loop length
const elapsed = (Date.now() - startTime) % (this.loopDuration * 1000);
const progress = elapsed / (this.loopDuration * 1000);
// Map progress to border quantity
const body = Math.spherical(this.loopStartFrame + progress * this.framesPerLoop);
if (body !== this._currentFrame.contents) {
this._currentFrame.contents = body;
this.renderFrame(this._currentFrame.contents);
}
requestAnimationFrame(animate);
};
// Preload loop frames earlier than beginning animation
await this.preloadFrames(this.loopStartFrame, this.loopEndFrame);
animate();
}
Loop Animation at Web page Begin
Canvas additionally allowed us to implement looping animations firstly of the web page with seamless transitions to scroll-triggered frames utilizing GSAP.
// Clean transition between loop and scroll-based animation
// Background loading of all frames utilizing parallel queue
non-public handleScrollTransition(scrollProgress: quantity) {
if (this.isLooping && scrollProgress > 0) {
// Transition from loop to scroll-based animation
this.isLooping = false;
gsap.to(this._currentFrame, {
length: this.transitionDuration,
contents: this.framesPerLoop - this.transitionStartScrollOffset,
ease: 'power2.inOut',
onComplete: () => (this.isLooping = false),
});
} else if (!this.isLooping && scrollProgress === 0) {
// Transition again to loop animation
this.preloadFrames(this.loopStartFrame, this.loopEndFrame);
this.isLooping = true;
this.playLoop();
}
}
Efficiency Optimizations
Dynamic Preloading Primarily based on Scroll Route
We enhanced smoothness by preloading frames dynamically in accordance with scroll motion:
- Scroll down: Preload 5 frames forward.
- Scroll up: Preload 5 frames behind.
- Optimized vary: Solely load crucial frames.
- Synchronized rendering: Preloading occurs in sync with the present body show.
// Sensible preloading primarily based on scroll course
_containerSequenceUpdate = async (self: ScrollTrigger) => {
const currentScroll = window.scrollY;
const isScrollingUp = currentScroll < this.lastScrollPosition;
this.lastScrollPosition = currentScroll;
// Calculate adjusted progress with finish offset
const totalHeight = doc.documentElement.scrollHeight - window.innerHeight;
const adjustedProgress = Math.min(1, currentScroll / (totalHeight - offsetVideoEnd[this._currentBreakpointType]));
// Deal with transition between states
this.handleScrollTransition(self.progress);
if (!this.isLooping) {
const body = Math.spherical(adjustedProgress * totalFrames[this._currentBreakpointType]);
if (body !== this._currentFrame.contents) {
this._currentFrame.contents = body;
// Preload frames in scroll course
const preloadAmount = 5;
await this.preloadFrames(
body + (isScrollingUp ? -preloadAmount : 1),
body + (isScrollingUp ? -1 : preloadAmount)
);
this.renderFrame(body);
}
}
};
Outcomes of the Transition
Advantages
- Steady efficiency throughout units.
- Predictable reminiscence utilization.
- No playback stuttering.
- Cross-platform consistency.
- Autoplay flexibility.
- Exact management over every body.
Technical Commerce-offs
- Elevated bandwidth on account of a number of requests.
- Bigger total knowledge measurement.
- Greater implementation complexity with caching and preloading logic.
Conclusion
Switching from video to border sequences for OPTIKKA demonstrated the significance of selecting the best expertise for the duty. Regardless of added complexity, the brand new strategy offered:
- Dependable efficiency throughout units.
- Constant, clean animation.
- High quality-grained management for varied situations.
Typically, a extra technically advanced answer is justified if it delivers a greater person expertise.


