On-Scroll Typography Animations

Movement and transformation of type elements can elevate the user experience and add an extra layer of creativity to your design. So today we’d love to share some typography animations with you that get triggered by scrolling the page.

When it comes to designing typographic animations, one of the key factors to consider is the transform origin. By adjusting the transform origin, you can change the direction and movement of the animation, totally changing the look and feel of it. Additionally, adjusting the rotation and scale values of the element can also greatly affect the animation’s visual appearance and overall impact. These values can be used to create a sense of depth, movement, and emphasis on the typography.

By playing with different combinations of all these values, you can create a wide range of animations, each one with a unique effect and suitable for different contexts like an editorial piece or semantically different sections of a website.

Just keep in mind that although typographic animation can add an extra layer of creativity to your design, it should not be used excessively, so use it wisely.

If you enjoy these animations, you might also like these on-scroll letter animation ideas.

Getting Creative with Infinite Loop Scrolling

Infinite scrolling is a web design technique that allows users to scroll through a never-ending list of content by automatically loading new items as the user reaches the bottom of the page. Instead of having to click through to a new page to see more content, the content is automatically loaded and appended to the bottom of the page as the user scrolls. This can create a seamless and engaging browsing experience for users, as they can easily access a large amount of content without having to wait for new pages to load. Forget about reaching the footer, though!

Looping the scroll of a page refers to the process of automatically taking users back to the top of the page once they reach the end of the scroll. This means that they will be able to continuously scroll through the same content over and over again, rather than being presented with new content as they scroll down the page.

In this article, we will show some examples creative loop scrolling, and then reimplement the effect seem on Bureau DAM. We will be using Lenis by Studio Freight to implement the looping effect and GSAP for the animations.

What is loop scrolling?

When your content is limited, you can do creative things with loop scrolling, which is basically infinite scrolling with repeating content. A great example for such a creative use is the effect seen on Bureau DAM:

Bureau DAM employs a fun looping scroll effect

Some great ideas how looping the scroll can be used in a creative way and add value to a website:

  • It can create an immersive experience for users. By continuously scrolling through the same content, users can feel more immersed in the website and engaged with the content. This can be particularly useful for websites that want to create a strong emotional connection with their audience.
  • It can be used to create a game or interactive experience. By adding interactive elements to the content that is being looped, developers can create a game or interactive experience for users. For example, a website could use looping the scroll to create a scrolling game or to allow users to interact with the content in a novel way.
  • It can be used to create a “time loop” effect. By looping the scroll in a particular way, developers can create the illusion of a “time loop,” where the content appears to repeat itself in a continuous loop. This can be particularly effective for websites that want to convey a sense of continuity or timelessness.

A simple example

Let’s create a simple example. We’ll set up a grid of 6 images that repeat on scroll. How can we achieve this? It’s simple: we need to repeat the content in a way that when reaching the end, we can simply reset the scroll to the top without anybody noticing! We’ve previously explored this concept in our CSS-only marquee effect. In another demo we also show how to play with this kind of infinite animation.

So, for our first example we have the following markup:

<div class="grid">
	<div class="grid__item" style="background-image:url(img/1.jpg);"></div>
	<div class="grid__item" style="background-image:url(img/4.jpg);"></div>
	<div class="grid__item" style="background-image:url(img/3.jpg);"></div>
	<div class="grid__item" style="background-image:url(img/5.jpg);"></div>
	<div class="grid__item" style="background-image:url(img/2.jpg);"></div>
	<div class="grid__item" style="background-image:url(img/6.jpg);"></div>
</div>

Our styles will create a 3×3 grid:

.grid {
	display: grid;
	grid-template-columns: repeat(3, 1fr);
	gap: 5vh;
}

.grid__item {
	height: 47.5vh; /* 50vh minus half of the gap */
	background-size: cover;
	background-position: 50% 20%;
}

.grid__item:nth-child(3n-2) {
	border-radius: 0 2rem 2rem 0;
}

.grid__item:nth-child(3n) {
	border-radius: 2rem 0 0 2rem;
}

.grid__item:nth-child(3n-1) {
	border-radius: 2rem;
}

Lenis comes with a handy option for making the scroll infinite. In our script we’ll make sure to repeat the visible grid items (in this case it’s 6):

const lenis = new Lenis({
    smooth: true,
    infinite: true,
});

function raf(time) {
    lenis.raf(time);
    requestAnimationFrame(raf);
}

requestAnimationFrame(raf);

// repeat first six items by cloning them and appending them to the .grid
const repeatItems = (parentEl, total = 0) => {
    const items = [...parentEl.children];
    for (let i = 0; i <= total-1; ++i) {
        var cln = items[i].cloneNode(true);
        parentEl.appendChild(cln);
    }
};
repeatItems(document.querySelector('.grid'), 6);

The result is a grid that repeats on scroll:

A simple example of loop scrolling. View the demo

We can also change the direction of the scroll and go sideways!

View the demo

Playing with animations

While we scroll, we can add some fancy animations to our grid items. Paired with switching the transform origin in the right time, we can get something playful like this:

Scale animation while scrolling. View the demo

We can also play with the scale and opacity values to create something like a vanishing effect:

View the demo

Or, add some extreme stretchiness:

View the demo

We can also play with a 3D animation and an additional filter effect:

Adding a filter effect and perspective. View the demo

The Bureau DAM example

Now, let’s see how we can remake the Bureau DAM animation. As we don’t have a grid here, things get a bit simpler. Just like them, we’ll use an SVG for the typography element, as we want to stretch it over the screen:

<div class="grid">
	<div class="grid__item grid__item--stack">
		<svg class="grid__item-logo" width="100%" height="100%" viewBox="0 0 503 277" preserveAspectRatio="none">
		<path d="M56.3 232.3 56.3 193.8C56.3 177.4 54.7 174.1 48.5 165.9 35.4 148.8 17.6 133 8.5 120.8.7 110.3.1 103.7.1 85.6L.1 45.2C.1 14.9 13.5.5 41 .5 68.8.5 79.1 15.3 79.1 45.2L79.1 94.5 56.9 94.5 56.9 48.5C56.9 35 53.5 25.8 40.7 25.8 29.8 25.8 24.1 32.4 24.1 45.2L24.1 85.3C24.1 96.8 25.1 100.1 29.8 106.3 41 121.8 59.1 137.6 68.8 150.4 77.2 161.6 80 169.8 80 193.5L80 232.3C80 260.9 68.8 277 40.4 277 12.3 277 .1 261.5.1 232.3L.1 174.7 22.9 174.7 22.9 228.7C22.9 243.1 26.9 252.3 40.1 252.3 51.6 252.3 56.3 245.1 56.3 232.3ZM176.5 277 101.5 277 101.5.5 127.1.5 127.1 251.8 176.5 251.8 176.5 277ZM290 277 264.5 277 258.4 230.6 217.1 230.6 211 277 186.2 277 224.1.5 254.1.5 290 277ZM218.1 207.1 253.4 207.1C247.7 159.7 241.6 114 236.3 65.3 230.5 114 224.5 159.7 218.1 207.1ZM399.6 277 374 277 326.3 75.1C326.6 117.1 326.6 155.7 326.6 197.7L326.6 277 304.5 277 304.5.5 335 .5 377.4 203.1C377 165.1 377 129.2 377 91.2L377 .5 399.6.5 399.6 277ZM471.5 277 446.3 277 446.3 26.3 415.3 26.3 415.3.5 502.4.5 502.4 26.3 471.5 26.3 471.5 277Z" id="SLANT" fill="#fff"></path>
		</svg>
		<p class="grid__item-text credits">An infinite scrolling demo based on <a href="https://www.bureaudam.com/">Bureau DAM</a></p>
	</div>
	<div class="grid__item">
		<div class="grid__item-inner">
			<div class="grid__item-img" style="background-image:url(img/1.jpg);"></div>
			<p class="grid__item-text"><a href="#">View all projects →</a></p>
		</div>
	</div>
</div>

In our CSS we set a couple of styles that will make sure the items are stretched to full screen:

.grid {
    display: flex;
    flex-direction: column;
    gap: 5vh;
}

.grid__item {
    height: 100vh; 
	place-items: center;
    display: grid;
}

.grid__item-inner {
	display: grid;
	gap: 1rem;
	place-items: center;
	text-align: center;
}

.grid__item--stack {
	display: grid;
	gap: 2rem;
	grid-template-rows: 1fr auto;
}

.grid__item-logo {
	padding: 8rem 1rem 0;
}

.grid__item-img {
	background-size: cover;
    background-position: 50% 50%;
	height: 70vh;
	aspect-ratio: 1.5;
}

.grid__item-text {
	margin: 0;
}

A proper setting for the transform origins ensures that we get the right visual effect:

gsap.registerPlugin(ScrollTrigger);

// repeat first three items by cloning them and appending them to the .grid
const repeatItems = (parentEl, total = 0) => {
    const items = [...parentEl.children];
    for (let i = 0; i <= total-1; ++i) {
        var cln = items[i].cloneNode(true);
        parentEl.appendChild(cln);
    }
};

const lenis = new Lenis({
    smooth: true,
    infinite: true
});

lenis.on('scroll',()=>{
    ScrollTrigger.update() // Thank you Clément!
})

function raf(time) {
    lenis.raf(time);
    requestAnimationFrame(raf);
}

