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 ♡

Responsive Animations for Every Screen Size and Device

Before I career jumped into development, I did a bunch of motion graphics work in After Effects. But even with that background, I still found animating on the web pretty baffling.

Video graphics are designed within a specific ratio and then exported out. Done! But there aren’t any “export settings” on the web. We just push the code out into the world and our animations have to adapt to whatever device they land on.

So let’s talk responsive animation! How do we best approach animating on the wild wild web? We’re going to cover some general approaches, some GSAP-specific tips and some motion principles. Let’s start off with some framing…

How will this animation be used?

Zach Saucier’s article on responsive animation recommends taking a step back to think about the final result before jumping into code.

Will the animation be a module that is repeated across multiple parts of your application? Does it need to scale at all? Keeping this in mind can help determine the method in which an animation should be scaled and keep you from wasting effort.

This is great advice. A huge part of designing responsive animation is knowing if and how that animation needs to scale, and then choosing the right approach from the start.

Most animations fall into the following categories:

  • Fixed: Animations for things like icons or loaders that retain the same size and aspect ratio across all devices. Nothing to worry about here! Hard-code some pixel values in there and get on with your day.
  • Fluid: Animations that need to adapt fluidly across different devices. Most layout animations fall into this category.
  • Targeted: Animations that are specific to a certain device or screen size, or change substantially at a certain breakpoint, such as desktop-only animations or interactions that rely on device-specific interaction, like touch or hover.

Fluid and targeted animations require different ways of thinking and solutions. Let’s take a look…

Fluid animation

As Andy Bell says: Be the browser’s mentor, not its micromanager — give the browser some solid rules and hints, then let it make the right decisions for the people that visit it. (Here are the slides from that presentation.)

Fluid animation is all about letting the browser do the hard work. A lot of animations can easily adjust to different contexts just by using the right units from the start. If you resize this pen you can see that the animation using viewport units scales fluidly as the browser adjusts:

The purple box even changes width at different breakpoints, but as we’re using percentages to move it, the animation scales along with it too.

Animating layout properties like left and top can cause layout reflows and jittery ‘janky’ animation, so where possible stick to transforms and opacity.

We’re not just limited to these units though — let’s take a look at some other possibilities.

SVG units

One of the things I love about working with SVG is that we can use SVG user units for animation which are responsive out of the box. The clue’s in the name really — Scalable Vector Graphic. In SVG-land, all elements are plotted at specific coordinates. SVG space is like an infinite bit of graph paper where we can arrange elements. The viewBox defines the dimensions of the graph paper we can see.

viewBox="0 0 100 50”

In this next demo, our SVG viewBox is 100 units wide and 50 units tall. This means if we animate the element by 100 units along the x-axis, it will always move by the entire width of its parent SVG, no matter how big or small that SVG is! Give the demo a resize to see.

Animating a child element based on a parent container’s width is a little tricker in HTML-land. Up until now, we’ve had to grab the parent’s width with JavaScript, which is easy enough when you’re animating from a transformed position, but a little fiddlier when you’re animating to somewhere as you can see in the following demo. If your end-point is a transformed position and you resize the screen, you’ll have to manually adjust that position. Messy… 🤔

If you do adjust values on resize, remember to debounce, or even fire the function after the browser is finished resizing. Resize listeners fire a ton of events every second, so updating properties on each event is a lot of work for the browser.

But, this animation speed-bump is soon going to be a thing of the past! Drum roll please… 🥁

Container Units! Lovely stuff. At the time I’m writing this, they only work in Chrome and Safari — but maybe by the time you read this, we’ll have Firefox too. Check them out in action in this next demo. Look at those little lads go! Isn’t that exciting, animation that’s relative to the parent elements!

This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.

Desktop

ChromeFirefoxIEEdgeSafari
105NoNo10516.0

Mobile / Tablet

Android ChromeAndroid FirefoxAndroidiOS Safari
106No10616.0

Fluid layout transitions with FLIP

As we mentioned earlier, in SVG-land every element is neatly placed on one grid and really easy to move around responsively. Over in HTML-land it’s much more complex. In order to build responsive layouts, we make use of a bunch of different positioning methods and layout systems. One of the main difficulties of animating on the web is that a lot of changes to layout are impossible to animate. Maybe an element needs to move from position relative to fixed, or some children of a flex container need to be smoothly shuffled around the viewport. Maybe an element even needs to be re-parented and moved to an entirely new position in the DOM.

Tricky, huh?

Well. The FLIP technique is here to save the day; it allows us to easily animate these impossible things. The basic premise is:

  • First: Grab the initial position of the elements involved in the transition.
  • Last: Move the elements and grab the final position.
  • Invert: Work out the changes between the first and last state and apply transforms to invert the elements back to their original position. This makes it look like the elements are still in the first position but they’re actually not.
  • Play: Remove the inverted transforms and animate to their faked first state to the last state.

Here’s a demo using GSAP’s FLIP plugin which does all the heavy lifting for you!

If you want to understand a little more about the vanilla implementation, head over to Paul Lewis’s blog post — he’s the brain behind the FLIP technique.

Fluidly scaling SVG

You got me… this isn’t really an animation tip. But setting the stage correctly is imperative for good animation! SVG scales super nicely by default, but we can control how it scales even further with preserveAspectRatio, which is mega handy when the SVG element’s aspect ratio and the viewBox aspect ratio are different. It works much in the same way as the background-position and background-size properties in CSS. The declaration is made up of an alignment value (background-position) and a Meet or Slice reference (background-size).

As for those Meet and Slice references — slice is like background size: cover, and meet is like background-size: contain.

  • preserveAspectRatio="MidYMax slice" — Align to the middle of the x-axis, the bottom of the y-axis, and scale up to cover the entire viewport.
  • preserveAspectRatio="MinYMin meet" — Align to the left of the x-axis, the top of the y-axis, and scale up while keeping the entire viewBox visible.

Tom Miller takes this a step further by using overflow: visible in CSS and a containing element to reveal “stage left” and “stage right” while keeping the height restricted:

For responsive SVG animations, it can be handy to make use of the SVG viewbox to create a view that crops and scales beneath a certain browser width, while also revealing more of the SVG animation to the right and left when the browser is wider than that threshold. We can achieve this by adding overflow visible on the SVG and teaming it up with a max-height wrapper to prevent the SVG from scaling too much vertically.

Fluidly scaling canvas

Canvas is much more performant for complex animations with lots of moving parts than animating SVG or HTML DOM, but it’s inherently more complex too. You have to work for those performance gains! Unlike SVG that has lovely responsive units and scaling out of the box, <canvas> has to be bossed around and micromanaged a bit.

I like setting up my <canvas> so that it works much in the same way as SVG (I may be biased) with a lovely unit system to work within and a fixed aspect ratio. <canvas> also needs to be redrawn every time something changes, so remember to delay the redraw until the browser is finished resizing, or debounce!

George Francis also put together this lovely little library which allows you to define a Canvas viewBox attribute and preserveAspectRatio — exactly like SVG!

Targeted animation

You may sometimes need to take a less fluid and more directed approach to your animation. Mobile devices have a lot less real estate, and less animation-juice performance-wise than a desktop machine. So it makes sense to serve reduced animation to mobile users, potentially even no animation:

Sometimes the best responsive animation for mobile is no animation at all! For mobile UX, prioritize letting the user quickly consume content versus waiting for animations to finish. Mobile animations should enhance content, navigation, and interactions rather than delay it. Eric van Holtz

In order to do this, we can make use of media queries to target specific viewport sizes just like we do when we’re styling with CSS! Here’s a simple demo showing a CSS animation being handled using media queries and a GSAP animation being handled with gsap.matchMedia():

The simplicity of this demo is hiding a bunch of magic! JavaScript animations require a bit more setup and clean-up in order to correctly work at only one specific screen size. I’ve seen horrors in the past where people have just hidden the animation from view in CSS with opacity: 0, but the animation’s still chugging away in the background using up resources. 😱

If the screen size doesn’t match anymore, the animation needs to be killed and released for garbage collection, and the elements affected by the animation need to be cleared of any motion-introduced inline styles in order to prevent conflicts with other styling. Up until gsap.matchMedia(), this was a fiddly process. We had to keep track of each animation and manage all this manually.

gsap.matchMedia() instead lets you easily tuck your animation code into a function that only executes when a particular media query matches. Then, when it no longer matches, all the GSAP animations and ScrollTriggers in that function get reverted automatically. The media query that the animations are popped into does all the hard work for you. It’s in GSAP 3.11.0 and it’s a game changer!

We aren’t just constrained to screen sizes either. There are a ton of media features out there to hook into!

(prefers-reduced-motion) /* find out if the user would prefer less animation */

(orientation: portrait) /* check the user's device orientation */

(max-resolution: 300dpi) /* check the pixel density of the device */

In the following demo we’ve added a check for prefers-reduced-motion so that any users who find animation disorienting won’t be bothered by things whizzing around.

And check out Tom Miller’s other fun demo where he’s using the device’s aspect ratio to adjust the animation:

Thinking outside of the box, beyond screen sizes

There’s more to thinking about responsive animation than just screen sizes. Different devices allow for different interactions, and it’s easy to get in a bit of a tangle when you don’t consider that. If you’re creating hover states in CSS, you can use the hover media feature to test whether the user’s primary input mechanism can hover over elements.

@media (hover: hover) {
 /* CSS hover state here */
}

Some advice from Jake Whiteley:

A lot of the time we base our animations on browser width, making the naive assumption that desktop users want hover states. I’ve personally had a lot of issues in the past where I would switch to desktop layout >1024px, but might do touch detection in JS – leading to a mismatch where the layout was for desktops, but the JS was for mobiles. These days I lean on hover and pointer to ensure parity and handle ipad Pros or windows surfaces (which can change the pointer type depending on whether the cover is down or not)

/* any touch device: */
(hover: none) and (pointer: coarse)
/* iPad Pro */
(hover: none) and (pointer: coarse) and (min-width: 1024px)

I’ll then marry up my CSS layout queries and my JavaScript queries so I’m considering the input device as the primary factor supported by width, rather than the opposite.

ScrollTrigger tips

If you’re using GSAP’s ScrollTrigger plugin, there’s a handy little utility you can hook into to easily discern the touch capabilities of the device: ScrollTrigger.isTouch.

  • 0no touch (pointer/mouse only)
  • 1touch-only device (like a phone)
  • 2 – device can accept touch input and mouse/pointer (like Windows tablets)
if (ScrollTrigger.isTouch) {
  // any touch-capable device...
}

// or get more specific: 
if (ScrollTrigger.isTouch === 1) {
  // touch-only device
}

Another tip for responsive scroll-triggered animation…

The following demo below is moving an image gallery horizontally, but the width changes depending on screen size. If you resize the screen when you’re halfway through a scrubbed animation, you can end up with broken animations and stale values. This is a common speedbump, but one that’s easily solved! Pop the calculation that’s dependent on screen size into a functional value and set invalidateOnRefresh:true. That way, ScrollTrigger will re-calculate that value for you when the browser resizes.

Bonus GSAP nerd tip!

On mobile devices, the browser address bar usually shows and hides as you scroll. This counts as a resize event and will fire off a ScrollTrigger.refresh(). This might not be ideal as it can cause jumps in your animation. GSAP 3.10 added ignoreMobileResize. It doesn’t affect how the browser bar behaves, but it prevents ScrollTrigger.refresh() from firing for small vertical resizes on touch-only devices.

ScrollTrigger.config({
  ignoreMobileResize: true
});

Motion principles

I thought I’d leave you with some best practices to consider when working with motion on the web.

Distance and easing

A small but important thing that’s easy to forget with responsive animation is the relationship between speed, momentum, and distance! Good animation should mimic the real world to feel believable, and it takes a longer in the real world to cover a larger distance. Pay attention to the distance your animation is traveling, and make sure that the duration and easing used makes sense in context with other animations.

You can also often apply more dramatic easing to elements with further to travel to show the increased momentum:

For certain use cases it may be helpful to adjust the duration more dynamically based on screen width. In this next demo we’re making use of gsap.utils to clamp the value we get back from the current window.innerWidth into a reasonable range, then we’re mapping that number to a duration.

Spacing and quantity

Another thing to keep in mind is the spacing and quantity of elements at different screen sizes. Quoting Steven Shaw:

If you have some kind of environmental animation (parallax, clouds, trees, confetti, decorations, etc) that are spaced around the window, make sure that they scale and/or adjust the quantity depending on screen size. Large screens probably need more elements spread throughout, while small screens only need a few for the same effect.

I love how Opher Vishnia thinks about animation as a stage. Adding and removing elements doesn’t just have to be a formality, it can be part of the overall choreography.

When designing responsive animations, the challenge is not how to cram the same content into the viewport so that it “fits”, but rather how to curate the set of existing content so it communicates the same intention. That means making a conscious choice of which pieces content to add, and which to remove. Usually in the world of animation things don’t just pop in or out of the frame. It makes sense to think of elements as entering or exiting the “stage”, animating that transition in a way that makes visual and thematic sense.

And that’s the lot. If you have any more responsive animation tips, pop them in the comment section. If there’s anything super helpful, I’ll add them to this compendium of information!

Addendum

One more note from Tom Miller as I was prepping this article:

I’m probably too late with this tip for your responsive animations article, but I highly recommend “finalize all the animations before building”. I’m currently retrofitting some site animations with “mobile versions”. Thank goodness for gsap.matchMedia… but I sure wish we’d known there’d be separate mobile layouts/animations from the beginning.

I think we all appreciate that this tip to “plan ahead” came at the absolute last minute. Thanks, Tom, and best of luck with those retrofits.


Responsive Animations for Every Screen Size and Device originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Collective #727



collective 727 item image
Our Sponsor

Our annual Summer Sale is back! This is one of only a few times we offer our biggest discounts. Get 20% off Divi, 40% off Divi Cloud and up to 50% off products in the Divi Marketplace!

Get the deal







collective 727 item image

VRSEAT

An immersive 3D experience using Three.js for a seat picker that emulates the experience. A new project by Anderson Mancini.

Check it out












collective 727 item image

lllove

An SVG heart maker that makes it easy to design heart shapes and save them as SVG to use in your print or web designs.

Check it out



collective 727 item image

Popsy

A no-code website builder that works like Notion where you can customize a design and publish.

Check it out


collective 727 item image

WebKit on GitHub!

Read how the WebKit project froze its Subversion tree and transitioned management and interaction with source code to git on GitHub.

Check it out




collective 727 item image

Digs.fm

Track and organize music releases you want to listen, listened or liked.

Check it out



Collective #725





Why React Re-Renders

In this tutorial, Josh W Comeau unpacks when and why React re-renders, and how we can use this information to optimize the performance of our React apps.

Read it



cohost!

Cohost is a new social media platform built from the ground up by a small team of developers and designers who like sharing things on the internet. It allows to use custom CSS in an update.

Check it out


Recoded

Recoded lets you create beautiful images or videos of your code. Made by Siddharth Roy.

Check it out




Vertex Gallery

Vertex gallery is a virtual art gallery that shows 34 new art pieces every day. These art pieces change every night at 22:00 UTC.

Check it out














Outline is your friend

Don‘t rely on properties like background-color or box-shadow alone for focusing styling. An article by Manuel Matuzović.

Read it


Coder

With Coder you can offload your team’s development from local workstations to cloud servers.

Check it out


Alphredo

Alphredo generates translucent colors looking the same as their opaque counterparts when placed against the same background.

Check it out





Collective #722





Collective 722 item image

DOM ready events considered harmful

