Home Blog Creating Smooth Scroll-Synchronized Animation for OPTIKKA: From HTML5 Video to Frame Sequences | Codrops

Creating Smooth Scroll-Synchronized Animation for OPTIKKA: From HTML5 Video to Frame Sequences | Codrops

0
Creating Smooth Scroll-Synchronized Animation for OPTIKKA: From HTML5 Video to Frame Sequences | Codrops


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:

  1. Take the source video.
  2. Split it into individual PNG frames.
  3. 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:

  1. Immediate start: Load the first 10 frames instantly.
  2. First-frame display: Users see animation immediately.
  3. 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

LEAVE A REPLY

Please enter your comment!
Please enter your name here