Hey there
How it’s made

If you’re wondering how some thing on this webpage are made, no need to to inspect and gues what’s going on, I’ll explain it right here.

Click on the section you’re interested in and I’ll show you how it’s done!

Multilayered canvas experiment

Javascript

For the intro of my portfolio I wanted to create a interactive canvas as a immediate eye catcher. The idea was to create three layers of circles and lay them on top of each other with all a slightly different behaviour. By giving them all the blending mode multiply the three different layers would be able to create six colours.

close
minimize
fullscreen
  • 1
let canvas = new CanvasAnimation()
  • 1
let canvas = new CanvasAnimation()

The setup
In the end my animation consists of two classes: CanvasAnimation and BallShape. The CanvasAnimation, which is created on the homepage, gets one variable: the canvas element. This way it is fairly simple to move this animation to a different page.

Inside the CanvasAnimation I setup the basics of the the canvas animation. This is split in three setup functions and the animation loop.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
constructor( canvas: any ) { this.time = 0; this.destroyed = false this.canvasSetup(canvas) this.createBallShapes() this.initEvents() this.loop = this.loop.bind(this) this.loop() }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
constructor( canvas: any ) { this.time = 0; this.destroyed = false this.canvasSetup(canvas) this.createBallShapes() this.initEvents() this.loop = this.loop.bind(this) this.loop() }

The first section is to take the canvas and get a context and set the correct width and height to the canvas according to the pixel ratio of the device.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
canvasSetup( canvas: any ) { this.ctx = canvas.getContext('2d'); this.dpr = window.devicePixelRatio; this.width = window.innerWidth; this.height = window.innerHeight * 2; canvas.width = this.width * this.dpr; canvas.height = this.height * this.dpr; this.ctx.scale(this.dpr, this.dpr); }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
canvasSetup( canvas: any ) { this.ctx = canvas.getContext('2d'); this.dpr = window.devicePixelRatio; this.width = window.innerWidth; this.height = window.innerHeight * 2; canvas.width = this.width * this.dpr; canvas.height = this.height * this.dpr; this.ctx.scale(this.dpr, this.dpr); }

Next is creating the shapes. In this case the shapes are created by initiating a class: BallShape. These will be explained later on. In here we create three instances of the BallShape all with a different color. The Instances also get the context, the amount of points in the shape, the width of the circle and lastly an offset left.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
createBallShapes() { this.balls = [ '#E701C2', '#E7C36F', '#34C3C2' ]; const leftOffset = this.getLeftOffset() this.balls = this.balls.map( (color, index) => { return new BallShape( { ctx: this.ctx, amountOfPoints: 100, circleWidth: window.innerHeight, leftOffset: leftOffset, color: color, }, index ) } ) }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
createBallShapes() { this.balls = [ '#E701C2', '#E7C36F', '#34C3C2' ]; const leftOffset = this.getLeftOffset() this.balls = this.balls.map( (color, index) => { return new BallShape( { ctx: this.ctx, amountOfPoints: 100, circleWidth: window.innerHeight, leftOffset: leftOffset, color: color, }, index ) } ) }