Jake and Cassie talk about DOM ready events, which can slow down your app in unexpected ways. But what are the alternatives? Learn more about it in this episode of HTTP 203.

Watch it


Collective 722 item image

The Study of Shaders with React Three Fiber

A complete guide on how to use shaders with React Three Fiber, work with uniforms and varyings, and build dynamic, interactive and composable materials with them through 8 unique 3D scenes. By Maxime Heckel.

Read it


Collective 722 item image

WebGi Camera Landing Page

A template for buildind scrollable landing pages with GSAP, ScrollTrigger and WebGi engine in TypeScript using the Parcel bundler. By Anderson Mancini.

Check it out










Collective 722 item image

Can SVG Symbols affect web performance?

When it comes to repeatable SVGs, most people would create a component and reuse it anywhere they want. There’s a high chance, that you would be missing some performance freebies. If you’re striving for that bang-for-the-buck kind of performance, this article might be interesting for you.

Check it out


Collective 722 item image

UpToDate what?

UpToDate tracks versions of popular projects so that you can check easily what the latest stable release is.

Check it out





Collective 722 item image

A good reset

A response to Jeremy’s post on “Control”, and why developers opt for divs, not buttons. By Trys Mudford.

Read it



Collective 722 item image

UI Buttons

Collection of hand-picked free HTML and CSS button code examples. Updated as of July 2022. 100 new items.

Check it out


Collective 722 item image

Maintenance Matters

Annie Kiley shares a list of ten simple things the team at Viget does to make their projects as maintainable as possible, regardless of the stack.

Read it



How I Chose an Animation Library for My Solitaire Game

There is an abundance of both CSS and JavaScript libraries for animation libraries out there. So many, in fact, that choosing the right one for your project can seem impossible. That’s the situation I faced when I decided to build an online Solitaire game. I knew I’d need an animation library, but which was the right one to choose?

In this article, I’ll go through which considerations I made, what to look out for and present you with some of the most popular libraries available. I’ll go through some real-world examples with you to illustrate my points, and in the end, hopefully, you’ll be better equipped than me when I first had to choose an animation library.

Your mileage with this advice may vary, of course. Everything I’m sharing here is specific to a thing I wanted to build. Your project may have completely different requirements and priorities and that’s OK. I think what’s important here is getting a first-hand account of thinking like a front-end developer with a particular goal.

Speaking of which, I do consider myself a front-end developer but my background is super heavy in design. So I know code, but not to the extent of someone who is a JavaScript engineer. Just wanted to clear that up because experience can certainly impact the final decision.

Here’s the goal

Before we get into any decision-making let’s take a look at the sorts of animations I needed to make in this CSS-Tricks-ified version of the game:

Pretty sweet, right? There’s nothing exactly trivial about these animations. There’s a lot going on — sometimes simultaneously — and a lot to orchestrate. Plus, a majority of the animations are triggered by user interactions. So, that left me with a few priorities heading into my decision:

  • Smooth animations: The way animations are applied can have a big impact on whether they run smoothly, or display a little choppiness.
  • Performance: Adopting any library is going to add weight to a project and I wanted my game to be as lean as possible.
  • Convenience: I wanted a nice, clean syntax that makes it easier to write and manage the animations. I’d even trade a little extra convenience for a small performance cost if it allows me to write better, more maintainable code. Again, this bodes well for a designer-turned-developer.
  • Browser support: Of course I wanted my game to work on any modern browser using some form of progressive enhancement to prevent completely borking legacy browsers. Plus, I definitely wanted  some future-proofing.

That’s what I took with me as I went in search of the right tool for this particular job.

Choosing between CSS or JavaScript animation libraries

The first thing I considered when choosing an animation library was whether to go with a CSS or JavaScript-based library. There are lots of great CSS libraries, many of them with excellent performance which was a high priority for me. I was looking to do some heavy-duty animations, like the  ability to sequence animations and get callbacks on animation completion. That’s all totally possible with pure CSS — still, it’s a lot less smooth than what most JavaScript libraries offer.

Let’s see how a simple sequenced animation looks in CSS and compare it to jQuery, which has plenty of built-in animation helpers:

The animations look the same but are created differently. To make the CSS animation, first, we have to define the keyframe animation in our CSS and attach it to a class:

.card.move {
  animation : move 2s;
}

@keyframes move {
  0% { left: 0 }
  50% { left: 100px }
  100% { left: 0 }
}

We then execute the animation using JavaScript and listen for a CSS callback on the element:

var cardElement = document.getElementsByClassName("card")[0];
var statusElement = document.getElementsByClassName("status")[0];

cardElement.classList.add("move");
statusElement.innerHTML = "Animating"

var animationEndCallback = function() {
  cardElement.classList.remove("move");
  statusElement.innerHTML = "Inactive"
}

cardElement.addEventListener("webkitAnimationEnd", animationEndCallback);
cardElement.addEventListener("oAnimationEnd", animationEndCallback); 
cardElement.addEventListener("antionend", animationEndCallback);

Having things happen in different places might be fine in a simple example like this, but it can become very confusing once things get a bit more complex. 

Compare this to how the animation is done with jQuery:

$(".status").text("Animating")
$( ".card" ).animate({
  left: "100px"
}, 1000);
$( ".card" ).animate({
  left: 0
}, 1000, function() {
  $(".status").text("Inactive")
});

Here, everything happens in the same place, simplifying things should the animations grow more complex in the future.

It seemed clear that a JavaScript library was the right way to go, but which was the right one to choose for my Solitaire game? I mean, jQuery is great and still widely used even today, but that’s not something I want to hang my hat on. There are plenty of JavaScript animation libraries, so I wanted to consider something built specifically to handle the type of heavy animations I had in mind.

Choosing a JavaScript animation library

It quickly became apparent to me that there’s no lack of JavaScript animation libraries and new, exciting technologies. They all have benefits and drawbacks, so let’s go through some of the ones I considered and why.

The Web Animations API is one such case that might replace many JavaScript animation libraries in the future. With it, you’ll be able to create complex staggered animations without loading any external libraries and with the same performance as CSS animations. The only drawback is that not all browsers support it yet

The <canvas> element presents another exciting opportunity. In it, we can animate things with JavaScript, as we would with the DOM, but the animation is rendered as raster, which means we can make some high-performance animations. The only drawback is that the canvas element is essentially rendered as an image in the DOM, so if we’re looking for pixel-perfection, we might be out of luck. As someone acutely in tune with design, this was a dealbreaker for me.

I needed something tried and tested, so I knew I probably had to go with one of the many JavaScript libraries. I started looking at libraries and narrowed my choices to Anime.js and GSAP. They both seemed to handle complex animations well and had excellent notes on performance. Anime is a well-maintained library with over 42.000 stars on GitHub, while GSAP is a super popular, battle-tested library with a thriving community.

An active community was critical to me since I needed a place to ask for help, and I didn’t want to use a library that might later be abandoned. I considered this as part of my convenience requirements.

Sequencing animations and callbacks

Once I had my choices narrowed down, the next step was to implement a complex animation using my two libraries. A recurrent animation in a solitaire game is that of a card moving somewhere and then turning over, so let’s see how that looks:

Both animations look great! They’re smooth, and implementing both of them was pretty straightforward. Both libraries had a timeline function that made creating sequences a breeze. This is how the implementation looks in AnimeJS:

var timeline = anime.timeline({
  begin: function() {
    $(".status").text("Animating")
  },
  complete: function() {
    $(".status").text("Inactive")
  }
});

timeline.add({
  targets: '.card',
  left: [0, 300],
  easing: 'easeInOutSine',
  duration: 500
}).add({
  targets: '.card .back',
  rotateY: [0, 90],
  easing: 'easeInSine',
  duration: 200
}).add({
  targets: '.card .front',
  rotateY: [-90, 0],
  easing: 'easeOutSine',
  duration: 200
})

Anime’s timeline() function comes built-in with callbacks on beginning and ending the animation, and creating the sequence is as easy as appending the sequential animations. First, I move the card, then I turn my back-image 90 degrees, so it goes out of view, and then I turn my front-image 90 degrees, so it comes into view.

The same implementation using GSAP’s timeline() function looks very similar:

var timeline = gsap.timeline({
  onStart: function() {
    $(".status").text("Animating")
  },
  onComplete: function() {
    $(".status").text("Inactive")
  }
});

timeline.fromTo(".card", {
  left: 0
}, {
  duration: 0.5,
  left: 300
}).fromTo(".card .back", {
  rotationY: 0
}, {
  rotationY: 90,
  ease: "power1.easeIn",
  duration: 0.2
}).fromTo(".card .front", {
  rotationY: -90
}, {
  rotationY: 0,
  ease: "power1.easeOut",
  duration: 0.2
})

Decision time

The main difference between Anime and GSAP appears to be the syntax, where GSAP might be a little more elaborate. I was stuck with two great libraries that had very similar functionality, were able to deal with complex animation, and had a thriving community. It seemed like I had a tie race!

PriorityAnimeGSAP
Smooth animations
Performance
Convenience
Browser support

So, what made me choose one library over the other?

I was very concerned about how the library would act under pressure. Having laggy animations in a game like Solitaire can greatly impact how fun it is to play the game. I knew I wouldn’t be able to fully see how the library performed before I created the game. Luckily, GSAP had made a stress test that compared different animation libraries to each other, including Anime.

Looking at that, GSAP certainly looked to be the superior library for dealing with loads of complex animations. GSAP was giving me upwards of 26 frames per second on a heavy animation that Anime was only able to top out at 19.  After reading up on GSAP more and looking into their forums, it became clear that performance was of the highest priority to the guys behind GSAP.

And even though both GSAP and Anime have been around a while, Anime’s repo has been sitting somewhat dormant a couple of years while GSAP had made commits in the past couple of months.

I ended up using GSAP and haven’t regretted my decision!

How about you? Does any of this square with how you evaluate and compare front-end tooling? Are there other priorities you might have considered (e.g. accessibility, etc.) in a project like this? Or do you have a project where you had to pare down your choices from a bunch of different options? Please share in the comments because I’d like to know! 

Oh, and if you want to see how it looks when animating a whole deck of cards, you can head over to my site and play a game of Solitaire. Have fun!


How I Chose an Animation Library for My Solitaire Game originally published on CSS-Tricks. You should get the newsletter.

Collective #713







Collective 713 item image

Layouts RFC

This most recent update will get you excited about the future of layouts, routing, and data fetching in Next.js

Check it out


Collective 713 item image

Aspect

A website builder for React where you can create React components visually.

Check it out


Collective 713 item image

Sherpa

A growing collection of hand-picked online free resources gathered and organised in the form of educational paths, aiming to be a reference and a valid alternative to similar paid offers.

Check it out


Collective 713 item image

OptimizeImages

Optimize images online with this free tool. Compress, convert and optimize images in PNG, JPEG, SVG, AVIF, WebP and GIF formats, and get SEO tags for your HTML.

Check it out














The post Collective #713 appeared first on Codrops.

An Interactive Starry Backdrop for Content

I was fortunate last year to get approached by Shawn Wang (swyx) about doing some work for Temporal. The idea was to cast my creative eye over what was on the site and come up with some ideas that would give the site a little “something” extra. This was quite a neat challenge as I consider myself more of a developer than a designer. But I love learning and leveling up the design side of my game.

One of the ideas I came up with was this interactive starry backdrop. You can see it working in this shared demo:

The neat thing about this design is that it’s built as a drop-in React component. And it’s super configurable in the sense that once you’ve put together the foundations for it, you can make it completely your own. Don’t want stars? Put something else in place. Don’t want randomly positioned particles? Place them in a constructed way. You have total control of what to bend it to your will.

So, let’s look at how we can create this drop-in component for your site! Today’s weapons of choice? React, GreenSock and HTML <canvas>. The React part is totally optional, of course, but, having this interactive backdrop as a drop-in component makes it something you can employ on other projects.

Let’s start by scaffolding a basic app

import React from 'https://cdn.skypack.dev/react'
import ReactDOM from 'https://cdn.skypack.dev/react-dom'
import gsap from 'https://cdn.skypack.dev/gsap'

const ROOT_NODE = document.querySelector('#app')

const Starscape = () => <h1>Cool Thingzzz!</h1>

const App = () => <Starscape/>

ReactDOM.render(<App/>, ROOT_NODE)

First thing we need to do is render a <canvas> element and grab a reference to it that we can use within React’s useEffect. For those not using React, store a reference to the <canvas> in a variable instead.

const Starscape = () => {
  const canvasRef = React.useRef(null)
  return <canvas ref={canvasRef} />
}

Our <canvas> is going to need some styles, too. For starters, we can make it so the canvas takes up the full viewport size and sits behind the content:

canvas {
  position: fixed;
  inset: 0;
  background: #262626;
  z-index: -1;
  height: 100vh;
  width: 100vw;
}

Cool! But not much to see yet.

We need stars in our sky

We’re going to “cheat” a little here. We aren’t going to draw the “classic” pointy star shape. We’re going to use circles of differing opacities and sizes.

Draw a circle on a <canvas> is a case of grabbing a context from the <canvas> and using the arc function. Let’s render a circle, err star, in the middle. We can do this within a React useEffect:

const Starscape = () => {
  const canvasRef = React.useRef(null)
  const contextRef = React.useRef(null)
  React.useEffect(() => {
    canvasRef.current.width = window.innerWidth
    canvasRef.current.height = window.innerHeight
    contextRef.current = canvasRef.current.getContext('2d')
    contextRef.current.fillStyle = 'yellow'
    contextRef.current.beginPath()
    contextRef.current.arc(
      window.innerWidth / 2, // X
      window.innerHeight / 2, // Y
      100, // Radius
      0, // Start Angle (Radians)
      Math.PI * 2 // End Angle (Radians)
    )
    contextRef.current.fill()
  }, [])
  return <canvas ref={canvasRef} />
}

So what we have is a big yellow circle:

This is a good start! The rest of our code will take place within this useEffect function. That’s why the React part is kinda optional. You can extract this code out and use it in whichever form you like.

We need to think about how we’re going to generate a bunch of “stars” and render them. Let’s create a LOAD function. This function is going to handle generating our stars as well as the general <canvas> setup. We can also move the sizing logic of the <canvas> sizing logic into this function:

const LOAD = () => {
  const VMIN = Math.min(window.innerHeight, window.innerWidth)
  const STAR_COUNT = Math.floor(VMIN * densityRatio)
  canvasRef.current.width = window.innerWidth
  canvasRef.current.height = window.innerHeight
  starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
    x: gsap.utils.random(0, window.innerWidth, 1),
    y: gsap.utils.random(0, window.innerHeight, 1),
    size: gsap.utils.random(1, sizeLimit, 1),
    scale: 1,
    alpha: gsap.utils.random(0.1, defaultAlpha, 0.1),
  }))
}

Our stars are now an array of objects. And each star has properties that define their characteristics, including:

  • x: The star’s position on the x-axis
  • y: The star’s position on the y-axis
  • size: The star’s size, in pixels
  • scale: The star’s scale, which will come into play when we interact with the component
  • alpha: The star’s alpha value, or opacity, which will also come into play during interactions

We can use GreenSock’s random() method to generate some of these values. You may also be wondering where sizeLimit, defaultAlpha, and densityRatio came from. These are now props we can pass to the Starscape component. We’ve provided some default values for them:

