• Sakarit Gunkhan (Chankaew) is a Brooklyn-based Graphic/Web Designer & Visual/Concept Artist

3D balloons floating upwards with a natural wobble effect

Overview

This interactive web project renders a dynamic 3D scene of colorful balloons continuously rising on a transparent canvas, built with Three.js and styled with HTML/CSS.

Key Features

  1. 3D Scene & Rendering:
    • Powered by Three.js with realistic lighting (ambient, key, and fill lights).3D Scene & Rendering:
    • Fully transparent background (alpha: true), allowing the canvas to overlay seamlessly onto any web page or background element.
    • Dynamic fog effect to smoothly fade distant balloons.
  2. Balloon Generation & Physics:
    • Spawns 80 procedurally built balloons, each complete with a main body, knot, and string.
    • Random vibrant colors generated using HSL color modeling.
    • Natural upward movement with multi-axis sine wave wobble animations.
    • Infinite floating cycle (balloons reset to the bottom after floating off-screen).
  3. Interactive Physics:
    • Squish & Bounce (Elastic Physics): Clicking or releasing a balloon triggers a squish effect using spring force and damping math (k = 0.15, damping = 0.82).
    • Grab & Drag: Pointer events allow users to pick up, hold, and move any 3D balloon anywhere in 3D space.
  4. Whimsical Custom Cursor:
    • Hides the default system pointer and replaces it with a glowing, pulsing magic wand.
    • Leaves colorful particle trails as the pointer moves across the canvas.
    • Triggers particle burst effects upon mouse down / tap events.

You can edit the code, scripts, and CSS styles as needed to see the preview in the CodePen window above. If you are happy with how it looks, you can copy the code snippets below.