imagesLoaded( document.querySelectorAll('.grid__item'), { background: true }, () => {

    document.body.classList.remove('loading');

    repeatItems(document.querySelector('.grid'), 1);

    const items = [...document.querySelectorAll('.grid__item')];

    // first item
    const firtsItem = items[0];
    gsap.set(firtsItem, {transformOrigin: '50% 100%'})
    gsap.to(firtsItem, {
        ease: 'none',
        startAt: {scaleY: 1},
        scaleY: 0,
        scrollTrigger: {
            trigger: firtsItem,
            start: 'center center',
            end: 'bottom top',
            scrub: true,
            fastScrollEnd: true,
            onLeave: () => {
                gsap.set(firtsItem, {scaleY: 1,})
            },
        }
    });

    // last item  
    const lastItem = items[2];
    gsap.set(lastItem, {transformOrigin: '50% 0%', scaleY: 0})
    gsap.to(lastItem, {
        ease: 'none',
        startAt: {scaleY: 0},
        scaleY: 1,
        scrollTrigger: {
            trigger: lastItem,
            start: 'top bottom',
            end: 'bottom top',
            scrub: true,
            fastScrollEnd: true,
            onLeaveBack: () => {
                gsap.set(lastItem, {scaleY: 1})
            }
        }
    });
    
    // in between
    let ft;
    let st;
    const middleItem = items[1];
        
    ft = gsap.timeline()
    .to(middleItem, {
        ease: 'none',
        onStart: () => {
            if (st) st.kill()
        },
        startAt: {scale: 0},
        scale: 1,
        scrollTrigger: {
            trigger: middleItem,
            start: 'top bottom',
            end: 'center center',
            scrub: true,
            onEnter: () => gsap.set(middleItem, {transformOrigin: '50% 0%'}),
            onEnterBack: () => gsap.set(middleItem, {transformOrigin: '50% 0%'}),
            onLeave: () => gsap.set(middleItem, {transformOrigin: '50% 100%'}),
            onLeaveBack: () => gsap.set(middleItem, {transformOrigin: '50% 100%'}),
        },
    });

    st = gsap.timeline()
    .to(middleItem, {
        ease: 'none',
        onStart: () => {
            if (ft) ft.kill()
        },
        startAt: {scale: 1},
        scale: 0,
        scrollTrigger: {
            trigger: middleItem,
            start: 'center center',
            end: 'bottom top',
            scrub: true,
            onEnter: () => gsap.set(middleItem, {transformOrigin: '50% 100%'}),
            onEnterBack: () => gsap.set(middleItem, {transformOrigin: '50% 100%'}),
            onLeave: () => gsap.set(middleItem, {transformOrigin: '50% 0%'}),
            onLeaveBack: () => gsap.set(middleItem, {transformOrigin: '50% 0%'}),
        },
    });
    
    requestAnimationFrame(raf);
    
    const refresh = () => {
        ScrollTrigger.clearScrollMemory();
        window.history.scrollRestoration = 'manual';
        ScrollTrigger.refresh(true);
    }

    refresh();
    window.addEventListener('resize', refresh);

});

The result is a squashy and squeezy sequence of joy:

Check out the demo

Update: Thanks to Clément’s comment and help on keeping Lenis and GSAP in sync, it’s now much smoother!

And that’s it! Hope you had some fun and that you got some inspiration for your next projects.

How to Code an On-Scroll Folding 3D Cardboard Box Animation with Three.js and GSAP

Today we’ll walk through the creation of a 3D packaging box that folds and unfolds on scroll. We’ll be using Three.js and GSAP for this.

We won’t use any textures or shaders to set it up. Instead, we’ll discover some ways to manipulate the Three.js BufferGeometry.

This is what we will be creating:

Scroll-driven animation

We’ll be using GSAP ScrollTrigger, a handy plugin for scroll-driven animations. It’s a great tool with a good documentation and an active community so I’ll only touch the basics here.

Let’s set up a minimal example. The HTML page contains:

  1. a full-screen <canvas> element with some styles that will make it cover the browser window
  2. a <div class=”page”> element behind the <canvas>. The .page element a larger height than the window so we have a scrollable element to track.

On the <canvas> we render a 3D scene with a box element that rotates on scroll.

To rotate the box, we use the GSAP timeline which allows an intuitive way to describe the transition of the box.rotation.x property.

gsap.timeline({})
    .to(box.rotation, {
        duration: 1, // <- takes 1 second to complete
        x: .5 * Math.PI,
        ease: 'power1.out'
    }, 0) // <- starts at zero second (immediately)

The x value of the box.rotation is changing from 0 (or any other value that was set before defining the timeline) to 90 degrees. The transition starts immediately. It has a duration of one second and power1.out easing so the rotation slows down at the end.

Once we add the scrollTrigger to the timeline, we start tracking the scroll position of the .page element (see properties trigger, start, end). Setting the scrub property to true makes the transition not only start on scroll but actually binds the transition progress to the scroll progress.

gsap.timeline({
    scrollTrigger: {
        trigger: '.page',
        start: '0% 0%',
        end: '100% 100%',
        scrub: true,
        markers: true // to debug start and end properties
    },
})
    .to(box.rotation, {
        duration: 1,
        x: .5 * Math.PI,
        ease: 'power1.out'
    }, 0)

Now box.rotation.x is calculated as a function of the scroll progress, not as a function of time. But the easing and timing parameters still matter. Power1.out easing still makes the rotation slower at the end (check out ease visualiser tool and try other options to see the difference). Start and duration values don’t mean seconds anymore but they still define the sequence of the transitions within the timeline.

For example, in the following timeline the last transition is finished at 2.3 + 0.7 = 3.

gsap.timeline({
    scrollTrigger: {
        // ... 
    },
})
    .to(box.rotation, {
        duration: 1,
        x: .5 * Math.PI,
        ease: 'power1.out'
    }, 0)
    .to(box.rotation, {
        duration: 0.5,
        x: 0,
        ease: 'power2.inOut'
    }, 1)
    .to(box.rotation, {
        duration: 0.7, // <- duration of the last transition
        x: - Math.PI,
        ease: 'none'
    }, 2.3) // <- start of the last transition

We take the total duration of the animation as 3. Considering that, the first rotation starts once the scroll starts and takes ⅓ of the page height to complete. The second rotation starts without any delay and ends right in the middle of the scroll (1.5 of 3). The last rotation starts after a delay and ends when we scroll to the end of the page. That’s how we can construct the sequences of transitions bound to the scroll.

To get further with this tutorial, we don’t need more than some basic understanding of GSAP timing and easing. Let me just mention a few tips about the usage of GSAP ScrollTrigger, specifically for a Three.js scene.

Tip #1: Separating 3D scene and scroll animation