const Starscape = ({ densityRatio = 0.5, sizeLimit = 5, defaultAlpha = 0.5 }) => {

A randomly generated star Object might look like this:

{
  "x": 1252,
  "y": 29,
  "size": 4,
  "scale": 1,
  "alpha": 0.5
}

But, we need to see these stars and we do that by rendering them. Let’s create a RENDER function. This function will loop over our stars and render each of them onto the <canvas> using the arc function:

const RENDER = () => {
  contextRef.current.clearRect(
    0,
    0,
    canvasRef.current.width,
    canvasRef.current.height
  )
  starsRef.current.forEach(star => {
    contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})`
    contextRef.current.beginPath()
    contextRef.current.arc(star.x, star.y, star.size / 2, 0, Math.PI * 2)
    contextRef.current.fill()
  })
}

Now, we don’t need that clearRect function for our current implementation as we are only rendering once onto a blank <canvas>. But clearing the <canvas> before rendering anything isn’t a bad habit to get into, And it’s one we’ll need as we make our canvas interactive.

Consider this demo that shows the effect of not clearing between frames.

Our Starscape component is starting to take shape.

See the code
const Starscape = ({ densityRatio = 0.5, sizeLimit = 5, defaultAlpha = 0.5 }) => {
  const canvasRef = React.useRef(null)
  const contextRef = React.useRef(null)
  const starsRef = React.useRef(null)
  React.useEffect(() => {
    contextRef.current = canvasRef.current.getContext('2d')
    const LOAD = () => {
      const VMIN = Math.min(window.innerHeight, window.innerWidth)
      const STAR_COUNT = Math.floor(VMIN * densityRatio)
      canvasRef.current.width = window.innerWidth
      canvasRef.current.height = window.innerHeight
      starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
        x: gsap.utils.random(0, window.innerWidth, 1),
        y: gsap.utils.random(0, window.innerHeight, 1),
        size: gsap.utils.random(1, sizeLimit, 1),
        scale: 1,
        alpha: gsap.utils.random(0.1, defaultAlpha, 0.1),
      }))
    }
    const RENDER = () => {
      contextRef.current.clearRect(
        0,
        0,
        canvasRef.current.width,
        canvasRef.current.height
      )
      starsRef.current.forEach(star => {
        contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})`
        contextRef.current.beginPath()
        contextRef.current.arc(star.x, star.y, star.size / 2, 0, Math.PI * 2)
        contextRef.current.fill()
      })
    }
    LOAD()
    RENDER()
  }, [])
  return <canvas ref={canvasRef} />
}

Have a play around with the props in this demo to see how they affect the the way stars are rendered.

Before we go further, you may have noticed a quirk in the demo where resizing the viewport distorts the <canvas>. As a quick win, we can rerun our LOAD and RENDER functions on resize. In most cases, we’ll want to debounce this, too. We can add the following code into our useEffect call. Note how we also remove the event listener in the teardown.

// Naming things is hard...
const RUN = () => {
  LOAD()
  RENDER()
}

RUN()

// Set up event handling
window.addEventListener('resize', RUN)
return () => {
  window.removeEventListener('resize', RUN)
}

Cool. Now when we resize the viewport, we get a new generated starry.

Interacting with the starry backdrop

Now for the fun part! Let’s make this thing interactive.

The idea is that as we move our pointer around the screen, we detect the proximity of the stars to the mouse cursor. Depending on that proximity, the stars both brighten and scale up.

We’re going to need to add another event listener to pull this off. Let’s call this UPDATE. This will work out the distance between the pointer and each star, then tween each star’s scale and alpha values. To make sure those tweeted values are correct, we can use GreenSock’s mapRange() utility. In fact, inside our LOAD function, we can create references to some mapping functions as well as a size unit then share these between the functions if we need to.

Here’s our new LOAD function. Note the new props for scaleLimit and proximityRatio. They are used to limit the range of how big or small a star can get, plus the proximity at which to base that on.

const Starscape = ({
  densityRatio = 0.5,
  sizeLimit = 5,
  defaultAlpha = 0.5,
  scaleLimit = 2,
  proximityRatio = 0.1
}) => {
  const canvasRef = React.useRef(null)
  const contextRef = React.useRef(null)
  const starsRef = React.useRef(null)
  const vminRef = React.useRef(null)
  const scaleMapperRef = React.useRef(null)
  const alphaMapperRef = React.useRef(null)
  
  React.useEffect(() => {
    contextRef.current = canvasRef.current.getContext('2d')
    const LOAD = () => {
      vminRef.current = Math.min(window.innerHeight, window.innerWidth)
      const STAR_COUNT = Math.floor(vminRef.current * densityRatio)
      scaleMapperRef.current = gsap.utils.mapRange(
        0,
        vminRef.current * proximityRatio,
        scaleLimit,
        1
      );
      alphaMapperRef.current = gsap.utils.mapRange(
        0,
        vminRef.current * proximityRatio,
        1,
        defaultAlpha
      );
    canvasRef.current.width = window.innerWidth
    canvasRef.current.height = window.innerHeight
    starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
      x: gsap.utils.random(0, window.innerWidth, 1),
      y: gsap.utils.random(0, window.innerHeight, 1),
      size: gsap.utils.random(1, sizeLimit, 1),
      scale: 1,
      alpha: gsap.utils.random(0.1, defaultAlpha, 0.1),
    }))
  }
}

And here’s our UPDATE function. It calculates the distance and generates an appropriate scale and alpha for a star:

const UPDATE = ({ x, y }) => {
  starsRef.current.forEach(STAR => {
    const DISTANCE = Math.sqrt(Math.pow(STAR.x - x, 2) + Math.pow(STAR.y - y, 2));
    gsap.to(STAR, {
      scale: scaleMapperRef.current(
        Math.min(DISTANCE, vminRef.current * proximityRatio)
      ),
      alpha: alphaMapperRef.current(
        Math.min(DISTANCE, vminRef.current * proximityRatio)
      )
    });
  })
};

But wait… it doesn’t do anything?

Well, it does. But, we haven’t set our component up to show updates. We need to render new frames as we interact. We can reach for requestAnimationFrame often. But, because we’re using GreenSock, we can make use of gsap.ticker. This is often referred to as “the heartbeat of the GSAP engine” and it’s is a good substitute for requestAnimationFrame.

To use it, we add the RENDER function to the ticker and make sure we remove it in the teardown. One of the neat things about using the ticker is that we can dictate the number of frames per second (fps). I like to go with a “cinematic” 24fps:

// Remove RUN
LOAD()
gsap.ticker.add(RENDER)
gsap.ticker.fps(24)

window.addEventListener('resize', LOAD)
document.addEventListener('pointermove', UPDATE)
return () => {
  window.removeEventListener('resize', LOAD)
  document.removeEventListener('pointermove', UPDATE)
  gsap.ticker.remove(RENDER)
}

Note how we’re now also running LOAD on resize. We also need to make sure our scale is being picked up in that RENDER function when using arc:

const RENDER = () => {
  contextRef.current.clearRect(
    0,
    0,
    canvasRef.current.width,
    canvasRef.current.height
  )
  starsRef.current.forEach(star => {
    contextRef.current.fillStyle = `hsla(0, 100%, 100%, ${star.alpha})`
    contextRef.current.beginPath()
    contextRef.current.arc(
      star.x,
      star.y,
      (star.size / 2) * star.scale,
      0,
      Math.PI * 2
    )
    contextRef.current.fill()
  })
}

It works! 🙌

It’s a very subtle effect. But, that’s intentional because, while it’s is super neat, we don’t want this sort of thing to distract from the actual content. I’d recommend playing with the props for the component to see different effects. It makes sense to set all the stars to low alpha by default too.

The following demo allows you to play with the different props. I’ve gone for some pretty standout defaults here for the sake of demonstration! But remember, this article is more about showing you the techniques so you can go off and make your own cool backdrops — while being mindful of how it interacts with content.

Refinements

There is one issue with our interactive starry backdrop. If the mouse cursor leaves the <canvas>, the stars stay bright and upscaled but we want them to return to their original state. To fix this, we can add an extra handler for pointerleave. When the pointer leaves, this tweens all of the stars down to scale 1 and the original alpha value set by defaultAlpha.

const EXIT = () => {
  gsap.to(starsRef.current, {
    scale: 1,
    alpha: defaultAlpha,
  })
}

// Set up event handling
window.addEventListener('resize', LOAD)
document.addEventListener('pointermove', UPDATE)
document.addEventListener('pointerleave', EXIT)
return () => {
  window.removeEventListener('resize', LOAD)
  document.removeEventListener('pointermove', UPDATE)
  document.removeEventListener('pointerleave', EXIT)
  gsap.ticker.remove(RENDER)
}

Neat! Now our stars scale back down and return to their previous alpha when the mouse cursor leaves the scene.

Bonus: Adding an Easter egg

Before we wrap up, let’s add a little Easter egg surprise to our interactive starry backdrop. Ever heard of the Konami Code? It’s a famous cheat code and a cool way to add an Easter egg to our component.

We can practically do anything with the backdrop once the code runs. Like, we could make all the stars pulse in a random way for example. Or they could come to life with additional colors? It’s an opportunity to get creative with things!

We’re going listen for keyboard events and detect whether the code gets entered. Let’s start by creating a variable for the code:

const KONAMI_CODE =
  'arrowup,arrowup,arrowdown,arrowdown,arrowleft,arrowright,arrowleft,arrowright,keyb,keya';

Then we create a second effect within our the starry backdrop. This is a good way to maintain a separation of concerns in that one effect handles all the rendering, and the other handles the Easter egg. Specifically, we’re listening for keyup events and check whether our input matches the code.

const codeRef = React.useRef([])
React.useEffect(() => {
  const handleCode = e => {
    codeRef.current = [...codeRef.current, e.code]
      .slice(
        codeRef.current.length > 9 ? codeRef.current.length - 9 : 0
      )
    if (codeRef.current.join(',').toLowerCase() === KONAMI_CODE) {
      // Party in here!!!
    }
  }
  window.addEventListener('keyup', handleCode)
  return () => {
    window.removeEventListener('keyup', handleCode)
  }
}, [])

We store the user input in an Array that we store inside a ref. Once we hit the party code, we can clear the Array and do whatever we want. For example, we may create a gsap.timeline that does something to our stars for a given amount of time. If this is the case, we don’t want to allow Konami code to input while the timeline is active. Instead, we can store the timeline in a ref and make another check before running the party code.

const partyRef = React.useRef(null)
const isPartying = () =>
  partyRef.current &&
  partyRef.current.progress() !== 0 &&
  partyRef.current.progress() !== 1;

For this example, I’ve created a little timeline that colors each star and moves it to a new position. This requires updating our LOAD and RENDER functions.

First, we need each star to now have its own hue, saturation and lightness:

// Generating stars! ⭐️
starsRef.current = new Array(STAR_COUNT).fill().map(() => ({
  hue: 0,
  saturation: 0,
  lightness: 100,
  x: gsap.utils.random(0, window.innerWidth, 1),
  y: gsap.utils.random(0, window.innerHeight, 1),
  size: gsap.utils.random(1, sizeLimit, 1),
  scale: 1,
  alpha: defaultAlpha
}));

Second, we need to take those new values into consideration when rendering takes place:

starsRef.current.forEach((star) => {
  contextRef.current.fillStyle = `hsla(
    ${star.hue},
    ${star.saturation}%,
    ${star.lightness}%,
    ${star.alpha}
  )`;
  contextRef.current.beginPath();
  contextRef.current.arc(
    star.x,
    star.y,
    (star.size / 2) * star.scale,
    0,
    Math.PI * 2
  );
  contextRef.current.fill();
});

And here’s the fun bit of code that moves all the stars around:

partyRef.current = gsap.timeline().to(starsRef.current, {
  scale: 1,
  alpha: defaultAlpha
});

const STAGGER = 0.01;

for (let s = 0; s < starsRef.current.length; s++) {
  partyRef.current
    .to(
    starsRef.current[s],
    {
      onStart: () => {
        gsap.set(starsRef.current[s], {
          hue: gsap.utils.random(0, 360),
          saturation: 80,
          lightness: 60,
          alpha: 1,
        })
      },
      onComplete: () => {
        gsap.set(starsRef.current[s], {
          saturation: 0,
          lightness: 100,
          alpha: defaultAlpha,
        })
      },
      x: gsap.utils.random(0, window.innerWidth),
      y: gsap.utils.random(0, window.innerHeight),
      duration: 0.3
    },
    s * STAGGER
  );
}

From there, we generate a new timeline and tween the values of each star. These new values get picked up by RENDER. We’re adding a stagger by positioning each tween in the timeline using GSAP’s position parameter.

That’s it!

That’s one way to make an interactive starry backdrop for your site. We combined GSAP and an HTML <canvas>, and even sprinkled in some React that makes it more configurable and reusable. We even dropped an Easter egg in there!

Where can you take this component from here? How might you use it on a site? The combination of GreenSock and <canvas> is a lot of fun and I’m looking forward to seeing what you make! Here are a couple more ideas to get your creative juices flowing…


An Interactive Starry Backdrop for Content originally published on CSS-Tricks. You should get the newsletter.

Case Study: Anatole Touvron’s Portfolio

Like many developers, I’ve decided to redo my portfolio in the summer of 2021. I wanted to do everything from scratch, including the design and development, because I liked the idea of:

  • doing the whole project on my own
  • working on something different than coding
  • being 100% responsible for the website
  • being free to do whatever I want

I know that I wanted to do something simple but efficient. I’ve tried many times to do another portfolio but I’ve always felt stuck at some point and wanted to wipe everything clean by remaking the design or the entire development. I talked to many developers about this and I think that we all know this feeling of getting tired of a project because of how long it is taking to finish. For the first time, I’ve managed to never get this feeling simply by designing this entire project in one afternoon and developing it in two weeks.

Without going further, I want to point out that prior to that I’ve studied @lhbizarro’s course on how to create an immersive website from scratch and I would not have been able to create my portfolio this fast without. It is a true wealth of knowledge that I now use on a regular basis.

Going fast

One of the best tips that I can give to any developer in order to not get tired of doing their portfolio is to go as fast as possible. If you know that you get tired easily with a project, I highly suggest doing it when you have the time and then rush it like crazy. This goes for the coding and for the design part.

Get inspired by websites that you like, find colours that match your vibe, fonts that you like, use your favourite design tool and don’t overthink it.

Things can always get better but if you don’t stick to what you’re doing you will never be able to finish it.

I’m really proud and happy about my portfolio but I know that it isn’t that creative and that crazy compared to stuff that you can see online. I’ve set the bar at a level that I knew I could reach and I think that this is a good way of being sure that you’ll have a new website that will make you progress and feel good about yourself.

Text animations

For the different text animations I’ve chose to do something that I’ve been doing a long time, which is animating lines for paragraphs and letters or words for titles with the help of the Intersection Observer API.

Using it is the most efficient and optimal way to do such animations as it only triggers once in your browser and allows you to have many spans translating without making your user’s machine launch like a rocket.

You have to know that, the more elements there are to animate, the harder it will be to have a smooth experience on every device, which is why I used the least amount of spans to do my animations.

To split my text I always use GSAP’s SplitText because it spoon-feeds you the least fun part of the work. It’s really easy to use and never disappoints.

How to animate

There is multiple ways of animating your divs or spans and I’ve decided to show you two ways of doing it:

  • fully with JavaScript
  • CSS and JavaScript-based

Controlling an animation entirely with JavaScript allows you to have code that’s easier to read and understand because everything will be in the same file. But it could a bit less optimal because you’ll be using JavaScript animation libraries.

Open the following example to see the code:

With CSS your code can get a bit out of hand if you are not organised. There’s a lot more classes to take care of. It’s up to you what you prefer to use but I feel like it’s important to talk about both options because each can have their advantages and disadvantages depending on your needs.