🌐
3D-Balloons.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3D Flying Balloons</title>
    <style>
        body {
            margin: 0;
            padding: 0;
            overflow: hidden; /* Prevent scrollbars */
            background-color: transparent; /* Transparent background */
            cursor: none; /* Hide default system cursor */
            user-select: none;
            -webkit-user-select: none;
        }
        canvas {
            display: block; /* Removes bottom margin on canvas */
            width: 100vw;
            height: 100vh;
            touch-action: none; /* Prevents default browser panning on mobile */
        }
        
        /* WHIMSICAL CURSOR STYLES */
        #whimsical-cursor {
            position: fixed;
            top: 0;
            left: 0;
            width: 24px;
            height: 24px;
            pointer-events: none;
            z-index: 9999;
            transform: translate(-50%, -50%);
            transition: transform 0.15s ease-out;
        }

        .cursor-wand {
            width: 100%;
            height: 100%;
            background: radial-gradient(circle, rgba(255,255,255,1) 0%, rgba(255,182,193,0.8) 50%, rgba(255,105,180,0) 80%);
            border-radius: 50%;
            box-shadow: 0 0 15px #ffb6c1, 0 0 30px #ff69b4;
            animation: wandPulse 1.5s infinite alternate ease-in-out;
        }

        @keyframes wandPulse {
            0% { transform: scale(0.9); box-shadow: 0 0 12px #ffb6c1, 0 0 25px #ff69b4; }
            100% { transform: scale(1.2); box-shadow: 0 0 20px #ffe4e1, 0 0 40px #ff1493; }
        }

        .magic-particle {
            position: fixed;
            pointer-events: none;
            border-radius: 50%;
            z-index: 9998;
            animation: particleFade 0.8s forwards linear;
        }

        @keyframes particleFade {
            0% {
                opacity: 1;
                transform: translate(0, 0) scale(1) rotate(0deg);
            }
            100% {
                opacity: 0;
                transform: translate(var(--dx), var(--dy)) scale(0.1) rotate(180deg);
            }
        }

        #loading {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-family: sans-serif;
            color: #333;
            font-size: 1.5rem;
            text-shadow: 1px 1px 4px rgba(255,255,255,0.8);
            pointer-events: none;
        }
        
        
    </style>
    <!-- Import Three.js -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head>
<body>
    <!-- WHIMSICAL CURSOR ELEMENT -->
    <div id="whimsical-cursor"><div class="cursor-wand"></div></div>

    <div id="loading">Inflating balloons...</div>

    <script>
        let scene, camera, renderer;
        let balloons = [];
        const numBalloons = 80;

        let apiKey = ""; // Injected environment key

        let raycaster = new THREE.Raycaster();
        let mouse = new THREE.Vector2();
        let dragPlane = new THREE.Plane();
        let planeIntersect = new THREE.Vector3();

        let isDragging = false;
        let draggedBalloon = null;

        window.onload = function() {
            const loadingElem = document.getElementById('loading');
            if (loadingElem) loadingElem.style.display = 'none';
            
            init();
            initWhimsicalCursor();
        };

        function initWhimsicalCursor() {
            const cursor = document.getElementById('whimsical-cursor');
            if (!cursor) return;
            const particleColors = ['#ff71ce', '#01cdfe', '#05ffa1', '#b967ff', '#fffb96'];

            window.addEventListener('pointermove', (e) => {
                cursor.style.left = `${e.clientX}px`;
                cursor.style.top = `${e.clientY}px`;

                if (Math.random() < 0.5) {
                    const particle = document.createElement('div');
                    particle.className = 'magic-particle';
                    
                    const size = Math.random() * 8 + 4;
                    const color = particleColors[Math.floor(Math.random() * particleColors.length)];
                    
                    const dx = (Math.random() - 0.5) * 40 + 'px';
                    const dy = (Math.random() - 0.5) * 40 + 20 + 'px';

                    particle.style.width = `${size}px`;
                    particle.style.height = `${size}px`;
                    particle.style.background = color;
                    particle.style.boxShadow = `0 0 10px ${color}`;
                    particle.style.left = `${e.clientX}px`;
                    particle.style.top = `${e.clientY}px`;
                    particle.style.setProperty('--dx', dx);
                    particle.style.setProperty('--dy', dy);

                    document.body.appendChild(particle);

                    setTimeout(() => particle.remove(), 800);
                }
            });

            window.addEventListener('pointerdown', (e) => {
                cursor.style.transform = 'translate(-50%, -50%) scale(0.7)';
                
                for (let i = 0; i < 10; i++) {
                    const particle = document.createElement('div');
                    particle.className = 'magic-particle';
                    const size = Math.random() * 10 + 5;
                    const color = particleColors[Math.floor(Math.random() * particleColors.length)];
                    
                    const angle = (Math.PI * 2 / 10) * i;
                    const distance = Math.random() * 40 + 20;
                    const dx = Math.cos(angle) * distance + 'px';
                    const dy = Math.sin(angle) * distance + 'px';

                    particle.style.width = `${size}px`;
                    particle.style.height = `${size}px`;
                    particle.style.background = color;
                    particle.style.boxShadow = `0 0 12px ${color}`;
                    particle.style.left = `${e.clientX}px`;
                    particle.style.top = `${e.clientY}px`;
                    particle.style.setProperty('--dx', dx);
                    particle.style.setProperty('--dy', dy);

                    document.body.appendChild(particle);

                    setTimeout(() => particle.remove(), 800);
                }
            });

            window.addEventListener('pointerup', () => {
                cursor.style.transform = 'translate(-50%, -50%) scale(1)';
            });
        }

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

            camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
            camera.position.z = 30;
            camera.position.y = 5;

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

            const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
            scene.add(ambientLight);

            const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
            directionalLight.position.set(10, 20, 10);
            scene.add(directionalLight);
            
            const fillLight = new THREE.DirectionalLight(0xffffff, 0.3);
            fillLight.position.set(-10, 0, -10);
            scene.add(fillLight);

            createBalloons();

            window.addEventListener('resize', onWindowResize, false);
            window.addEventListener('pointerdown', onPointerDown, false);
            window.addEventListener('pointermove', onPointerMove, false);
            window.addEventListener('pointerup', onPointerUp, false);

            animate();
        }

        function createBalloons() {
            const balloonGeo = new THREE.SphereGeometry(1, 32, 32);
            balloonGeo.scale(1, 1.3, 1);
            
            const knotGeo = new THREE.CylinderGeometry(0.1, 0.25, 0.3, 16);
            const stringGeo = new THREE.CylinderGeometry(0.01, 0.01, 4, 8);
            const stringMat = new THREE.MeshBasicMaterial({ color: 0xdddddd });

            for (let i = 0; i < numBalloons; i++) {
                const hue = Math.random();
                const color = new THREE.Color().setHSL(hue, 0.8, 0.6);
                
                const balloonMat = new THREE.MeshStandardMaterial({ 
                    color: color,
                    roughness: 0.2,
                    metalness: 0.1
                });

                const balloonGroup = new THREE.Group();

                const balloonMesh = new THREE.Mesh(balloonGeo, balloonMat);
                balloonGroup.add(balloonMesh);

                const knotMesh = new THREE.Mesh(knotGeo, balloonMat);
                knotMesh.position.y = -1.35;
                balloonGroup.add(knotMesh);

                const stringMesh = new THREE.Mesh(stringGeo, stringMat);
                stringMesh.position.y = -3.35;
                balloonGroup.add(stringMesh);

                resetBalloonPosition(balloonGroup, true);

                balloonGroup.userData = {
                    speedY: Math.random() * 0.06 + 0.03,
                    wobbleSpeed: Math.random() * 2 + 1,
                    wobbleDistance: Math.random() * 0.05 + 0.02,
                    wobbleOffset: Math.random() * Math.PI * 2,
                    squishProgress: 0,
                    squishVel: 0,
                    scaleX: 1,
                    scaleY: 1,
                    scaleZ: 1
                };

                scene.add(balloonGroup);
                balloons.push(balloonGroup);
            }
        }

        function resetBalloonPosition(balloon, initial = false) {
            balloon.position.x = (Math.random() - 0.5) * 60;
            balloon.position.z = (Math.random() - 0.5) * 40 - 10;
            
            if (initial) {
                balloon.position.y = (Math.random() - 0.5) * 60;
            } else {
                balloon.position.y = -30 - (Math.random() * 10);
            }
        }

        function triggerSquish(balloon) {
            balloon.userData.squishProgress = -0.4;
            balloon.userData.squishVel = 0.08;
        }

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

            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(scene.children, true);

            if (intersects.length > 0) {
                let object = intersects[0].object;
                while (object.parent && object.parent.type === 'Group') {
                    object = object.parent;
                }

                if (object.userData) {
                    isDragging = true;
                    draggedBalloon = object;

                    triggerSquish(draggedBalloon);

                    dragPlane.setFromNormalAndCoplanarPoint(
                        camera.getWorldDirection(dragPlane.normal).negate(),
                        draggedBalloon.position
                    );
                }
            }
        }

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

            if (isDragging && draggedBalloon) {
                raycaster.setFromCamera(mouse, camera);
                if (raycaster.ray.intersectPlane(dragPlane, planeIntersect)) {
                    draggedBalloon.position.copy(planeIntersect);
                }
            }
        }

        function onPointerUp() {
            if (isDragging && draggedBalloon) {
                triggerSquish(draggedBalloon);
                isDragging = false;
                draggedBalloon = null;
            }
        }

        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        }

        function animate() {
            requestAnimationFrame(animate);

            const time = Date.now() * 0.001;

            balloons.forEach((balloon) => {
                if (!isDragging || draggedBalloon !== balloon) {
                    balloon.position.y += balloon.userData.speedY;
                    balloon.position.x += Math.sin(time * balloon.userData.wobbleSpeed + balloon.userData.wobbleOffset) * balloon.userData.wobbleDistance;
                    balloon.position.z += Math.cos(time * balloon.userData.wobbleSpeed * 0.8 + balloon.userData.wobbleOffset) * (balloon.userData.wobbleDistance * 0.5);
                }

                if (balloon.userData.squishVel !== 0 || balloon.userData.squishProgress !== 0) {
                    const k = 0.15;
                    const damping = 0.82;

                    const force = -k * balloon.userData.squishProgress;
                    balloon.userData.squishVel += force;
                    balloon.userData.squishVel *= damping;
                    balloon.userData.squishProgress += balloon.userData.squishVel;

                    const scaleY = 1 + balloon.userData.squishProgress;
                    const scaleXZ = 1 - (balloon.userData.squishProgress * 0.5);

                    balloon.userData.scaleX = scaleXZ;
                    balloon.userData.scaleY = scaleY;
                    balloon.userData.scaleZ = scaleXZ;

                    if (Math.abs(balloon.userData.squishProgress) < 0.001 && Math.abs(balloon.userData.squishVel) < 0.001) {
                        balloon.userData.squishProgress = 0;
                        balloon.userData.squishVel = 0;
                        balloon.userData.scaleX = 1;
                        balloon.userData.scaleY = 1;
                        balloon.userData.scaleZ = 1;
                    }
                }

                balloon.scale.set(
                    balloon.userData.scaleX,
                    balloon.userData.scaleY,
                    balloon.userData.scaleZ
                );

                if (balloon.position.y > 40) {
                    resetBalloonPosition(balloon);
                }
            });

            camera.position.x = Math.sin(time * 0.1) * 5;
            camera.lookAt(0, 10, 0);

            renderer.render(scene, camera);
        }
    </script>
</body>
</html>
Tirakas Gunkhan
Tirakas Gunkhan
http://tirakas.com
Brooklyn-based designer and visual artist with an omnidirectional creative practice, passionately integrating digital craft, physical narratives, and diverse artistic forms into immersive visual worlds. Driven by material curiosity, wearable art, circular design principles, and whimsical experimentation, his work transforms disparate media, forms, and ideas into unexpected, cohesive experiences that challenge conventions and reimagine the possibilities of artistic expression.
0:00
0:00