I found it useful to introduce an additional variable params = { angle: 0 } to hold animated parameters. Instead of directly changing rotation.x in the timeline, we animate the properties of the “proxy” object, and then use it for the 3D scene (see the updateSceneOnScroll() function under tip #2). This way, we keep scroll-related stuff separate from 3D code. Plus, it makes it easier to use the same animated parameter for multiple 3D transforms; more about that a bit further on.

Tip #2: Render scene only when needed

Maybe the most common way to render a Three.js scene is calling the render function within the window.requestAnimationFrame() loop. It’s good to remember that we don’t need it, if the scene is static except for the GSAP animation. Instead, the line renderer.render(scene, camera) can be simply added to to the onUpdate callback so the scene is redrawing only when needed, during the transition.

// No need to render the scene all the time
// function animate() {
//     requestAnimationFrame(animate);
//     // update objects(s) transforms here
//     renderer.render(scene, camera);
// }

let params = { angle: 0 }; // <- "proxy" object

// Three.js functions
function updateSceneOnScroll() {
    box.rotation.x = angle.v;
    renderer.render(scene, camera);
}

// GSAP functions
function createScrollAnimation() {
    gsap.timeline({
        scrollTrigger: {
            // ... 
            onUpdate: updateSceneOnScroll
        },
    })
        .to(angle, {
            duration: 1,
            v: .5 * Math.PI,
            ease: 'power1.out'
        })
}

Tip #3: Three.js methods to use with onUpdate callback

Various properties of Three.js objects (.quaternion, .position, .scale, etc) can be animated with GSAP in the same way as we did for rotation. But not all the Three.js methods would work. 

Some of them are aimed to assign the value to the property (.setRotationFromAxisAngle(), .setRotationFromQuaternion(), .applyMatrix4(), etc.) which works perfectly for GSAP timelines.

But other methods add the value to the property. For example, .rotateX(.1) would increase the rotation by 0.1 radians every time it’s called. So in case box.rotateX(angle.v) is placed to the onUpdate callback, the angle value will be added to the box rotation every frame and the 3D box will get a bit crazy on scroll. Same with .rotateOnAxis, .translateX, .translateY and other similar methods – they work for animations in the window.requestAnimationFrame() loop but not as much for today’s GSAP setup.

View the minimal scroll sandbox here.

Note: This Three.js scene and other demos below contain some additional elements like axes lines and titles. They have no effect on the scroll animation and can be excluded from the code easily. Feel free to remove the addAxesAndOrbitControls() function, everything related to axisTitles and orbits, and <div> classed ui-controls to get a truly minimal setup.

Now that we know how to rotate the 3D object on scroll, let’s see how to create the package box.

Box structure

The box is composed of 4 x 3 = 12 meshes:

We want to control the position and rotation of those meshes to define the following:

  • unfolded state
  • folded state 
  • closed state

For starters, let’s say our box doesn’t have flaps so all we have is two width-sides and two length-sides. The Three.js scene with 4 planes would look like this:

let box = {
    params: {
        width: 27,
        length: 80,
        depth: 45
    },
    els: {
        group: new THREE.Group(),
        backHalf: {
            width: new THREE.Mesh(),
            length: new THREE.Mesh(),
        },
        frontHalf: {
            width: new THREE.Mesh(),
            length: new THREE.Mesh(),
        }
    }
};

scene.add(box.els.group);
setGeometryHierarchy();
createBoxElements();

function setGeometryHierarchy() {
    // for now, the box is a group with 4 child meshes
    box.els.group.add(box.els.frontHalf.width, box.els.frontHalf.length, box.els.backHalf.width, box.els.backHalf.length);
}

function createBoxElements() {
    for (let halfIdx = 0; halfIdx < 2; halfIdx++) {
        for (let sideIdx = 0; sideIdx < 2; sideIdx++) {

            const half = halfIdx ? 'frontHalf' : 'backHalf';
            const side = sideIdx ? 'width' : 'length';

            const sideWidth = side === 'width' ? box.params.width : box.params.length;
            box.els[half][side].geometry = new THREE.PlaneGeometry(
                sideWidth,
                box.params.depth
            );
        }
    }
}

All 4 sides are by default centered in the (0, 0, 0) point and lying in the XY-plane:

Folding animation

To define the unfolded state, it’s sufficient to:

  • move panels along X-axis aside from center so they don’t overlap

Transforming it to the folded state means

  • rotating width-sides to 90 deg around Y-axis
  • moving length-sides to the opposite directions along Z-axis 
  • moving length-sides along X-axis to keep the box centered

Aside of box.params.width, box.params.length and box.params.depth, the only parameter needed to define these states is the opening angle. So the box.animated.openingAngle parameter is added to be animated on scroll from 0 to 90 degrees.

let box = {
    params: {
        // ...
    },
    els: {
        // ...
    },
    animated: {
        openingAngle: 0
    }
};

function createFoldingAnimation() {
    gsap.timeline({
        scrollTrigger: {
            trigger: '.page',
            start: '0% 0%',
            end: '100% 100%',
            scrub: true,
        },
        onUpdate: updatePanelsTransform
    })
        .to(box.animated, {
            duration: 1,
            openingAngle: .5 * Math.PI,
            ease: 'power1.inOut'
        })
}

Using box.animated.openingAngle, the position and rotation of sides can be calculated

function updatePanelsTransform() {

    // place width-sides aside of length-sides (not animated)
    box.els.frontHalf.width.position.x = .5 * box.params.length;
    box.els.backHalf.width.position.x = -.5 * box.params.length;

    // rotate width-sides from 0 to 90 deg 
    box.els.frontHalf.width.rotation.y = box.animated.openingAngle;
    box.els.backHalf.width.rotation.y = box.animated.openingAngle;

    // move length-sides to keep the closed box centered
    const cos = Math.cos(box.animated.openingAngle); // animates from 1 to 0
    box.els.frontHalf.length.position.x = -.5 * cos * box.params.width;
    box.els.backHalf.length.position.x = .5 * cos * box.params.width;

    // move length-sides to define box inner space
    const sin = Math.sin(box.animated.openingAngle); // animates from 0 to 1
    box.els.frontHalf.length.position.z = .5 * sin * box.params.width;
    box.els.backHalf.length.position.z = -.5 * sin * box.params.width;
}
View the sandbox here.

Nice! Let’s think about the flaps. We want them to move together with the sides and then to rotate around their own edge to close the box.

To move the flaps together with the sides we simply add them as the children of the side meshes. This way, flaps inherit all the transforms we apply to the sides. An additional position.y transition will place them on top or bottom of the side panel.

let box = {
    params: {
        // ...
    },
    els: {
        group: new THREE.Group(),
        backHalf: {
            width: {
                top: new THREE.Mesh(),
                side: new THREE.Mesh(),
                bottom: new THREE.Mesh(),
            },
            length: {
                top: new THREE.Mesh(),
                side: new THREE.Mesh(),
                bottom: new THREE.Mesh(),
            },
        },
        frontHalf: {
            width: {
                top: new THREE.Mesh(),
                side: new THREE.Mesh(),
                bottom: new THREE.Mesh(),
            },
            length: {
                top: new THREE.Mesh(),
                side: new THREE.Mesh(),
                bottom: new THREE.Mesh(),
            },
        }
    },
    animated: {
        openingAngle: .02 * Math.PI
    }
};

scene.add(box.els.group);
setGeometryHierarchy();
createBoxElements();

function setGeometryHierarchy() {
    // as before
    box.els.group.add(box.els.frontHalf.width.side, box.els.frontHalf.length.side, box.els.backHalf.width.side, box.els.backHalf.length.side);

    // add flaps
    box.els.frontHalf.width.side.add(box.els.frontHalf.width.top, box.els.frontHalf.width.bottom);
    box.els.frontHalf.length.side.add(box.els.frontHalf.length.top, box.els.frontHalf.length.bottom);
    box.els.backHalf.width.side.add(box.els.backHalf.width.top, box.els.backHalf.width.bottom);
    box.els.backHalf.length.side.add(box.els.backHalf.length.top, box.els.backHalf.length.bottom);
}

function createBoxElements() {
    for (let halfIdx = 0; halfIdx < 2; halfIdx++) {
        for (let sideIdx = 0; sideIdx < 2; sideIdx++) {

            // ...

            const flapWidth = sideWidth - 2 * box.params.flapGap;
            const flapHeight = .5 * box.params.width - .75 * box.params.flapGap;

            // ...

            const flapPlaneGeometry = new THREE.PlaneGeometry(
                flapWidth,
                flapHeight
            );
            box.els[half][side].top.geometry = flapPlaneGeometry;
            box.els[half][side].bottom.geometry = flapPlaneGeometry;
            box.els[half][side].top.position.y = .5 * box.params.depth + .5 * flapHeight;
            box.els[half][side].bottom.position.y = -.5 * box.params.depth -.5 * flapHeight;
        }
    }
}

The flaps rotation is a bit more tricky.

Changing the pivot point of Three.js mesh

Let’s get back to the first example with a Three.js object rotating around the X axis.

There’re many ways to set the rotation of a 3D object: Euler angle, quaternion, lookAt() function, transform matrices and so on. Regardless of the way angle and axis of rotation are set, the pivot point (transform origin) will be at the center of the mesh.

Say we animate rotation.x for the 4 boxes that are placed around the scene:

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    boxes[i] = boxMesh.clone();
    boxes[i].position.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(boxes[i]);
}
boxes[1].position.y = .5 * boxSize[1];
boxes[2].rotation.y = .5 * Math.PI;
boxes[3].position.y = - boxSize[1];
See the sandbox here.

For them to rotate around the bottom edge, we need to move the pivot point to -.5 x box size. There are couple of ways to do this:

  • wrap mesh with additional Object3D
  • transform geometry of mesh
  • assign pivot point with additional transform matrix
  • could be some other tricks

If you’re curious why Three.js doesn’t provide origin positioning as a native method, check out this discussion.

Option #1: Wrapping mesh with additional Object3D

For the first option, we add the original box mesh as a child of new Object3D. We treat the parent object as a box so we apply transforms (rotation.x) to it, exactly as before. But we also translate the mesh to half of its size. The mesh moves up in the local space but the origin of the parent object stays in the same point.

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    boxes[i] = new THREE.Object3D();
    const mesh = boxMesh.clone();
    mesh.position.y = .5 * boxSize[1];
    boxes[i].add(mesh);

    boxes[i].position.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(boxes[i]);
}
boxes[1].position.y = .5 * boxSize[1];
boxes[2].rotation.y = .5 * Math.PI;
boxes[3].position.y = - boxSize[1];
See the sandbox here.

Option #2: Translating the geometry of Mesh

With the second option, we move up the geometry of the mesh. In Three.js, we can apply a transform not only to the objects but also to their geometry.

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
boxGeometry.translate(0, .5 * boxSize[1], 0);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    boxes[i] = boxMesh.clone();
    boxes[i].position.x = (i - .5 * numberOfBoxes) * (boxSize[0] + 2);
    scene.add(boxes[i]);
}
boxes[1].position.y = .5 * boxSize[1];
boxes[2].rotation.y = .5 * Math.PI;
boxes[3].position.y = - boxSize[1];
See the sandbox here.

The idea and result are the same: we move the mesh up ½ of its height but the origin point is staying at the same coordinates. That’s why rotation.x transform makes the box rotate around its bottom side.

Option #3: Assign pivot point with additional transform matrix

I find this way less suitable for today’s project but the idea behind it is pretty simple. We take both, pivot point position and desired transform as matrixes. Instead of simply applying the desired transform to the box, we apply the inverted pivot point position first, then do rotation.x as the box is centered at the moment, and then apply the point position.

object.matrix = inverse(pivot.matrix) * someTranformationMatrix * pivot.matrix

You can find a nice implementation of this method here.

I’m using geometry translation (option #2) to move the origin of the flaps. Before getting back to the box, let’s see what we can achieve if the very same rotating boxes are added to the scene in hierarchical order and placed one on top of another.

const boxGeometry = new THREE.BoxGeometry(boxSize[0], boxSize[1], boxSize[2]);
boxGeometry.translate(0, .5 * boxSize[1], 0);
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);

const numberOfBoxes = 4;
for (let i = 0; i < numberOfBoxes; i++) {
    boxes[i] = boxMesh.clone();
    if (i === 0) {
        scene.add(boxes[i]);
    } else {
        boxes[i - 1].add(boxes[i]);
        boxes[i].position.y = boxSize[1];
    }
}

We still animate rotation.x of each box from 0 to 90 degrees, so the first mesh rotates to 90 degrees, the second one does the same 90 degrees plus its own 90 degrees rotation, the third does 90+90+90 degrees, etc.

See the sandbox here.

A very easy and quite useful trick.

Animating the flaps

Back to the flaps. Flaps are made from translated geometry and added to the scene as children of the side meshes. We set their position.y property once and animate their rotation.x property on scroll.

function setGeometryHierarchy() {
    box.els.group.add(box.els.frontHalf.width.side, box.els.frontHalf.length.side, box.els.backHalf.width.side, box.els.backHalf.length.side);
    box.els.frontHalf.width.side.add(box.els.frontHalf.width.top, box.els.frontHalf.width.bottom);
    box.els.frontHalf.length.side.add(box.els.frontHalf.length.top, box.els.frontHalf.length.bottom);
    box.els.backHalf.width.side.add(box.els.backHalf.width.top, box.els.backHalf.width.bottom);
    box.els.backHalf.length.side.add(box.els.backHalf.length.top, box.els.backHalf.length.bottom);
}

function createBoxElements() {
    for (let halfIdx = 0; halfIdx < 2; halfIdx++) {
        for (let sideIdx = 0; sideIdx < 2; sideIdx++) {

            // ...

            const topGeometry = flapPlaneGeometry.clone();
            topGeometry.translate(0, .5 * flapHeight, 0);

            const bottomGeometry = flapPlaneGeometry.clone();
            bottomGeometry.translate(0, -.5 * flapHeight, 0);

            box.els[half][side].top.position.y = .5 * box.params.depth;
            box.els[half][side].bottom.position.y = -.5 * box.params.depth;
        }
    }
}

The animation of each flap has an individual timing and easing within the gsap.timeline so we store the flap angles separately.

let box = {
    // ...
    animated: {
        openingAngle: .02 * Math.PI,
        flapAngles: {
            backHalf: {
                width: {
                    top: 0,
                    bottom: 0
                },
                length: {
                    top: 0,
                    bottom: 0
                },
            },
            frontHalf: {
                width: {
                    top: 0,
                    bottom: 0
                },
                length: {
                    top: 0,
                    bottom: 0
                },
            }
        }
    }
}