The next example shows how to do it with CSS + JS (open the Codesandbox):

Infinite slider

I’d like to show you the HTML and CSS part of the infinite slider on my portfolio. If you’d like to understand the WebGL part, I can recommend the following tutorial: Creating an Infinite Circular Gallery using WebGL with OGL and GLSL Shaders

The main idea of an infinite slider is to duplicate the actual content so that you can never see the end of it.

You can see the example here (open the Codesandbox for a better scroll experience):

Basically, you wrap everything in a big division that contains the doubled content and when you’ve translated the equivalent of 50% of the big wrapper, you put everything back to its initial position. It’s more like an illusion and it will make more sense when you look at this schematic drawing:

At this very moment the big wrapper snaps back to its initial position to mimic the effect of an infinite slider.

WebGL

Although I won’t explain the WebGL part because Luis already explains it in his tutorial, I do want to talk about the post-processing part with OGL because I love this process and it provides a very powerful way of adding interactions to your website.

To do the post-processing, all you need is the following:

this.post = new Post(this.gl);
this.pass = this.post.addPass({
  fragment,
  uniforms: {
    uResolution: {
      value: new Vec2(1, window.innerHeight / window.innerWidth)
    },
    uMouse: {
      value: new Vec2()
    },
    uVelo: {
      value: 0
    },
    uAmount: {
      value: 0
    }
  }
});

We pass the mouse position, velocity and the time to the uniform. It will allow us to create a grain effect, and an RGB distortion based on the mouse position. You can find many shaders like this and I find it really cool to have it. And it’s not that hard to implement.

float circle(vec2 uv, vec2 disc_center, float disc_radius, float border_size) {
  uv -= disc_center;
  uv*= uResolution;
  float dist = sqrt(dot(uv, uv));
  return smoothstep(disc_radius+border_size, disc_radius-border_size, dist);
}

float random( vec2 p )
{
  vec2 K1 = vec2(
    23.14069263277926,
    2.665144142690225
  );
  return fract( cos( dot(p,K1) ) * 12345.6789 );
}

void main() {
  vec2 newUV = vUv;

  float c = circle(newUV, uMouse, 0.0, 0.6);

  float r = texture2D(tMap, newUV.xy += c * (uVelo * .9)).x;
	float g = texture2D(tMap, newUV.xy += c * (uVelo * .925)).y;
	float b = texture2D(tMap, newUV.xy += c * (uVelo * .95)).z;

  vec4 newColor = vec4(r, g, b, 1.);

  newUV.y *= random(vec2(newUV.y, uAmount));
  newColor.rgb += random(newUV)* 0.10;

  gl_FragColor = newColor;
}

You can find the complete implementation here:

Conclusion

I hoped that you liked this case study and learned a few tips and tricks! If you have any questions you can hit me up @anatoletouvron 🙂

The post Case Study: Anatole Touvron’s Portfolio 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.

Building a Scrollable and Draggable Timeline with GSAP

We’re going to build a timeline showing albums released by the rock band Radiohead. The subject of our timeline doesn’t really matter — the main thing is a series of events that happen over a number of dates — so feel free to pick your own subject matter to make it more personal to you!

We’ll have a timeline along the top of our webpage showing our dates, and a number of full-width sections where our content for each of those dates will live. Dragging the horizontal timeline should scroll the page to the appropriate place in the content, and likewise scrolling the page will cause our timeline to update. Additionally, clicking any of the links in the timeline will allow the user to jump straight to the relevant section. This means we have three different methods for navigating our page — and they all have to sync perfectly with one another.

Three stages showing the horizontal timeline moving from right to left while the page itself is scrolled vertically

We’re going to walk through the steps for creating our timeline. Feel free to jump straight to the final demo if you want to get stuck into the code, or use this starter Codepen, which includes some simple initial styles so you can concentrate on the JS.

Markup

Let’s start with our HTML. As this is going to be our main page navigation, we’ll use the <nav> element. Inside this, we have a marker, which we’ll style with CSS to indicate the position on the timeline. We also have a <div> with a class of nav__track, which will be our draggable trigger. It houses our list of navigation links.

<nav>
	<!--Shows our position on the timeline-->
	<div class="marker"></div>
	
	<!--Draggable element-->
	<div class="nav__track" data-draggable>
		<ul class="nav__list">
			<li>
				<a href="#section_1" class="nav__link" data-link><span>1993</span></a>
			</li>
			<li>
				<a href="#section_2" class="nav__link" data-link><span>1995</span></a>
			</li>
			<li>
				<a href="#section_3" class="nav__link" data-link><span>1997</span></a>
			</li>
			<!--More list items go here-->
		</ul>
	</div>
</nav>

Below our nav, we have the main content of our page, which includes a number of sections. We’ll give each one an id that corresponds to one of the links in the navigation. That way, when a user clicks a link they’ll be scrolled to the relevant place in the content — no JS required.

We’ll also set each one a custom property corresponding to the section’s index. This is optional, but can be useful for styling. We won’t worry about the content of our sections for now.

<main>
	<section id="section_1" style="--i: 0"></section>
	<section id="section_2" style="--i: 1"></section>
	<section id="section_3" style="--i: 2"></section>
	<!--More list sections go here-->
</main>

CSS

Next we’ll move onto our basic layout. We’ll give each section a min-height of 100vh. We can also give them a background color, to make it obvious when we’re scrolling through the sections. We can use the custom property we set in the last step in combination with the hsl() color function to give each one a unique hue:

section {
	--h: calc(var(--i) * 30);
	
	min-height: 100vh;
	background-color: hsl(var(--h, 0) 75% 50%);
}

We’ll position our nav along the top of the page and give it a fixed position.

nav {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
}

While the nav itself will be fixed (to ensure it remains visible as the user scrolls), the track inside it will be draggable. This will need to be wider than the viewport, as we want the user to be able to drag it all the way along. It also needs some padding, as we’ll need the user to be able to drag on the area after our items have ended, so that they can move the track all the way along. To ensure our track has a suitable width at all viewport sizes, we can use the max() function. This returns the largest of two comma-separated values. At narrow viewport widths our track will be a minimum of 200rem wide, ensuring that our items retain a pleasing distance from one another. At larger viewport widths the track will be 200% wide which, accounting for the padding, means our items will be dispersed evenly along the width of the viewport when position them with flexbox.

.nav__track {
	position: relative;
	min-width: max(200rem, 200%);
	padding: 1.5rem max(100rem, 100%) 0 0;
	height: 6rem;
}

.nav__list {
	/* Remove default list styles */
	list-style: none;
	margin: 0;
	padding: 0;
	
	/* Position items horizontally */
	display: flex;
	justify-content: space-between;
}

We can also style our marker, which will show the user the current position on the timeline. For now we’ll add a simple dot, which we’ll position 4rem from the left. If we also set a width of 4rem on our navigation items, this should center the first navigation item below the marker on the left of the viewport.

.marker {
	position: fixed;
	top: 1.75rem;
	left: 4rem;
	width: 1rem;
	height: 1rem;
	transform: translate3d(-50%, 0, 0);
	background: blue;
	border-radius: 100%;
	z-index: 2000;
}

.nav__link {
	position: relative;
	display: block;
	min-width: 8rem;
	text-align: center;
}

You might want to add some custom styling to the track like I’ve done in the demo, but this should be enough for us to move onto the next step.

The JavaScript

Installing plugins

We’ll be using the GSAP (Greensock) core package and its ScrollTrigger and Draggable plugins. There are many ways to install GSAP — check out this page for options. If you go for the NPM option, you’ll’ll need to import the modules at the top of the JS file, and register the plugins:

import gsap from 'gsap'
import ScrollTrigger from 'gsap/ScrollTrigger'
import Draggable from 'gsap/Draggable'

gsap.registerPlugin(ScrollTrigger, Draggable)

Creating the animation timeline

We want the track to move horizontally when the user scrolls the page or drags the timeline itself. We could allow the user to drag the marker instead, but this wouldn’t work well if we had more navigation items than would fit horizontally in the viewport. If we keep the marker stationary while moving the track, it gives us a lot more flexibility.

The first thing we’ll do is create an animation timeline with GSAP. Our timeline is quite simple: it will include just a single tween to move the track to the left, until the last item is just below the marker we positioned earlier. We’ll need to use the width of the last nav item in some other places, so we’ll create a function we can call whenever we need this value. We can use GSAP’s toArray utility function to set an array of our nav links as a variable:

const navLinks = gsap.utils.toArray('[data-link]')

const lastItemWidth = () => navLinks[navLinks.length - 1].offsetWidth

Now we can use that to calculate the x value in our tween:

const track = document.querySelector('[data-draggable]')

const tl = gsap.timeline()
	.to(track, {
		x: () => {
			return ((track.offsetWidth * 0.5) - lastItemWidth()) * -1
		},
		ease: 'none' // important!
	})

Easing

We’re also removing the easing on our timeline tween. This is very important, as the movement will be tied to the scroll position, and easing would play havoc with our calculations later on!

Creating the ScrollTrigger instance

We’re going to create a ScrollTrigger instance, which will trigger the timeline animation. We’ll set the scrub value as 0. This will cause our animation to play at the rate the user scrolls. A value other than 0 creates a lag between the scroll action and the animation, which can work nicely in some instances, but won’t serve us well here.

const st = ScrollTrigger.create({
	animation: tl,
	scrub: 0
})

Our animation timeline will start playing as soon as the user starts scrolling from the top of the page, and end when the page is scrolled all the way to the bottom. If you need anything different, you’ll need to specify start and end values on the ScrollTrigger instance too. (See the ScrollTrigger documentation for more details).

Creating the Draggable instance

Now we’ll create a Draggable instance. We’ll pass in our track as the first argument (the element we want to make draggable). In our options (the second argument) we’ll specify <em>x</em> for the type, as we only want it to be dragged horizontally. We can also set inertia to true. This is optional, as it requires the Inertia plugin, a premium plugin for Greensock members (but free to use on Codepen). Using Inertia mean that when the user lets go after dragging the element, it will glide to a stop in a more naturalistic way. It’s not strictly necessary for this demo, but I prefer the effect.

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true
})

Next we want to set the bounds, otherwise there’s a danger the element could be dragged right off the screen. We’ll set the minimum and maximum values the element can be dragged. We don’t want it to be dragged any further to the right than its current start position, so we’ll set minX as 0. The maxX value will in fact need to be the same value as used in our timeline tween — so how about we make a function for that:

const getDraggableWidth = () => {
	return (track.offsetWidth * 0.5) - lastItemWidth()
}

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1 // Don’t allow any dragging beyond the bounds
})

We’ll need to set edgeResistance to 1, which will prevent any dragging at all beyond our specified bounds.

Putting them together

Now, for the technical part! We’re going to programmatically scroll the page when the user drags the element. The first thing to do is to disable the ScrollTrigger instance when the user starts dragging the track, and re-enable it when the drag ends. We can use the onDragStart and onDragEnd options on our Draggable instance to do that:

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable()
})

Then we’ll write a function that gets called on drag. We’ll get the offset position of our draggable element (using getBoundingClientRect()). We’ll also need to know the total scrollable height of the page, which will be the document height minus the viewport height. Let’s create a function for this, to keep it tidy.

const getUseableHeight = () => document.documentElement.offsetHeight - window.innerHeight

We’ll use GSAP’s mapRange() utility function to find the relative scroll position (see the documentation), and call the scroll() method on the ScrollTrigger instance to update the scroll position on drag:

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable(),
	onDrag: () => {
		const left = track.getBoundingClientRect().left * -1
		const width = getDraggableWidth()
		const useableHeight = getUseableHeight()
		const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)
		
    st.scroll(y)
  }
})

As we’re using the Inertia plugin, we’ll want to call the same function during the “throw” part of the interaction — after the user lets go of the element, but while it retains momentum. So let’s write it as a separate function we can call for both:

const updatePosition = () => {
	const left = track.getBoundingClientRect().left * -1
	const width = getDraggableWidth()
	const useableHeight = getUseableHeight()
	const y = gsap.utils.mapRange(0, width, 0, useableHeight, left)

	st.scroll(y)
}

const draggableInstance = Draggable.create(track, {
	type: 'x',
	inertia: true,
	bounds: {
		minX: 0,
		maxX: getDraggableWidth() * -1
	},
	edgeResistance: 1,
	onDragStart: () => st.disable(),
	onDragEnd: () => st.enable(),
	onDrag: updatePosition,
	onThrowUpdate: updatePosition
})

Now our scroll position and timeline track should be perfectly in sync when we scroll the page or drag the track.

Navigating on click

We also want users to be able to scroll to the desired section by clicking on any of the timeline links. We could do this with JS, but we don’t necessarily need to: CSS has a property that allows smooth scrolling within the page, and it’s supported in most modern browsers (Safari is currently the exception). All we need is this one line of CSS, and our users will be scrolled smoothly to the desired section on click:

html {
	scroll-behavior: smooth;
}

Accessibility

It’s good practice to consider users who may be sensitive to motion, so let’s include a prefers-reduced-motion media query to ensure that users who have specified a system-level preference for reduced motion will be jumped straight to the relevant section instead:

@media (prefers-reduced-motion: no-preference) {
	html {
		scroll-behavior: smooth;
	}
}

Our navigation currently presents a problem for users who navigate using a keyboard. When our nav overflows the viewport, some of our nav links are hidden from view, as they are offscreen. When the user tabs through the links, we need those links to be brought into view. We can attach an event listener to our track to get the scroll position of the corresponding section, and call scroll() on the ScrollTrigger instance, which will have the effect of moving the timeline too (keeping them both in sync):

track.addEventListener('keyup', (e) => {
	const id = e.target.getAttribute('href')
	
	/* Return if no section href or the user isn’t using the tab key */
	if (!id || e.key !== 'Tab') return
	
	const section = document.querySelector(id)
	
	/* Get the scroll position of the section */
	const y = section.getBoundingClientRect().top + window.scrollY
	
	/* Use the ScrollTrigger to scroll the window */
	st.scroll(y)
})

Calling scroll() also respects our users’ motion preferences — users with a reduced-motion preference will be jumped to the section instead of smoothly scrolled.

See the Pen
GSAP Draggable and ScrollTrigger timeline [Simple 1]
by Michelle Barker (@michellebarker)
on CodePen.0

Animating the sections

Our timeline should work pretty well now, but we don’t yet have any content. Let’s add a heading and image for each section, and animate them when the come into view. Here’s an example of the HTML for one section, which we can repeat for the other (adjusting the content as needed):

<main>
	<section id="section_1" style="--i: 0">
		<div class="container">
			<h2 class="section__heading">
				<span>1993</span>
				<span>Pablo Honey</span>
			</h2>
			<div class="section__image">
				<img src="https://assets.codepen.io/85648/radiohead_pablo-honey.jpg" width="1200" height="1200" />
			</div>
		</div>
	</section>
	<!--more sections-->
</main>

I’m using display: grid to position the heading and image in a pleasing arrangement — but feel free to position them as you like. We’ll just concentrate on the JS for this part.

Creating the timelines with GSAP

We’ll create a function called initSectionAnimation(). The first thing we’ll do is return early if our users prefer reduced motion. We can used a prefers-reduced-motion media query using the matchMedia method:

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')

const initSectionAnimation = () => {
	/* Do nothing if user prefers reduced motion */
	if (prefersReducedMotion.matches) return
}

initSectionAnimation()

Next we’ll set our animation start state for each section:

