[Blender&Three.js] Animated 3D Social Media Button on Hover
08/04/2022
Demo
Video
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(0.9, 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;
margin: 0;
overflow: hidden;
}
.label {
display: none;
height: 14vh;
}
.hover {
display: block;
}
.label p {
transform: var(--transform);
margin-top: -100%;
padding: .8rem 2rem;
border-radius: 30px;
box-shadow: 6px -6px rgba(0, 0, 0, .5);
background: #C4302B;
color: #FFF;
font-size: 1.25rem;
letter-spacing: 1px;
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
main.js
import HoverEffect from './HoverEffect.js';
(() => {
const canvas = document.querySelector('#canvas');
new HoverEffect(canvas);
})();
HoverEffect.js
import * as THREE from 'three';
import { GLTFLoader } from './three/jsm/loaders/GLTFLoader.js';
import { CSS2DRenderer, CSS2DObject } from './three/jsm/renderers/CSS2DRenderer.js';
export default class HoverEffect {
constructor(canvas) {
this.canvas = canvas;
this.init();
}
init() {
this.setRenderer();
this.setCamera();
this.setLight();
this.setFloor();
this.setModel();
this.events();
this.animate();
}
setRenderer() {
this.scene = new THREE.Scene();
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
alpha: true,
antialias: true
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(this.viewport.width, this.viewport.height);
this.renderer.setClearColor(0x000000, 0);
this.renderer.physicallyCorrectLights = true;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.labelRenderer = new CSS2DRenderer();
this.labelRenderer.setSize(this.viewport.width, this.viewport.height);
this.labelRenderer.domElement.style.position = 'absolute';
this.labelRenderer.domElement.style.top = '0px';
document.body.appendChild(this.labelRenderer.domElement);
}
setCamera() {
this.camera = new THREE.PerspectiveCamera(45, this.viewport.aspectRatio, 1, 10000);
this.camera.position.set(0, 150, 200);
this.camera.lookAt(new THREE.Vector3(0, 20, 0));
}
setLight() {
this.dirLight = new THREE.DirectionalLight(0xFFFFFF, 1.0);
this.dirLight.position.set(-100, 100, 50);
this.dirLight.castShadow = true;
this.dirLight.shadow.mapSize.width = 4096;
this.dirLight.shadow.mapSize.height = 4096;
this.scene.add(this.dirLight);
this.spotLight = new THREE.SpotLight(0xFFFFFF, 3000);
this.spotLight.position.set(0, 60, -20);
this.spotLight.angle = Math.PI / 3;
this.spotLight.penumbra = 0.9;
this.spotLight.decay = 2;
this.spotLight.distance = 200;
this.spotLight.castShadow = true;
this.spotLight.shadow.mapSize.width = 128;
this.spotLight.shadow.mapSize.height = 128;
this.scene.add(this.spotLight);
this.ambLight = new THREE.AmbientLight(0xFFFFFF, 3.0);
this.scene.add(this.ambLight);
}
setFloor() {
this.geometry = new THREE.PlaneGeometry(2000, 2000, 1, 1);
this.material = new THREE.MeshPhongMaterial( { color: 0x2590D1, side: THREE.DoubleSide } );
this.floor = new THREE.Mesh(this.geometry, this.material);
this.floor.rotation.x = Math.PI / 2;
this.floor.position.set(0, -4, 0);
this.floor.receiveShadow = true;
this.scene.add(this.floor);
}
setModel() {
const loader = new GLTFLoader();
const modelURL = './models/btn.glb';
this.targetMesh = [];
loader.load(modelURL, (gltf) => {
this.btnModel = gltf.scene;
this.btnModel.scale.set(50.0, 50.0, 50.0);
this.btnModel.position.set(0, 0, 0);
this.btnModel.rotation.y = - Math.PI / 4;
this.scene.add(this.btnModel);
this.btnModel.traverse((obj) => {
if(obj.isMesh) {
if (!this.targetMesh.length) this.targetMesh.push(obj);
obj.castShadow = true;
console.log(obj);
}
});
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);
this.btnModel.add(btnLabel);
}, (error) => {
console.log(error);
});
}
events() {
window.addEventListener('mousemove', this.onPointerMove.bind(this));
window.addEventListener('resize', this.onResize.bind(this));
}
onPointerMove(event) {
const pointer = new THREE.Vector2();
pointer.x = ( event.clientX / this.viewport.width ) * 2 - 1;
pointer.y = - ( event.clientY / this.viewport.height ) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(pointer, this.camera);
const intersects = raycaster.intersectObjects(this.targetMesh, false);
if (intersects.length > 0) {
document.body.style.cursor = 'pointer';
this.btnModel.position.set(0, -2, 0);
document.querySelector('.label').classList.add("hover");
} else {
document.body.style.cursor = 'default';
this.btnModel.position.set(0, 0, 0);
document.querySelector('.label').classList.remove("hover");
}
}
onResize() {
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(this.viewport.width, this.viewport.height);
this.labelRenderer.setSize(this.viewport.width, this.viewport.height);
this.camera.aspect = this.viewport.aspectRatio;
this.camera.updateProjectionMatrix();
}
animate() {
requestAnimationFrame(this.animate.bind(this));
this.renderer.render(this.scene, this.camera);
this.labelRenderer.render(this.scene, this.camera);
}
get viewport() {
const width = window.innerWidth;
const height = window.innerHeight;
const aspectRatio = width / height;
return {
width,
height,
aspectRatio
}
}
}