function createFoldingAnimation() {
    gsap.timeline({
        scrollTrigger: {
            // ...
        },
        onUpdate: updatePanelsTransform
    })
        .to(box.animated, {
            duration: 1,
            openingAngle: .5 * Math.PI,
            ease: 'power1.inOut'
        })
        .to([ box.animated.flapAngles.backHalf.width, box.animated.flapAngles.frontHalf.width ], {
            duration: .6,
            bottom: .6 * Math.PI,
            ease: 'back.in(3)'
        }, .9)
        .to(box.animated.flapAngles.backHalf.length, {
            duration: .7,
            bottom: .5 * Math.PI,
            ease: 'back.in(2)'
        }, 1.1)
        .to(box.animated.flapAngles.frontHalf.length, {
            duration: .8,
            bottom: .49 * Math.PI,
            ease: 'back.in(3)'
        }, 1.4)
        .to([box.animated.flapAngles.backHalf.width, box.animated.flapAngles.frontHalf.width], {
            duration: .6,
            top: .6 * Math.PI,
            ease: 'back.in(3)'
        }, 1.4)
        .to(box.animated.flapAngles.backHalf.length, {
            duration: .7,
            top: .5 * Math.PI,
            ease: 'back.in(3)'
        }, 1.7)
        .to(box.animated.flapAngles.frontHalf.length, {
            duration: .9,
            top: .49 * Math.PI,
            ease: 'back.in(4)'
        }, 1.8)
}

function updatePanelsTransform() {

    // ... folding / unfolding

    box.els.frontHalf.width.top.rotation.x = -box.animated.flapAngles.frontHalf.width.top;
    box.els.frontHalf.length.top.rotation.x = -box.animated.flapAngles.frontHalf.length.top;
    box.els.frontHalf.width.bottom.rotation.x = box.animated.flapAngles.frontHalf.width.bottom;
    box.els.frontHalf.length.bottom.rotation.x = box.animated.flapAngles.frontHalf.length.bottom;

    box.els.backHalf.width.top.rotation.x = box.animated.flapAngles.backHalf.width.top;
    box.els.backHalf.length.top.rotation.x = box.animated.flapAngles.backHalf.length.top;
    box.els.backHalf.width.bottom.rotation.x = -box.animated.flapAngles.backHalf.width.bottom;
    box.els.backHalf.length.bottom.rotation.x = -box.animated.flapAngles.backHalf.length.bottom;
}
See the sandbox here.

With all this, we finish the animation part! Let’s now work on the look of our box.

Lights and colors 

This part is as simple as replacing multi-color wireframes with a single color MeshStandardMaterial and adding a few lights.

const ambientLight = new THREE.AmbientLight(0xffffff, .5);
scene.add(ambientLight);
lightHolder = new THREE.Group();
const topLight = new THREE.PointLight(0xffffff, .5);
topLight.position.set(-30, 300, 0);
lightHolder.add(topLight);
const sideLight = new THREE.PointLight(0xffffff, .7);
sideLight.position.set(50, 0, 150);
lightHolder.add(sideLight);
scene.add(lightHolder);

const material = new THREE.MeshStandardMaterial({
    color: new THREE.Color(0x9C8D7B),
    side: THREE.DoubleSide
});
box.els.group.traverse(c => {
    if (c.isMesh) c.material = material;
});

Tip: Object rotation effect with OrbitControls

OrbitControls make the camera orbit around the central point (left preview). To demonstrate a 3D object, it’s better to give users a feeling that they rotate the object itself, not the camera around it (right preview). To do so, we keep the lights position static relative to camera.

It can be done by wrapping lights in an additional lightHolder object. The pivot point of the parent object is (0, 0, 0). We also know that the camera rotates around (0, 0, 0). It means we can simply apply the camera’s rotation to the lightHolder to keep the lights static relative to the camera.

function render() {
    // ...
    lightHolder.quaternion.copy(camera.quaternion);
    renderer.render(scene, camera);
}
See the sandbox here.

Layered panels

So far, our sides and flaps were done as a simple PlaneGeomery. Let’s replace it with “real” corrugated cardboard material ‐ two covers and a fluted layer between them.


First step is replacing a single plane with 3 planes merged into one. To do so, we need to place 3 clones of PlaneGeometry one behind another and translate the front and back levels along the Z axis by half of the total cardboard thickness.

There’re many ways to move the layers, starting from the geometry.translate(0, 0, .5 * thickness) method we used to change the pivot point. But considering other transforms we’re about to apply to the cardboard geometry, we better go through the geometry.attributes.position array and add the offset to the z-coordinates directly:

fconst baseGeometry = new THREE.PlaneGeometry(
    params.width,
    params.height,
);

const geometriesToMerge = [
    getLayerGeometry(- .5 * params.thickness),
    getLayerGeometry(0),
    getLayerGeometry(.5 * params.thickness)
];

function getLayerGeometry(offset) {
    const layerGeometry = baseGeometry.clone();
    const positionAttr = layerGeometry.attributes.position;
    for (let i = 0; i < positionAttr.count; i++) {
        const x = positionAttr.getX(i);
        const y = positionAttr.getY(i)
        const z = positionAttr.getZ(i) + offset;
        positionAttr.setXYZ(i, x, y, z);
    }
    return layerGeometry;
}

For merging the geometries we use the mergeBufferGeometries method. It’s pretty straightforward, just don’t forget to import the BufferGeometryUtils module into your project.

See the sandbox here.

Wavy flute

To turn a mid layer into the flute, we apply the sine wave to the plane. In fact, it’s the same z-coordinate offset, just calculated as Sine function of the x-attribute instead of a constant value.

function getLayerGeometry() {
    const baseGeometry = new THREE.PlaneGeometry(
        params.width,
        params.height,
        params.widthSegments,
        1
    );

    const offset = (v) => .5 * params.thickness * Math.sin(params.fluteFreq * v);
    const layerGeometry = baseGeometry.clone();
    const positionAttr = layerGeometry.attributes.position;
    for (let i = 0; i < positionAttr.count; i++) {
        const x = positionAttr.getX(i);
        const y = positionAttr.getY(i)
        const z = positionAttr.getZ(i) + offset(x);
        positionAttr.setXYZ(i, x, y, z);
    }
    layerGeometry.computeVertexNormals();

    return layerGeometry;
}

The z-offset is not the only change we need here. By default, PlaneGeometry is constructed from two triangles. As it has only one width segment and one height segment, there’re only corner vertices. To apply the sine(x) wave, we need enough vertices along the x axis – enough resolution, you can say.

Also, don’t forget to update the normals after changing the geometry. It doesn’t happen automatically.

See the sandbox here.

I apply the wave with an amplitude equal to the cardboard thickness to the middle layer, and the same wave with a little amplitude to the front and back layers, just to give some texture to the box.

The surfaces and cuts look pretty cool. But we don’t want to see the wavy layer on the folding lines. At the same time, I want those lines to be visible before the folding happens:

To achieve this, we can “press” the cardboard on the selected edges of each panel.

We can do so by applying another modifier to the z-coordinate. This time it’s a power function of the x or y attribute (depending on the side we’re “pressing”). 

function getLayerGeometry() {
    const baseGeometry = new THREE.PlaneGeometry(
        params.width,
        params.height,
        params.widthSegments,
        params.heightSegments // to apply folding we need sufficient number of segments on each side
    );

    const offset = (v) => .5 * params.thickness * Math.sin(params.fluteFreq * v);
    const layerGeometry = baseGeometry.clone();
    const positionAttr = layerGeometry.attributes.position;
    for (let i = 0; i < positionAttr.count; i++) {
        const x = positionAttr.getX(i);
        const y = positionAttr.getY(i)
        let z = positionAttr.getZ(i) + offset(x); // add wave
        z = applyFolds(x, y, z); // add folds
        positionAttr.setXYZ(i, x, y, z);
    }
    layerGeometry.computeVertexNormals();

    return layerGeometry;
}

function applyFolds(x, y, z) {
    const folds = [ params.topFold, params.rightFold, params.bottomFold, params.leftFold ];
    const size = [ params.width, params.height ];
    let modifier = (c, size) => (1. - Math.pow(c / (.5 * size), params.foldingPow));

    // top edge: Z -> 0 when y -> plane height,
    // bottom edge: Z -> 0 when y -> 0,
    // right edge: Z -> 0 when x -> plane width,
    // left edge: Z -> 0 when x -> 0

    if ((x > 0 && folds[1]) || (x < 0 && folds[3])) {
        z *= modifier(x, size[0]);
    }
    if ((y > 0 && folds[0]) || (y < 0 && folds[2])) {
        z *= modifier(y, size[1]);
    }
    return z;
}
See the sandbox here.

The folding modifier is applied to all 4 edges of the box sides, to the bottom edges of the top flaps, and to the top edges of bottom flaps.

With this the box itself is finished.

There is room for optimization, and for some extra features, of course. For example, we can easily remove the flute level from the side panels as it’s never visible anyway. Let me also quickly describe how to add zooming buttons and a side image to our gorgeous box.

Zooming

The default behaviour of OrbitControls is zooming the scene by scroll. It means that our scroll-driven animation is in conflict with it, so we set orbit.enableZoom property to false.

We still can have zooming on the scene by changing the camera.zoom property. We can use the same GSAP animation as before, just note that animating the camera’s property doesn’t automatically update the camera’s projection. According to the documentation, updateProjectionMatrix() must be called after any change of the camera parameters so we have to call it on every frame of the transition:

// ...
// changing the zoomLevel variable with buttons

gsap.to(camera, {
    duration: .2,
    zoom: zoomLevel,
    onUpdate: () => {
        camera.updateProjectionMatrix();
    }
})

Side image

The image, or even a clickable link, can be added on the box side. It can be done with an additional plane mesh with a texture on it. It should be just moving together with the selected side of the box:

function updatePanelsTransform() {

   // ...

   // for copyright mesh to be placed on the front length side of the box
   copyright.position.copy(box.els.frontHalf.length.side.position);
   copyright.position.x += .5 * box.params.length - .5 * box.params.copyrightSize[0];
   copyright.position.y -= .5 * (box.params.depth - box.params.copyrightSize[1]);
   copyright.position.z += box.params.thickness;
}