const initSectionAnimation = () => {
	/* Do nothing if user prefers reduced motion */
	if (prefersReducedMotion.matches) return
	
	sections.forEach((section, index) => {
		const heading = section.querySelector('h2')
		const image = section.querySelector('.section__image')
		
		/* Set animation start state */
		gsap.set(heading, {
			opacity: 0,
			y: 50
		})
		gsap.set(image, {
			opacity: 0,
			rotateY: 15
		})
	}
}

Then we’ll create a new timeline for each section, adding ScrollTrigger to the timeline itself to control when the animation is played. We can do this directly this time, rather than creating a separate ScrollTrigger instance, as we don’t need this timeline to be connected to a draggable element. (This code is all within the forEach loop.) We’ll add some tweens to the timeline to animate the heading and image into view.

/* In the `forEach` loop: */

/* Create the section timeline */
const sectionTl = gsap.timeline({
	scrollTrigger: {
		trigger: section,
		start: () => 'top center',
		end: () => `+=${window.innerHeight}`,
		toggleActions: 'play reverse play reverse'
	}
})

/* Add tweens to the timeline */
sectionTl.to(image, {
	opacity: 1,
	rotateY: -5,
	duration: 6,
	ease: 'elastic'
})
.to(heading, {
	opacity: 1,
	y: 0,
	duration: 2
}, 0.5) // the heading tween will play 0.5 seconds from the start

By default our tweens will play one after the other. But I’m using the position parameter to specify that the heading tween should play 0.5 seconds from the beginning of the timeline, so our animations overlap.

Here’s the complete demo in action:

See the Pen
GSAP Draggable and ScrollTrigger timeline [FINAL]
by Michelle Barker (@michellebarker)
on CodePen.0

The post Building a Scrollable and Draggable Timeline with GSAP appeared first on Codrops.

Case Study: A Unique Website for Basement Grotesque

Basement Grotesque is one of basement studio’s first self-initiated projects. We wanted to create a typeface tailored to the studio’s visual language. It became the perfect excuse to show it off with a website that could suit its inherent bold and striking style.

The Beginnings

Our design team had just redesigned the studio’s identity. For the wordmark, instead of using an existing font, we decided to draw each of the characters in an effort to capture the distinct voice and style of the studio. Once we had drawn those final seven letters (and the period sign), it seemed only natural to expand them to a full-fledged bespoke font. That was the beginning of Basement Grotesque.

From then on, our task was fairly clear: we had to create a strong typeface, suitable for large headings and titling, and it had to be bold. Inspired by the specimens of grotesque typefaces of the early 19th century, and combining them with the renewed brutalist aesthetics of the contemporary visual landscape, Basement Grotesque would become our first venture into the daunting world of type design. We weren’t just designing and releasing a new font; we were on the journey of learning a new craft by doing.

Coincidentally, the studio had established quarterly hackathons to devote specific time and space to develop self-initiated projects where the whole team would get together, work on and ship anything we wanted to explore and test that wouldn’t normally be possible with client work. After some brainstorming, the first project chosen for our first hackathon was the site to launch and share our first typeface. Again, it just felt like a natural decision.

“This font is really the result of a creative itch we wanted to scratch a long time ago. We took on the challenge and now we have a growing typeface, open and available to anyone who wants to use it” — Andres Briganti, Head of Design

The Process

Once the goal was set, the challenge was to finish at least one weight of the typeface and then make a website to promote it and its future additions. We had to do something captivating so that it would motivate our community to use it and share it.

The font quickly got to a stage where it was advanced enough to be released. Part of the appeal of the project was the idea to create something that was an ongoing effort and a work in progress. New weights and widths, and even revisions and updates would become available and open to the public as time goes by.

Starting with the Black Weight (800), each character was carefully designed to convey the studio’s visual style, and some of the features of the current version are:

  • 413 unique glyphs
  • 14 discretionary ligatures
  • Bold, our voice and tone, of course
  • Old style and lining figures
  • Language support for Western and Central European languages

Create, Develop and Show Off

With the font in hand, we now had to build a website to present it. It had to be interactive enough to show the typeface’s features, but also feel solid and performant.

The preferred tech stack at basement is Next.js with TypeScript. Having those as basics, we then chose GSAP for animations, Stitches for CSS-in-JS, and Locomotive Scroll for better scrolling experiences — among other libraries. We started from a template repository, next-typescript, for faster development.

“We worked on a tight deadline, and the team really delivered. Each of us worked on a specific section, and then we arranged everything together to get to the final result.” Julian Benegas, Head of Development.

The repository was made open-source, so feel free to fork and play around with it: https://github.com/basementstudio/basement-grotesque.

Interesting Code Challenges

1) In the “Try this beauty” section, we have an interactive preview, where you can adjust the font size, leading, and tracking, to see how it looks. We had to create a ResizableTextarea component to solve some spacing issues. The syncHeights function synced the height of a placeholder div with the actual textarea node. A bit hacky!

function syncHeights(textarea: HTMLTextAreaElement, div: HTMLDivElement) {
  const value = textarea.value.replace(/\n/g, '<br>')
  div.innerHTML = value + '<br style="line-height: 3px;">'
  div.style.display = 'block'
  textarea.style.height = div.offsetHeight + 'px'
  div.style.display = 'none'
}

Full source here.

2) On the footer, we used the 2d physics library p2.js to get that cool “falling letters” animation. The code is a bit long, but please, feel free to follow the source from here.

3) We integrated with Twitter’s API to get and render tweets hashtagged #basementgrotesque on the website. On the download flow, we suggested that users tweet something to show some love 🖤

const handleDownload = useCallback(() => {
    const encoded = {
      text: encodeURIComponent(
        'I’m downloading the #basementgrotesque typeface, the boldest font I’ll ever use on my side-projects. Thank you guys @basementstudio 🏴 Get it now: https://grotesque.basement.studio/'
      )
    }
    window.open(
      `https://twitter.com/intent/tweet?text=${encoded.text}`,
      '_blank'
    )
    download(
      encodeURI(location.origin + '/BasementGrotesque-Black_v1.202.zip')
    )
  }, [])

Full source here.

The Results

See it for yourself: https://grotesque.basement.studio/.

The code is open-source: https://github.com/basementstudio/basement-grotesque.

More About Us

basement.studio is a small team of like-minded individuals whose purpose is to create visually compelling brands and websites, focusing not only on their looks but also on their performance. As we like to say: we make cool shit that performs.

Our award-winning team of designers and developers are hungry for even more interesting projects to build.

Do you have any projects in mind? Don’t be shy: https://basement.studio/contact.

The post Case Study: A Unique Website for Basement Grotesque appeared first on Codrops.

Creating 3D Characters in Three.js

Three.js is a JavaScript library for drawing in 3D with WebGL. It enables us to add 3D objects to a scene, and manipulate things like position and lighting. If you’re a developer used to working with the DOM and styling elements with CSS, Three.js and WebGL can seem like a whole new world, and perhaps a little intimidating! This article is for developers who are comfortable with JavaScript but relatively new to Three.js. Our goal is to walk through building something simple but effective with Three.js — a 3D animated figure — to get a handle on the basic principles, and demonstrate that a little knowledge can take you a long way!

Setting the scene

In web development we’re accustomed to styling DOM elements, which we can inspect and debug in our browser developer tools. In WebGL, everything is rendered in a single <canvas> element. Much like a video, everything is simply pixels changing color, so there’s nothing to inspect. If you inspected a webpage rendered entirely with WebGL, all you would see is a <canvas> element. We can use libraries like Three.js to draw on the canvas with JavaScript.

Basic principles

First we’re going to set up the scene. If you’re already comfortable with this you can skip over this part and jump straight to the section where we start creating our 3D character.

We can think of our Three.js scene as a 3D space in which we can place a camera, and an object for it to look at.

Drawing of a transparent cube, with a smaller cube inside, showing the x, y and z axis and center co-ordinates
We can picture our scene as a giant cube, with objects placed at the center. In actual fact, it extends infinitely, but there is a limit to how much we can see.

First of all we need to create the scene. In our HTML we just need a <canvas> element:

<canvas data-canvas></canvas>

Now we can create the scene with a camera, and render it on our canvas in Three.js:

const canvas = document.querySelector('[data-canvas]')

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

// Create the camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 1000)
scene.add(camera)

// Create the renderer
const renderer = new THREE.WebGLRenderer({ canvas })

// Render the scene
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.render(scene, camera)

For brevity, we won’t go into the precise details of everything we’re doing here. The documentation has much more detail about creating a scene and the various camera attributes. However, the first thing we’ll do is move the position of our camera. By default, anything we add to the scene is going to be placed at co-ordinates (0, 0, 0) — that is, if we imagine the scene itself as a cube, our camera will be placed right in the center. Let’s place our camera a little further out, so that our camera can look at any objects placed in the center of the scene.

Showing the camera looking towards the center of the scene
Moving the camera away from the center allows us to see the objects placed in the center of the scene.

We can do this by setting the z position of the camera:

camera.position.z = 5

We won’t see anything yet, as we haven’t added any objects to the scene. Let’s add a cube to the scene, which will form the basis of our figure.

3D shapes

Objects in Three.js are known as meshes. In order to create a mesh, we need two things: a geometry and a material. Geometries are 3D shapes. Three.js has a selection of geometries to choose from, which can be manipulated in different ways. For the purpose of this tutorial — to see what interesting scenes we can make with just some basic principles — we’re going to limit ourselves to only two geometries: cubes and spheres.

Let’s add a cube to our scene. First we’ll define the geometry and material. Using Three.js BoxGeometry, we pass in parameters for the x, y and z dimensions.

// Create a new BoxGeometry with dimensions 1 x 1 x 1
const geometry = new THREE.BoxGeometry(1, 1, 1)

For the material we’ll choose MeshLambertMaterial, which reacts to light and shade but is more performant than some other materials.

// Create a new material with a white color
const material = new THREE.MeshLambertMaterial({ color: 0xffffff })

Then we create the mesh by combining the geometry and material, and add it to the scene:

const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)

Unfortunately we still won’t see anything! That’s because the material we’re using depends on light in order to be seen. Let’s add a directional light, which will shine down from above. We’ll pass in two arguments: 0xffffff for the color (white), and the intensity, which we’ll set to 1.

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1)
scene.add(lightDirectional)
Light shining down from above at position 0, 1, 0
By default, the light points down from above

If you’ve followed all the steps so far, you still won’t see anything! That’s because the light is pointing directly down at our cube, so the front face is in shadow. If we move the z position of the light towards the camera and off-center, we should now see our cube.

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1)
scene.add(lightDirectional)

// Move the light source towards us and off-center
lightDirectional.position.x = 5
lightDirectional.position.y = 5
lightDirectional.position.z = 5
Light position at 5, 5, 5
Moving the light gives us a better view

We can alternatively set the position on the x, y and z axis simultaneously by calling set():

lightDirectional.position.set(5, 5, 5)

We’re looking at our cube straight on, so only one face can be seen. If we give it a little bit of rotation, we can see the other faces. To rotate an object, we need to give it a rotation angle in [radians](). I don’t know about you, but I don’t find radians very easy to visualize, so I prefer to use a JS function to convert from degrees:

const degreesToRadians = (degrees) => {
	return degrees * (Math.PI / 180)
}

mesh.rotation.x = degreesToRadians(30)
mesh.rotation.y = degreesToRadians(30)

We can also add some ambient light (light that comes from all directions) with a color tint, which softens the effect slightly end ensures the face of the cube turned away from the light isn’t completely hidden in shadow:

const lightAmbient = new THREE.AmbientLight(0x9eaeff, 0.2)
scene.add(lightAmbient)

Now that we have our basic scene set up, we can start to create our 3D character. To help you get started I’ve created a boilerplate which includes all the set-up work we’ve just been through, so that you can jump straight to the next part if you wish.

Creating a class

The first thing we’ll do is create a class for our figure. This will make it easy to add any number of figures to our scene by instantiating the class. We’ll give it some default parameters, which we’ll use later on to position our character in the scene.

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
	}
}

Groups

In our class constructor, let’s create a Three.js group and add it to our scene. Creating a group allows us to manipulate several geometries as one. We’re going to add the different elements of our figure (head, body, arms, etc.) to this group. Then we can position, scale or rotate the figure anywhere in our scene without having to concern ourselves with individually positioning those parts individually every time.

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group = new THREE.Group()
		scene.add(this.group)
	}
}

Creating the body parts

Next let’s write a function to render the body of our figure. It’ll be much the same as the way we created a cube earlier, except, we’ll make it a little taller by increasing the size on the y axis. (While we’re at it, we can remove the lines of code where we created the cube earlier, to start with a clear scene.) We already have the material defined in our codebase, and don’t need to define it within the class itself.

Instead of adding the body to the scene, we instead add it to the group we created.

const material = new THREE.MeshLambertMaterial({ color: 0xffffff })

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group = new THREE.Group()
		scene.add(this.group)
	}
	
	createBody() {
		const geometry = new THREE.BoxGeometry(1, 1.5, 1)
		const body = new THREE.Mesh(geometry, material)
		this.group.add(body)
	}
}

We’ll also write a class method to initialize the figure. So far it will call only the createBody() method, but we’ll add others shortly. (This and all subsequent methods will be written inside our class declaration, unless otherwise specified.)

createBody() {
	const geometry = new THREE.BoxGeometry(1, 1.5, 1)
	const body = new THREE.Mesh(geometry, material)
	this.group.add(body)
}
	
init() {
	this.createBody()
}

Adding the figure to the scene

At this point we’ll want to render our figure in our scene, to check that everything’s working. We can do that by instantiating the class.

const figure = new Figure()
figure.init()

Next we’ll write a similar method to create the head of our character. We’ll make this a cube, slightly larger than the width of the body. We’ll also need to adjust the position so it’s just above the body, and call the function in our init() method:

createHead() {
	const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4)
	const head = new THREE.Mesh(geometry, material)
	this.group.add(head)
	
	// Position it above the body
	head.position.y = 1.65
}

init() {
	this.createBody()
	this.createHead()
}

You should now see a narrower cuboid (the body) rendered below the first cube (the head).

Adding the arms

Now we’re going to give our character some arms. Here’s where things get slightly more complex. We’ll add another method to our class called createArms(). Again, we’ll define a geometry and a mesh. The arms will be long, thin cuboids, so we’ll pass in our desired dimensions for these.

As we need two arms, we’ll create them in a for loop.

createArms() {
	for(let i = 0; i < 2; i++) {
		const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
		const arm = new THREE.Mesh(geometry, material)
		
		this.group.add(arm)
	}
}

We don’t need to create the geometry in the for loop, as it will be the same for each arm.

Don’t forget to call the function in our init() method:

init() {
	this.createBody()
	this.createHead()
	this.createArms()
}

We’ll also need to position each arm either side of the body. I find it helpful here to create a variable m (for multiplier). This helps us position the left arm in the opposite direction on the x axis, with minimal code. (We’ll also use it rotate the arms in a moment too.)

createArms() {
	for(let i = 0; i < 2; i++) {
		const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
		const arm = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		this.group.add(arm)
		
		arm.position.x = m * 0.8
		arm.position.y = 0.1
	}
}

Additionally, we can rotate the arms in our for loop, so they stick out at a more natural angle (as natural as a cube person can be!):

arm.rotation.z = degreesToRadians(30 * m)
Figure with co-ordinate system overlaid
If our figure is placed in the center, the arm on the left will be positioned at the negative equivalent of the x-axis position of the arm on the right

Pivoting

When we rotate the arms you might notice that they rotate from a point of origin in the center. It can be hard to see with a static demo, but try moving the slider in this example.

See the Pen
ThreeJS figure arm pivot example (default pivot from center)
by Michelle Barker (@michellebarker)
on CodePen.0

We can see that the arms don’t move naturally, at an angle from the shoulder, but instead the entire arm rotates from the center. In CSS we would simply set the transform-origin. Three.js doesn’t have this option, so we need to do things slightly differently.

Two figures, the leftmost with an arm that pivots from the center, the rightmost with an arm that pivots from the top left
The figure on the right has arms that rotate from the top, for a more natural effect

Our steps are as follows for each arm:

  1. Create a new Three.js group.
  2. Position the group at the “shoulder” of our figure (or the point from which we want to rotate).
  3. Create a new mesh for the arm and position it relative to the group.
  4. Rotate the group (instead of the arm).

Let’s update our createArms() function to follow these steps. First we’ll create the group for each arm, add the arm mesh to the group, and position the group roughly where we want it:

createArms() {
	const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const arm = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		// Create group for each arm
		const armGroup = new THREE.Group()
		
		// Add the arm to the group
		armGroup.add(arm)
		
		// Add the arm group to the figure
		this.group.add(armGroup)
		
		// Position the arm group
		armGroup.position.x = m * 0.8
		armGroup.position.y = 0.1
	}
}

To assist us with visualizing this, we can add one of Three.js’s built-in helpers to our figure. This creates a wireframe showing the bounding box of an object. It’s useful to help us position the arm, and once we’re done we can remove it.

// Inside the `for` loop:
const box = new THREE.BoxHelper(armGroup, 0xffff00)
this.group.add(box)

To set the transform origin to the top of the arm rather than the center, we then need to move the arm (within the group) downwards by half of its height. Let’s create a variable for height, which we can use when creating the geometry:

createArms() {
	// Set the variable
	const height = 1
	const geometry = new THREE.BoxGeometry(0.25, height, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const armGroup = new THREE.Group()
		const arm = new THREE.Mesh(geometry, material)
		
		const m = i % 2 === 0 ? 1 : -1
		
		armGroup.add(arm)
		this.group.add(armGroup)
		
		// Translate the arm (not the group) downwards by half the height
		arm.position.y = height * -0.5
		
		armGroup.position.x = m * 0.8
		armGroup.position.y = 0.6
		
		// Helper
		const box = new THREE.BoxHelper(armGroup, 0xffff00)
		this.group.add(box)
	}
}

Then we can rotate the arm group.

// In the `for` loop
armGroup.rotation.z = degreesToRadians(30 * m)

In this demo, we can see that the arms are (correctly) being rotated from the top, for a more realistic effect. (The yellow is the bounding box.)

See the Pen
ThreeJS figure arm pivot example (using group)
by Michelle Barker (@michellebarker)
on CodePen.0

Eyes

Next we’re going to give our figure some eyes, for which we’ll use the Sphere geometry in Three.js. We’ll need to pass in three parameters: the radius of the sphere, and the number of segments for the width and height respectively (defaults shown here).

const geometry = new THREE.SphereGeometry(1, 32, 16)

As our eyes are going to be quite small, we can probably get away with fewer segments, which is better for performance (fewer calculations needed).

Let’s create a new group for the eyes. This is optional, but it helps keep things neat. If we need to reposition the eyes later on, we only need to reposition the group, rather than both eyes individually. Once again, let’s create the eyes in a for loop and add them to the group. As we want the eyes to be a different color from the body, we can define a new material:

createEyes() {
	const eyes = new THREE.Group()
	const geometry = new THREE.SphereGeometry(0.15, 12, 8)
	
	// Define the eye material
	const material = new THREE.MeshLambertMaterial({ color: 0x44445c })
	
	for(let i = 0; i < 2; i++) {
		const eye = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		// Add the eye to the group
		eyes.add(eye)
		
		// Position the eye
		eye.position.x = 0.36 * m
	}
}

We could add the eye group directly to the figure. However, if we decide we want to move the head later on, it would be better if the eyes moved with it, rather than being positioned entirely independently! For that, we need to modify our createHead() method to create another group, comprising both the main cube of the head, and the eyes:

createHead() {
	// Create a new group for the head
	this.head = new THREE.Group()
	
	// Create the main cube of the head and add to the group
	const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4)
	const headMain = new THREE.Mesh(geometry, material)
	this.head.add(headMain)
	
	// Add the head group to the figure
	this.group.add(this.head)
	
	// Position the head group
	this.head.position.y = 1.65
	
	// Add the eyes by calling the function we already made
	this.createEyes()
}

In the createEyes() method we then need to add the eye group to the head group, and position them to our liking. We’ll need to position them forwards on the z axis, so they’re not hidden inside the cube of the head:

// in createEyes()
this.head.add(eyes)

// Move the eyes forwards by half of the head depth - it might be a good idea to create a variable to do this!
eyes.position.z = 0.7

Legs

Lastly, let’s give our figure some legs. We can create these in much the same way as the eyes. As they should be positioned relative to the body, we can create a new group for the body in the same way that we did with the head, then add the legs to it:

createLegs() {
	const legs = new THREE.Group()
	const geometry = new THREE.BoxGeometry(0.25, 0.4, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const leg = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		legs.add(leg)
		leg.position.x = m * 0.22
	}
	
	this.group.add(legs)
	legs.position.y = -1.15
	
	this.body.add(legs)
}

Positioning in the scene

If we go back to our constructor, we can position our figure group according to the parameters:

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group.position.x = this.params.x
		this.group.position.y = this.params.y
		this.group.position.z = this.params.z
		this.group.rotation.y = this.params.ry
	}
}

Now, passing in different parameters enables us to position it accordingly. For example, we can give it a bit of rotation, and adjust its x and y position:

const figure = new Figure({ 
	x: -4,
	y: -2,
	ry: degreesToRadians(35)
})
figure.init()

Alternatively, if we want to center the figure within the scene, we can use the Three.js Box3 function, which computes the bounding box of the figure group. This line will center the figure horizontally and vertically:

new THREE.Box3().setFromObject(figure.group).getCenter(figure.group.position).multiplyScalar(-1)

Making it generative

At the moment our figure is all one color, which doesn’t look particularly interesting. We can add a bit more color, and take the extra step of making it generative, so we get a new color combination every time we refresh the page! To do this we’re going to use a function to randomly generate a number between a minimum and a maximum. This is one I’ve borrowed from George Francis, which allows us to specify whether we want an integer or a floating point value (default is an integer).

const random = (min, max, float = false) => {
  const val = Math.random() * (max - min) + min

  if (float) {
    return val
  }

  return Math.floor(val)
}

Let’s define some variables for the head and body in our class constructor. Using the random() function, we’ll generate a value for each one between 0 and 360:

class Figure {
	constructor(params) {
		this.headHue = random(0, 360)
		this.bodyHue = random(0, 360)
	}
}

I like to use HSL when manipulating colors, as it gives us a fine degree of control over the hue, saturation and lightness. We’re going to define the material for the head and body, generating different colors for each by using template literals to pass the random hue values to the hsl color function. Here I’m adjusting the saturation and lightness values, so the body will be a vibrant color (high saturation) while the head will be more muted:

class Figure {
	constructor(params) {
		this.headHue = random(0, 360)
		this.bodyHue = random(0, 360)
		
		this.headMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.headHue}, 30%, 50%` })
		this.bodyMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.bodyHue}, 85%, 50%)` })
	}
}

Our generated hues range from 0 to 360, a full cycle of the color wheel. If we want to narrow the range (for a limited color palette), we could select a lower range between the minimum and maximum. For example, a range between 0 and 60 would select hues in the red, orange and yellow end of the spectrum, excluding greens, blues and purples.

We could similarly generate values for the lightness and saturation if we choose to.

Now we just need to replace any reference to material with this.headMaterial or this.bodyMaterial to apply our generative colors. I’ve chosen to use the head hue for the head, arms and legs.

See the Pen
ThreeJS figure (generative)
by Michelle Barker (@michellebarker)
on CodePen.0

We could use generative parameters for much more than just the colors. In this demo I’m generating random values for the size of the head and body, the length of the arms and legs, and the size and position of the eyes.

See the Pen
ThreeJS figure random generated
by Michelle Barker (@michellebarker)
on CodePen.0

Animation

Part of the fun of working with 3D is having our objects move in a three-dimensional space and behave like objects in the real world. We can add a bit of animation to our 3D figure using the Greensock animation library (GSAP).

GSAP is more commonly used to animate elements in the DOM. As we’re not animating DOM elements in this case, it requires a different approach. GSAP doesn’t require an element to animate — it can animate JavaScript objects. As one post in the GSAP forum puts it, GSAP is just “changing numbers really fast”.

We’ll let GSAP do the work of changing the parameters of our figure, then re-render our figure on each frame. To do this, we can use GSAP’s ticker method, which uses requestAnimationFrame. First, let’s animate the ry value (our figure’s rotation on the y axis). We’ll set it to repeat infinitely, and the duration to 20 seconds:

gsap.to(figure.params, {
	ry: degreesToRadians(360),
	repeat: -1,
	duration: 20
})

We won’t see any change just yet, as we aren’t re-rendering our scene. Let’s now trigger a re-render on every frame:

gsap.ticker.add(() => {
	// Update the rotation value
	figure.group.rotation.y = this.params.ry
	
	// Render the scene
	renderer.setSize(window.innerWidth, window.innerHeight)
	renderer.render(scene, camera)
})

Now we should see the figure rotating on its y axis in the center of the scene. Let’s give him a little bounce action too, by moving him up and down and rotating the arms. First of all we’ll set his starting position on the y axis to be a little further down, so he’s not bouncing off screen. We’ll set yoyo: true on our tween, so that the animation repeats in reverse (so our figure will bounce up and down):

// Set the starting position
gsap.set(figure.params, {
	y: -1.5
})

// Tween the y axis position and arm rotation
gsap.to(figure.params, {
	y: 0,
	armRotation: degreesToRadians(90),
	repeat: -1,
	yoyo: true,
	duration: 0.5
})

As we need to update a few things, let’s create a method called bounce() on our Figure class, which will handle the animation. We can use it to update the values for the rotation and position, then call it within our ticker, to keep things neat:

/* In the Figure class: */
bounce() {
	this.group.rotation.y = this.params.ry
	this.group.position.y = this.params.y
}

/* Outside of the class */
gsap.ticker.add(() => {
	figure.bounce()
	
	// Render the scene
	renderer.setSize(window.innerWidth, window.innerHeight)
	renderer.render(scene, camera)
})

To make the arms move, we need to do a little more work. In our class constructor, let’s define a variable for the arms, which will be an empty array:

class Figure {
	constructor(params) {
		this.arms = []
	}
}

In our createArms() method, in addition to our code, we’ll push each arm group to the array:

createArms() {
	const height = 0.85
	
	for(let i = 0; i < 2; i++) {
		/* Other code for creating the arms.. */
		
		// Push to the array
		this.arms.push(armGroup)
	}
}

Now we can add the arm rotation to our bounce() method, ensuring we rotate them in opposite directions:

bounce() {
	// Rotate the figure
	this.group.rotation.y = this.params.ry
	
	// Bounce up and down
	this.group.position.y = this.params.y
	
	// Move the arms
	this.arms.forEach((arm, index) => {
		const m = index % 2 === 0 ? 1 : -1
		arm.rotation.z = this.params.armRotation * m
	})
}

Now we should see our little figure bouncing, as if on a trampoline!

See the Pen
ThreeJS figure with GSAP
by Michelle Barker (@michellebarker)
on CodePen.0

Wrapping up

There’s much, much more to Three.js, but we’ve seen that it doesn’t take too much to get started building something fun with just the basic building blocks, and sometimes limitation breeds creativity! If you’re interested in exploring further, I recommend the following resources.

Resources

The post Creating 3D Characters in Three.js appeared first on Codrops.

Dynamic CSS Masks with Custom Properties and GSAP

I recently redesigned my personal website to include a fun effect in the hero area, where the user’s cursor movement reveals alternative styling on the title and background, reminiscent of a spotlight. In this article we’ll walk through how the effect was created, using masks, CSS custom properties and much more.

Duplicating the content

We start with HTML to create two identical hero sections, with the title repeated.

<div class="wrapper">
 <div class="hero">
  <h1 class="hero__heading">Welcome to my website</h1>
 </div>

 <div class="hero hero--secondary" aria-hidden="true">
  <p class="hero__heading">Welcome to my website</p>
 </div>
</div>

Duplicating content isn’t a great experience for someone accessing the website using a screenreader. In order to prevent screenreaders announcing it twice, we can use aria-hidden="true" on the second component. The second component is absolute-positioned with CSS to completely cover the first.

Hero section with bright gradient positioned over the dark section
The two sections with the same content are layered one on top of the other

Using pointer-events: none on the second component ensures that the text of the first will be selectable by users.

Styling

Now we can add some CSS to style the two components. I deliberately chose a bright, rich gradient for the “revealed” background, in contrast to the dark monochrome of the initial view.

Somewhat counterintuitively, the component with the bright background is actually the one that will cover the other. In a moment we’ll add the mask, so that parts of it will be hidden — which is what gives the impression of it being underneath.

Text effects

There are a couple of different text effects at play in this component. The first applies to the bright text on the dark background. This uses -webkit-text-stroke, a non-standard CSS property that is nonetheless supported in all modern browsers. It allows us to outline our text, and works great with bold, chunky fonts like the one we’re using here. It requires a prefix in all browsers, and can be used as shorthand for -webkit-stroke-width and -webkit-stroke-color.

In order to get the “glow” effect, we can set the text color to a transparent value and use the CSS drop-shadow filter with the same color value. (We’re using a CSS custom property for the color in this example):

.heading {
  -webkit-text-stroke: 2px var(--primary);
  color: transparent;
  filter: drop-shadow(0 0 .35rem var(--primary));
}

See the Pen Outlined text by Michelle Barker (@michellebarker) on CodePen.dark

The text on the colored panel has a different effect applied. The intention was, for it to feel a little like an x-ray revealing the skeleton underneath. The text fill has a dotted pattern, which is created using a repeated radial gradient. To get this effect on the text, we in fact apply it to the background of the element, and use background-clip: text, which also requires a prefix in most browsers (at the time of writing). Again, we need to set the text color to a transparent value in order to see the result of the background-clip property:

.hero--secondary .heading {
  background: radial-gradient(circle at center, white .11rem, transparent 0);
  background-size: .4rem .4rem;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

See the Pen Dotted text by Michelle Barker (@michellebarker) on CodePen.dark

Creating the spotlight

We have two choices when it comes to creating the spotlight effect with CSS: clip-path and mask-image. These can produce very similar effects, but with some important differences.

Clipping

We can think of clipping a shape with clip-path as a bit like cutting it out with scissors. This is ideal for shapes with clean lines. In this case, we could create a circle shape for our spotlight, using the circle() function:

.hero--secondary {
  --clip: circle(20% at 70%);
  -webkit-clip-path: var(--clip);
  clip-path: var(--clip);
}

(clip-path still needs to be prefixed in Safari, so I like to use a custom property for this.)

clip-path can also take an ellipse, polygon, SVG path or a URL with an SVG path ID.

See the Pen Hero with clip-path by Michelle Barker (@michellebarker) on CodePen.dark

Masking

Unlike clip-path the mask-image property is not limited to shapes with clean lines. We can use a PNGs, SVGs or even GIFs to create a mask. We can even use gradients: the blacker parts of the image (or gradient) act as the mask whereas the element will be hidden by the transparent parts.

We can use a radial gradient to create a mask very similar to the clip-path circle:

.hero--secondary {
  --mask: radial-gradient(circle at 70%, black 25%, transparent 0);
  -webkit-clip-path: var(--mask);
  clip-path: var(--mask);
}

Another advantage is that there are additional mask properties than correspond to CSS background properties — so we can control the size and position of the mask, and whether or not it repeats in much the same way, with mask-size, mask-position and mask-repeat respectively.

See the Pen Hero with mask by Michelle Barker (@michellebarker) on CodePen.dark

There’s much more we could delve into with clipping and masking, but let’s leave that for another day! I chose to use a mask instead of a clip-path for this project — hopefully the reason will become clear a little later on.

Tracking the cursor

Now we have our mask, it’s a matter of tracking the position of the user’s cursor, for which we’ll need some Javascript. First we can set custom properties for the center co-ordinates of our gradient mask. We can use default values, to give the mask an initial position before the JS is executed. This will also ensure that non-mouse users see a static mask, rather than none at all.

.hero--secondary {
  --mask: radial-gradient(circle at var(--x, 70%) var(--y, 50%), black 25%, transparent 0);
}

In our JS, we can listen for the mousemove event, then update the custom properties for the x and y percentage position of the circle in accordance with the cursor position:

const hero = document.querySelector('[data-hero]')

window.addEventListener('mousemove', (e) => {
  const { clientX, clientY } = e
  const x = Math.round((clientX / window.innerWidth) * 100)
  const y = Math.round((clientY / window.innerHeight) * 100)
	
  hero.style.setProperty('--x', `${x}%`)
  hero.style.setProperty('--y', `${y}%`)
})

See the Pen Hero with cursor tracking by Michelle Barker (@michellebarker) on CodePen.dark

(For better performance, we might want to throttle or debounce that function, or use requestAnimationFrame, to prevent it repeating too frequently. If you’re not sure which to use, this article has you covered.)

Adding animation

At the moment there is no easing on the movement of the spotlight — it immediately updates its position when the mouse it moved, so feels a bit rigid. We could remedy that with a bit of animation.

If we were using clip-path we could animate the path position with a transition:

.hero--secondary {
  --clip: circle(25% at 70%);
  -webkit-clip-path: var(--clip);
  clip-path: var(--clip);
  transition: clip-path 300ms 20ms;
}

Animating a mask requires a different route.

Animating with CSS Houdini

In CSS we can transition or animate custom property values using Houdini – a set of low-level APIs that give developers access to the browser’s rendering engine. The upshot is we can animate properties (or, more accurately, values within properties, in this case) that aren’t traditionally animatable.

We first need to register the property, specifying the syntax, whether or not it inherits, and an initial value. The initial-value property is crucial, otherwise it will have no effect.

@property --x {
  syntax: '';
  inherits: true;
  initial-value: 70%;
}

Then we can transition or animate the custom property just like any regular animatable CSS property. For our spotlight, we can transition the --x and --y values, with a slight delay, to make them feel more natural:

.hero--secondary {
  transition: --x 300ms 20ms ease-out, --y 300ms 20ms ease-out;
}

See the Pen Hero with cursor tracking (with Houdini animation) by Michelle Barker (@michellebarker) on CodePen.dark

Unfortunately, @property is only supported in Chromium browsers at the time of writing. If we want an improved animation in all browsers, we could instead reach for a JS library.

Animating with GSAP

In CSS we can transition or animate custom property values using Houdini –I love using the Greensock(GSAP) JS animation library. It has an intuitive API, and contains plenty of easing options, all of which makes animating UI elements easy and fun! As I was already using it for other parts of the project, it was a simple decision to use it here to bring some life to the spotlight. Instead of using setProperty we can let GSAP take care of setting our custom properties, and configure the easing using the built in options:

import gsap from 'gsap'

const hero = document.querySelector('[data-hero]')

window.addEventListener('mousemove', (e) => {
  const { clientX, clientY } = e
  const x = Math.round((clientX / window.innerWidth) * 100)
  const y = Math.round((clientY / window.innerHeight) * 100)
	
  gsap.to(hero, {
    '--x': `${x}%`,
    '--y': `${y}%`,
    duration: 0.3,
    ease: 'sine.out'
  })
})

See the Pen Hero with cursor tracking (GSAP) by Michelle Barker (@michellebarker) on CodePen.dark

Animating the mask with a timeline

The mask on my website’s hero section is slightly more elaborate than a simple spotlight. We start with a single circle, then suddenly another circle “pops” out of the first, surrounding it. To get an effect like this, we can once again turn to custom properties, and animate them on a GSAP timeline.

Our radial gradient mask becomes a little more complex: We’re creating a gradient of two concentric circles, but setting the initial values of the gradient stops to 0% (via the default values in our custom properties), so that their size can be animated with JS:

.hero {
  --mask: radial-gradient(
    circle at var(--x, 50%) var(--y, 50%),
    black var(--maskSize1, 0%), 
    transparent 0, 
    transparent var(--maskSize2, 0%),
    black var(--maskSize2, 0%), 
    black var(--maskSize3, 0%), 
    transparent 0);
}

Our mask will be invisible at this point, as the circle created with the gradient has a size of 0%. Now we can create a timeline with GSAP, so the central spot will spring to life, followed by the second circle. We’re also adding a delay of one second before the timeline starts to play.

const tl = gsap.timeline({ delay: 1 })

tl
  .to(hero, {
    '--maskSize1': '20%',
    duration: 0.5,
    ease: 'back.out(2)'
  })
  .to(hero, {
    '--maskSize2': '28%',
     '--maskSize3': 'calc(28% + 0.1rem)',
    duration: 0.5,
    delay: 0.5,
    ease: 'back.out(2)'
})

See the Pen Hero with cursor tracking (GSAP) by Michelle Barker (@michellebarker) on CodePen.dark

Using a timeline, our animations will execute one after the other. GSAP offers plenty of options for orchestrating the timing of animations with timelines, and I urge you to explore the documentation to get a taste of the possibilities. You won’t be disappointed!

Smoothing the gradient

For some screen resolutions, a gradient with hard color stops can result in jagged edges. To avoid this we can add some additional color stops with fractional percentage values:

.hero {
  --mask: radial-gradient(
    circle at var(--x, 50%) var(--y, 50%),
    black var(--maskSize1, 0%) 0,
    rgba(0, 0, 0, 0.1) calc(var(--maskSize1, 0%) + 0.1%),
    transparent 0,
    transparent var(--maskSize2, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize2, 0%) + 0.1%),
    black var(--maskSize2, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize3, 0%) - 0.1%),
    black var(--maskSize3, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize3, 0%) + 0.1%),
    transparent 0
  );
}

This optional step results in a smoother-edged gradient. You can read more about this approach in this article by Mandy Michael.

A note on default values

While testing this approach, I initially used a default value of 0 for the custom properties. When creating the smoother gradient, it turned out that the browser didn’t compute those zero values with calc, so the mask wouldn’t be applied at all until the values were updated with JS. For this reason, I’m setting the defaults as 0% instead, which works just fine.

Creating the menu animation

There’s one more finishing touch to the hero section, which is a bit of visual trickery: When the user clicks on the menu button, the spotlight expands to reveal the full-screen menu, seemingly underneath it. To create this effect, we need to give the menu an identical background to the one on our masked element.

:root {
  --gradientBg: linear-gradient(45deg, turquoise, darkorchid, deeppink, orange);
}

.hero--secondary {
  background: var(--gradientBg);
}

.menu {
  background: var(--gradientBg);
}

The menu is absolute-positioned, the same as the masked hero element, so that it completely overlays the hero section.

Then we can use clip-path to clip the element to a circle 0% wide. The clip path is positioned to align with the menu button, at the top right of the viewport. We also need to add a transition, for when the menu is opened.

.menu {
  background: var(--gradientBg);
  clip-path: circle(0% at calc(100% - 2rem) 2rem);
  transition: clip-path 500ms;
}

When a user clicks the menu button, we’ll use JS to apply a class of .is-open to the menu.

const menuButton = document.querySelector('[data-btn="menu"]')
const menu = document.querySelector('[data-menu]')

menuButton.addEventListener('click', () => {
  menu.classList.toggle('is-open')
})

(In a real project there’s much more we would need to do to make our menu fully accessible, but that’s beyond the scope of this article.)

Then we need to add a little more CSS to expand our clip-path so that it reveals the menu in its entirety:

.menu.is-open {
  clip-path: circle(200% at calc(100% - 2rem) 2rem);
}

See the Pen Hero with cursor tracking and menu by Michelle Barker (@michellebarker) on CodePen.dark

Text animation

In the final demo, we’re also implementing a staggered animation on the heading, before animating the spotlight into view. This uses Splitting.js to split the text into <span> elements. As it assigns each character a custom property, it’s great for CSS animations. The GSAP timeline however, is a more convenient way to implement the staggered effect in this case, as it means we can let the timeline handle when to start the next animation after the text finishes animating. We’ll add that to the beginning of our timeline:

// Set initial text styles (before animation)
gsap.set(".hero--primary .char", {
  opacity: 0,
  y: 25,
});

/* Timeline */
const tl = gsap.timeline({ delay: 1 });

tl
  .to(".hero--primary .char", {
    opacity: 1,
    y: 0,
    duration: 0.75,
    stagger: 0.1,
  })
  .to(hero, {
    "--maskSize1": "20%",
    duration: 0.5,
    ease: "back.out(2)",
  })
  .to(hero, {
    "--maskSize2": "28%",
    "--maskSize3": "calc(28% + 0.1rem)",
    duration: 0.5,
    delay: 0.3,
    ease: "back.out(2)",
  });

I hope this inspires you to play around with CSS masks and the fun effects that can be created!

The full demo

The post Dynamic CSS Masks with Custom Properties and GSAP appeared first on Codrops.

Collective #658















Collective 658 item image

Kallithea

Kallithea, a free software source code management system supporting two leading version control systems, Mercurial and Git.

Check it out


Collective 658 item image

Una Kravets shares the latest flexbox debugging features in Chrome DevTools.

Check it out










The post Collective #658 appeared first on Codrops.

Going “Meta GSAP”: The Quest for “Perfect” Infinite Scrolling

I‘m not sure how this one came about. But, it‘s a story. This article is more about grokking a concept, one that’s going to help you think about your animations in a different way. It so happens that this particular example features infinite scrolling — specifically the “perfect” infinite scroll for a deck of cards without duplicating any of them.

Why am I here? Well, this all started from a tweet. A tweet that got me thinking about layouts and side-scrolling content.

I took that concept and used it on my site. And it’s still there in action at the time of writing.

Then I got to thinking more about gallery views and side-scrolling concepts. We hopped on a livestream and decided to try and make something like the old Apple “Cover Flow” pattern. Remember it?

My first thoughts for making this assumed I‘d make this so it works without JavaScript, as it does in the demo above, in a way that uses “progressive enhancement.” I grabbed Greensock and ScrollTrigger, and off we went. I came away from that work pretty disappointed. I had something but couldn‘t quite get infinite scrolling to work how the way I wanted. The “Next” and “Previous” buttons didn’t want to play ball. You can see it here, and it requires horizontal scrolling.

So I opened up a new thread on the Greensock forum. Little did I know I was about to open myself up to some serious learning! We solved the issue with the buttons. But, being me, I had to ask whether something else was possible. Was there a “clean” way to do infinite scrolling? I‘d tried something on stream but had no luck. I was curious. I’d tried a technique like that used in this pen which I created for the ScrollTrigger release.

The initial answer was that it is kinda tricky to do:

The hard part about infinite things on scroll is that the scroll bar is limited while the effect that you’re wanting is not. So you have to either loop the scroll position like this demo (found in the ScrollTrigger demos section) or hook directly into the scroll-related navigation events (like the wheel event) instead of actually using the actual scroll position.

I figured that was the case and was happy to leave it “as-is.” A couple of days passed and Jack dropped a reply that kinda blew my mind when I started digging into it. And now, after a bunch of going through it, I’m here to share the technique with you.

Animate anything

One thing that is often overlooked with GSAP, is that you can animate almost anything with it. This is often because visual things are what spring to mind when thinking about animation — the actual physical movement of something. Our first thought isn’t about taking that process to a meta-level and animating from a step back.

But, think about animation work on a larger scale and then break it down into layers. For example, you play a cartoon. The cartoon is a collection of compositions. Each composition is a scene. And then you have the power to scrub through that collection of compositions with a remote, whether it’s on YouTube, using your TV remote, or whatever. There are almost three levels to what is happening.

And this is the trick we need for creating different types of infinite loops. This is the main concept right here. We animate the play head position of a timeline with a timeline. And then we can scrub that timeline with our scroll position.

Don‘t worry if that sounds confusing. We’re going to break it down.

Going “meta”

Let‘s start with an example. We’re going to create a tween that moves some boxes from left to right. Here it is.

Ten boxes that keep going left to right. That’s quite straightforward with Greensock. Here, we use fromTo and repeat to keep the animation going. But, we have a gap at the start of each iteration. We’re also using stagger to space out the movement and that’s something that will play an important role as we continue.

gsap.fromTo('.box', {
  xPercent: 100
}, {
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  repeat: -1,
  ease: 'none',
})

Now comes the fun part. Let’s pause the tween and assign it to a variable. Then let’s create a tween that plays it. We can do this by tweening the totalTime of the tween, which allows us to get or set the tween’s playhead tween, while considering repeats and repeat delays.

const SHIFT = gsap.fromTo('.box', {
  xPercent: 100
}, {
  paused: true,
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  repeat: -1,
  ease: 'none',
})

const DURATION = SHIFT.duration()

gsap.to(SHIFT, {
  totalTime: DURATION,
  repeat: -1,
  duration: DURATION,
  ease: 'none',
})

This is our first “meta” tween. It looks exactly the same but we’re adding another level of control. We can change things on this layer without affecting the original layer. For example, we could change the tween ease to power4.in. This completely changes the animation but without affecting the underlying animation. We’re kinda safeguarding ourselves with a fallback.

Not only that, we might choose to repeat only a certain part of the timeline. We could do that with another fromTo, like this:

The code for that would be something like this.

gsap.fromTo(SHIFT, {
  totalTime: 2,
}, {
  totalTime: DURATION - 1,
  repeat: -1,
  duration: DURATION,
  ease: 'none'
})

Do you see where this is going? Watch that tween. Although it keeps looping, the numbers flip on each repeat. But, the boxes are in the correct position.

Achieving the “perfect” loop

If we go back to our original example, there’s a noticeable gap between each repetition.

Here comes the trick. The part that unlocks everything. We need to build a perfect loop.

Let‘s start by repeating the shift three times. It’s equal to using repeat: 3. Notice how we’ve removed repeat: -1 from the tween.

const getShift = () => gsap.fromTo('.box', {
  xPercent: 100
}, {
  xPercent: -200,
  stagger: 0.5,
  duration: 1,
  ease: 'none',
})

const LOOP = gsap.timeline()
  .add(getShift())
  .add(getShift())
  .add(getShift())

We’ve turned the initial tween into a function that returns the tween and we add it to a new timeline three times. And this gives us the following.

OK. But, there’s still a gap. Now we can bring in the position parameter for adding and positioning those tweens. We want it to be seamless. That means inserting each each set of tweens before the previous one ends. That’s a value based on the stagger and the amount of elements.

const stagger = 0.5 // Used in our shifting tween
const BOXES = gsap.utils.toArray('.box')
const LOOP = gsap.timeline({
  repeat: -1
})
  .add(getShift(), 0)
  .add(getShift(), BOXES.length * stagger)
  .add(getShift(), BOXES.length * stagger * 2)

If we update our timeline to repeat and watch it (while adjusting the stagger to see how it affects things)…

You‘ll notice that there‘s a window in the middle there that creates a “seamless” loop. Recall those skills from earlier where we manipulated time? That’s what we need to do here: loop the window of time where the loop is “seamless.”

We could try tweening the totalTime through that window of the loop.

const LOOP = gsap.timeline({
  paused: true,
  repeat: -1,
})
.add(getShift(), 0)
.add(getShift(), BOXES.length * stagger)
.add(getShift(), BOXES.length * stagger * 2)

gsap.fromTo(LOOP, {
  totalTime: 4.75,
},
{
  totalTime: '+=5',
  duration: 10,
  ease: 'none',
  repeat: -1,
})

Here, we’re saying tween the totalTime from 4.75 and add the length of a cycle to that. The length of a cycle is 5. And that’s the middle window of the timeline. We can use GSAP’s nifty += to do that, which gives us this:

Take a moment to digest what‘s happening there. This could be the trickiest part to wrap your head around. We’re calculating windows of time in our timeline. It’s kinda hard to visualize but I’ve had a go.

This is a demo of a watch that takes 12 seconds for the hands go round once. It‘s looped infinitely with repeat: -1 and then we‘re using fromTo to animate a specific time window with a given duration. If you, reduce the time window to say 2 and 6, then change the duration to 1, the hands will go from 2 o‘clock to 6 o’clock on repeat. But, we never changed the underlying animation.

Try configuring the values to see how it affects things.

At this point, it’s a good idea to put together a formula for our window position. We could also use a variable for the duration it takes for each box to transition.

const DURATION = 1
const CYCLE_DURATION = BOXES.length * STAGGER
const START_TIME = CYCLE_DURATION + (DURATION * 0.5)
const END_TIME = START_TIME + CYCLE_DURATION

Instead of using three stacked timelines, we could loop over our elements three times where we get the benefit of not needing to calculate the positions. Visualizing this as three stacked timelines is a neat way to grok the concept, though, and a nice way to help understand the main idea.

Let’s change our implementation to create one big timeline from the start.

const STAGGER = 0.5
const BOXES = gsap.utils.toArray('.box')

const LOOP = gsap.timeline({
  paused: true,
  repeat: -1,
})

const SHIFTS = [...BOXES, ...BOXES, ...BOXES]

SHIFTS.forEach((BOX, index) => {
  LOOP.fromTo(BOX, {
    xPercent: 100
  }, {
    xPercent: -200,
    duration: 1,
    ease: 'none',
  }, index * STAGGER)
})

This is easier to put together and gives us the same window. But, we don’t need to think about math. Now we loop through three sets of the boxes and position each animation according to the stagger.

How might that look if we adjust the stagger? It will squish the boxes closer together.

But, it’s broken the window because now the totalTime is out. We need to recalculate the window. Now’s a good time to plug in the formula we calculated earlier.

const DURATION = 1
const CYCLE_DURATION = STAGGER * BOXES.length
const START_TIME = CYCLE_DURATION + (DURATION * 0.5)
const END_TIME = START_TIME + CYCLE_DURATION

gsap.fromTo(LOOP, {
  totalTime: START_TIME,
},
{
  totalTime: END_TIME,
  duration: 10,
  ease: 'none',
  repeat: -1,
})

Fixed!

We could even introduce an “offset” if we wanted to change the starting position.

const STAGGER = 0.5
const OFFSET = 5 * STAGGER
const START_TIME = (CYCLE_DURATION + (STAGGER * 0.5)) + OFFSET

Now our window starts from a different position.

But still, this isn’t great as it gives us these awkward stacks at each end. To get rid of that effect, we need to think about a “physical” window for our boxes. Or think about how they enter and exit the scene.

We’re going to use document.body as the window for our example. Let’s update the box tweens to be individual timelines where the boxes scale up on enter and down on exit. We can use yoyo and repeat: 1 to achieve entering and exiting.

SHIFTS.forEach((BOX, index) => {
  const BOX_TL = gsap
    .timeline()
    .fromTo(
      BOX,
      {
        xPercent: 100,
      },
      {
        xPercent: -200,
        duration: 1,
        ease: 'none',
      }, 0
    )
    .fromTo(
      BOX,
      {
        scale: 0,
      },
      {
        scale: 1,
        repeat: 1,
        yoyo: true,
        ease: 'none',
        duration: 0.5,
      },
      0
    )
  LOOP.add(BOX_TL, index * STAGGER)
})

Why are we using a timeline duration of 1? It makes things easier to follow. We know the time is 0.5 when the box is at the midpoint. It‘s worth noting that easing won’t have the effect we usually think of here. In fact, easing will actually play a part in how the boxes position themselves. For example, an ease-in would bunch the boxes up on the right before they move across.

The code above gives us this.

Almost. But, our boxes disappear for a time in the middle. To fix this, let’s introduce the immediateRender property. It acts like animation-fill-mode: none in CSS. We’re telling GSAP that we don’t want to retain or pre-record any styles that are being set on a box.

SHIFTS.forEach((BOX, index) => {
  const BOX_TL = gsap
    .timeline()
    .fromTo(
      BOX,
      {
        xPercent: 100,
      },
      {
        xPercent: -200,
        duration: 1,
        ease: 'none',
        immediateRender: false,
      }, 0
    )
    .fromTo(
      BOX,
      {
        scale: 0,
      },
      {
        scale: 1,
        repeat: 1,
        zIndex: BOXES.length + 1,
        yoyo: true,
        ease: 'none',
        duration: 0.5,
        immediateRender: false,
      },
      0
    )
  LOOP.add(BOX_TL, index * STAGGER)
})

That small change fixes things for us! Note how we’ve also included z-index: BOXES.length. That should safeguard us against any z-index issues.

There we have it! Our first infinite seamless loop. No duplicate elements and perfect continuation. We’re bending time! Pat yourself on the back if you’ve gotten this far! 🎉

If we want to see more boxes at once, we can tinker with the timing, stagger, and ease. Here, we have a STAGGER of 0.2 and we’ve also introduced opacity into the mix.

The key part here is that we can make use of repeatDelay so that the opacity transition is quicker than the scale. Fade in over 0.25 seconds. Wait 0.5 seconds. Fade back out over 0.25 seconds.

.fromTo(
  BOX, {
    opacity: 0,
  }, {
    opacity: 1,
    duration: 0.25,
    repeat: 1,
    repeatDelay: 0.5,
    immediateRender: false,
    ease: 'none',
    yoyo: true,
  }, 0)

Cool! We could do whatever we want with those in and out transitions. The main thing here is that we have our window of time that gives us the infinite loop.

Hooking this up to scroll

Now that we have a seamless loop, let’s attach it to scroll. For this we can use GSAP’s ScrollTrigger. This requires an extra tween to scrub our looping window. Note how we’ve set the loop to be paused now, too.

const LOOP_HEAD = gsap.fromTo(LOOP, {
  totalTime: START_TIME,
},
{
  totalTime: END_TIME,
  duration: 10,
  ease: 'none',
  repeat: -1,
  paused: true,
})

const SCRUB = gsap.to(LOOP_HEAD, {
  totalTime: 0,
  paused: true,
  duration: 1,
  ease: 'none',
})

The trick here is to use ScrollTrigger to scrub the play head of the loop by updating the totalTime of SCRUB. There are various ways we could set up this scroll. We could have it horizontal or bound to a container. But, what we‘re going to do is wrap our boxes in a .boxes element and pin that to the viewport. (This fixes its position in the viewport.) We’ll also stick with vertical scrolling. Check the demo to see the styling for .boxes which sets things to the size of the viewport.

import ScrollTrigger from 'https://cdn.skypack.dev/gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)

ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    SCRUB.vars.totalTime = LOOP_HEAD.duration() * self.progress
    SCRUB.invalidate().restart()
  }
})

The important part is inside onUpdate. That’s where we set the totalTime of the tween based on the scroll progress. The invalidate call flushes any internally recorded positions for the scrub. The restart then sets the position to the new totalTime we set.

Try it out! We can go back and forth in the timeline and update the position.

How cool is that? We can scroll to scrub a timeline that scrubs a timeline that is a window of a timeline. Digest that for a second because that‘s what’s happening here.

Time travel for infinite scrolling

Up to now, we‘ve been manipulating time. Now we’re going to time travel!

To do this, we‘re going to use some other GSAP utilities and we‘re no longer going to scrub the totalTime of LOOP_HEAD. Instead, we’re going to update it via proxy. This is another great example of going “meta” GSAP.

Let’s start with a proxy object that marks the playhead position.

const PLAYHEAD = { position: 0 }

Now we can update our SCRUB to update the position. At the same time, we can use GSAP’s wrap utility, which wraps the position value around the LOOP_HEAD duration. For example, if the duration is 10 and we provide the value 11, we will get back 1.

const POSITION_WRAP = gsap.utils.wrap(0, LOOP_HEAD.duration())

const SCRUB = gsap.to(PLAYHEAD, {
  position: 0,
  onUpdate: () => {
    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))
  },
  paused: true,
  duration: 1,
  ease: 'none',
})

Last, but not least, we need to revise ScrollTrigger so it updates the correct variable on the SCRUB. That’s position, instead of totalTime.

ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    SCRUB.vars.position = LOOP_HEAD.duration() * self.progress
    SCRUB.invalidate().restart()
  }
})

At this point we’ve switched to a proxy and we won’t see any changes.

We want an infinite loop when we scroll. Our first thought might be to scroll to the start when we complete scroll progress. And it would do exactly that, scroll back. Although that‘s what we want to do, we don’t want the playhead to scrub backwards. This is where totalTime comes in. Remember? It gets or sets the position of the playhead according to the totalDuration which includes any repeats and repeat delays.

For example, say the duration of the loop head was 5 and we got there, we won‘t scrub back to 0. Instead, we will keep scrubbing the loop head to 10. If we keep going, it‘ll go to 15, and so on. Meanwhile, we‘ll keep track of an iteration variable because that tells us where we are in the scrub. We’ll also make sure that we only update iteration when we hit the progress thresholds.

Let’s start with an iteration variable:

let iteration = 0

Now let’s update our ScrollTrigger implementation:

const TRIGGER = ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    const SCROLL = self.scroll()
    if (SCROLL > self.end - 1) {
      // Go forwards in time
      WRAP(1, 1)
    } else if (SCROLL < 1 && self.direction <; 0) {
      // Go backwards in time
      WRAP(-1, self.end - 1)
    } else {
      SCRUB.vars.position = (iteration + self.progress) * LOOP_HEAD.duration()
      SCRUB.invalidate().restart() 
    }
  }
})

Notice how we‘re now factoring iteration into the position calculation. Remember that this gets wrapped with the scrubber. We‘re also detecting when we hit the limits of our scroll, and that’s the point where we WRAP. This function sets the appropriate iteration value and sets the new scroll position.

const WRAP = (iterationDelta, scrollTo) => {
  iteration += iterationDelta
  TRIGGER.scroll(scrollTo)
  TRIGGER.update()
}

We have infinite scrolling! If you have one of those fancy mice with the scroll wheel that you can let loose on, give it a go! It’s fun!

Here’s a demo that displays the current iteration and progress:

Scroll snapping

We‘re there. But, there are always ”nice to haves” when working on a feature like this. Let’s start with scroll snapping. GSAP makes this easy, as we can use gsap.utils.snap without any other dependencies. That handles snapping to a time when we provide the points. We declare the step between 0 and 1 and we have 10 boxes in our example. That means a snap of 0.1 would work for us.

const SNAP = gsap.utils.snap(1 / BOXES.length)

And that returns a function we can use to snap our position value.

We only want to snap once the scroll has ended. For that, we can use an event listener on ScrollTrigger. When the scroll ends, we are going to scroll to a certain position.

ScrollTrigger.addEventListener('scrollEnd', () => {
  scrollToPosition(SCRUB.vars.position)
})

And here’s scrollToPosition:

const scrollToPosition = position => {
  const SNAP_POS = SNAP(position)
  const PROGRESS =
    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()
  const SCROLL = progressToScroll(PROGRESS)
  TRIGGER.scroll(SCROLL)
}

What are we doing here?

  1. Calculating the point in time to snap to
  2. Calculating the current progress. Let’s say the LOOP_HEAD.duration() is 1 and we’ve snapped to 2.5. That gives us a progress of 0.5 resulting in an iteration of 2, where 2.5 - 1 * 2 / 1 === 0.5 . We calculate the progress so that it’s always between 1 and 0.
  3. Calculating the scroll destination. This is a fraction of the distance our ScrollTrigger can cover. In our example, we’ve set a distance of 2000 and we want a fraction of that. We create a new function progressToScroll to calculate it.
const progressToScroll = progress =>
  gsap.utils.clamp(1, TRIGGER.end - 1, gsap.utils.wrap(0, 1, progress) * TRIGGER.end)

This function takes the progress value and maps it to the largest scroll distance. But we use a clamp to make sure the value can never be 0 or 2000. This is important. We are safeguarding against snapping to these values as it would put us in an infinite loop.

There is a bit to take in there. Check out this demo that shows the updated values on each snap.

Why are things a lot snappier? The scrubbing duration and ease have been altered. A smaller duration and punchier ease give us the snap.

const SCRUB = gsap.to(PLAYHEAD, {
  position: 0,
  onUpdate: () => {
    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))
  },
  paused: true,
  duration: 0.25,
  ease: 'power3',
})

But, if you played with that demo, you‘ll notice there‘s an issue. Sometimes when we wrap around inside the snap, the playhead jumps about. We need to account for that by making sure we wrap when we snap — but, only when it’s necessary.

const scrollToPosition = position => {
  const SNAP_POS = SNAP(position)
  const PROGRESS =
    (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()
  const SCROLL = progressToScroll(PROGRESS)
  if (PROGRESS >= 1 || PROGRESS < 0) return WRAP(Math.floor(PROGRESS), SCROLL)
  TRIGGER.scroll(SCROLL)
}

And now we have infinite scrolling with snapping!

What next?

We’ve completed the groundwork for a solid infinite scroller. We can leverage that to add things, like controls or keyboard functionality. For example, this could be a way to hook up “Next” and “Previous” buttons and keyboard controls. All we have to do is manipulate time, right?

const NEXT = () => scrollToPosition(SCRUB.vars.position - (1 / BOXES.length))
const PREV = () => scrollToPosition(SCRUB.vars.position + (1 / BOXES.length))

// Left and Right arrow plus A and D
document.addEventListener('keydown', event => {
  if (event.keyCode === 37 || event.keyCode === 65) NEXT()
  if (event.keyCode === 39 || event.keyCode === 68) PREV()
})

document.querySelector('.next').addEventListener('click', NEXT)
document.querySelector('.prev').addEventListener('click', PREV)

That could give us something like this.

We can leverage our scrollToPosition function and bump the value as we need.

That’s it!

See that? GSAP can animate more than elements! Here, we bent and manipulated time to create an almost perfect infinite slider. No duplicate elements, no mess, and good flexibility.

Let’s recap what we covered:

  • We can animate an animation. 🤯
  • We can think about timing as a positioning tools when we manipulate time.
  • How to use ScrollTrigger to scrub an animation via proxy.
  • How to use some of GSAP’s awesome utilities to handle logic for us.

You can now manipulate time! 😅

That concept of going “meta” GSAP opens up a variety of possibilities. What else could you animate? Audio? Video? As for the ”Cover Flow” demo, here’s where that went!


The post Going “Meta GSAP”: The Quest for “Perfect” Infinite Scrolling appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.