Interactive Repulsion Effect with Three.js

A tutorial on how to recreate the interactive repulsion effect of grid items seen in BestServedBold’s Dribbble shot “Holographic-Interactions”.

This tutorial is going to demonstrate how to build an interesting repulsion effect for a grid of elements using three.js and TweenMax (GSAP). The effect is a recreation of BestServedBold’s Dribbble shot Holographic-Interactions.

Attention: We assume that you already have some basic JavaScript and three.js knowledge. If you are not familiar with it, I highly recommend checking out the official documentation and examples.

basic-grid

In the first demo we did a little practical example and in the second demo you will find the tweakable version of the tutorial code.

The Original Idea

The original idea is based on BestServedBold’s Dribbble shot Holographic-Interactions:

The Core Concept

The idea is to create a grid of random elements that reacts on mouse move.

Each element of the grid will update their Y position, rotation and scale value based on the distance from the current mouse location to the element’s center.

top-view

The closer the mouse gets to an element the large it will appear.

side-view

We also define a radius for this, affecting only one element or any number of elements inside that radius. The bigger the radius is, the more elements will react when the mouse is moved.

area-radius

Getting started

First we have to setup our HTML page for the demo. It’s a simple boilerplate since all the code will be running inside a canvas element:


<html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <meta name="target" content="all">
      <meta http-equiv="cleartype" content="on">
      <meta name="apple-mobile-web-app-capable" content="yes">
      <meta name="mobile-web-app-capable" content="yes">
      <title>Repulsive Force Interavtion</title>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/96/three.min.js"></script>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.3/TweenMax.min.js"></script>
    </head>
  <body>
  </body>
</html>

As you can see, we also link to three.js and TweenMax from a CDN.

Helpers

Let’s define some helper functions to calculate the distance between two points, map values and convert degrees to radians:


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

const distance = (x1, y1, x2, y2) => {
  return Math.sqrt(Math.pow((x1 - x2), 2) + Math.pow((y1 - y2), 2));
}

const map = (value, start1, stop1, start2, stop2) => {
  return (value - start1) / (stop1 - start1) * (stop2 - start2) + start2
}

Grid Elements

Before we build our grid we need to define the objects we will be using:

Box

    
  // credits for the RoundedBox mesh - <a href="https://github.com/pailhead" target="_blank" rel="noopener noreferrer">Dusan Bosnjak</a>

  import RoundedBoxGeometry from 'roundedBox';

  class Box {
    constructor() {
      this.geom = new <a href="https://github.com/pailhead/three-rounded-box" target="_blank" rel="noopener noreferrer">RoundedBoxGeometry</a>(.5, .5, .5, .02, .2);
      this.rotationX = 0;
      this.rotationY = 0;
      this.rotationZ = 0;
    }
  }

Cone

    
  class Cone {
    constructor() {
      this.geom = new <a href="https://threejs.org/docs/#api/en/geometries/ConeBufferGeometry" target="_blank" rel="noopener noreferrer">THREE.ConeBufferGeometry</a>(.3, .5, 32);
      this.rotationX = 0;
      this.rotationY = 0;
      this.rotationZ = radians(-180);
    }
  }

Torus

    
  class Torus {
    constructor() {
      this.geom = new <a href="https://threejs.org/docs/#api/en/geometries/TorusGeometry" target="_blank" rel="noopener noreferrer">THREE.TorusBufferGeometry</a>(.3, .12, 30, 200);
      this.rotationX = radians(90);
      this.rotationY = 0;
      this.rotationZ = 0;
    }
  }

Setting up the 3D world

Inside our main class we create a function for the setup:

    
  setup() {
    // handles mouse coordinates mapping from 2D canvas to 3D world
    this.raycaster = new <a href="https://threejs.org/docs/#api/en/core/Raycaster" target="_blank" rel="noopener noreferrer">THREE.Raycaster</a>();

    this.gutter = { size: 1 };
    this.meshes = [];
    this.grid = { cols: 14, rows: 6 };
    this.width = window.innerWidth;
    this.height = window.innerHeight;
    this.mouse3D = new THREE.Vector2();
    this.geometries = [
      new Box(),
      new Tourus(),
      new Cone()
    ];

    window.addEventListener('mousemove', this.onMouseMove.bind(this), { passive: true });

    // we call this to simulate the initial position of the mouse cursor
    this.onMouseMove({ clientX: 0, clientY: 0 });
  }