As for the texture, we can import an image/video file, or use a canvas element we create programmatically. In the final demo I use a canvas with a transparent background, and two lines of text with an underline. Turning the canvas into a Three.js texture makes me able to map it on the plane:

function createCopyright() {
    
    // create canvas
    
    const canvas = document.createElement('canvas');
    canvas.width = box.params.copyrightSize[0] * 10;
    canvas.height = box.params.copyrightSize[1] * 10;
    const planeGeometry = new THREE.PlaneGeometry(box.params.copyrightSize[0], box.params.copyrightSize[1]);

    const ctx = canvas.getContext('2d');
    ctx.clearRect(0, 0, canvas.width, canvas.width);
    ctx.fillStyle = '#000000';
    ctx.font = '22px sans-serif';
    ctx.textAlign = 'end';
    ctx.fillText('ksenia-k.com', canvas.width - 30, 30);
    ctx.fillText('codepen.io/ksenia-k', canvas.width - 30, 70);

    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(canvas.width - 160, 35);
    ctx.lineTo(canvas.width - 30, 35);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(canvas.width - 228, 77);
    ctx.lineTo(canvas.width - 30, 77);
    ctx.stroke();

    // create texture

    const texture = new THREE.CanvasTexture(canvas);

    // create mesh mapped with texture

    copyright = new THREE.Mesh(planeGeometry, new THREE.MeshBasicMaterial({
        map: texture,
        transparent: true,
        opacity: .5
    }));
    scene.add(copyright);
}

To make the text lines clickable, we do the following:

  • use Raycaster and mousemove event to track if the intersection between cursor ray and plane, change the cursor appearance if the mesh is hovered
  • if a click happened while the mesh is hovered, check the uv coordinate of intersection
  • if the uv coordinate is on the top half of the mesh (uv.y > .5) we open the first link, if uv coordinate is below .5, we go to the second link

The raycaster code is available in the full demo.


Thank you for scrolling this far!
Hope this tutorial can be useful for your Three.js projects ♡

Smooth Panel Scroll Effects

If you have browsed through our latest website roundup you might have seen the amazing website of Margot Priolet. I absolutely love the hero section effect when scrolling and I wanted to remake it and explore some variations including playing with transforms and filter effects.

So the initial view is a hero section like this:

When scrolling, an image section is revealed with an opacity effect. Here, there are many possibilities for variations.

After scrolling for a bit and triggering the effects on the images, we reach the final section.

This is how it all comes together:

This demo shows how to play with smaller images and the black and white filter.

Hope you enjoy this and find it useful!

On-Scroll Animation and View Switch

Today I’d love to share a little layout with you where we have some fun on-scroll animations going on and an overview switch. The idea is to have a view switch which allows the layout (specifically the images) to change into a grid. The switch is made with the help of GreenSocks’ Flip plugin and we use Lenis for the smooth scrolling.

Our initial layout looks as follows:

While scrolling, a few things happen. The titles next to the images will be hidden and we also have a marquee that moves across the page.

Once we click on the switch, all images will animate to their position in the grid:

I hope you like this little layout and find it useful!

Fullscreen Scrolling Slideshow

Today I’d like to share a little fullscreen slideshow effect with you. It is based on the amazing design of Chan + Eayrs and it’s powered by GreenSock’s Observer plugin that allows us to easily manage events when scrolling, for example.

The idea is to navigate a fullscreen slideshow based on scrolling and when we click a slide image, it reveals some content underneath. We also have a menu/navigation that allows us to directly jump to a specific slide.

The initial view is the following:

As a micro animation, we make the letters of the custom text cursor disappear while making the close button appear with the same kind of random letter animation:

The content view looks as follows:

And this is how it all comes together:

I hope you enjoy this and find it useful! Thanks for checking by!

Large Image to Content Page Transition

Today I’d like to share a little scroll effect and a page transition with you. It is inspired by Vitalii Burhonskyi‘s wonderful Dribbble shot. The idea is to have a parallax-like scroll effect on a main page with large images and titles. When clicking on an item, the large image animates to a smaller version in a content view.

For the smooth page scroll we are using Lenis and GSAP’s Flip plugin lets us easily animate the large image to its place in the content area.

The scroll effect contains a bit of an asymmetric scale animation, which gives it an interesting touch.

This is the initial view:

When we click on an item, the image animates to its place in a content area with more text:

And this is how it all comes together:

I hope you enjoy this little animation and find it useful!

Thanks for checking by 🙂

How to Animate SVG Shapes on Scroll

Scrolling can be so much fun! Let’s have a look at how to make SVG shapes and clip-paths animate on scroll to add a bit of drama and waviness to a design. We can morph one path into another and we’ll do that once a shape enters the viewport.

Let’s get started!

Path Animation on Separators

Animating a path on scroll can be particularly interesting for separators and borders to full screen images. So, let’s have a look at this first example, where we simply animate the path of an SVG that has the same fill color as the background of the page:

As we scroll, we’ll animate an SVG path from a rectangle to a wave shape.

For this to work, we need two paths: one initial path that is a rectangle and a final path that is the wavy shape. When we create paths that will be animated, we have to keep in mind that all points present in the final path, also need to be there in the initial shape. So the best way to make sure our path animation doesn’t turn out funky, is to start crafting the most complex shape which in our case is the final path with the curve.

Creating the SVG paths

Unfortunately, graphic design softwares might not be the best choice for making proper, optimized paths. I usually start making the shapes in Sketch and then I optimize them using SVGOMG. Then, I copy the path and paste it into the SvgPathEditor. The optimization step is not always needed as the path editor offers rounding, which is great. I use it for paths or groups that had transforms applied to them by Sketch. SVGOMG can remove those.

SVGOMG cleans up SVGs and removes transforms

Once we have our optimized SVG with a “clean” path, we can use the editor to create the initial shape out of the more complex one:

Although this shape is not really visible, we need all the points of the final path to be present in the initial one.

When doing this, it’s as well a great way to roughly visualize how the animation will look and feel like (in reverse, of course). Once we have both paths, we can use them in our HTML.

Markup and Styling

<svg class="separator separator--up" width="100%" height="100%" viewBox="0 0 100 10" preserveAspectRatio="none">
	<path 
		class="separator__path path-anim" 
		d="M 0 0 C 40 0 60 0 100 0 L 0 0 Z" 
		data-path-to="M 0 0 C 40 10 60 10 100 0 L 0 0 Z" 
		vector-effect="non-scaling-stroke" 
	/>
</svg>

Using a data-attribute, we define the final path that we want the initial one to animate to. A bit of CSS will make sure that our SVG is placed in full width, at the top of the large background image:

.separator {
	display: block;
	position: absolute;
	z-index: 1000;
	pointer-events: none;
	width: 100%;
	height: 150px;
	fill: var(--color-bg);
}

Note that you can stretch your SVG to your desired height, depending on how dramatic you want the wave to look when scrolling.

The JavaScript

For the smooth scrolling, we’ll use the new Lenis library by Studio Freight. GSAP’s ScrollTrigger plugin will allow us to animate an element when it enters or exits the viewport.

Let’s import the scripts that we need:

import Lenis from '@studio-freight/lenis'
import { gsap } from 'gsap';
import { preloader } from './preloader';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

Let’s preload the images:

preloader();

Now we need all the path elements that we want animated (they have the class “path-anim”):

const paths = [...document.querySelectorAll('path.path-anim')];

Next, we initialize smooth scrolling:

const lenis = new Lenis({
    lerp: 0.1,
    smooth: true,
});
const scrollFn = () => {
    lenis.raf();
    requestAnimationFrame(scrollFn);
};
requestAnimationFrame(scrollFn);

And finally, we animate our paths when they enter the viewport. The final path is defined in the data-attribute “data-path-to” in the path element, as we have seen previously.

The start and end is defined by the SVG’s element top reaching the bottom of the viewport, and its bottom reaching the top of the viewport:

paths.forEach(el => {
    const svgEl = el.closest('svg');
    const pathTo = el.dataset.pathTo;

    gsap.timeline({
        scrollTrigger: {
            trigger: svgEl,
            start: "top bottom",
            end: "bottom top",
            scrub: true
        }
    })
    .to(el, {
        ease: 'none',
        attr: { d: pathTo }
    });
});

Now, we have our morphing SVG paths while scrolling the page!

While we can use this animation technique on paths and “separators” that simply cover some big background image, we can also animate clip-paths on images, like in this example:

<svg class="image-clip" width="500px" height="750px" viewBox="0 0 500 750">
	<defs>
		<clipPath id="shape1">
			<path 
				class="path-anim" 
				d="M 0 0 L 500 0 C 500 599.6 500 677.1 500 750 L 0 750 C 0 205 0 105 0 0 Z"
				data-path-to="M 0 0 L 500 0 C 331 608 485 551 500 750 L 0 750 C 120 281 7 296 0 0 Z" 
			/>
		</clipPath>
	</defs>
	<image 
		clip-path="url(#shape1)" 
		xlink:href="img/2.jpg" 
		x="0" y="0" 
		width="500" 
		height="750"
	/>
</svg>
We can animate clip-paths on SVG images

Final Result

Combining smooth scrolling with SVG path animations can add an extra level of morph-coolness to a design. It’s not very complicated but the result can look very dramatic. This technique gives an organic touch to a scroll experience and might spare the need to use WebGL for certain simple distortion effects.

I really hope you enjoyed this little tutorial and find it useful for creating your own animations!

The post How to Animate SVG Shapes on Scroll appeared first on Codrops.

Scroll Animation Ideas for Image Grids

Today I’d love to share some experimental scroll animations with you! The idea is simple: when scrolling a grid of images, animate the images that enter or leave the viewport in an interesting and creative way 🙂

For these effects I’m using the super helpful ScrollTrigger plugin of GSAP and the new smooth scroll library Lenis by the amazing folks of Studio Freight.

When scrolling a grid layout, we can move, scale or rotate the images, there’s really endless possibilities! In this demo, we squeeze them:

In the following demo we add a 3D rotation effect, creating the illusion of something like a roll:

