Three.js 3D Keyword Cloud
Creating a 3D keyword cloud using Three.js and stimulus
three.js
stimulus
javascript
rails
3d

You can create a simple word cloud using HTML/CSS or JavaScript to add interactive elements, but with Three.js, the result is much more responsive and aesthetically clean.
However, the process involves some additional coding to implement. Here’s how I used Three.js to create a smooth, responsive, and animated keyword cloud for this website:
1. Installation and Setup.
First, you need to install the Three.js library in your app and set up a canvas in your HTML. Note that depending on your app setup, the process may differ slightly.
npm install --save three
To display the scene generated, you need a canvas to render the 3D objects.
<div class="content-main-banner"> <%= content_tag(:div, "", data: { controller: "threejs", threejs_target: "canvas"}, class: "canvas") %> </div>
I am using Rails Stimulus to target the controller with the div class name "canvas." Now, in the threejs_controller.js file, you'll need to import Three.js and the Stimulus controller, then target the canvas to work with.
import { Controller } from "@hotwired/stimulus"; import axios from "axios"; import * as THREE from "three"; import { TextGeometry } from 'three/addons/geometries/TextGeometry.js'; import { OrbitControls } from "three/examples/jsm/Addons.js"; import { TTFLoader } from "three/examples/jsm/loaders/TTFLoader.js"; import { Font } from 'three/examples/jsm/loaders/FontLoader.js'; export default class extends Controller { static targets = ["canvas"]; connect() { console.log("Three.js Canvas Connected!"); } }
I've imported all the required modules and classes for now but I'll explain them later as I build the scene. Now let's explore Three.js.
2. Scene Initialization
connect() { // console.log("Three.js Canvas Connected!"); this.radiusValue = 30; // Cloud sphere radius this.isMouseOverText = false; // For hovering effect this.initThree(); // Initialize Three object this.animate(); // Runs and updates animation } initThree() { this.setupScene(); this.setupCamera(); this.setupRenderer(); this.setupControls(); this.addEventListeners(); this.loadKeywords() }
I modularised all the initial setup to make it more oraganised.
setupScene() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color("#342056"); }
This creates the root container for all 3D objects. The purple background (#342056) was chosen to create high contrast with white text while reducing eye strain during prolonged viewing. Dark backgrounds in 3D environments help depth perception by making it easier to distinguish object positions.
setupCamera() { this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); this.camera.position.set(0, 0, 55); }
The perspective camera mimics human vision with a 75° field of view. The z-position of 55 units was determined through testing to fully contain the keyword cloud within view at default zoom. The near/far planes (0.1/1000) ensure both close-up text details and distant elements render correctly.
setupControls() { this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.autoRotate = true; this.controls.enablePan = false; }
OrbitControls were chosen over alternatives like TrackballControls for their intuitive mobile-friendly behavior. Damping adds realistic momentum to rotations, while disabling panning (enablePan: false) keeps the cloud centered. Auto-rotation serves dual purposes: it creates an eye-catching initial animation and demonstrates the visualization's 3D nature to first-time users.
addEventListeners() { this.hoveredMesh = null; this.canvasTarget.addEventListener('mousemove', this.onMouseMove.bind(this)); this.canvasTarget.addEventListener('click', this.onMouseClick.bind(this)); window.addEventListener("resize", this.resizeCanvas.bind(this)); }
This function adds event listeners for tracking mouse movement, click events, and window resize events on the canvas. The event listeners are used to handle user interaction with the 3D scene and ensure the canvas resizes correctly when the window size changes.
loadKeywords() { axios.get('/keywords') .then((response) => { this.createCloud(response.data); }) .catch((error) => { console.error("Error fetching keywords: ", error); }); }
This function loads keywords via an API call using Axios. On success, it passes the fetched data to the createCloud() function.
3. Cloud Creation & Text Positioning Algorithm
createCloud(keywords) { const group = new THREE.Group(); const spherical = new THREE.Spherical(); const phiSpan = Math.PI / (keywords.length + 1); const thetaSpan = (Math.PI * 2) / keywords.length; const fontLoader = new TTFLoader(); fontLoader.load('/Righteous-Regular.ttf', (ttf) => { const font = new Font(ttf); keywords.forEach((keyword, index) => { const phi = phiSpan * (index + 1); const theta = thetaSpan * index; const pos = new THREE.Vector3().setFromSpherical(spherical.set(this.radiusValue, phi, theta)); const wordMesh = this.createWord(keyword, pos, font); group.add(wordMesh); this.keywordObjects.push({ mesh: wordMesh, phi, theta }); }); this.scene.add(group); this.group = group; }); }
Keywords are distributed across a sphere using spherical coordinates (phi and theta angles), ensuring even spacing regardless of keyword count. The phi controls vertical placement (from top to bottom poles), while theta handles horizontal distribution. The +1 in phiSpan prevents keywords from clustering at the poles.
Font loading via TTFLoader ensures cross-browser compatibility with custom typography (Righteous-Regular).
4. Create Keyword
createWord(text, position, font) { const geometry = new TextGeometry(text.word, { font: font, size: 3, depth: 0.05, }); // Compute bounding box for the text geometry geometry.computeBoundingBox(); const bbox = geometry.boundingBox; // Create main text mesh const material = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, opacity: 1.0, }); const mesh = new THREE.Mesh(geometry, material); mesh.position.copy(position); mesh.userData.text = text.word; // Create invisible hover plane const size = new THREE.Vector3(); bbox.getSize(size); const hoverGeometry = new THREE.PlaneGeometry(size.x, size.y); const hoverMaterial = new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }); const hoverPlane = new THREE.Mesh(hoverGeometry, hoverMaterial); hoverPlane.userData.isHoverPlane = true; // Position the hover plane at the center of the text const center = new THREE.Vector3(); bbox.getCenter(center); hoverPlane.position.copy(center); // Align with text mesh rotation hoverPlane.quaternion.copy(mesh.quaternion); mesh.add(hoverPlane); return mesh; }
The createWord function generates interactive 3D text elements by first creating a visible text mesh using Three.js's TextGeometry with specified font, size, and extrusion depth. It calculates the text's bounding box to determine its dimensions, then attaches an invisible hover plane matching those dimensions to enable precise interaction. The hover plane is centered on the text, aligned to its rotation, and parented to the text mesh, creating a hidden interactive layer that moves/rotates with the visible text while remaining undetectable to users. This dual-structure approach ensures accurate hover/click detection without compromising visual fidelity, storing metadata like the original text in userData for event handling.
5. Ray Casting in 3D Interaction
getIntersection(event) { const rect = this.canvasTarget.getBoundingClientRect(); const mouse = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1 ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, this.camera); const intersects = raycaster.intersectObjects(this.scene.children, true); if (intersects.length === 0) return null; const firstIntersect = intersects[0].object; return firstIntersect.userData.isHoverPlane ? firstIntersect.parent : firstIntersect; }
Ray casting is a technique that projects an invisible "laser" (ray) from a point (e.g., mouse position) into a 3D scene to detect intersecting objects.
This function converts mouse coordinates to 3D space positions, casts a ray through the scene, and detects intersections with objects. It first normalizes mouse coordinates to WebGL's clip space ([-1, 1] range) relative to the canvas, accounting for viewport positioning. A raycaster then projects this position through the camera's perspective, testing collisions with all scene objects and their children. When an intersection occurs, it checks if the hit object is an invisible hover plane (flagged via userData), returning either the hover plane's parent text mesh (for accurate text interaction) or the direct intersection. This elegant abstraction handles both visible meshes and hidden collision planes in one unified process.
6. Mouse Event Listeners & Hovering
onMouseMove(event) { const hoveredObject = this.getIntersection(event); if (hoveredObject) { if (this.hoveredMesh !== hoveredObject) { this.hoveredMesh = hoveredObject; this.canvasTarget.style.cursor = 'pointer'; this.isMouseOverText = true; this.controls.autoRotate = false; } } else { this.hoveredMesh = null; this.canvasTarget.style.cursor = 'default'; this.isMouseOverText = false; this.controls.autoRotate = true; } } onMouseClick(event) { const clickedObject = this.getIntersection(event); if (clickedObject?.isMesh && clickedObject.geometry.type === "TextGeometry") { const url = `/posts_by_keyword?keyword=${encodeURIComponent(clickedObject.userData.text)}`; window.location.href = url; console.log("Navigating to:", url); } } updateHoverColor() { if (this.hoveredMesh) { this.hoveredMesh.material.color.lerp(new THREE.Color(0xd52a47), 0.1); } this.scene.traverse((object) => { if (object.isMesh && object !== this.hoveredMesh) { object.material.color.lerp(new THREE.Color(0xffffff), 0.1); } }); }
The onMouseMove function dynamically tracks cursor position over 3D elements using raycasting detection, toggling between pointer and default cursors while controlling scene rotation - pausing auto-rotation during text hover to emphasize interactivity. When clicking (onMouseClick), the system identifies clicked text meshes through their geometry type, extracts stored keyword data, and initiates page navigation to Rails-generated filtered content URLs, bridging 3D visualization with backend functionality.
The updateHoverColor method creates smooth visual feedback by gradually blending hovered text using Three.js's color interpolation while resetting other objects to white through scene-wide traversal, maintaining 60 FPS performance through efficient material updates synchronized with the animation loop.
7. Update Canvas Size & Cleanup
resizeCanvas() { // Get the current size of the canvas container const width = this.canvasTarget.clientWidth; const height = this.canvasTarget.clientHeight; // Resize renderer this.renderer.setSize(width, height); // Update camera aspect ratio this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); } // Cleanup disconnect() { cancelAnimationFrame(this.animationFrameId); this.renderer.dispose(); this.controls.dispose(); super.disconnect(); }
resizeCanvas() dynamically adjusts the Three.js rendering system to container size changes by first measuring the canvas element's current dimensions, then resizing the WebGL renderer's output buffer to match these measurements while simultaneously updating the camera's aspect ratio to prevent visual distortion, ensuring consistent perspective proportions across all viewport sizes through recalculation of the projection matrix.
disconnect() orchestrates proper resource cleanup by terminating the animation loop via cancellation of pending frame requests, systematically releasing WebGL GPU memory through renderer disposal, dismantling camera controls' event listeners, and finalizing Stimulus controller teardown via parent class invocation - a critical sequence preventing memory leaks and ensuring graceful component removal from the DOM.
8. Conclusion
This implementation masterfully bridges Rails' backend capabilities with Three.js' visualization power through Stimulus, creating an immersive 3D keyword cloud experience. By strategically combining spherical text distribution, camera-relative opacity calculations, and invisible hover planes, it achieves precise interaction while maintaining visual elegance.
The solution stands out for its balance between mathematical precision (spherical coordinates, vector dot products) and user-centric design (intuitive controls, visual feedback). Future enhancements could introduce particle effects, dynamic scaling based on keyword frequency, or WebGL post-processing filters, but the current implementation provides a robust foundation for interactive 3D visualizations in modern web applications.