Lastly there’s gonna be two eventListeners: mousemove and resize. Both of these are on the window since the canvas animation needs to respond to events outside of the canvas scope.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
initEvents() { this.mouseMoveListener = this.mouseMoveListener.bind(this) this.resizeListener = this.resizeListener.bind(this) window.addEventListener('mousemove', this.mouseMoveListener ) window.addEventListener('resize', this.resizeListener ) } mouseMoveListener(e) { if ( window.innerWidth < 768 || window.innerWidth < window.innerHeight ) return this.balls.forEach( ball => { ball.mouseMove({ x: this.mouseMoveOffset( e.pageX, window.innerWidth ), y: this.mouseMoveOffset( e.pageY, window.innerHeight ) }) } ) } resizeListener(e) { this.balls.forEach( ball => { ball.onResize( event, this.getLeftOffset() ) } ) }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
initEvents() { this.mouseMoveListener = this.mouseMoveListener.bind(this) this.resizeListener = this.resizeListener.bind(this) window.addEventListener('mousemove', this.mouseMoveListener ) window.addEventListener('resize', this.resizeListener ) } mouseMoveListener(e) { if ( window.innerWidth < 768 || window.innerWidth < window.innerHeight ) return this.balls.forEach( ball => { ball.mouseMove({ x: this.mouseMoveOffset( e.pageX, window.innerWidth ), y: this.mouseMoveOffset( e.pageY, window.innerHeight ) }) } ) } resizeListener(e) { this.balls.forEach( ball => { ball.onResize( event, this.getLeftOffset() ) } ) }

Now that all is set up, the animation loop can be fired once. In the loop I do as little as possible, the time is increased, the canvas is cleared and each BallShape gets a update call. And finally on the next frame we repeat this function. This goes on until the CanvasAnimation gets a destroy call. In that case we escape out of the animationLoop and destroy both eventListeners.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
loop() { if ( this.destroyed ) return this.time += .01 this.ctx.clearRect(0, 0, this.width, this.height); this.balls.forEach( ball => { ball.onUpdate(event, this.time) } ) requestAnimationFrame(this.loop); } destroy() { this.destroyed = true window.removeEventListener('mousemove', this.mouseMoveListener ) window.removeEventListener('resize', this.resizeListener ) }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
loop() { if ( this.destroyed ) return this.time += .01 this.ctx.clearRect(0, 0, this.width, this.height); this.balls.forEach( ball => { ball.onUpdate(event, this.time) } ) requestAnimationFrame(this.loop); } destroy() { this.destroyed = true window.removeEventListener('mousemove', this.mouseMoveListener ) window.removeEventListener('resize', this.resizeListener ) }

The shape
At this point not a single pixel is drawn in the canvas yet. Next up is drawing the shape in the canvas based on the information that’s passed in setting up the BallShape. In the constructor the following things needs to happend:
- Make the setup settings accessible
- Set some variables for the mousemove animation
- Draw the path

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
constructor( settings, index ) { this.index = index; this.ballOffset = index * (Math.PI / 3); this.ctx = settings.ctx this.settings = settings; this.cursorLoc = { x: 0, y: 0 } this.lerp = { ...this.cursorLoc } this.points = []; this.setupBallShape() this.generatePoints() this.drawPath() }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
constructor( settings, index ) { this.index = index; this.ballOffset = index * (Math.PI / 3); this.ctx = settings.ctx this.settings = settings; this.cursorLoc = { x: 0, y: 0 } this.lerp = { ...this.cursorLoc } this.points = []; this.setupBallShape() this.generatePoints() this.drawPath() }

At the end of the constructor the first draw is initiated. Here initiate the drawing of a path. Because the shape will be on the right side of the screen and the canvas drawing start at 0,0 the position needs to be moved to the top right. Note that the globalCompositeOperation is set to multiply.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
generatePoints() { for ( let i = 0; i <= this.settings.amountOfPoints; i++) { const relativeIndex = i * this.diameter / this.settings.amountOfPoints ; let thisPoint = { x: this.getPointX( relativeIndex ), y: relativeIndex } this.points.push( thisPoint ) } }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
generatePoints() { for ( let i = 0; i <= this.settings.amountOfPoints; i++) { const relativeIndex = i * this.diameter / this.settings.amountOfPoints ; let thisPoint = { x: this.getPointX( relativeIndex ), y: relativeIndex } this.points.push( thisPoint ) } }

Finally we’re drawing the points. First thing is a for loop “looping” the amountOfPoints given in the CanvasAnimation class. In this case that’s set to 100 points. Inside the loop there is a check if the current point exists in a array. If that’s the case it can take the x and y coords from the list and there is no need to recalculate them.