I really hope you enjoy this set of experiments and find it useful for your projects!

Thanks for checking by 🙂

The post Scroll Animation Ideas for Image Grids appeared first on Codrops.

On-Scroll Text Repetition Animation

Today I’d like to share a simple, but intriguing looking typography effect with you. This on-scroll animation is a recreation of the effect seen on the fabulous site of Dr. Dabber, where you see fragments of a large text moving as you scroll the page.

As we scroll we move out several layers of the same text at the top and bottom. By setting the same background color as the page, we can create gaps between these layers.

With some style adjustments we can create a variety of looks, text-shadow like ones or outlines, the possibilities are endless!

This is how it looks when you scroll:

I really hope you enjoy this little effect and find it useful!

The post On-Scroll Text Repetition Animation appeared first on Codrops.

Crafting Scroll Based Animations in Three.js

Having an experience composed of only WebGL is great, but sometimes, you’ll want the experience to be part of a classic website.

The experience can be in the background to add some beauty to the page, but then, you’ll want that experience to integrate properly with the HTML content.

In this tutorial, we will:

  • learn how to use Three.js as a background of a classic HTML page
  • make the camera translate to follow the scroll
  • discover some tricks to make the scrolling more immersive
  • add a cool parallax effect based on the cursor position
  • trigger some animations when arriving at the corresponding sections
See the live version

This tutorial is part of the 39 lessons available in the Three.js Journey course.

Three.js Journey is the ultimate course to learn WebGL with Three.js. Once you’ve subscribed, you get access to 45 hours of videos also available as text version. First, you’ll start with the basics like the reasons to use Three.js and how to setup a simple scene. Then, you’ll start animating it, creating cool environments, interacting with it, creating your own models in Blender. To finish, you will learn advanced techniques like physics, shaders, realistic renders, code structuring, baking, etc.

As a member of the Three.js Journey community, you will also get access to a members-only Discord server.

Use the code CODROPS1 for a 20% discount.

Starter

This tutorial is intended for beginners but with some basic knowledge of Three.js.

Installation

For this tutorial, a starter.zip file is provided.

You should see a red cube at the center with “My Portfolio” written on it:

The libraries are loaded as plain <script> to keep things simple and accessible for everyone:

  • Three.js in version 0.136.0
  • GSAP in version 3.9.1

For specific techniques like Three.js controls or texture loading, you are going to need a development server, but we are not going to use those here.

Setup

We already have a basic Three.js setup.

Here’s a quick explaination of what each part of the setup does, but if you want to learn more, everything is explained in the Three.js Journey course:

index.html

<canvas class="webgl"></canvas>

Creates a <canvas> in which we are going to draw the WebGL renders.

<section class="section">
    <h1>My Portfolio</h1>
</section>
<section class="section">
    <h2>My projects</h2>
</section>
<section class="section">
    <h2>Contact me</h2>
</section>

Creates some sections with a simple title in them. You can add whatever you want in these.

<script src="./three.min.js"></script>
<script src="./gsap.min.js"></script>
<script src="./script.js"></script>

Loads the Three.js library, the GSAP library, and to finish, our JavaScript file.

style.css

*
{
    margin: 0;
    padding: 0;
}

Resets any margin or padding.

.webgl
{
    position: fixed;
    top: 0;
    left: 0;
}

Makes the WebGL <canvas> fit the viewport and stay fixed while scrolling.

.section
{
    display: flex;
    align-items: center;
    height: 100vh;
    position: relative;
    font-family: 'Cabin', sans-serif;
    color: #ffeded;
    text-transform: uppercase;
    font-size: 7vmin;
    padding-left: 10%;
    padding-right: 10%;
}

section:nth-child(odd)
{
    justify-content: flex-end;
}

Centers the sections. Also centers the text vertically and aligns it on the right for one out of two sections.

script.js

/**
 * Base
 */
// Canvas
const canvas = document.querySelector('canvas.webgl')

// Scene
const scene = new THREE.Scene()

Retrieves the canvas from the HTML and create a Three.js Scene.

/**
 * Test cube
 */
const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
scene.add(cube)

Creates the red cube that we can see at the center. We are going to remove it shortly.

/**
 * Sizes
 */
const sizes = {
    width: window.innerWidth,
    height: window.innerHeight
}

window.addEventListener('resize', () =>
{
    // Update sizes
    sizes.width = window.innerWidth
    sizes.height = window.innerHeight

    // Update camera
    camera.aspect = sizes.width / sizes.height
    camera.updateProjectionMatrix()

    // Update renderer
    renderer.setSize(sizes.width, sizes.height)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})

Saves the size of the viewport in a sizes variable, updates that variable when a resize event occurs and updates the camera and renderer at the same time (more about these two right after).

/**
 * Camera
 */
// Base camera
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
scene.add(camera)

Creates a PerspectiveCamera and moves it backward on the positive z axis.

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

Creates the WebGLRenderer that will render the scene seen from the camera and updates its size and pixel ratio with a maximum of 2 to prevent performance issues.

/**
 * Animate
 */
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Render
    renderer.render(scene, camera)

    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}

tick()

Starts a loop with a classic requestAnimationFrame to call the tick function on each frame and animates our experience. In that tick function, we do a render of the scene from the camera on each frame.

The Clock lets us retrieve the elapsed time that we save in the elapsedTime variable for later use.

HTML Scroll

Fix the elastic scroll

In some environments, you might notice that, if you scroll too far, you get a kind of elastic animation when the page goes beyond the limit:

While this is a cool feature, by default, the back of the page is white and doesn’t match our experience.

We want to keep that elastic effect for those who have it, but make the white parts the same color as the renderer.

We could have set the background-color of the page to the same color as the clearColor of the renderer. But instead, we are going to make the clearColor transparent and only set the background-color on the page so that the background color is set at one place only.

To do that, in /script.js, you need to set the alpha property to true on the WebGLRenderer:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    alpha: true
})

By default, the clear alpha value is 0 which is why we didn’t have to set it ourselves. Telling the renderer to handle alpha is enough. But if you want to change that value, you can do it with setClearAlpha:

renderer.setClearAlpha(0)

We can now see the back of the page which is white:

In /style.css, add a background-color to the html in CSS:

html
{
    background: #1e1a20;
}

We get a nice uniform background color and the elastic scroll isn’t an issue anymore:

Objects

We are going to create an object for each section to illustrate each of them.

To keep things simple, we will use Three.js primitives, but you can create whatever you want or even import custom models into the scene.

In /script.js, remove the code for the cube. In its place, create three Meshes using a TorusGeometry, a ConeGeometry and a TorusKnotGeometry:

/**
 * Objects
 */
// Meshes
const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)

scene.add(mesh1, mesh2, mesh3)

All the objects should be on top of each other (we will fix that later):

In order to keep things simple, our code will be a bit redundant. But don’t hesitate to use arrays or other code structuring solutions if you have more sections.

Material

Base material

We are going to use the MeshToonMaterial for the objects and are going to create one instance of the material and use it for all three Meshes.

When creating the MeshToonMaterial, use '#ffeded' for the color property and apply it to all 3 Meshes:

// Material
const material = new THREE.MeshToonMaterial({ color: '#ffeded' })

// Meshes
const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    material
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    material
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    material
)

scene.add(mesh1, mesh2, mesh3)

Unfortunately, it seems that the objects are now black:

The reason is that the MeshToonMaterial is one of the Three.js materials that appears only when there is light.

Light

Add one DirectionalLight to the scene:

/**
 * Lights
 */
const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.position.set(1, 1, 0)
scene.add(directionalLight)

You should now see your objects:

Position

By default, in Three.js, the field of view is vertical. This means that if you put one object on the top part of the render and one object on the bottom part of the render and then you resize the window, you’ll notice that the objects stay put at the top and at the bottom.

To illustrate this, temporarily add this code:

mesh1.position.y = 2
mesh1.scale.set(0.5, 0.5, 0.5)

mesh2.visible = false

mesh3.position.y = - 2
mesh3.scale.set(0.5, 0.5, 0.5)

The torus stays at the top and the torus knot stays at the bottom:

When you’re done, remove the code above.

This is good because it means that we only need to make sure that each object is far enough away from the other on the y axis, so that we don’t see them together.

Create an objectsDistance variable and choose a random value like 2:

const objectsDistance = 2

Use that variable to position the meshes on the y axis. The values must be negative so that the objects go down:

mesh1.position.y = - objectsDistance * 0
mesh2.position.y = - objectsDistance * 1
mesh3.position.y = - objectsDistance * 2

Increase the objectsDistance until the objects are far enough apart. A good amount should be 4, but you can go back to change that value later.

const objectsDistance = 4

Now, we can only see the first object:

The two others will be below. We will position them horizontally once we move the camera with the scroll and they appear again.

The objectsDistance will get handy a bit later, which is why we saved the value in a variable.

Permanent rotation

To give more life to the experience, we are going to add a permanent rotation to the objects.

First, add the objects to a sectionMeshes array:

const sectionMeshes = [ mesh1, mesh2, mesh3 ]

Then, in the tick function, loop through the sectionMeshes array and apply a slow rotation by using the elapsedTime already available:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Animate meshes
    for(const mesh of sectionMeshes)
    {
        mesh.rotation.x = elapsedTime * 0.1
        mesh.rotation.y = elapsedTime * 0.12
    }

    // ...
}

All the meshes (though we can see only one here) should slowly rotate:

Camera

Scroll

It’s time to make the camera move with the scroll.

First, we need to retrieve the scroll value. This can be done with the window.scrollY property.

Create a scrollY variable and assign it window.scrollY:

/**
 * Scroll
 */
let scrollY = window.scrollY

But then, we need to update that value when the user scrolls. To do that, listen to the 'scroll' event on window:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    console.log(scrollY)
})

You should see the scroll value in the logs. Remove the console.log.

In the tick function, use scrollY to make the camera move (before doing the render):

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = scrollY

    // ...
}

Not quite right yet:

The camera is way too sensitive and going in the wrong direction. We need to work a little on that value.

scrollY is positive when scrolling down, but the camera should go down on the y axis. Let’s invert the value:

camera.position.y = - scrollY

Better, but still too sensitive:

scrollY contains the amount of pixels that have been scrolled. If we scroll 1000 pixels (which is not that much), the camera will go down of 1000 units in the scene (which is a lot).

Each section has exactly the same size as the viewport. This means that when we scroll the distance of one viewport height, the camera should reach the next object.

To do that, we need to divide scrollY by the height of the viewport which is sizes.height:

camera.position.y = - scrollY / sizes.height

The camera is now going down of 1 unit for each section scrolled. But the objects are currently separated by 4 units which is the objectsDistance variable:

We need to multiply the value by objectsDistance:

camera.position.y = - scrollY / sizes.height * objectsDistance

To put it in a nutshell, if the user scrolls down one section, then the camera will move down to the next object:

Position object horizontally

Now is a good time to position the objects left and right to match the titles:

mesh1.position.x = 2
mesh2.position.x = - 2
mesh3.position.x = 2

Parallax

We call parallax the action of seeing one object through different observation points. This is done naturally by our eyes and it’s how we feel the depth of things.

To make our experience more immersive, we are going to apply this parallax effect by making the camera move horizontally and vertically according to the mouse movements. It will create a natural interaction, and help the user feel the depth.

Cursor

First, we need to retrieve the cursor position.

To do that, create a cursor object with x and y properties:

/**
 * Cursor
 */
const cursor = {}
cursor.x = 0
cursor.y = 0

Then, listen to the mousemove event on window and update those values:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX
    cursor.y = event.clientY

    console.log(cursor)
})

You should get the pixel positions of the cursor in the console:

While we could use those values directly, it’s always better to adapt them to the context.

First, the amplitude depends on the size of the viewport and users with different screen resolutions will have different results. We can normalize the value (from 0 to 1) by dividing them by the size of the viewport:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width
    cursor.y = event.clientY / sizes.height

    console.log(cursor)
})

While this is better already, we can do even more.

We know that the camera will be able to go as much on the left as on the right. This is why, instead of a value going from 0 to 1 it’s better to have a value going from -0.5 to 0.5.

To do that, subtract 0.5:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = event.clientY / sizes.height - 0.5

    console.log(cursor)
})

Here is a clean value adapted to the context:

Remove the console.log.

We can now use the cursor values in the tick function. Create a parallaxX and a parallaxY variable and put the cursor.x and cursor.y in them:

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = - scrollY / sizes.height * objectsDistance

    const parallaxX = cursor.x
    const parallaxY = cursor.y
    camera.position.x = parallaxX
    camera.position.y = parallaxY

    // ...
}

Unfortunately, we have two issues.

The x and y axes don’t seem synchronized in terms of direction. And, the camera scroll doesn’t work anymore:

Let’s fix the first issue. When we move the cursor to the left, the camera seems to go to the left. Same thing for the right. But when we move the cursor up, the camera seems to move down and the opposite when moving the cursor down.

To fix that weird feeling, invert the cursor.y:

    const parallaxX = cursor.x
    const parallaxY = - cursor.y
    camera.position.x = parallaxX
    camera.position.y = parallaxY

For the second issue, the problem is that we update the camera.position.y twice and the second one will replace the first one.

To fix that, we are going to put the camera in a Group and apply the parallax on the group and not the camera itself.

Right before instantiating the camera, create the Group, add it to the scene and add the camera to the Group:

/**
 * Camera
 */
// Group
const cameraGroup = new THREE.Group()
scene.add(cameraGroup)

// Base camera
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
cameraGroup.add(camera)

This shouldn’t change the result, but now, the camera is inside a group.

In the tick function, instead of applying the parallax on the camera, apply it on the cameraGroup:

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = - scrollY / sizes.height * objectsDistance

    const parallaxX = cursor.x
    const parallaxY = - cursor.y
    
    cameraGroup.position.x = parallaxX
    cameraGroup.position.y = parallaxY

    // ...
}

The scroll animation and parallax animation are now mixed together nicely:

But we can do even better.

Easing

The parallax animation is a good start, but it feels a bit too mechanic. Having such a linear animation is impossible in real life for a number of reasons: the camera has weight, there is friction with the air and surfaces, muscles can’t make such a linear movement, etc. This is why the movement feels a bit wrong. We are going to add some “easing” (also called “smoothing” or “lerping”) and we are going to use a well-known formula.

The idea behind the formula is that, on each frame, instead of moving the camera straight to the target, we are going to move it (let’s say) a 10th closer to the destination. Then, on the next frame, another 10th closer. Then, on the next frame, another 10th closer.

On each frame, the camera will get a little closer to the destination. But, the closer it gets, the slower it moves because it’s always a 10th of the actual position toward the target position.

First, we need to change the = to += because we are adding to the actual position:

    cameraGroup.position.x += parallaxX
    cameraGroup.position.y += parallaxY

Then, we need to calculate the distance from the actual position to the destination:

    cameraGroup.position.x += (parallaxX - cameraGroup.position.x)
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y)

Finally, we only want a 10th of that distance:

    cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 0.1
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 0.1

The animation feels a lot smoother:

But there is still a problem that some of you might have noticed.

If you test the experience on a high frequency screen, the tick function will be called more often and the camera will move faster toward the target. While this is not a big issue, it’s not accurate and it’s preferable to have the same result across devices as much as possible.

To fix that, we need to use the time spent between each frame.

Right after instantiating the Clock, create a previousTime variable:

const clock = new THREE.Clock()
let previousTime = 0

At the beginning of the tick function, right after setting the elapsedTime, calculate the deltaTime by subtracting the previousTime from the elapsedTime:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime

    // ...
}

And then, update the previousTime to be used on the next frame:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime
    previousTime = elapsedTime

    console.log(deltaTime)

    // ...
}

You now have the time spent between the current frame and the previous frame in seconds. For high frequency screens, the value will be smaller because less time was needed.

We can now use that deltaTime on the parallax, but, because the deltaTime is in seconds, the value will be very small (around 0.016 for most common screens running at 60fps). Consequently, the effect will be very slow.

To fix that, we can change 0.1 to something like 5:

    cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime

We now have a nice easing that will feel the same across different screen frequencies:

Finally, now that we have the animation set properly, we can lower the amplitude of the effect:

    const parallaxX = cursor.x * 0.5
    const parallaxY = - cursor.y * 0.5

Particles

A good way to make the experience more immersive and to help the user feel the depth is to add particles.

We are going to create very simple square particles and spread them around the scene.

Because we need to position the particles ourselves, we are going to create a custom BufferGeometry.

Create a particlesCount variable and a positions variable using a Float32Array:

/**
 * Particles
 */
// Geometry
const particlesCount = 200
const positions = new Float32Array(particlesCount * 3)

Create a loop and add random coordinates to the positions array:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = Math.random()
    positions[i * 3 + 1] = Math.random()
    positions[i * 3 + 2] = Math.random()
}

We will change the positions later, but for now, let’s keep things simple and make sure that our geometry is working.

Instantiate the BufferGeometry and set the position attribute:

const particlesGeometry = new THREE.BufferGeometry()
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

Create the material using PointsMaterial:

// Material
const particlesMaterial = new THREE.PointsMaterial({
    color: '#ffeded',
    sizeAttenuation: true,
    size: 0.03
})

Create the particles using Points:

// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)

You should get a bunch of particles spread around in a cube:

We can now position the particles on the three axes.

For the x (horizontal) and z (depth), we can use random values that can be as much positive as they are negative:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    positions[i * 3 + 1] = Math.random()
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}

For the y (vertical) it’s a bit more tricky. We need to make the particles start high enough and then spread far enough below so that we reach the end with the scroll.

To do that, we can use the objectsDistance variable and multiply by the number of objects which is the length of the sectionMeshes array:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    positions[i * 3 + 1] = objectsDistance * 0.5 - Math.random() * objectsDistance * sectionMeshes.length
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}

That’s all for the particles, but you can improve them with random sizes, random alpha. And, we can even animate them.

Triggered rotations

As a final feature and to make the exercise just a bit harder, we are going to make the objects do a little spin when we arrive at the corresponding section in addition to the permanent rotation.

Knowing when to trigger the animation

First, we need a way to know when we reach a section. There are plenty of ways of doing that and we could even use a library, but in our case, we can use the scrollY value and do some math to find the current section.

After creating the scrollY variable, create a currentSection variable and set it to 0:

let scrollY = window.scrollY
let currentSection = 0

In the 'scroll' event callback function, calculate the current section by dividing the scrollY by sizes.height:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    const newSection = scrollY / sizes.height
    
    console.log(newSection)
})

This works because each section is exactly one height of the viewport.

To get the exact section instead of that float value, we can use Math.round():

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    const newSection = Math.round(scrollY / sizes.height)
    
    console.log(newSection)
})

We can now test if newSection is different from currentSection. If so, that means we changed the section and we can update the currentSection in order to do our animation:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY
    const newSection = Math.round(scrollY / sizes.height)

    if(newSection != currentSection)
    {
        currentSection = newSection

        console.log('changed', currentSection)
    }
})

Animating the meshes

We can now animate the meshes and, to do that, we are going to use GSAP.

The GSAP library is already loaded from the HTML file as we did for Three.js.

Then, in the if statement we did earlier, we can do the animation with gsap.to():

window.addEventListener('scroll', () =>
{
    // ...
    
    if(newSection != currentSection)
    {
        // ...

        gsap.to(
            sectionMeshes[currentSection].rotation,
            {
                duration: 1.5,
                ease: 'power2.inOut',
                x: '+=6',
                y: '+=3'
            }
        )
    }
})

While this code is valid, it will unfortunately not work. The reason is that, on each frame, we are already updating the rotation.x and rotation.y of each mesh with the elapsedTime.

To fix that, in the tick function, instead of setting a very specific rotation based on the elapsedTime, we are going to add the deltaTime to the current rotation:

const tick = () =>
{
    // ...

    for(const mesh of sectionMeshes)
    {
        mesh.rotation.x += deltaTime * 0.1
        mesh.rotation.y += deltaTime * 0.12
    }

    // ...
}

Final code

You can download the final project here https://threejs-journey.com/resources/codrops/threejs-scroll-based-animation/final.zip

Go further