Mouse Move handler

    
  onMouseMove({ clientX, clientY }) {
    this.mouse3D.x = (clientX / this.width) * 2 - 1;
    this.mouse3D.y = -(clientY / this.height) * 2 + 1;
  }

Creating our 3D scene

    
  createScene() {
    this.scene = new THREE.Scene();

    this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.setPixelRatio(window.devicePixelRatio);

    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;

    document.body.appendChild(this.renderer.domElement);
  }

Camera

Now let’s add a camera for our scene:

    
  createCamera() {
    this.camera = new <a href="https://threejs.org/docs/#api/en/cameras/PerspectiveCamera" target="_blank" rel="noopener noreferrer">THREE.PerspectiveCamera</a>(20, window.innerWidth / window.innerHeight, 1);

    // set the distance our camera will have from the grid
    this.camera.position.set(0, 65, 0);

    // we rotate our camera so we can get a view from the top
    this.camera.rotation.x = -1.57;

    this.scene.add(this.camera);
  }

Random objects helper

We want to randomly place a variety of boxes, cones and tourus objects, so we create a little helper:

    
  getRandomGeometry() {
    return this.geometries[Math.floor(Math.random() * Math.floor(this.geometries.length))];
  }

Create Mesh helper

This is just a little helper to create a mesh based on a geometry and material

    
  getMesh(geometry, material) {
    const mesh = new THREE.Mesh(geometry, material);

    mesh.castShadow = true;
    mesh.receiveShadow = true;

    return mesh;
  }

Grid

Now we are going to place those random elements in a grid layout

grid

    
  createGrid() {
    // create a basic 3D object to be used as a container for our grid elements so we can move all of them together
    this.groupMesh = new THREE.Object3D();

    const meshParams = {
      color: '#ff00ff',
      metalness: .58,
      emissive: '#000000',
      roughness: .18,
    };

    // we create our material outside the loop to keep it more performant
    const material = new <a href="https://threejs.org/docs/#api/en/materials/MeshPhysicalMaterial" target="_blank" rel="noopener noreferrer">THREE.MeshPhysicalMaterial</a>(meshParams);

    for (let row = 0; row < this.grid.rows; row++) {
      this.meshes[row] = [];

      for (let col = 0; col < this.grid.cols; col++) {
        const geometry = this.getRandomGeometry();
        const mesh = this.getMesh(geometry.geom, material);

        mesh.position.set(col + (col * this.gutter.size), 0, row + (row * this.gutter.size));
        mesh.rotation.x = geometry.rotationX;
        mesh.rotation.y = geometry.rotationY;
        mesh.rotation.z = geometry.rotationZ;

        // store the initial rotation values of each element so we can animate back
        mesh.initialRotation = {
          x: mesh.rotation.x,
          y: mesh.rotation.y,
          z: mesh.rotation.z,
        };

        this.groupMesh.add(mesh);

        // store the element inside our array so we can get back when need to animate
        this.meshes[row][col] = mesh;
      }
    }

    //center on the X and Z our group mesh containing all the grid elements
    const centerX = ((this.grid.cols - 1) + ((this.grid.cols - 1) * this.gutter.size)) * .5;
    const centerZ = ((this.grid.rows - 1) + ((this.grid.rows - 1) * this.gutter.size)) * .5;
    this.groupMesh.position.set(-centerX, 0, -centerZ);

    this.scene.add(this.groupMesh);
  }

Ambient Light

Next, we’ll add an ambient light to give some nice color effect:

ambient-light

    
  addAmbientLight() {
    const light = new <a href="https://threejs.org/docs/#api/en/lights/AmbientLight" target="_blank" rel="noopener noreferrer">THREE.AmbientLight</a>('#2900af', 1);

    this.scene.add(light);
  }

Spot Light

We also add a SpotLight to the scene for a realistic touch:

spot-light

    
  addSpotLight() {
    const ligh = new <a href="https://threejs.org/docs/#api/en/lights/SpotLight" target="_blank" rel="noopener noreferrer">THREE.SpotLight</a>('#e000ff', 1, 1000);

    ligh.position.set(0, 27, 0);
    ligh.castShadow = true;

    this.scene.add(ligh);
  }

RectArea Light

To shine some uniform light, we use RectAreaLight:

rectarea-light

    
  addRectLight() {
    const light = new <a href="https://threejs.org/docs/#api/en/lights/RectAreaLight" target="_blank" rel="noopener noreferrer">THREE.RectAreaLight</a>('#0077ff', 1, 2000, 2000);

    light.position.set(5, 50, 50);
    light.lookAt(0, 0, 0);

    this.scene.add(light);
  }

