[Blender&Three.js] Animated 3D Social Media Button on Hover

Web Dev
Sponsored links

Demo

Full Screen

 

Sponsored links

 

Code

HTML

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Animated 3D Social Media Button on Hover</title>
    <link rel="stylesheet" type="text/css" href="style.css">
  </head>
  <body>
    <canvas id="canvas"></canvas>
    <script async src="https://unpkg.com/es-module-shims@1.5.9/dist/es-module-shims.js"></script>
    <script type="importmap">
      {
        "imports": {
          "three": "./three/build/three.module.js"
        }
      }
    </script>
    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

@charset "utf-8";
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400&display=swap');
:root {
  --transform: rotateY(45deg) rotate3d(1, 0, 1, 30deg) translateX(90px) translateZ(-80px);
}
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}
html {
  font-size: 16px;
}
body {
  font-family: 'Roboto', sans-serif;
  overflow: hidden;
}
.label {
  display: none;
  height: 22vh;
}
.hover {
  display: block;
}
.label p {
  transform: var(--transform);
  margin-top: -85%;
  padding: 1rem 2.5rem;
  border-radius: 8px;
  box-shadow: 6px -6px rgba(0, 0, 0, .5);
  background: #C4302B;
  color: #FFF;
  font-size: 1.75rem;
  animation: bounce .8s linear infinite;
}
.label p::after {
  position: absolute;
  top: calc(100% - 1px);
  left: 50%;
  margin-left: -20px;
  border: 15px solid transparent;
  border-top: 15px solid #C4302B;
  content: "";
}
@keyframes bounce {
  0% {
    transform: translateY(0px) var(--transform);
  }
  50% {
    transform: translateY(20px) var(--transform);
  }
  100% {
    transform: translateY(0px) var(--transform);
  }
}

JavaScript

import * as THREE from 'three';

import { GLTFLoader } from './three/jsm/loaders/GLTFLoader.js';
import { CSS2DRenderer, CSS2DObject } from './three/jsm/renderers/CSS2DRenderer.js';

const canvas = document.querySelector('#canvas');
let scene, camera, renderer, labelRenderer;
let btnModel;
let targetMesh = [];
let INTERSECTED;
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();

init();
animate();

function init() {
  scene = new THREE.Scene();

  renderer = new THREE.WebGLRenderer({
      canvas: canvas,
      alpha: true,
      antialias: true
  });
  renderer.setClearColor(0x000000, 0);
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.physicallyCorrectLights = true;
  renderer.outputEncoding = THREE.sRGBEncoding;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;

  labelRenderer = new CSS2DRenderer();
  labelRenderer.setSize(window.innerWidth, window.innerHeight);
  labelRenderer.domElement.style.position = 'absolute';
  labelRenderer.domElement.style.top = '0px';
  document.body.appendChild(labelRenderer.domElement);

  camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 10000);
  camera.position.set(0, 150, 200);
  camera.lookAt(new THREE.Vector3(0, 20, 0));

  const dirLight = new THREE.DirectionalLight(0xFFFFFF, 3.0);
  dirLight.position.set(-100, 100, 100);
  dirLight.castShadow = true;
  scene.add(dirLight);

  const ambLight = new THREE.AmbientLight(0x404040, 5.0);
  scene.add(ambLight);

  const loader = new GLTFLoader();
  const url = './models/btn.glb';

  loader.load(
    url,
    function (gltf) {
      btnModel = gltf.scene;
      btnModel.scale.set(80.0, 80.0, 80.0);
      btnModel.position.set(0, 0, 0);
      btnModel.rotation.y = - Math.PI / 4;
      btnModel.castShadow = true;
      scene.add(btnModel);

      btnModel.traverse((object) => {
        if(object.isMesh && !targetMesh.length) {
          targetMesh.push(object);
        }
      });

      const labelDiv = document.createElement('div');
      labelDiv.className = 'label';
      const labelText = document.createElement('p');
      labelText.textContent = 'YouTube';
      labelDiv.insertBefore(labelText, labelDiv.firstChild);
      const btnLabel = new CSS2DObject(labelDiv);
      btnLabel.position.set(0, 0, 0);
      btnModel.add(btnLabel);
    },
    function (error) {
        console.log(error);
    }
  );

  const floor = new THREE.Mesh(
    new THREE.BoxGeometry(2000, 0.1, 2000),
    new THREE.MeshStandardMaterial({ color: 0x01D2FF, roughness: 0.0 })
  );
  floor.position.set(0, -6, 0);
  floor.receiveShadow = true;
  scene.add(floor);

  window.addEventListener('mousemove', onPointerMove);
  window.addEventListener('resize', onResize);
}

function onResize() {
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  labelRenderer.setSize(window.innerWidth, window.innerHeight);

  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
}

function onPointerMove(event) {
  pointer.x = ( event.clientX / window.innerWidth ) * 2 - 1;
  pointer.y = - ( event.clientY / window.innerHeight ) * 2 + 1;

  detectHover();
}

function detectHover() {
  raycaster.setFromCamera(pointer, camera);

  const intersects = raycaster.intersectObjects(targetMesh, false);

  if (intersects.length > 0) {
    if (INTERSECTED != intersects[0].object) {
      if (INTERSECTED) INTERSECTED.material.emissive.setHex(INTERSECTED.currentHex);
      INTERSECTED = intersects[0].object;
      INTERSECTED.currentHex = INTERSECTED.material.emissive.getHex();
      INTERSECTED.material.emissive.setHex(0xFF0000);
    }
    document.body.style.cursor = 'pointer';
    btnModel.position.set(0, -2, 0);
    document.querySelector('.label').classList.add("hover");
  } else {
    if (INTERSECTED) INTERSECTED.material.emissive.setHex(INTERSECTED.currentHex);
    INTERSECTED = null;
    document.body.style.cursor = 'default';
    btnModel.position.set(0, 0, 0);
    document.querySelector('.label').classList.remove("hover");
  }
}

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
  labelRenderer.render(scene, camera);
}