8.6 C
New York
Thursday, October 16, 2025

Creating Clean Scroll-Synchronized Animation for OPTIKKA: From HTML5 Video to Body Sequences



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:

  1. Take the supply video.
  2. Cut up it into particular person PNG frames.
  3. 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:

  1. Speedy begin: Load the primary 10 frames immediately.
  2. First-frame show: Customers see animation instantly.
  3. 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.



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles