Studio Maertens Project Page Rebuild

Maray profile image thumbnail.
Blog post cover image.

In this tutorial, I’d like to share what I learned recreating Studio Märtens project page design.

I used Webflow to build the page, CurtainsJS to implement WebGL, GSAP to animate elements and Lenis to make the scrolling smooth.

There are a few parts that I was interested to learn and implement.

- understand how CurtainsJS works

- use Lenis for smooth scrolling experience

- add custom vertex and fragment shaders that react to scroll

- create intro and on scroll animations

- structure the code using ES6 Javascript class syntax

### Links:

- [Demo](https://maertens.webflow.io/)

- [Github repo](https://github.com/maray29/maertens)

- Codesandbox

- [Webflow cloneable](https://webflow.com/made-in-webflow/website/maertens)

### Libraries and tools used on this project:

- Webflow

- Vanilla Javascript

- CurtainsJS (WebGL)

- Lenis

- GSAP

- SplitType

- Vite

- GPT (copy)

- Midjourney (images)

This tutorial focuses on Javascript. The html/css are pretty straightforward. You can have a look at the cloneable and github repo to understand how it’s built.

Let’s get started.

## Part 1 - WebGL - CurtainsJS - Lenis

Once the html and css were done, I started to work on the interesting part: WebGL.

There are several libraries that are built on top of WebGL, such as Three.js, OGL and Pixi.js. I chose Curtains.js because its main feature is to turn html images into webgl planes while making them responsive and accessible.

I read through the documentation, chose an example that was similar to the build that I wanted to implement and started experimenting and trying understand how the library works.

It was a lot of back and forth, but slowly things came together.

Here’s the main structure of the code:

```js

/* eslint-disable no-unused-vars */

import Lenis from '@studio-freight/lenis'

import { Curtains, Plane, Vec2 } from 'curtainsjs'

import gsap from 'gsap'

import { ScrollTrigger } from 'gsap/ScrollTrigger'

import SplitType from 'split-type'

import fragment from './shaders/fragmentShader.glsl'

import vertex from './shaders/vertexShader.glsl'

gsap.registerPlugin(ScrollTrigger)

// App class to structure and init the code

class App {

 constructor() {

this.init()

 }

 // Initialize all methods

 init() {

   this.createLoaderAnimation()

   this.createCurtains()

   this.setupCurtains()

   this.createLenis()

   this.createPlanes()

   this.createPageAnimations()

 }

  // (fake) loader animation

 createLoaderAnimation() {

   ...

 }

  // Initialize Curtains and WebGL

 createCurtains() {

 }

  // Methods on Curtains such as .onRender, .onScroll, etc.

 setupCurtains() {

   ...

 }

  // Smooth scroll

 createLenis() {

   ...

 }

  // Get html planes and create Curtains (webgl planes.

 createPlanes() {

   ...

 }

  // Methods to call on Plane such as .onRender, .onReady, etc.

 handlePlanes(plane) {

   ...

 }

  // Intro and page animation

 createPageAnimations() {

 ...

 }

}

window.addEventListener('load', () => {

 const app = new App() // eslint-disable-line

 console.log('Loaded')

})

```

First, let’s declare some variables that we’ll need later.

```js

// App class to structure and init the code

class App {

 // Curtains object

  curtains

  // Smooth scroll Lenis object

 lenis

  // WebGL planes

 planes = []

  // Scroll value that we'll use to animate plane deformation

 scrollEffect = 0

  // An object where we store all the DOM elements

 DOM = {

   h1: document.querySelector('h1'),

   planeElements: [...document.querySelectorAll('[data-animation="image"]')],

   heroImage: document.querySelector('.project_header_img'),

   heroWebGlPlane: null,

   paragraphs: [...document.querySelectorAll('[data-animation="paragraph"]')],

   wheel: document.querySelector('.wheel_icon'),

   wheelWrapper: document.querySelector('.wheel_wrapper'),

   pageWrap: document.querySelector('.page-wrapper'),

 }

  // An object where we store timelines and track animation state

 animationState = {}

 constructor() {

  this.init()

 }

  // Rest of the code

}

window.addEventListener('load', () => {

 const app = new App() // eslint-disable-line

 console.log('Loaded')

})

```

Next, we’ll begin implement methods outlined in `init()` function starting with `createCurtains()`.

[Curtains](https://www.curtainsjs.com/curtains-class.html) class instantiates WebGL context and handles the scene that contains our 3d planes.

It is pretty self-explanatory. Here we create a curtains object with a few defined options.

```js

createCurtains() {

 this.curtains = new Curtains({

  // HTML canvas element, specified with id

 container: 'canvas',

  // limit pixel ratio for performance

 pixelRatio: Math.min(1.5, window.devicePixelRatio),

 // Set watchScroll to false since we are going to use Lenis as our scroll manager

  watchScroll: false,

 })

}

```

And setup curtains with `setupCurtains()` so that we can update our scrollEffect value and catch some errors. Almost the same as in the curtainsjs [example](https://www.curtainsjs.com/examples/multiple-planes-scroll-effect-custom-scroll/index.html) I took as a base.

```js

setupCurtains() {

   this.curtains

     .onRender(() => {

       // update our planes deformation

       this.scrollEffect = this.curtains.lerp(this.scrollEffect, 0, 0.075)

     })

     .onScroll(() => {

       // get scroll deltas to apply the effect on scroll

       const delta = this.curtains.getScrollDeltas()

       // invert value for the effect

       delta.y = -delta.y

       // threshold

       if (delta.y > 60) {

         delta.y = 60

       } else if (delta.y < -60) {

         delta.y = -60

       }

       if (Math.abs(delta.y) > Math.abs(this.scrollEffect)) {

         this.scrollEffect = this.curtains.lerp(

           this.scrollEffect,

           delta.y,

           0.5

         )

       }

     })

     .onError(() => {

       // we will add a class to the document body to display original images

       document.body.classList.add('no-curtains', 'planes-loaded')

     })

     .onContextLost(() => {

       // on context lost, try to restore the context

       this.curtains.restoreContext()

     })

 }

```

The next method is `createLenis()` where we setup [Lenis scroll manager](https://github.com/studio-freight/lenis) by Studio Freight.

All of its options and methods are well explained in the documentation on Github.

```js

createLenis() {

 this.curtains.disableDrawing()

 this.lenis = new Lenis({

   duration: 2.5,

   easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),

   direction: 'vertical', // vertical, horizontal

   gestureDirection: 'vertical', // vertical, horizontal, both

   smooth: true,

   mouseMultiplier: 1,

   smoothTouch: false,

   touchMultiplier: 2,

   infinite: false,

 })

 this.lenis.on('scroll', ({ scroll }) => {

   // update our scroll manager values

   this.curtains.updateScrollValues(0, scroll)

   // render scene

   this.curtains.needRender()

 })

 gsap.ticker.add((time) => {

   this.lenis.raf(time * 1000)

 })

}

```

Now it’s time to create out planes with `createPlanes()` method.

```js

createPlanes() {

  // Parameters to use when creating planes

 const params = {

  // Shaders

  vertexShader: vertex,

  fragmentShader: fragment,

  // Geometry tesselation

  widthSegments: 20,

  heightSegments: 20,

  // Scroll effect uniform

  uniforms: {

    scrollEffect: {

    name: 'uScrollEffect',

    type: '1f',

    value: 0.0,

    },

  },

}

 // add our planes and handle them

 for (let i = 0; i < this.DOM.planeElements.length; i++) {

   const plane = new Plane(this.curtains, this.DOM.planeElements[i], params)

    // Store planes in an array we declared at the beginning

   this.planes.push(plane)

    // Dome somthing with planes

   this.handlePlanes(plane)

 }

 this.DOM.heroWebGlPlane = this.planes[0]

}

// handle all the planes

handlePlanes(plane) {

// Similar to CSS z-depth

 plane.setRenderOrder(-10)

 plane

   .onReady(() => {

     // once everything is ready, display everything

     if (plane === this.planes[this.planes.length - 1]) {

       document.body.classList.add('planes-loaded')

     }

   })

   .onRender(() => {

     // pass the uniform to shader when the intro animation is not active

     if (!this.animationState.pageIntroTimeline.isActive()) {

       plane.uniforms.scrollEffect.value = this.scrollEffect

     }

   })

}

```

If you implement these methods and initialize the App, everything will work but you wont see any difference from regular html and css. The WebGL planes will look exactly like html because we haven’t yet written the vertex and fragement shader code.

If you are not yet familiar wth GLSL, here are few resources that will get you started:

- [Book of Shaders](https://thebookofshaders.com/)

- Articles and resources from [Inigo Quilez](https://iquilezles.org/)

- Youtube: [Akella](https://www.youtube.com/@akella_) and many others

- Codrops tutorials

I am still learning and trying to understand how glsl works, but here’s the breakdown of vertex and fragment shaders.

First of all, these shaders are usually either created in html inside `<script type="x-shader/x-fragment">` and `<script type="x-shader/x-vertex">` tags or as a string in the javascript file.

The former option is widely used with [Three.js examples](https://github.com/mrdoob/three.js/blob/master/examples/webgl_shader2.html), the latter you can find in [Curtains.js examples](https://github.com/martinlaxenaire/curtainsjs/blob/master/examples/multiple-planes-scroll-effect-custom-scroll/js/multiple.planes.parallax.setup.js).

The other option is to write them in separate files and import where you need them. The advantage is syntax highlighting and autocompletion. Compare these two:

GLSL shader code comparing highlighted syntax with stringified code.

It’s clear that it’s much easier to read, write and understand with syntax highlighting.

To be able to import shader code from external files, you need plugins. Since I use Vite development environment, I found `vite-plugin-glsl` that does exactly that. There’re other ways, but I found this plugin the easiest to use.

Shader files are stored in `./src/shaders/vertexShader.glsl` and `fragmentShader.glsl`.

Let’s start with vertex shader.

```glsl

// Support OpenGL ES for simpler, cheaper hardware, and low enough power

// requirements to run on batteries

precision mediump float;

// default mandatory variables, learn more here: https://www.curtainsjs.com/get-started.html

attribute vec3 aVertexPosition;

attribute vec2 aTextureCoord;

// built-in uniform variables

uniform mat4 uMVMatrix;

uniform mat4 uPMatrix;

uniform mat4 planeTextureMatrix;

uniform float time;

// custom variables that we pass to fragment shader

varying vec3 vVertexPosition;

varying vec2 vTextureCoord;

varying float distanceZ;

// our custom variable that we update on scroll and pass it through plane.onRender method

uniform float uScrollEffect;

void main() {

  // Get original vertices positions

  vec3 vertexPosition = aVertexPosition;

  // Change vertices z position using math and uScrollEffect variable

  // play with the values to change the effect, waves number, etc

  vertexPosition.z += cos(time + -uScrollEffect * 0.02 + vertexPosition.y * 2.5) * (uScrollEffect * 0.001);

   

  // Change vertices y position on scroll

  // Notice the slight vertical shift on the planes when you scroll

  vertexPosition.y += sin(-uScrollEffect / 300.0);

  // use built-in gl_Position to set all vertices positions

  gl_Position = uPMatrix * uMVMatrix * vec4(vertexPosition, 1.0);

  // varyings to pass to fragmentShader

  distanceZ = vertexPosition.z;

  vVertexPosition = vertexPosition;

  vTextureCoord = (planeTextureMatrix * vec4(aTextureCoord, 0.0, 1.0)).xy;

}

```

```glsl

precision mediump float;

// variables that we passed from vertex shader

varying vec3 vVertexPosition;

varying vec2 vTextureCoord;

varying float distanceZ;

// the texture that curtains.js takes from html images and passes to fragment shader

uniform sampler2D planeTexture;

void main(void) {

   // Final color.

   vec3 color = vec3(0.2, 0.3, 0.7);

   color.r = texture2D(planeTexture, vTextureCoord).r;

   color.g = texture2D(planeTexture, vTextureCoord).g;

   color.b = texture2D(planeTexture, vTextureCoord).b;

    // distanceZ is the value passed from the vertex shader when the plane deforms in z direction

    // when it does, we want to highlight that motion with color

   if (distanceZ > 0.0) {

       color += 0.145 * distanceZ * 30.0;

   }

    // use built-in gl_FragColor to set color

   gl_FragColor = vec4(color, 1.0);

}

```

Yay! Now we can see some difference from plain html. If you scroll, you can see how planes deform and the crest of the wave is highlighted. Pretty cool, right?

## Part 2 - Animation - GSAP - SplitType

Let’s start working on motion of the page and its elements - the most fun part.

We’ll use GSAP to tween values and animate elements and SplitType to split paragraphs and text into lines and words.

If you check Studio Maertens website, you’ll see that there are three parts of the motion design:

- preloader animation

- intro animation

- on-page scroll reveal

Let’s start with the preloader. It’s common for websites that use 3d models and webgl to have preloaders as it’s more time consuming to load 3d data than text and images. That’s why everything is loaded first, and then displayed.

The project page I’ve built takes 250-500ms to load so there’s really no need for any preloader. However, I wanted to replicate the motion design and page transition. That’s why I created a fake preloader where the wheel spins for a moment, moves to the bottom left corner of the screen and then rotates on scroll.

As a side note, I don’t recommend creating a preloader when there’s no good reason for it. It’s not a good practice to make the user wait for nothing. If it’s part of the experience, don’t make it longer than 2 seconds.

After the preloader is finished, the page intro animation starts. The hero plane flows in, and the text elements are revealed from bottom of their containers. The text elements that are not yet visible, would be revealed when they appear in the viewport.

Let’s start with the preloader.

You can see the wheel in upper left corner. It’s a simple wrapper and an svg element I created in Figma.

In the original design, the wheel rotates while the number that represents the progress goes from 0 to 100. We’ll omit the number sequence since it’s a fake preloader and only use the wheel rotation. When the page is ready, the wheel moves to the bottom left corner and the loader sequence is finished.

Remember we defined all the DOM elements in a DOM object? We’ll use it conviniently to access all the elements we needs.

```glsl

class App {

 ...

 DOM = {

   h1: document.querySelector('h1'),

   planeElements: [...document.querySelectorAll('[data-animation="image"]')],

   heroImage: document.querySelector('.project_header_img'),

   heroWebGlPlane: null,

   paragraphs: [...document.querySelectorAll('[data-animation="paragraph"]')],

   wheel: document.querySelector('.wheel_icon'),

   wheelWrapper: document.querySelector('.wheel_wrapper'),

   pageWrap: document.querySelector('.page-wrapper'),

 }

...

  init() {

    this.createLoaderAnimation()

    ...

 }

  createLoaderAnimation() {

    ...

  }

}

```

We’ll create a method `createLoaderAnimation()` and initalize it with `init()`.

Since we want everything to wait until the preloader is finished, we’ll use javascript `Promise` and `async/await` syntax.

```js

createLoaderAnimation() {

  const loaderDuration = 2

  // return the sequence as a Promise so that we can wait until it's finished

 return new Promise((resolve) => {

  // Our loader timeline stored in animationState object

  this.animationState.pageLoaderTimeline = gsap.timeline()

}

```

We could also use GSAP `onComplete` callback, but I wanted to learn a new way to asynchronously call functions. It also felt weird to initialize other key methods of the class from GSAP timeline. With `Promise` syntax, we can do the following:

```js

createLoaderAnimation() {

  const loaderDuration = 2

  // return the sequence as a Promise so that we can wait until it's finished

 return new Promise((resolve) => {

  // Our loader timeline stored in animationState object

  this.animationState.pageLoaderTimeline = gsap.timeline()

  this.animationState.pageLoaderTimeline.to(this.DOM.wheelWrapper, {

  // Animation settings go here

  // Resolve when the sequence is finished

    onComplete: resolve,

  })

}

```

And then make `init()` `async` and `await` `createLoaderAnimation()`.

```js

async init() {

  await this.createLoaderAnimation()

    ...

}

```

With this setup, `init()` will wait `createLoaderAnimation()` and then initialize other methods. Pretty cool.

With the structure and flow set, let’s define what values need to be tweened/animated.

Since the wheel is in the wrong position in our html, we’ll set it with GSAP:

```js

createLoaderAnimation() {

 const loaderDuration = 2

 return new Promise((resolve) => {

  this.animationState.pageLoaderTimeline = gsap.timeline()

  // page loading

  this.animationState.pageLoaderTimeline.set(this.DOM.wheelWrapper, {

  // Move the wheel to the center of the screen

    x: innerWidth / 2,

    y: innerHeight / 2,

   // Move the wheel half of its width to the left and down

     xPercent: -50,

     yPercent: -50,

     scale: 1.4,

   })

}

```

And now we’ll rotate it for 2 seconds and move it to the bottom left corner.

```js

this.animationState.pageLoaderTimeline.to(this.DOM.wheelWrapper, {

  // In CSS we set visibility: hidden so that there's no FOUC

  // That's why we set autoAlpha to 1 to make it visible

  // Read more here: https://greensock.com/fouc/

 autoAlpha: 1,

 rotation: 360 * 3,

 duration: loaderDuration,

})

// Move to the bottom left corner

this.animationState.pageLoaderTimeline.to(this.DOM.wheelWrapper, {

  // Reset the relative transformation

 xPercent: 0,

 yPercent: -100,

 x: 20,

 y: innerHeight - 20,

 scale: 1,

 duration: 1.25,

 onComplete: resolve,

})

```

Done!

Let’s move to page intro and on scroll animation.

The first thing we see is the hero plane flowing in from the bottom of the viewport while waving on motion.

So what we need are some values that will be tweened by GSAP and passed to shader as uniform variables. In our case, there’re two values: `translationY` and `wiggle` (you can call them however you like).

```js

createPageAnimations() {

  // New timeline

 this.animationState.pageIntroTimeline = gsap.timeline({})

  // Values to tween

 this.animationState.pageIntroValues = {

   translationY: 1000,

   wiggle: 1500,

 }

}

```

We’ll separate movement and wiggle into two parts because we want different duration for each. In case of translation, we can use curtains `plane.relativeTranslation.y` reactive property. To wiggle the plane, we need to pass the value to uniform.

Translation:

```js

// hero plane intro

this.animationState.pageIntroTimeline.to(

 this.animationState.pageIntroValues,

 {

   translationY: 0,

   duration: 1.5,

    onUpdate: () => {

      // plane translation

      this.DOM.heroWebGlPlane.relativeTranslation.y =

      this.animationState.pageIntroValues.translationY

      // Don't forget to ask curtains to render, since we only render on scroll

      this.curtains.needRender()

   },

 },

 'start'

)

```

Wiggle:

```js

// hero plane intro

this.animationState.pageIntroTimeline.to(

  this.animationState.pageIntroValues,

 {

   wiggle: 0,

   duration: 2,

   onUpdate: () => {

     // update uniform value

     this.DOM.heroWebGlPlane.uniforms.scrollEffect.value = this.scrollEffect + this.animationState.pageIntroValues.wiggle * 0.2

     this.curtains.needRender()

   },

 },

 'start'

)

```

Plane intro — done. Next, h1 heading.

We’ll use SplitType to split the text into lines and each line with a stagger effect:

```js

// Set h1 visibility back to visible

this.animationState.pageIntroTimeline.set(this.DOM.h1, {

 autoAlpha: 1,

})

// h1 intro

const splitH1 = new SplitType(this.DOM.h1, {

 types: 'lines, words',

 lineClass: 'line-wrapper',

})

this.animationState.pageIntroTimeline.from(

 splitH1.words,

 {

   yPercent: 100,

   duration: 0.6,

   stagger: 0.075,

 },

 'start+=1.5'

)

```

Next, wheel on scroll rotation:

```js

// wheel rotation on scroll

this.animationState.pageIntroTimeline.to(this.DOM.wheel, {

 rotate: 360 * 4,

 scrollTrigger: {

   trigger: this.DOM.pageWrap,

   scrub: 1,

   start: 'top top',

   end: '+=10000',

 },

})

```

And finally, we’ll animate all text/paragraph elements that are revealed on scroll.

In html, every element that needs such animation has `data-animation=”paragraph”` attribute. We select all these elements and store in an aray in our DOM object:

```js

DOM = {

 ...

 paragraphs: [...document.querySelectorAll('[data-animation="paragraph"]')],

...

}

```

Let’s split text elements into `lines` and nest them into `line-wrapper` so that we can set `overflow: hidden` on the wrapper.

```js

// iterate over each paragraph

this.DOM.paragraphs.forEach((paragraph) => {

  // in CSS we set .line-wrapper to have overflow: hidden

  const parentSplitText = new SplitType(paragraph, {

    types: 'lines',

    lineClass: 'line-wrapper',

  })

  // We'll be animating these lines

  const splitText = new SplitType(parentSplitText.lines, {

    types: `lines`,

    })

}

```

And finally, animate `splitText.lines`:

```js

// Set paragraph visibility to visible

gsap.set(paragraph, {

 autoAlpha: 1,

})

// Animate lines when they are 90% from top of the viewport, almsot at the bottom

// Navbar elements will animate immediately

gsap.from(splitText.lines, {

 autoAlpha: 0,

 yPercent: 150,

 stagger: 0.1,

 duration: 0.75,

 ease: 'power.out4',

 delay: 0.5,

 scrollTrigger: {

   trigger: paragraph,

   start: 'top 90%',

   once: true,

 },

})

```

And that’s it!

We’ve implemented everything I wanted to cover.

I think it’s a pretty nice project and I definitely learned a lot working on it and I hope you too.

If you know something that can be optimized, improved, or fixed, please let me know, I am always open to feedback.

Email icon.
send a message
ayk@maray.ai

If you plan a web design or development project, send me a message and I'll get back within a few hours.

Alternatively, book a free discovery call to discuss your project in more detail.

Book an appointment
discord_icon

Join discord

The Internet made meeting likeminded people much easier. Join my group if you want to ask something or collaborate on a project. Here's the invite link ↗.

Come to say hi 👋