We kept things really simple on purpose, but you can for sure go much further!

  • Add more content to the HTML
  • Animate other properties like the material
  • Animate the HTML texts
  • Improve the particles
  • Add more tweaks to the Debug UI
  • Test other colors
  • Add mobile and touch support
  • Etc.

If you liked this tutorial or want to learn more about WebGL and Three.js, join the Three.js Journey course!

As a reminder, here’s a 20% discount CODROPS1 for you 😉

The post Crafting Scroll Based Animations in Three.js appeared first on Codrops.

Qi Theme

Qi Theme 21

Qi Theme is a free WordPress theme created by Qode Interactive – an award-winning studio. This theme perfectly combines top speed and performance with a beautiful design.

It comes with 100 demos, allowing you to easily set up any type of website, whether it’s an online store, an artist’s portfolio, or a simple blog. If you can think it up, this theme will help you build it – it will even grant you free access to premium stock photos.

Qi Theme is fully supported by video tutorials as well as an extensive knowledge base, so you’ll always have a place to look for help.

Spark SEO

spark seo

Spark SEO offers search engine Optimisation services to small and medium local businesses. We are a remote team of absolute subject matter experts, and we are ready to help your local business grow

The post Spark SEO appeared first on WeLoveWP.

Recreating the “100 Days of Poetry” Effect with Shader, ScrollTrigger and CSS Grid

Editor’s note: We want to share more of the web dev and design community directly here on Codrops, so we’re very happy to start featuring Yuriy’s newest live coding sessions that he records twice a month plus the demo he creates during the session!

For this episode of ALL YOUR HTML, I decided to replicate the effect seen on 100 DAYS OF POETRY with the technologies I know. So I did a fragment shader for the background effect, then coded a dummy CSS grid, and connected both of them with the power of GSAP’s ScrollTrigger plugin. This is of course a really simplified version of the website, but I hope you will learn something from the process of recreating this.

This coding session was streamed live on Oct 4, 2020.

Check out the live demo.

Original website: https://100daysofpoetry.gallery/

By Keita Yamada https://twitter.com/p5_keita

Support: https://www.patreon.com/allyourhtml

Setup: https://gist.github.com/akella/a19954…

The post Recreating the “100 Days of Poetry” Effect with Shader, ScrollTrigger and CSS Grid appeared first on Codrops.

An Overview of Scroll Technologies

Scroll-related animations have been used on the web for years. In recent years, they’ve started to become more common, perhaps in part due to devices being higher-performing and thus able to handle more animation. 

There are a number of scroll related technologies out there, so this article’s aim is to provide an overview of them and tools to help choose the one that’s right for you. I’d argue that these technologies can be broken down into two broad categories: ones for specific scroll-related behaviors and ones for more generic scroll-related behaviors.

Technologies for specific scroll-related behaviors

There are a few simple native CSS scroll effects that are supported by modern browsers. In some limited use cases they can be sufficient for your scroll animation needs.

position: sticky;

If all you need is for an element to stay in the same place on scroll for a portion of the page, using position: sticky is a good option. It’s straightforward and built into modern browsers. That said, a polyfill is required for IE support and some mobile browsers. For a solid overview, check out this article by Preethi.

CSS parallax

This isn’t a technology as much as a technique, but it’s pretty handy for simple parallax effects where you want different pieces of the page to move at different speeds on scroll. There’s a good write up of the technique on Alligator.io and a bunch of examples on CodePen, like this Firewatch header. The biggest downside for me is that it’s difficult to understand what values to use to set the perspective and transforms in order to get the parallax effect exactly right.

CSS scroll snap points

Scroll snap points allow the browser to snap to particular scroll positions that you set after a user is done with their normal scrolling. This can be helpful for keeping certain elements in view. However, the API is still in flux so be careful to use the most up to date API and be careful about relying on it in production. This CSS-Tricks article by Max Kohler is a good place to learn about it right now.

Smooth scrolling

Smooth scrolling is supported natively when jumping from section to section within a page using window.scrollTo() in JavaScript or even the scroll-behavior property in CSS. Generic smooth scrolling that smooths out mouse wheel actions is not supported natively in all browsers at this time. There are various JavaScript libraries that attempt to add smooth scroll support for the mousewheel action, but I’ve yet to find one that is bug-free and plays nicely with all other scroll technologies. Plus, smooth scrolling isn’t always good in the first place.

Technologies for generic scroll behaviors

Currently, there is no way to create or fire generic animations based on the scroll position using just CSS (though there is a proposal that could support some form of generic scroll based animations in CSS in the distant future) or to scrub through part of an animation. As such, if you want to animate elements on scroll, you’ll need to use at least some JavaScript to create the effect you want. There are two broad methods of using JavaScript to fire animations on scroll: using intersection observers and using the scroll event.

IntersectionObserver

Intersection observers are great if all you need for your animation is information related to whether or not and how much of an element is visible in the viewport. This makes them a good option for reveal animations. Even then, some things are difficult (though not impossible) using intersection observers, such as firing different animations depending on the direction an element enters the viewport. Intersection observers also aren’t very helpful if you want to do any scroll animations when an element is in between and not overlapping with the start and end points. 

Using the scroll event

Using the scroll event will give you the most freedom in controlling animations on scroll. It allows you to affect an element on scroll no matter where it is in terms of the viewport and set up starting and ending points exactly as you need for your project. 

With that being said, it can also be intense on performance if it isn’t throttled correctly and doesn’t have a convenient API to create particular behaviors. This is why it’s oftentimes helpful to use a good scrolling library that can handle the throttling for you and give you a more handy API to work with. Some can even handle a lot of the resizing issues for you!

Tools to create generic scroll behaviors

There are a few holistic scrolling libraries that attempt to give you full control over animations on scroll without you having to perform all of the calculations yourself. 

ScrollMagic

ScrollMagic provides a relatively simple API to create various effects on scroll and can be hooked into different animation libraries like GSAP and Velocity.js. However, it has become less maintained over the past few years, which lead to the creation of ScrollScene.

ScrollScene

ScrollScene is essentially a wrapper to try and make ScrollMagic and/or the intersection observer more usable. It uses a custom, more maintained version of ScrollMagic and adds additional features like video playback, scene init breakpoints, and scene duration breakpoints. It also makes use of GSAP

ScrollTrigger

ScrollTrigger is an official GreenSock plugin for GSAP. It has a long list of features and has the most easy to use API of any scroll library (at least to me). Using it, you can have complete control to define where your scroll animations start and end, animate anything (WebGL, canvas, SVG, DOM, whatever) on scroll, pin elements in place while the animation is running, and more. Plus it has the support of GreenSock and the GreenSock forums

Notable mention: Locomotive Scroll

While it doesn’t attempt to be as comprehensive of a scrolling library as the other libraries mentioned above, Locomotive Scroll is focused on providing custom smooth scrolling. You can also animate certain properties of DOM objects by adding data attributes or hook into the onscroll event to animate other types of objects. 

In summary

For some particular scroll animation effects, like sticky positioning and parallax, CSS technologies may be sufficient, at least when using a polyfill for browsers that don’t support those properties.

I generally recommend using GSAP’s ScrollTrigger because it can do everything that CSS properties can do, plus much more. ScrollTrigger will handle the browser support and calculations so that you can focus on animating!

Here’s a table covering which tools you can use to create particular effects:

position: stickyCSS parallaxCSS scroll snap pointsSmooth ScrollingIntersection observersScrollMagicScrollSceneLocomotive ScrollScrollTrigger
Pinning⚪️
Parallax effects
Scrubbing through animation with easing⚪️⚪️⚪️⚪️⚪️
Snaps scroll position⚪️⚪️⚪️⚪️
Dynamic Batching / Staggering
Supports horizontal scroll effects

Here’s a table comparing various other aspects of scroll technology:

position: stickyCSS parallaxCSS scroll snap pointsSmooth scrollingIntersection observersScrollMagicScrollSceneLocomotive ScrollScrollTrigger
Usable in production (good browser support)⚪️⚪️⚪️⚪️⚪️
Complete freedom in animation⚪️
Maintainedn/an/an/an/an/a
Works with DOM, Canvas, WebGl, SVG⚪️
Works easily with resizing⚪️
Restricts animation to relevant section⚪️⚪️
Directionally aware⚪️⚪️
Native technology
✅ = Yes
⚪️ = Partial support
❌ = No

The post An Overview of Scroll Technologies appeared first on CSS-Tricks.

Bamboo Sushi

Bamboo Sushi is the first in a series of restaurants from Sustainable Restaurant Group whose mission to provide the most ethically caught fish with the lowest carbon footprint possible. To help tell their story, we created an interactive platform that not only showcases their food, but helps to tell the story of where they get their ingredients and how they get to each of their restaurant’s location. Our goal was to create a design that felt extremely high-end with a focus on a nontraditional navigation and rich user experience. When browsing the site you feel transported into another world, immersed in the Bamboo Sushi brand.

We also took a mobile first design approach to this project, given that the majority of the users browse the site on mobile devises. This allowed us the opportunity to re-imagine what the mobile experience was going to be like. Users can use the intuitive swipe gesture to navigate through the content on their phones, in addition to using the traditional navigation.

The post Bamboo Sushi appeared first on WeLoveWP.

Believer Magazine

Believer Magazine is an award winning literary magazine, celebrating over ten years of publishing. Their exceptional content, celebrated writers and evocative illustrations have made it one of the most well known publications in the literary community. Before engaging with CMYK their online platform was a static HTML website where all content was hard coded. We built them a custom online editorial platform for them to showcase their entire archive of content to a much wider audience.

In order to achieve this we built a custom import structure that crawled through over 5,000 pieces of HTML content in order to migrate it into the new CMS. The streamlined and automated process was able to standardize the article structure, without the heavy lifting of editing each piece of content individually. Additionally, we build a robust custom content library to allow the editorial team to create dynamic and visually compelling content. The challenge was to create a design that embraced the physical form of the print magazine. We took a lot of design cues from the magazine’s layout to create a reading experience that felt special. We also gave the team the ability to change the entire color scheme of the website with the release of each new issue. By doing this, the site always matches the newest publications cover art and changes the overall aesthetic with the click of a button.

The post Believer Magazine appeared first on WeLoveWP.