Point Lights

And for the final light effects we create a PointLight function to add as much light as we want:

point-lights

    
  addPointLight(color, position) {
    const light = new <a href="https://threejs.org/docs/#api/en/lights/PointLight" target="_blank" rel="noopener noreferrer">THREE.PointLight</a>(color, 1, 1000, 1);

    light.position.set(position.x, position.y, position.z);

    this.scene.add(light);
  }

Shadow Floor

Now we need to add a shape to serve as a mapping object for where the mouse cursor is able to hover:

    
  addFloor() {
    const geometry = new <a href="https://threejs.org/docs/#api/en/geometries/PlaneGeometry" target="_blank" rel="noopener noreferrer">THREE.PlaneGeometry</a>(100, 100);
    const material = new <a href="https://threejs.org/docs/#api/en/materials/ShadowMaterial" target="_blank" rel="noopener noreferrer">THREE.ShadowMaterial</a>({ opacity: .3 });

    this.floor = new <a href="https://threejs.org/docs/#api/en/objects/Mesh" target="_blank" rel="noopener noreferrer">THREE.Mesh</a>(geometry, material);
    this.floor.position.y = 0;
    this.floor.receiveShadow = true;
    this.floor.rotateX(- Math.PI / 2);

    this.scene.add(this.floor);
  }

Draw / Animate Elements

This is the function where all the animations are handled; it will be called on every frame inside a requestAnimationFrame callback:

    
  draw() {
    // maps our mouse coordinates from the camera perspective
    this.raycaster.setFromCamera(this.mouse3D, this.camera);

    // checks if our mouse coordinates intersect with our floor shape
    const intersects = this.raycaster.intersectObjects([this.floor]);

    if (intersects.length) {

      // get the x and z positions of the intersection
      const { x, z } = intersects[0].point;

      for (let row = 0; row < this.grid.rows; row++) {
        for (let col = 0; col < this.grid.cols; col++) {

          // extract out mesh base on the grid location
          const mesh = this.meshes[row][col];

          // calculate the distance from the intersection down to the grid element
          const mouseDistance = distance(x, z,
            mesh.position.x + this.groupMesh.position.x,
            mesh.position.z + this.groupMesh.position.z);

          // based on the distance we map the value to our min max Y position
          // it works similar to a radius range

          const maxPositionY = 10;
          const minPositionY = 0;
          const startDistance = 6;
          const endDistance = 0;
          const y = map(mouseDistance, startDistance, endDistance, minPositionY, maxPositionY);

          // based on the y position we animate the mesh.position.y
          // we don´t go below position y of 1
          TweenMax.to(mesh.position, .4, { y: y < 1 ? 1 : y });

          // create a scale factor based on the mesh.position.y
          const scaleFactor = mesh.position.y / 2.5;

          // to keep our scale to a minimum size of 1 we check if the scaleFactor is below 1
          const scale = scaleFactor < 1 ? 1 : scaleFactor;

          // animates the mesh scale properties
          TweenMax.to(mesh.scale, .4, {
            ease: Back.easeOut.config(1.7),
            x: scale,
            y: scale,
            z: scale,
          });

          // rotate our element
          TweenMax.to(mesh.rotation, .7, {
            ease: Back.easeOut.config(1.7),
            x: map(mesh.position.y, -1, 1, radians(45), mesh.initialRotation.x),
            z: map(mesh.position.y, -1, 1, radians(-90), mesh.initialRotation.z),
            y: map(mesh.position.y, -1, 1, radians(90), mesh.initialRotation.y),
          });
        }
      }
    }
  }

And this is it! There are many more possibilities here, i.e. adding more objects etc. Take a look at the following grid variation:

fancy-grid

…or the camera rotation variant:

rotate-camera

We hope you enjoyed this tutorial and find it useful!

Ion D. Filho

My name is Ion Drimba Filho, I'm a Front-End Developer based in Brazil. I’m passionate about developing highly interactive interfaces with front-end technologies (JS, CSS, HTML, Canvas).

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!

Feedback 6

Comments are closed.
  1. Nice demo!
    However, you might want to test it in Safari…
    It runs at a ghastly 2fps and genuinely freezes your computer.

  2. Haven’t checked the code but from the demo it feels like you’re scaling it instead of using the Y index and the shadow grows with the shape, when the shadow should project smaller as the object goes further.

    Cool effect nonetheless.