Frontend Development 15 min read

Creating a Romantic Animated Heart with Three.js – 2D Particle Heart and 3D Model Heart

This article walks through building a visually striking heart animation using Three.js, covering the mathematical generation of 2D particle hearts, their canvas rendering and animation, and then extending the effect to a 3D heart model with scene, camera, lighting, GLTF loading, and GSAP-driven motion.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Creating a Romantic Animated Heart with Three.js – 2D Particle Heart and 3D Model Heart

Preface

Hello, I'm Xiao Bao. After a short break I returned to share a series of Three.js tutorials, starting with a romantic heart animation inspired by a popular drama scene.

Li Xun Romantic Heart – Implementation

The goal is to reproduce the "Li Xun Romantic Heart" effect using canvas. The heart is built from multiple layers (outer contour, outline, inner part) and rendered with particles.

Implementation Analysis

The heart consists of several concentric layers.

Particles are used to draw the shape.

The animation of the heart is the challenging part.

Heart Generation

In the front‑end, we use the canvas API. The lineTo method can draw any shape, but determining the points of a heart curve requires mathematics.

Using the classic polar equation for a heart shape, we generate points with the following function:

// scale is the magnification factor
// width and height are the canvas dimensions
function generatorHeart(t, scale = 11.6) {
  let x = 16 * Math.sin(t) ** 3;
  let y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
  x = x * scale + width / 2;
  y = y * scale + height / 2;
  return new Point(x, y);
}

We generate 360 points over the interval [0, 2π] :

const hearts = [];
for (let i = 0; i < 360; i++) {
  hearts.push(generatorHeart(2 * Math.PI * (i / 360)));
}

To draw the heart we connect the points with lineTo :

function drawHeart2(context, points) {
  context.beginPath();
  points.forEach(point => {
    context.strokeStyle = "#00ffff";
    context.lineTo(point.x, point.y);
    context.stroke();
  });
  context.closePath();
}

For a particle effect we replace the line drawing with small circles using arc and fill :

function drawHeart(context, points) {
  points.forEach(point => {
    context.beginPath();
    context.fillStyle = "#00ffff";
    context.arc(point.x, point.y, point.size, 0, Math.PI * 2);
    context.fill();
    context.closePath();
  });
}

Overall Structure

A Point class stores x, y, size for each particle:

class Point {
  constructor(x, y, size) {
    this.x = x;
    this.y = y;
    this.size = size;
  }
}

The Heart class aggregates particles, computes per‑frame positions, and stores them in allHearts . The position calculation uses a force based on distance to the canvas centre and adds random jitter, while the animation curve is driven by Math.sin and a custom curve function.

class Heart {
  constructor(particles, generateFrame) {
    this.particles = particles;
    this.generateFrame = generateFrame;
    this.boardHeart = [];
    this.middleHeart = [];
    this.centerHeart = [];
    this.allHearts = [];
    this.initHeart();
    for (let i = 0; i < generateFrame; i++) {
      this.calcFrame(i);
    }
  }
  // ... (initialisation and calcFrame omitted for brevity)
}

Animation is performed with requestAnimationFrame :

let k = 0;
(function animateloop() {
  k = (k + 1) % 80;
  if (k % 4 === 0) {
    render(k / 4);
  }
  requestAnimationFrame(animateloop);
})();

3D Heart

We now switch to a simple 3D implementation using Three.js (Vite + Vue3). The steps are:

Basic Setup

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, 10000);
camera.position.set(0, 0, 300);
scene.add(camera);

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

Lighting

Multiple directional lights and an ambient light are added to achieve a balanced illumination:

const light1 = new THREE.DirectionalLight(0x333333, 1);
light1.position.set(0, 0, 20);
scene.add(light1);
// ... (light2 … light10 omitted for brevity)

Model Loading

The heart model is loaded with GLTFLoader (the actual file path is kept private):

const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
loader.load("/model/heart.glb", gltf => {
  const heart = gltf.scene;
  scene.add(heart);
});

Animation with GSAP

GSAP provides a continuous rotation and a bouncing motion:

gsap.to(heart.rotation, {
  y: Math.PI * 2,
  duration: 6,
  repeat: -1,
});
gsap.to(heart.position, {
  y: 0.8,
  duration: 1,
  yoyo: true,
  repeat: -1,
});

Comparison

Li Xun Romantic Heart

3D Heart

Implementation Difficulty

Higher, involves math formulas

Lower

Coolness

Very flashy

Moderate (3D adds depth)

Complex Parts

Formulas, canvas drawing

Finding a suitable model, lighting

Flexibility

Can be freely customized

Limited by the model

Conclusion

Both the 2D particle heart and the 3D heart have been implemented. The 2D version is more mathematically intensive, while the 3D version is easier once a model is available. Both serve as practical introductions to Three.js for front‑end developers.

Continue exploring Three.js to create more sophisticated 3D effects.

animationJavaScriptCanvasWebGLThreeJS
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.