For the first draw this wil be false and the position needs to be calculated. The Y position is not that difficult, take the diameter of the shape and calculate the current point to the percentage of the diameter. This way no matter the amount of points the shape will always fill the entire canvas.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
drawPath( time: number = 0 ) { this.ctx.globalCompositeOperation = 'multiply'; this.ctx.beginPath(); this.ctx.moveTo( this.settings.leftOffset, 0); this.drawPoints( time ) this.ctx.fillStyle = this.settings.color; this.ctx.fill(); } drawPoints( time: number = 0 ) { this.lerp.x += ( this.cursorLoc.x - this.lerp.x ) * .05 this.lerp.y += ( this.cursorLoc.y - this.lerp.y ) * .05 for ( let i = 0; i <= this.settings.amountOfPoints; i++) { const { x, y } = this.points[i] this.ctx.lineTo( x + this.lerp.x + this.getSinusOffset( time, i ), y + this.lerp.y ) } }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
drawPath( time: number = 0 ) { this.ctx.globalCompositeOperation = 'multiply'; this.ctx.beginPath(); this.ctx.moveTo( this.settings.leftOffset, 0); this.drawPoints( time ) this.ctx.fillStyle = this.settings.color; this.ctx.fill(); } drawPoints( time: number = 0 ) { this.lerp.x += ( this.cursorLoc.x - this.lerp.x ) * .05 this.lerp.y += ( this.cursorLoc.y - this.lerp.y ) * .05 for ( let i = 0; i <= this.settings.amountOfPoints; i++) { const { x, y } = this.points[i] this.ctx.lineTo( x + this.lerp.x + this.getSinusOffset( time, i ), y + this.lerp.y ) } }

The x coordination is a bit more complicated. To get the perfect curve we need to know how to draw a perfect halve a circle with math.

y=√(1 - (x - r)2 + r2)

Given the radius and x this formula will give you a perfect halve a circle starting from 0 going up to the diameter.

The thing is we can’t copy past this formula into our javascript one on one and expect it to work. Because in canvas the 0,0 coords are in the top left corner of the canvas. For this reason there needs to be a few modifications in this formula with the following values:

x = relativeIndex
r = this.radius


We don’t want to make this calculation every frame, for this reason generatePoints and drawPoints are split up. So we can call the generatePoints at the start and when the conditions change, like on a resize. The drawPoints is the function we call every frame.

