Build a scroll-driven hero animation with Anime.js

Code
27 February 2025
Demo
Anime Js Feature Image

I built this hero animation to transition from a video introduction to a second panel as the page scrolls. Anime.js controls the sequence: fade the first text, blur and scale the video, then reveal the second panel.

The example uses Blade, Statamic's modify helper, Tailwind CSS, and Anime.js. The video include and content variables come from the original project.

HTML

Split the hero into two panels. hero-1 holds the video and introductory text. hero-2 holds the content revealed during the transition.

<section class="section-hero">
<div class="careers-hero" id="careers-hero">
<div class="hero-1" id="hero-1">
<div class="hero-1-video mb-6 mt-12 lg:my-12" id="hero-1-video">
@include('includes.video', ['src' => $hero_video, 'poster' => $hero_image])
</div>
<div class="hero-1-text mx-auto text-center">
<h1 class="mb-6 text-5xl text-white lg:mb-8 lg:text-6xl">{!! modify($hero_title)->nl2br() !!}</h1>
<p class="max-w-3xl text-center text-lg text-base-gray">{!! modify($hero_text)->nl2br() !!}</p>
</div>
</div>
<div class="hero-2" id="hero-2">
<div class="hero-2-text text-center">
<h2 class="mb-6 text-4xl lg:mb-8 lg:text-5xl">{!! modify($hero_colored_title)->nl2br() !!}</h2>
<p class="mx-auto max-w-3xl text-base">{!! modify($hero_colored_text)->nl2br() !!}</p>
</div>
</div>
</div>
</section>

The video comes from includes.video. The Blade variables provide the titles, text, video source, and poster image.

JavaScript

Create a paused Anime.js timeline and move through it with seek() as the page scrolls. The animation is registered when the viewport is at least 768px wide.

import anime from 'animejs'

export default function () {
let hero1El = document.getElementById('hero-1')
let hero2El = document.getElementById('hero-2')
let hero = document.getElementById('careers-hero')
let videoElement = document.querySelector('#hero-1-video video')

function registerAnimations() {
let w = window.innerWidth

function adaptLayout(progress) {
if (progress === 0) {
// Natural flow; remove fixed or absolute positioning.
hero1El.classList.remove('hero-1-fixed')
hero1El.classList.remove('hero-1-absolute')
hero2El.classList.remove('hero-2-fixed')
hero2El.classList.remove('hero-2-absolute')
videoElement?.play()
} else if (progress === 100) {
// When fully scrolled, switch to absolute positioning.
hero1El.classList.add('hero-1-absolute')
hero2El.classList.add('hero-2-absolute')
hero1El.classList.remove('hero-1-fixed')
hero2El.classList.remove('hero-2-fixed')
videoElement?.play()
} else if (progress > 0 && progress < 100 && !hero1El.classList.contains('hero-1-fixed')) {
// Mid-transition: fix the hero sections to create a parallax effect.
hero1El.classList.add('hero-1-fixed')
hero2El.classList.add('hero-2-fixed')
hero1El.classList.remove('hero-1-absolute')
hero2El.classList.remove('hero-2-absolute')
videoElement?.pause()
}
}

if (w >= 768) {
let tl2 = anime.timeline({
autoplay: false,
update: function (anim) {
adaptLayout(anim.progress)
},
})

let hero1text = {
targets: '.hero-1-text',
opacity: [1, 0],
duration: 60,
easing: 'easeInOutQuad',
}

let hero1video = {
targets: '.hero-1-video',
opacity: [1, 0.9],
filter: ['blur(0px)', 'blur(50px)'],
scale: [1, 4],
duration: 180,
easing: 'easeInOutQuad',
}

let hero2 = {
targets: '.hero-2',
opacity: [0, 1],
duration: 300,
easing: 'linear',
}

let hero2text = {
targets: '.hero-2-text',
opacity: [0, 1],
translateY: [150, 0],
duration: 80,
easing: 'easeInOutQuad',
}

let heroStay = {
targets: '.hero-2',
opacity: [1, 1],
duration: 50,
}

tl2.add(hero1text)
.add(hero1video)
.add(hero2)
.add(hero2text)
.add(heroStay)

const scrollPercent = () => {
const bodyST = document.body.scrollTop
const docST = document.documentElement.scrollTop
const docSH = document.documentElement.scrollHeight
const docCH = document.documentElement.clientHeight

const divHeightPerc = hero.offsetHeight / docSH
const perc = (docST + bodyST) / (docSH - docCH)
return (perc / divHeightPerc) * 2
}

const handleScroll = () => {
tl2.seek(scrollPercent() * tl2.duration)
}

window.onscroll = handleScroll
}
}

registerAnimations()
}

How it works

  • The timeline fades out the first heading, scales and blurs the video, reveals the second panel, and moves its text upward.

  • scrollPercent relates page scroll to the hero height. The scroll handler uses that value to set the timeline position.

  • adaptLayout removes the positioning overrides at the start, fixes both panels during the transition, and switches them to absolute positioning at the end.

  • The video pauses during the transition and resumes at either endpoint.

CSS

Use Tailwind utilities to define the layout and the positioning classes toggled by JavaScript. The md: classes provide the layout used by the scroll animation.

.careers-hero {
@apply overflow-hidden h-full md:h-[140vh] relative w-full z-[10];

.hero-1 {
@apply relative md:absolute flex flex-col items-center justify-center px-6 py-16 md:top-0 md:z-20 md:z-[1] md:h-full md:w-full md:p-0;
@apply w-full md:h-screen top-0 z-[1];
}

.hero-1-fixed {
@apply !fixed;
}

.hero-1-absolute {
@apply !absolute !bottom-0;
top: unset;
}

.hero-2 {
@apply md:absolute flex md:h-screen w-full flex-col items-center justify-center bg-gradient-to-r from-[#3157FF] to-[#B42DFF];
@apply !py-32 px-6 text-center text-white md:fixed md:top-0 md:z-[6] md:p-0 md:opacity-0;
@apply top-0 left-0;
}

.hero-2-fixed {
@apply !fixed !top-0;
}

.hero-2-absolute {
@apply !absolute !bottom-0;
top: unset;
}
}

Adjust the effect

Change the blur, scale, and text offset in the timeline to adjust the effect. Because scrolling sets the timeline position, the duration values control how much of the sequence each transition occupies.

Want to see an example? Demo