Right this moment we’re going to construct an animated, multi-page gallery the place photographs reveal with a WebGL shader as you scroll, after which animate right into a full-size element view if you click on.
To tug this off we’ll mix a number of methods that present up rather a lot in fashionable artistic dev work:
- Syncing WebGL and the DOM so your Three.js planes completely match your HTML photographs
- Clean scrolling that performs properly with a render loop
- Scroll-triggered shader animation to disclose photographs as they enter the viewport
- Seamless web page transitions the place the clicked picture visually travels between pages (with out jumps)
We’ll use Astro to maintain the venture light-weight and straightforward to construction as an actual multi-page website, Barba.js to regulate navigation and run our transition logic, and GSAP (ScrollSmoother, ScrollTrigger, SplitText, Flip) to drive the movement.
Establishing our surroundings
That is our preliminary setup:
- A house web page with all the photographs
- A particulars web page for every picture
This shall be our our house web page at index.astro:
---
import Format from '../layouts/Format.astro';
import medias from '../information.json';
import Header from '../parts/header.astro';
---
{medias[0].identify}
{medias[1].identify}
{medias[2].identify}
{medias[3].identify}
{medias[4].identify}
Darkish spruce forest frowned on both facet the frozen waterway. The
timber had been stripped by a current wind of their white masking of
frost, they usually appeared to lean towards one another, black and ominous,
within the fading mild. An enormous silence reigned over the land. The land
itself was a desolation, lifeless, with out motion, so lonely and
chilly that the spirit of it was not even that of unhappiness. There was a
trace in it of laughter, however a laughter extra horrible than any
unhappiness—a laughter that was mirthless because the smile of the sphinx, a
laughter chilly because the frost and partaking of the grimness of
infallibility. It was the masterful and incommunicable knowledge of
eternity laughing on the futility of life and the trouble of life. It
was the Wild, the savage, frozen-hearted Northland Wild.
{medias[5].identify}
{medias[6].identify}
{medias[7].identify}
The person strode forward of the workforce. He was a younger man, tall, robust,
with mild hair and blue eyes, and his face was expressionless because the
land he traversed. He carried his rifle loosely in his hand, as if
it have been a part of his physique, and he swung together with the convenience of lengthy
familiarity. The lady adopted the sled. She was younger, too, and her
face bore the stamp of the Northland—endurance and persistence, and a
obscure trace of struggling. The kid, wrapped in furs, slept within the
sled, its face peaceable and unconscious of the grim wrestle being
waged on its behalf.
{medias[8].identify}
And this shall be our particulars web page template at [index].astro, It’s fetching a information.json file containing all the information and producing a web page for every picture.
---
import Format from '../layouts/Format.astro';
import medias from '../information.json';
import Header from '../parts/header.astro';
export const getStaticPaths = async () => {
return medias.map((media) => {
return {
params: { index: media.index.toString() },
props: {
media,
},
};
});
};
const { media } = Astro.props;
---
Now Let’s apply some styling and add some information for every picture.
The Format part is the foundation container the place our JavaScript app shall be imported. It’s a template that shall be shared throughout all of the pages of our web site. That’s additionally the place our WebGL canvas shall be sitting
---
import "../types/index.css"
---
WebGL Pixel Impact on Scroll with GSAP, Three.js and Astro
The major.ts file is the entry level to our JavaScript app:
class App {
constructor()
{
// Utility entry level
}
}
export default new App()
Now for our picture reveal impact we might want to use WebGL, for that I'll create a Canvas.ts class that may instantiate a Three.js surroundings.
import * as THREE from "three"
export default class Canvas {
ingredient: HTMLCanvasElement
scene: THREE.Scene
digicam: THREE.PerspectiveCamera
renderer: THREE.WebGLRenderer
constructor() {
this.ingredient = doc.getElementById("webgl") as HTMLCanvasElement
this.createScene()
this.createCamera()
this.createRenderer()
this.setSizes()
this.addEventListeners()
}
createScene() {
this.scene = new THREE.Scene()
}
createCamera() {
this.digicam = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
100,
)
this.scene.add(this.digicam)
this.digicam.place.z = 10
}
createRenderer() {
this.dimensions = {
width: window.innerWidth,
top: window.innerHeight,
pixelRatio: Math.min(2, window.devicePixelRatio),
}
this.renderer = new THREE.WebGLRenderer({
canvas: this.ingredient,
alpha: true,
})
this.renderer.setSize(this.dimensions.width, this.dimensions.top)
this.renderer.render(this.scene, this.digicam)
this.renderer.setPixelRatio(this.dimensions.pixelRatio)
}
setSizes() {
let fov = this.digicam.fov * (Math.PI / 180)
let top = this.digicam.place.z * Math.tan(fov / 2) * 2
let width = top * this.digicam.facet
this.sizes = {
width: width,
top: top,
}
}
addEventListeners() {
window.addEventListener("resize", this.onResize.bind(this))
}
onResize() {
ScrollTrigger.refresh()
this.dimensions = {
width: window.innerWidth,
top: window.innerHeight,
pixelRatio: Math.min(2, window.devicePixelRatio),
}
this.digicam.facet = window.innerWidth / window.innerHeight
this.digicam.updateProjectionMatrix()
this.setSizes()
this.renderer.setPixelRatio(this.dimensions.pixelRatio)
this.renderer.setSize(this.dimensions.width, this.dimensions.top)
}
render()
{
//updating the Canvas information
}
}
This class shall be known as and instantiated in the primary app and up to date in a render loop.
import "./type.css"
import Canvas from "./canvas"
class App {
canvas: Canvas
constructor() {
this.canvas = new Canvas()
this.render()
}
render() {
this.canvas.render()
requestAnimationFrame(this.render.bind(this))
}
}
export default new App()
As soon as it’s initialized it is going to create a Media object for every picture on the web page:
createMedias() {
const photographs = doc.querySelectorAll("img")
photographs.forEach((picture) => {
const media = new Media({
ingredient: picture,
scene: this.scene,
sizes: this.sizes,
})
this.medias?.push(media)
})
}
For the demo, choosing all photographs with doc.querySelectorAll("img") retains issues easy, however in an actual venture you’ll often need to scope the choice (for instance, to the gallery container) or goal a devoted attribute like [data-webgl-media]. That forestalls choosing up unintended photographs (logos, UI icons, hidden photographs) and retains your WebGL layer tightly coupled to the content material you really need to render.
The Media object is a class that obtain an Picture ingredient and create a PlaneGeometry Mesh that shall be scaled to match the HTML ingredient and use the picture itself as a texture. It’s utilizing a ShaderMaterial which have a uProgress uniform that may management the progress of the reveal impact.
import * as THREE from "three"
interface Props {
ingredient: HTMLImageElement
scene: THREE.Scene
sizes: Dimension
}
export default class Media {
ingredient: HTMLImageElement
scene: THREE.Scene
sizes: Dimension
materials: THREE.ShaderMaterial
geometry: THREE.PlaneGeometry
mesh: THREE.Mesh
nodeDimensions: Dimension
meshDimensions: Dimension
meshPostion: Place
elementBounds: DOMRect
currentScroll: quantity
lastScroll: quantity
scrollSpeed: quantity
constructor({ ingredient, scene, sizes }: Props) {
this.ingredient = ingredient
this.scene = scene
this.sizes = sizes
this.currentScroll = 0
this.lastScroll = 0
this.scrollSpeed = 0
this.createGeometry()
this.createMaterial()
this.createMesh()
this.setNodeBounds()
this.setMeshDimensions()
this.setMeshPosition()
this.setTexture()
this.scene.add(this.mesh)
}
createGeometry() {
this.geometry = new THREE.PlaneGeometry(1, 1, 1, 1)
}
createMaterial() {
this.materials = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTexture: new THREE.Uniform(new THREE.Vector4()),
uResolution: new THREE.Uniform(new THREE.Vector2(0, 0)),
uContainerRes: new THREE.Uniform(new THREE.Vector2(0, 0)),
uProgress: new THREE.Uniform(1),
uGridSize: new THREE.Uniform(20),
uColor: new THREE.Uniform(new THREE.Coloration("#242424")),
},
})
}
createMesh() {
this.mesh = new THREE.Mesh(this.geometry, this.materials)
}
setNodeBounds() {
this.elementBounds = this.ingredient.getBoundingClientRect()
this.nodeDimensions = {
width: this.elementBounds.width,
top: this.elementBounds.top,
}
}
setMeshDimensions() {
this.meshDimensions = {
width: (this.nodeDimensions.width * this.sizes.width) / window.innerWidth,
top:
(this.nodeDimensions.top * this.sizes.top) / window.innerHeight,
}
this.mesh.scale.x = this.meshDimensions.width
this.mesh.scale.y = this.meshDimensions.top
}
setMeshPosition() {
this.meshPostion = {
x: (this.elementBounds.left * this.sizes.width) / window.innerWidth,
y: (-this.elementBounds.prime * this.sizes.top) / window.innerHeight,
}
this.meshPostion.x -= this.sizes.width / 2
this.meshPostion.x += this.meshDimensions.width / 2
this.meshPostion.y -= this.meshDimensions.top / 2
this.meshPostion.y += this.sizes.top / 2
this.mesh.place.x = this.meshPostion.x
this.mesh.place.y = this.meshPostion.y
}
setTexture() {
this.materials.uniforms.uTexture.worth = new THREE.TextureLoader().load(
this.ingredient.src,
({ picture }) => {
const { naturalWidth, naturalHeight } = picture
this.materials.uniforms.uResolution.worth = new THREE.Vector2(
naturalWidth,
naturalHeight,
)
this.materials.uniforms.uContainerRes.worth = new THREE.Vector2(
this.nodeDimensions.width,
this.nodeDimensions.top,
)
},
)
}
destroy() {
this.scene.take away(this.mesh)
this.scrollTrigger.scrollTrigger?.kill()
this.scrollTrigger?.kill()
this.anchorElement?.removeEventListener("click on", this.onClickHandler)
this.anchorElement?.removeAttribute("data-home-link-active")
this.geometry.dispose()
this.materials.dispose()
}
onResize(sizes: Dimension) {
this.sizes = sizes
this.setNodeBounds()
this.setMeshDimensions()
this.setMeshPosition()
this.materials.uniforms.uContainerRes.worth = new THREE.Vector2(
this.nodeDimensions.width,
this.nodeDimensions.top,
)
}
}
ScrollSmoother
As soon as now we have the meshes scaled and positioned accurately, we have to synchronize the scroll place of the html photographs and the Three.js meshes. However we are able to’t depend on the browser’s native scroll.
The explanation for that's the means we're updating our Three.js app.
We're calling the render technique in a requestAnimationFrame. The difficult half is that native scrolling and our render loop aren’t assured to replace in lockstep. The browser can replace scroll place independently of the timing we use to render WebGL. To maintain our Three.js planes completely aligned with the DOM, we wish a scroll worth that’s up to date in sync with the identical tick that drives rendering, which is why we introduce {smooth} scrolling and browse the scroll place from that system.
To take action, we have to add smooth-scrolling to our app. I’m utilizing a Scroll object that depends on the GSAP ScrollSmoother plugin.
import { ScrollSmoother } from "gsap/ScrollSmoother"
export default class Scroll {
scroll: quantity
s: globalThis.ScrollSmoother | null
constructor() {
this.init()
}
init() {
this.scroll = 0
this.s = ScrollSmoother.create({
{smooth}: 1,
normalizeScroll: true,
wrapper: doc.getElementById("app") as HTMLElement,
content material: doc.getElementById("smooth-content") as HTMLElement,
onUpdate: (self) => {
this.scroll = self.scrollTop()
},
})
}
getScroll() {
return this.scroll
}
}
The scroll property will maintain the variety of pixels scrolled from the highest of the web page, this worth is returned from the scrollTop() technique within the onUpdate callback of the ScrollSmoother occasion. This callback shall be known as every time the scroll place is up to date.
The wrapper ingredient is the foundation scroll container, and the content material ingredient is the scrollable content material container, these two shall be sitting within the root Format :
However earlier than that, to ensure that any GSAP plugin to work, we have to first register it. The very best place to do this is correct above our App declaration
import gsap from "gsap"
import { ScrollSmoother } from "gsap/ScrollSmoother"
gsap.registerPlugin(ScrollSmoother)
class App {
//our major app
}
The final step is to synchronize our Three.js app with the GSAP interior time:
class App {
canvas: Canvas
scroll:Scroll
constructor() {
this.scroll = new Scroll()
this.canvas = new Canvas()
this.canvas.createMedias()
this.render = this.render.bind(this)
gsap.ticker.add(this.render)
}
render() {
const scrollTop = this.scroll.getScroll()
this.canvas.render(scrollTop)
}
}
The gsap.ticker is just like the heartbeat of the GSAP engine, it updates the globalTimeline on each requestAnimationFrame occasion, so it's completely synchronized with the browser’s rendering cycle.
As soon as we go the worth of the scroll to the Canvas object we apply this worth to all of our medias:
export default class Canvas {
constructor() {
// Canvas constructor
}
// Canvas strategies
render(scroll: quantity, updateScroll: boolean = true) {
this.medias?.forEach((media) => {
if (updateScroll) {
media?.updateScroll(scroll)
}
})
this.renderer.render(this.scene, this.digicam)
}
}
And we add the updateScroll technique to the Media object:
export default class Media {
constructor({ ingredient, scene, sizes }: Props) {
// Media contructor
}
// Media strategies
updateScroll(scrollY: quantity) {
this.currentScroll = (-scrollY * this.sizes.top) / window.innerHeight
const deltaScroll = this.currentScroll - this.lastScroll
this.lastScroll = this.currentScroll
this.updateY(deltaScroll)
}
updateY(deltaScroll: quantity) {
this.meshPostion.y -= deltaScroll
this.mesh.place.y = this.meshPostion.y
}
}
Now our scroll is completely synchronized!
ScrollTrigger
To realize our picture reveal impact, we have to animate the uProgress shader uniform of every Mesh when It’s corresponding picture HTML ingredient enters the viewport. That’s the place ScrollTrigger is available in.
We're going to add a technique to the Media object that may create a scrollTrigger occasion to watch the picture ingredient and animate our uniform when the picture enters the viewport.
observe() {
this.scrollTrigger = gsap.to(this.materials.uniforms.uProgress, {
worth: 1,
scrollTrigger: {
set off: this.ingredient,
begin: "prime backside",
},
period: 1.6,
ease: "linear",
})
}
To make this impact a bit bit extra attention-grabbing we're going to reset it when the ingredient goes out of the viewport and restart it when It turns into seen once more. To take action, we're going to use the toggleActions property of ScrollTrigger. You may examine toggleActions right here however to maintain it quick. It’s a property that means that you can decide the scrolltrigger animation habits at 4 distinct steps of the scroll:
- When the scroll place strikes ahead previous the “begin” set off
- When the scroll place strikes ahead previous the “finish” set off
- When the scroll place strikes backward previous the “finish” set off
- When the scroll place strikes backward previous the “begin” set off
Right here’s how we're utilizing the property:
observe() {
this.scrollTrigger = gsap.to(this.materials.uniforms.uProgress, {
worth: 1,
scrollTrigger: {
set off: this.ingredient,
begin: "prime backside",
finish: "backside prime",
toggleActions: "play reset restart reset",
},
period: 1.6,
ease: "linear",
})
}
This technique shall be known as proper after the Media objects are initialized. within the createMedias technique of the Canvas object:
createMedias() {
const photographs = doc.querySelectorAll("img")
photographs.forEach((picture) => {
const media = new Media({
ingredient: picture,
scene: this.scene,
sizes: this.sizes,
})
this.medias?.push(media)
})
this.medias?.forEach((media) => {
media?.observe()
})
}
SplitText
To make our demo extra immersive, we're going to animate the touchdown moments of the textual content on the pages. The animation consists of a state transition between an invisible state and a visual state.
That is what the animation seems to be like:
Let’s first create a TextAnimation object that may goal the textual content components and put together the content material for the animation.
To realize the animation above, we'd like a technique to break up the textual content content material into particular person traces and animate every line with an incremental delay. Luckily GSAP permits us to do that utilizing the SplitText plugin.
The TextAnimation object selects all components with data-text-animation and data-text-animation-split and applies SplitText to them. Components that solely have data-text-animation will merely fade out and in.
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
interface BaseAnimationProps {
ingredient: HTMLElement;
inDuration: quantity;
outDuration: quantity;
inDelay?: quantity;
}
interface SplitAnimationProps extends BaseAnimationProps {
break up: globalThis.SplitText;
inStagger?: quantity;
outStagger?: quantity;
}
export default class TextAnimation {
components: HTMLElement[];
splitAnimations: SplitAnimationProps[] = [];
fadeAnimations: BaseAnimationProps[] = [];
splitTweens: gsap.core.Tween[] = [];
fadeTweens: gsap.core.Tween[] = [];
constructor() {}
init() {
this.splitAnimations = [];
this.fadeAnimations = [];
this.components = doc.querySelectorAll(
'[data-text-animation]'
) as unknown as HTMLElement[];
this.components.forEach((el) => {
const inDuration = parseFloat(
el.getAttribute('data-text-animation-in-duration') || '0.6'
);
const outDuration = parseFloat(
el.getAttribute('data-text-animation-out-duration') || '0.3'
);
const inDelay = parseFloat(
el.getAttribute('data-text-animation-in-delay') || '0'
);
// Test if this must be a break up textual content animation
if (el.hasAttribute('data-text-animation-split')) {
const break up = SplitText.create(el, {
kind: 'traces',
masks: 'traces',
autoSplit: true,
});
const inStagger = parseFloat(
el.getAttribute('data-text-animation-in-stagger') || '0.06'
);
const outStagger = parseFloat(
el.getAttribute('data-text-animation-out-stagger') || '0.06'
);
break up.traces.forEach((line) => {
gsap.set(line, { yPercent: 100 });
});
gsap.set(el, { autoAlpha: 1, visibility: 'seen' });
this.splitAnimations.push({
ingredient: el,
break up,
inDuration,
outDuration,
inStagger,
outStagger,
inDelay,
});
} else {
// Default fade animation
gsap.set(el, { autoAlpha: 0, visibility: 'hidden' });
this.fadeAnimations.push({
ingredient: el,
inDuration,
outDuration,
inDelay,
});
}
});
}
This code is splitting the textual content content material of the goal components to an array of traces.
By including masks: "traces" the traces are then wrapped in a div with overflow:clip.
We will then entry the traces components in break up.traces.
We're additionally making use of a rework: translateY(100%) to the traces of the textual content container for the preliminary state of the animation. It can make the textual content invisible since every character is wrapped in a masking div.
Now let’s add this technique to the TextAnimation class. It can animate the textual content from the invisible to the seen state. I additionally added the identical Scrolltrigger logic we had for the photographs.
animateIn({ delay = 0 } = {}) {
// Break up textual content animations
this.splitAnimations.forEach(
({ ingredient, break up, inDuration, inStagger, inDelay }) => {
const tweenWithScroll = gsap.to(break up.traces, {
yPercent: 0,
stagger: inStagger,
scrollTrigger: {
set off: ingredient,
begin: 'prime backside',
finish: 'backside prime',
toggleActions: 'play reset restart reset',
},
ease: 'expo',
period: inDuration,
delay: inDelay + delay,
});
this.splitTweens.push(tweenWithScroll);
}
);
// Fade animations
this.fadeAnimations.forEach(({ ingredient, inDuration, inDelay }) => {
const fadeTween = gsap.to(ingredient, {
autoAlpha: 1,
scrollTrigger: {
set off: ingredient,
begin: 'prime backside',
finish: 'backside prime',
toggleActions: 'play reset restart reset',
},
ease: 'power2.out',
period: inDuration,
delay: inDelay + delay,
});
this.fadeTweens.push(fadeTween);
});
return gsap.timeline();
}
This technique known as as soon as the photographs are loaded to ensure our textual content animation is synchronized with the photographs animations:
class App {
canvas: Canvas
scroll: Scroll
textAnimation: TextAnimation
constructor() {
this.scroll = new Scroll()
this.canvas = new Canvas()
this.textAnimation = new TextAnimation()
this.loadFont(() => {
this.textAnimation.init();
});
this.loadImages(() => {
this.canvas.createMedias();
if (this.fontLoaded) {
this.textAnimation.animateIn();
} else {
window.addEventListener('fontLoaded', () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.textAnimation.init();
this.textAnimation.animateIn({ delay: 0.3 });
});
});
});
}
});
// ...App constructor
}
// ...App strategies
}
This is what the ultimate model of the TextAnimation seems to be like. I added an animateOut technique and a destroy logic to clear the SplitText object when the consumer change the web page.
Flip
To create this web page transition, we have to change the Picture mother or father ingredient from the homepage hyperlink container to the small print web page cowl.
Altering an HTML ingredient mother or father is achieved by merely appending the ingredient itself to the goal mother or father. However It’s performed immediately and with none technique to animate the state change natively. For that we have to use Flip.
Flip plugin allows you to seamlessly transition between two states even when there are sweeping modifications to the construction of the DOM that might usually trigger components to leap.
For the web page transition I’m utilizing Barba.js. This library permits us to run customized code when a navigation occasion is triggered (by clicking on a hyperlink or the browser’s navigation buttons) and run the web page change solely as soon as our code is completed.
On this setup, Astro generates actual pages (/, /[index], and so on.), and Barba merely intercepts inner hyperlink navigations to swap the web page content material with no full refresh. Which means your hyperlinks should stay commonplace navigations, and Barba’s lifecycle hooks (earlier than, depart, beforeEnter, after) give us the right place to run animations and DOM strikes whereas the previous and new containers are each accessible.
That is the way it works:
barba.init({
prefetchIgnore: true,
transitions: [
{
name: "my-custom-page-transition",
from: {
custom: () => {
//the condition to determine if the transition should run
const myCondition = true;
if (!myCondition) return false
return true
},
},
before: () => {
//Runs right after the page change event is triggered
return new Promise((resolve) => {
resolve()
// run the leave() callback
})
},
leave: () => {
//runs right before we leave the current page
return new Promise((resolve) => {
resolve()
//leave the current page
})
},
beforeEnter: () => {
//right after leaving the old page and before entering the new one
},
after: () => {
//runs after we have entered the new page
},
},
],
})
This enables us to entry the DOM components of each pages on the identical place!
As a result of Barba swaps pages with no full refresh, it’s vital to wash up something that “sticks round” between navigations. GSAP plugins like ScrollTrigger and SplitText create cases and DOM wrappers that gained’t routinely disappear when the previous web page is eliminated, so it is best to kill your triggers/tweens and revert SplitText earlier than leaving a route, then re-init them after coming into the following one. The identical applies to Three.js: eradicating a mesh from the scene isn’t sufficient, for those who recreate WebGL objects per web page, be sure to dispose geometries/supplies/textures for the medias you not have to keep away from GPU reminiscence creeping up after a number of transitions.
If you click on on a picture hyperlink on the homepage it is going to set off a web page change, the earlier than callback of our Barba occasion runs and we are able to save the picture ingredient in our reminiscence by assigning it to a variable, then as soon as we get to the brand new web page, within the after callback we are able to append the beforehand saved picture to the duvet container.
This the concept: when clicking on a hyperlink, we add the data-home-link-active attribute to the clicked hyperlink
this.anchorElement?.addEventListener("click on", (e) => {
e.currentTarget.setAttribute(
"data-home-link-active",
"true"
)
})
Let’s decompose the web page transition animation step-by-step. Step one for a Barba transition is the situation for the animation to occur. In our instance we wish the transition to occur provided that the consumer click on on a picture hyperlink.
Let’s identify this transition home-detail
identify: 'home-detail',
from: {
customized: () => {
const activeLink = doc.querySelector('a[data-home-link-active="true"]');
if (!activeLink) return false;
return true;
},
},
The animation occur provided that we discover a hyperlink with the data-home-link-active attribute.
Subsequent, within the earlier than callback we have to :
- Block The scroll through the animation
- Animate out the textual content with the
animateOutcallback that returns a gsap timeline. - Animate out the non-selected medias and clear off their scrollTrigger cases
- Animate within the chosen media to ensure the reveal animation is completed earlier than operating the web page transition. this fashion we are able to additionally seamlessly unhide the html picture ingredient through the transition.
The earlier than finishes operating as soon as the GSAP timeline is over. At this level we're nonetheless within the house web page:
earlier than: () => {
this.scrollBlocked = true;
this.scroll.s?.paused(true);
const tl = this.textAnimation.animateOut();
activeLinkImage = doc.querySelector('a[data-home-link-active="true"] img')
this.canvas.medias?.forEach((media) => {
if (!media) return;
media.scrollTrigger.kill();
const currentProgress = media.materials.uniforms.uProgress.worth;
const totalDuration = 1.2;
if (media.ingredient !== activeLinkImage) {
const remainingDuration = totalDuration * currentProgress;
tl.to(
media.materials.uniforms.uProgress,
{
period: remainingDuration,
worth: 0,
ease: 'linear',
},
0
);
} else {
const remainingDuration = totalDuration * (1 - currentProgress);
tl.to(
media.materials.uniforms.uProgress,
{
worth: 1,
period: remainingDuration,
ease: 'linear',
onComplete: () => {
media.ingredient.type.opacity = '1';
media.ingredient.type.visibility = 'seen';
gsap.set(media.materials.uniforms.uProgress, { worth: 0 });
},
},
0
);
}
});
return new Promise((resolve) => {
tl.name(() => {
resolve();
});
});
}
Through the leaving animation, we're going to offset the web page content material by minus the present scrolled worth to “cancel” the scroll distance through the transition.
As soon as all the leaving/cleansing logic is dealt with, we are able to begin implementing the Flip animation.
The animation consists of three steps:
- Within the
departcallback we useFlip.getStateon our goal picture to register It’s preliminary state. - Within the
aftercallback we append the saved picture to It’s new container, that is the ultimate state. - Proper after altering the state, we name
Flip.fromto animate the state transition.
depart: () => {
scrollTop = this.scroll.getScroll();
const container = doc.querySelector('.container') as HTMLElement;
container.type.place = 'fastened';
container.type.prime = `-${scrollTop}px`;
container.type.width = '100%';
container.type.zIndex = '1000';
this.mediaHomeState = Flip.getState(activeLinkImage);
this.textAnimation.destroy();
},
// We reset and clear up the scroll logic earlier than coming into the brand new web page.
beforeEnter: () => {
this.scroll.reset();
this.scroll.destroy();
},
As soon as we land on the brand new web page, we are able to append our picture to It’s goal container and animate the mother or father change with Flip.from:
after: () => {
this.scroll.init();
this.textAnimation.init();
const detailContainer = doc.querySelector('.details-container')
detailContainer.innerHTML = '';
detailContainer.append(activeLinkImage);
return new Promise((resolve) => {
let activeMedia: Media | null = null;
this.textAnimation.animateIn({ delay: 0.3 });
Flip.from(this.mediaHomeState, {
absolute: true,
period: 1,
ease: 'power3.inOut',
onComplete: () => {
this.scrollBlocked = false;
this.canvas.medias?.forEach((media) => {
if (!media) return;
if (media.ingredient !== activeLinkImage) {
media.destroy();
media = null;
} else {
activeMedia = media;
}
});
this.canvas.medias = [activeMedia];
resolve();
},
});
});
},
When the animation is over, we destroy the opposite Meshes since they're not seen.
One small caveat: the data-home-link-active method is pushed by a click on, so it gained’t routinely cowl circumstances like browser again/ahead navigation or direct web page hundreds. Within the full implementation you’ll usually add a fallback primarily based on the present URL (or Barba’s navigation information) so the proper media could be recognized even when there’s no click on occasion.
As at all times this can be a simplified model to focus on essentially the most attention-grabbing steps, you possibly can see the total code of this class right here. I added further logic for the routing and dealing with some edge circumstances.
And that’s it, thanks for studying!