Now we should see a perfect halve a circle in a dark purple color, cool! But it’s a bit static. Let’s add the waves to the shapes so if feels more alive.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
getPointX( index: number) { // Get a perfect half a circle with math const calculatedPos = Math.sqrt( 1 - Math.pow( index - this.radius, 2) + this.radiusSqr ) // Invert the urve give it the offset return calculatedPos * -1 + this.settings.leftOffset; }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
getPointX( index: number) { // Get a perfect half a circle with math const calculatedPos = Math.sqrt( 1 - Math.pow( index - this.radius, 2) + this.radiusSqr ) // Invert the urve give it the offset return calculatedPos * -1 + this.settings.leftOffset; }

In the drawPoints function we add the output of a function to the x without saving it anywhere else. This is the offset that will make the points go left to right. To generate this value it requires the time and the index of the point.

Inside of the function there is one thing happening, there is a sinus generated on the current time. However depending of the point and the instance of the BallShape the output will be slightly different. This creates the offset that makes it look like waves.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
getSinusOffset( time: number, pointNumber: number ) { // Make each ball rotate on a slightly different speed const rotationSpeed = time * (3 + this.index * 2) // Set the wavelenght of the sinus const sinusX = (rotationSpeed + pointNumber) / (Math.PI * 2) // Set the amplitude of the sinus return Math.sin( sinusX + this.ballOffset ) * 15; }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
getSinusOffset( time: number, pointNumber: number ) { // Make each ball rotate on a slightly different speed const rotationSpeed = time * (3 + this.index * 2) // Set the wavelenght of the sinus const sinusX = (rotationSpeed + pointNumber) / (Math.PI * 2) // Set the amplitude of the sinus return Math.sin( sinusX + this.ballOffset ) * 15; }

The addition of the waves made it from dull to pretty cool. But we can make it even cooler by adding some mouse interaction to it. One way to do this is to listen to the mouseMove on the CanvasAnimation and use those values to manipulate the BallShape.

If you remember, in the initialise of the CanvasAnimation we added the event listeners, so now what we need to do is save these values in the BallShapes.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
mouseMoveListener(e) { if ( window.innerWidth < 768 || window.innerWidth < window.innerHeight ) return this.balls.forEach( ball => { ball.mouseMove({ x: this.mouseMoveOffset( e.pageX, window.innerWidth ), y: this.mouseMoveOffset( e.pageY, window.innerHeight ) }) } ) } mouseMoveOffset( pos: number, maxPos: number ) { const halfwayMark = maxPos / 2 const offsetPositive = pos - halfwayMark >= 0; const offset = Math.abs(pos - halfwayMark); const formattedOffset = this.rangeMap( offset, 0, halfwayMark, 0, 90 ); return offsetPositive ? formattedOffset : formattedOffset * -1 } rangeMap(value: number, low1: number, high1: number, low2: number, high2: number) { return low2 + (high2 - low2) * (value - low1) / (high1 - low1); }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
mouseMoveListener(e) { if ( window.innerWidth < 768 || window.innerWidth < window.innerHeight ) return this.balls.forEach( ball => { ball.mouseMove({ x: this.mouseMoveOffset( e.pageX, window.innerWidth ), y: this.mouseMoveOffset( e.pageY, window.innerHeight ) }) } ) } mouseMoveOffset( pos: number, maxPos: number ) { const halfwayMark = maxPos / 2 const offsetPositive = pos - halfwayMark >= 0; const offset = Math.abs(pos - halfwayMark); const formattedOffset = this.rangeMap( offset, 0, halfwayMark, 0, 90 ); return offsetPositive ? formattedOffset : formattedOffset * -1 } rangeMap(value: number, low1: number, high1: number, low2: number, high2: number) { return low2 + (high2 - low2) * (value - low1) / (high1 - low1); }

Each time the users moves the position of the mouse is mapped on a range of -90 to 90. Then for the index of the ball I add a 10% extra offset. So for ball one the max offset is 90, for ball two the max is 100 and for ball three its 110. This difference in offset makes the animation more playful.

Inside of the ballShape animation we apply the offset for each ball and only save the new offset. We use this new value inside the drawPoints functions. We add the new value to all the points in the shape. But instead of adding the value right away we lerp the value. This makes it feel more smooth. If you want to read more about lerping check this article over here.

close
minimize
fullscreen
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
drawPoints( time: number = 0 ) { this.lerp.x += ( this.cursorLoc.x - this.lerp.x ) * .05 this.lerp.y += ( this.cursorLoc.y - this.lerp.y ) * .05 for ( let i = 0; i <= this.settings.amountOfPoints; i++) { const { x, y } = this.points[i] this.ctx.lineTo( x + this.lerp.x + this.getSinusOffset( time, i ), y + this.lerp.y ) } }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
drawPoints( time: number = 0 ) { this.lerp.x += ( this.cursorLoc.x - this.lerp.x ) * .05 this.lerp.y += ( this.cursorLoc.y - this.lerp.y ) * .05 for ( let i = 0; i <= this.settings.amountOfPoints; i++) { const { x, y } = this.points[i] this.ctx.lineTo( x + this.lerp.x + this.getSinusOffset( time, i ), y + this.lerp.y ) } }

And now we have the completed canvas ball animation experiment. If you have more questions about this setup feel free to send me an email. 😀