Menu
AkshatCodes
  • Log
  • Builds
  • About
Use dark theme
  • homeHome
  • constructionBuilds
  • personAbout
  • bookmarkLibrary
Designed with discipline.
AKSHATCODES © 2026
GithubX / TwitterInstagramLinkedInEmail
Designed with discipline.
/
·

/blog/flower-bloom-hand-gesture-mediapipe-javascript
Build LogJuly 3, 2026·30 min read
Save
Share

I Built a Flower That Blooms When You Pinch Your Fingers (Using Just JavaScript + MediaPipe)

A step-by-step breakdown of Flower Bloom, a real-time hand gesture controlled flower animation built with MediaPipe Hands and Canvas API — no ML training, no backend, just the browser.

Originally published at blog.akshatcodes.com


Building Flower Bloom: A Real-Time Hand Gesture Flower Controller (Full Code Tutorial)

This is the complete, code-first version of my Flower Bloom build-log. If you want the full MediaPipe Hands + Canvas API source code — not just the concepts — copy-paste your way through this one. By the end you'll have a working webcam app where your left hand pinch controls how much a flower blooms, your right hand pinch controls how tall it grows, and swaying your hands generates wind that bends the stem and scatters glowing pollen particles.

This project is a natural continuation of the gesture-tracking work I started with HandConnect — same core idea (MediaPipe landmarks driving a real-time visual), applied to procedural nature/generative art instead of a utility app.

What You'll Learn

  • How to wire up MediaPipe Hands for real-time 21-point landmark detection in the browser
  • How to calculate a normalized pinch gesture that works regardless of hand distance from the camera
  • How to fake organic, non-robotic motion using layered sine waves (a lightweight Perlin-noise alternative)
  • How to build a lightweight particle system on Canvas
  • How to draw procedural, generative graphics (petals, stems, branches) with bezier curves and gradients
  • How to fix the mirrored-canvas text problem that trips up most webcam-overlay projects
  • How to structure a smooth, self-correcting animation loop with requestAnimationFrame and delta-time

Tech Stack

  • MediaPipe Hands (@mediapipe/hands@0.4) — hand landmark detection, loaded via CDN
  • MediaPipe Camera Utils (@mediapipe/camera_utils@0.3) — webcam frame piping
  • HTML5 Canvas 2D API — all rendering (flower, particles, HUD, hand guides)
  • Vanilla JavaScript (ES6 classes) — no framework, no build step, no bundler

Prerequisites

  • Basic comfort with JavaScript ES6 classes
  • Basic familiarity with the Canvas 2D API (ctx.beginPath, ctx.fill, etc.)
  • A webcam
  • A way to serve static files locally (camera access requires localhost or HTTPS — opening index.html directly via file:// will NOT work)

Project Structure

flower-bloom/ ├── index.html ├── style.css └── app.js

Three files, zero dependencies to install. Let's build it top to bottom.


Step 1: The HTML Shell

The HTML is intentionally minimal: a <video> element for the raw webcam feed, a <canvas> sitting on top of it for all the drawing, plus a loading screen and an instructions toast. MediaPipe's two scripts are loaded from a CDN before our own app.js.

html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="Control a blooming flower with hand gestures in real-time using your webcam">
    <title>Flower Bloom — Hand Gesture Control</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <!-- Webcam feed -->
        <video id="webcam" autoplay playsinline></video>
 
        <!-- Canvas overlay for rendering flower + hand skeleton -->
        <canvas id="canvas"></canvas>
 
        <!-- Subtle vignette -->
        <div class="vignette"></div>
 
        <!-- Loading screen -->
        <div class="loading-overlay" id="loading">
            <div class="loading-flower"></div>
            <div class="loading-text">Initializing Hand Tracking</div>
            <div class="loading-subtext">Please allow camera access</div>
        </div>
 
        <!-- Instructions -->
        <div class="instructions" id="instructions">
            <span>Left hand pinch</span> controls Bloom &nbsp;·&nbsp;
            <span>Right hand pinch</span> controls Growth &nbsp;·&nbsp;
            <span>Sway hands</span> to create wind
        </div>
    </div>
 
    <!-- MediaPipe Hands -->
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands@0.4/hands.js" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils@0.3/camera_utils.js" crossorigin="anonymous"></script>
 
    <!-- Application -->
    <script src="app.js"></script>
</body>
</html>

A couple of details worth noting: playsinline on the <video> prevents iOS Safari from forcing fullscreen playback, and autoplay combined with muted-by-default browser behavior means the feed starts immediately once permission is granted.

Step 2: Styling the Stage

The CSS does three jobs: makes the video and canvas fill the screen and mirror like a selfie camera, styles the loading/instruction overlays with a glassy aesthetic, and adds a vignette so the generated flower pops against the webcam feed.

css
/* ============================================
   FLOWER BLOOM - Hand Gesture Controller
   ============================================ */
 
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
 
*,
*::before,
*::after {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
 
html, body {
    width: 100vw;
    height: 100vh;
    overflow: hidden;
    background: #000;
    font-family: 'Inter', -apple-system, sans-serif;
}
 
/* ---- Container ---- */
.container {
    position: relative;
    width: 100%;
    height: 100%;
}
 
/* ---- Webcam Video ---- */
#webcam {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    transform: scaleX(-1);
    z-index: 1;
}
 
/* ---- Canvas Overlay ---- */
#canvas {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    transform: scaleX(-1);
    z-index: 2;
    pointer-events: none;
}
 
/* ---- Loading Screen ---- */
.loading-overlay {
    position: absolute;
    inset: 0;
    z-index: 100;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: radial-gradient(ellipse at center, #1a0a1e 0%, #0a0510 60%, #000 100%);
    transition: opacity 0.8s ease, visibility 0.8s ease;
}
 
.loading-overlay.hidden {
    opacity: 0;
    visibility: hidden;
}
 
.loading-flower {
    width: 80px;
    height: 80px;
    position: relative;
    margin-bottom: 30px;
}
 
.loading-flower::before {
    content: '✿';
    font-size: 60px;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    animation: bloom-pulse 2s ease-in-out infinite;
    filter: drop-shadow(0 0 15px rgba(255, 120, 180, 0.8));
}
 
@keyframes bloom-pulse {
    0%, 100% {
        transform: translate(-50%, -50%) scale(0.8);
        opacity: 0.6;
        filter: drop-shadow(0 0 10px rgba(255, 120, 180, 0.5));
    }
    50% {
        transform: translate(-50%, -50%) scale(1.2);
        opacity: 1;
        filter: drop-shadow(0 0 25px rgba(255, 120, 180, 1));
    }
}
 
.loading-text {
    color: rgba(255, 200, 220, 0.9);
    font-size: 16px;
    font-weight: 300;
    letter-spacing: 3px;
    text-transform: uppercase;
    animation: text-fade 2s ease-in-out infinite;
}
 
.loading-subtext {
    color: rgba(255, 180, 200, 0.5);
    font-size: 12px;
    font-weight: 300;
    letter-spacing: 2px;
    margin-top: 10px;
}
 
@keyframes text-fade {
    0%, 100% { opacity: 0.5; }
    50% { opacity: 1; }
}
 
/* ---- Instruction Toast ---- */
.instructions {
    position: absolute;
    bottom: 40px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 10;
    background: rgba(0, 0, 0, 0.6);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: 1px solid rgba(255, 150, 200, 0.2);
    border-radius: 16px;
    padding: 16px 28px;
    color: rgba(255, 220, 240, 0.9);
    font-size: 14px;
    font-weight: 300;
    letter-spacing: 0.5px;
    text-align: center;
    line-height: 1.6;
    max-width: 520px;
    transition: opacity 1s ease, transform 1s ease;
    box-shadow: 0 8px 32px rgba(255, 100, 150, 0.1);
}
 
.instructions.hidden {
    opacity: 0;
    transform: translateX(-50%) translateY(20px);
    pointer-events: none;
}
 
.instructions span {
    color: rgba(255, 160, 200, 1);
    font-weight: 600;
}
 
/* ---- Vignette ---- */
.vignette {
    position: absolute;
    inset: 0;
    z-index: 3;
    pointer-events: none;
    background: radial-gradient(
        ellipse at center,
        transparent 50%,
        rgba(0, 0, 0, 0.4) 100%
    );
}

Notice both #webcam and #canvas get transform: scaleX(-1). Mirroring both together means the flower and hand guides stay perfectly aligned with your actual hand position on screen — this one CSS decision is what makes the whole overlay feel "attached" to your hand instead of floating independently.

Step 3: The Organic Noise Engine

Now into app.js. Everything in this app that needs to feel "alive" — stem sway, leaf flutter, petal flicker — runs through this small noise generator instead of raw Math.random(). It layers four sine waves at different frequencies and phases per "channel," which produces smooth, continuous, non-repeating motion — essentially a cheap, dependency-free stand-in for Perlin/Simplex noise.

js
/* ===========================================================
   FLOWER BLOOM — Real-time Hand Gesture Flower Controller
   ===========================================================
   Uses MediaPipe Hands to track hand gestures and render a
   procedural glowing flower that blooms, grows, and sways
   with wind — all on a Canvas overlay atop the webcam feed.
   =========================================================== */
 
// =============================================================
// NOISE — Organic movement via layered sine waves
// =============================================================
class OrganicNoise {
    constructor() {
        this.seeds = Array.from({ length: 8 }, () => Math.random() * 1000);
    }
 
    /** Returns a value roughly in [-1, 1] */
    get(t, channel = 0) {
        const s = this.seeds[channel % this.seeds.length];
        return (
            Math.sin(t * 0.7 + s) * 0.4 +
            Math.sin(t * 1.3 + s * 1.7) * 0.3 +
            Math.sin(t * 2.1 + s * 0.3) * 0.2 +
            Math.sin(t * 3.7 + s * 2.1) * 0.1
        );
    }
}

The channel parameter is the trick that makes this reusable: each call site (stem sway, leaf angle, petal flutter) passes a different channel number, so every part of the flower moves independently instead of pulsing in sync.

Step 4: The Particle System

60 small glowing "pollen" particles drift upward from the bottom of the screen, get nudged sideways by wind, flicker in brightness, and quietly recycle themselves once they die or drift off-screen — no garbage collection churn from constantly creating and destroying objects.

js
// =============================================================
// PARTICLE — Floating pollen / sparkle
// =============================================================
class Particle {
    constructor(cw, ch) {
        this.cw = cw;
        this.ch = ch;
        this.reset(true);
    }
 
    reset(initial = false) {
        this.x = Math.random() * this.cw;
        this.y = initial ? Math.random() * this.ch : this.ch + Math.random() * 40;
        this.radius = Math.random() * 2.5 + 0.5;
        this.vx = (Math.random() - 0.5) * 0.3;
        this.vy = -(Math.random() * 0.6 + 0.15);
        this.life = Math.random() * 300 + 150;
        this.maxLife = this.life;
        this.hue = 330 + Math.random() * 40;          // pink-ish
        this.brightness = 70 + Math.random() * 20;
        this.flickerPhase = Math.random() * Math.PI * 2;
    }
 
    update(windForce, dt) {
        this.x += this.vx + windForce * 1.8;
        this.y += this.vy;
        this.life -= dt;
        if (this.life <= 0 || this.y < -20 || this.x < -20 || this.x > this.cw + 20) {
            this.reset();
        }
    }
 
    draw(ctx) {
        const t = this.life / this.maxLife;
        const flicker = 0.5 + 0.5 * Math.sin(this.life * 0.08 + this.flickerPhase);
        const alpha = t * 0.75 * flicker;
        if (alpha < 0.02) return;
 
        ctx.save();
        ctx.globalAlpha = alpha;
        ctx.shadowBlur = 12;
        ctx.shadowColor = `hsla(${this.hue}, 100%, ${this.brightness}%, 0.8)`;
        ctx.fillStyle = `hsla(${this.hue}, 90%, ${this.brightness}%, 1)`;
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
    }
}

The alpha = t * 0.75 * flicker line is doing double duty: t (remaining life ÷ max life) fades particles out naturally as they age, while flicker layers a sine-based twinkle on top so they don't fade in a flat, linear way.

Step 5: The App Constructor & Initial Setup

This is the main controller class. The constructor wires up DOM references, initializes all gesture state to zero, and kicks off particle creation, hand tracking, and the render loop.

js
// =============================================================
// MAIN APPLICATION
// =============================================================
class FlowerBloomApp {
    constructor() {
        // DOM
        this.canvas = document.getElementById('canvas');
        this.ctx = this.canvas.getContext('2d');
        this.video = document.getElementById('webcam');
        this.loadingEl = document.getElementById('loading');
        this.instructionsEl = document.getElementById('instructions');
 
        // Noise
        this.noise = new OrganicNoise();
 
        // Time
        this.time = 0;
        this.lastTimestamp = 0;
 
        // Gesture state (smoothed values)
        this.bloom = 0;
        this.growth = 0;
        this.windForce = 0;
 
        // Gesture targets (raw from detection)
        this.targetBloom = 0;
        this.targetGrowth = 0;
        this.targetWindForce = 0;
 
        // Previous hand X for velocity-based wind
        this.prevHandX = 0.5;
 
        // Hand landmarks (updated each frame by MediaPipe)
        this.handLandmarks = [];
        this.handHandedness = [];
        this.handsDetected = 0;
 
        // Particles
        this.particles = [];
 
        // Setup
        this.resize();
        window.addEventListener('resize', () => this.resize());
 
        this.initParticles();
        this.initHandTracking();
 
        // Hide instructions after 8 seconds
        setTimeout(() => {
            this.instructionsEl?.classList.add('hidden');
        }, 8000);
 
        // Kick off render
        requestAnimationFrame((ts) => this.animate(ts));
    }

Notice the split between target values (targetBloom, targetGrowth, targetWindForce) and smoothed values (bloom, growth, windForce). Gesture detection writes to the targets; the animation loop slowly eases the smoothed values toward them every frame. That separation is what prevents the flower from looking twitchy.

Next, the resize and particle-init helpers:

js
    // ---------------------------------------------------------
    // Setup
    // ---------------------------------------------------------
    resize() {
        this.canvas.width = window.innerWidth;
        this.canvas.height = window.innerHeight;
 
        // Re-bound particles
        for (const p of this.particles) {
            p.cw = this.canvas.width;
            p.ch = this.canvas.height;
        }
    }
 
    initParticles() {
        const count = 60;
        for (let i = 0; i < count; i++) {
            this.particles.push(new Particle(this.canvas.width, this.canvas.height));
        }
    }

Step 6: Hooking Up MediaPipe Hands

This is where the actual computer vision setup happens. Hands loads MediaPipe's model files from a CDN, setOptions tunes accuracy vs. speed, and Camera (from camera_utils) handles the plumbing of feeding webcam frames into the model at a steady rate.

js
    initHandTracking() {
        const hands = new Hands({
            locateFile: (file) =>
                `https://cdn.jsdelivr.net/npm/@mediapipe/hands@0.4/${file}`,
        });
 
        hands.setOptions({
            maxNumHands: 2,
            modelComplexity: 1,
            minDetectionConfidence: 0.65,
            minTrackingConfidence: 0.5,
        });
 
        hands.onResults((r) => this.onHandResults(r));
 
        const cam = new Camera(this.video, {
            onFrame: async () => {
                await hands.send({ image: this.video });
            },
            width: 1280,
            height: 720,
        });
 
        cam.start().then(() => {
            setTimeout(() => this.loadingEl?.classList.add('hidden'), 600);
        });
    }

A few tuning notes for anyone adapting this:

  • modelComplexity: 1 is the sweet spot for browser use — 0 is faster but less accurate, 1 balances both well on most laptops.
  • minDetectionConfidence / minTrackingConfidence around 0.5–0.7 avoids both false positives and constant flicker between detected/not-detected.
  • maxNumHands: 2 is required here since Bloom and Growth are mapped to different hands simultaneously.

Step 7: Reading Gestures From Hand Landmarks

Every time MediaPipe processes a frame, onResults fires with an array of detected hands and their handedness labels. This is where raw landmark data becomes meaningful gesture values.

js
    // ---------------------------------------------------------
    // Hand results callback
    // ---------------------------------------------------------
    onHandResults(results) {
        this.handLandmarks = results.multiHandLandmarks || [];
        this.handHandedness = results.multiHandedness || [];
        this.handsDetected = this.handLandmarks.length;
 
        let leftPinch = 0;
        let rightPinch = 0;
        let hasLeft = false;
        let hasRight = false;
 
        if (this.handsDetected > 0) {
            for (let i = 0; i < this.handsDetected; i++) {
                const hand = this.handLandmarks[i];
                const handedness = results.multiHandedness[i];
                // MediaPipe handedness label is 'Left' or 'Right'
                const isLeft = handedness && handedness.label === 'Left';
                const pinch = this.calcPinchDistance(hand);
 
                if (isLeft) {
                    leftPinch = pinch;
                    hasLeft = true;
                } else {
                    rightPinch = pinch;
                    hasRight = true;
                }
 
                // Wind from hand horizontal velocity
                const c = this.palmCenter(hand);
                const dx = c.x - this.prevHandX;
                this.targetWindForce = dx * 12;
                this.prevHandX = c.x;
            }
 
            // Left hand controls Bloom
            this.targetBloom = hasLeft ? leftPinch : 0;
 
            // Right hand controls Growth
            this.targetGrowth = hasRight ? rightPinch : 0;
        } else {
            // No hands → slowly close and shrink back to 0
            this.targetBloom *= 0.94;
            this.targetGrowth *= 0.94;
            this.targetWindForce *= 0.9;
        }
    }

Worth calling out: when no hands are detected, the code doesn't just snap everything to zero — it decays the targets by * 0.94 each frame, so the flower gracefully closes and shrinks instead of vanishing the instant your hand leaves the frame.

Step 8: Pinch Distance & Palm Center Math

Two small geometry helpers do all the actual gesture math.

js
    /**
     * Pinch distance: distance between thumb tip (4) and index fingertip (8),
     * normalized by hand size so it works at any distance from the camera.
     * Returns 0 (pinched) → 1 (fully spread).
     */
    calcPinchDistance(lm) {
        const thumb = lm[4];   // thumb tip
        const index = lm[8];   // index fingertip
        const wrist = lm[0];
        const mcp = lm[9];     // middle-finger MCP
 
        // Reference = wrist-to-MCP distance (scales with hand size in frame)
        const ref = Math.hypot(mcp.x - wrist.x, mcp.y - wrist.y);
        if (ref < 0.01) return 0;
 
        const dist = Math.hypot(thumb.x - index.x, thumb.y - index.y);
        // Normalize: pinch ~0 when touching, ~1 when spread wide
        return Math.min(1, Math.max(0, (dist / ref - 0.15) * 1.6));
    }
 
    /** Rough palm center (average of wrist + MCP joints) */
    palmCenter(lm) {
        const ids = [0, 5, 9, 13, 17];
        let x = 0, y = 0;
        for (const i of ids) { x += lm[i].x; y += lm[i].y; }
        return { x: x / ids.length, y: y / ids.length };
    }

The normalization against ref (wrist-to-MCP distance) is the single most important line in this whole project if you're building your own gesture app. Raw pixel or normalized-coordinate distances between two fingertips change depending on how close your hand is to the camera. Dividing by a reference distance that scales with the hand itself makes the pinch value consistent whether your hand is 20cm or 80cm from the lens.

Step 9: Drawing Hand Guides on a Mirrored Canvas

This method draws the dashed guide line, glowing tip markers, and text label you see between your thumb and index finger while the app is tracking you.

js
    // =============================================================
    // RENDERING
    // =============================================================
 
    // ----- Hand Skeleton -----
    drawHandSkeleton(lm, handedness) {
        const ctx = this.ctx;
        const cw = this.canvas.width;
        const ch = this.canvas.height;
 
        // Draw guide line between thumb tip (4) and index fingertip (8)
        const thumbTip = lm[4];
        const indexTip = lm[8];
        if (thumbTip && indexTip) {
            const tx = thumbTip.x * cw;
            const ty = thumbTip.y * ch;
            const ix = indexTip.x * cw;
            const iy = indexTip.y * ch;
 
            const isLeft = handedness && handedness.label === 'Left';
            const labelText = isLeft ? '✿ Left Hand: Bloom' : '🌱 Right Hand: Grow';
            const glowColor = isLeft ? 'rgba(255, 80, 130, 0.85)' : 'rgba(56, 193, 114, 0.85)';
            const strokeStyle = isLeft ? '#ff5082' : '#38c172';
 
            ctx.save();
            ctx.setLineDash([4, 4]);
            ctx.lineWidth = 2;
            ctx.strokeStyle = strokeStyle;
            ctx.shadowBlur = 8;
            ctx.shadowColor = glowColor;
            ctx.beginPath();
            ctx.moveTo(tx, ty);
            ctx.lineTo(ix, iy);
            ctx.stroke();
            ctx.restore();
 
            // Draw glowing circles at thumb and index tips
            ctx.save();
            ctx.shadowBlur = 10;
            ctx.shadowColor = glowColor;
            ctx.fillStyle = strokeStyle;
            ctx.beginPath();
            ctx.arc(tx, ty, 6, 0, Math.PI * 2);
            ctx.arc(ix, iy, 6, 0, Math.PI * 2);
            ctx.fill();
            ctx.restore();
 
            // Draw unmirrored text label at the midpoint
            const midX = (tx + ix) / 2;
            const midY = (ty + iy) / 2;
 
            ctx.save();
            // Counter-flip coordinates on X around the canvas center to make text unmirrored
            ctx.translate(cw, 0);
            ctx.scale(-1, 1);
 
            ctx.font = 'bold 12px Inter, sans-serif';
            const textWidth = ctx.measureText(labelText).width;
            const paddingX = 10;
            const paddingY = 6;
            const pillWidth = textWidth + paddingX * 2;
            const pillHeight = 22;
 
            const drawX = cw - midX;
            const drawY = midY - 20; // draw slightly above the midpoint
 
            // Draw background pill
            ctx.fillStyle = 'rgba(10, 5, 20, 0.8)';
            ctx.strokeStyle = strokeStyle;
            ctx.lineWidth = 1;
            ctx.shadowBlur = 6;
            ctx.shadowColor = glowColor;
            ctx.beginPath();
            ctx.roundRect(drawX - pillWidth / 2, drawY - pillHeight / 2, pillWidth, pillHeight, 6);
            ctx.fill();
            ctx.stroke();
 
            // Draw text
            ctx.fillStyle = '#ffffff';
            ctx.shadowBlur = 0;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(labelText, drawX, drawY);
 
            ctx.restore();
        }
    }

This is the most reusable trick in the entire project. Because #canvas has transform: scaleX(-1) in CSS (to match the mirrored webcam), anything drawn normally would render backwards — including text. The fix is to ctx.translate(cw, 0) then ctx.scale(-1, 1) right before drawing the label, which flips the drawing operation itself so it cancels out the CSS mirror. Everything else (the line, the glowing dots) stays in mirrored space, which is fine since they're symmetric shapes with no "correct" orientation.

Step 10: Growing the Stem With Wind Physics

The stem isn't a static line — it's built from 24 small segments, each one bending progressively more toward the tip based on wind force, plus a layer of organic sway from the noise engine.

js
    // ----- Stem -----
    drawStem(baseX, baseY, height, windAngle) {
        const ctx = this.ctx;
        const segs = 24;
        const segH = height / segs;
 
        // Build stem path points
        const pts = [{ x: baseX, y: baseY }];
        for (let i = 1; i <= segs; i++) {
            const t = i / segs;
            const windBend = windAngle * t * t * 40;
            const sway = this.noise.get(this.time * 0.6 + i * 0.25, 0) * 10 * t;
            pts.push({
                x: baseX + windBend + sway,
                y: baseY - segH * i,
            });
        }
 
        // Draw stem (thick gradient line)
        ctx.save();
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
 
        // Outer glow
        ctx.lineWidth = 6;
        ctx.strokeStyle = 'rgba(40, 120, 35, 0.25)';
        ctx.shadowBlur = 8;
        ctx.shadowColor = 'rgba(80, 180, 60, 0.3)';
        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
        ctx.stroke();
 
        // Core stem
        ctx.lineWidth = 3.5;
        ctx.strokeStyle = '#3a8a30';
        ctx.shadowBlur = 0;
        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
        ctx.stroke();
 
        // Leaves
        this.drawLeaves(pts);
 
        ctx.restore();
 
        return { tip: pts[pts.length - 1], pts };
    }

The t * t (squared) term in windBend is a small but important detail: it means bend increases quadratically toward the tip rather than linearly, so the base of the stem stays anchored while the top whips around — exactly how a real flexible stem behaves in wind.

Leaves are placed at fixed positions along the stem (25%, 45%, 65% up) and alternate sides:

js
    drawLeaves(stemPts) {
        const ctx = this.ctx;
        const positions = [0.25, 0.45, 0.65];
 
        for (let li = 0; li < positions.length; li++) {
            const idx = Math.floor(positions[li] * (stemPts.length - 1));
            const pt = stemPts[idx];
            const side = li % 2 === 0 ? 1 : -1;
            const len = 22 + this.growth * 18;
            const angle = side * (0.45 + this.noise.get(this.time * 0.6 + li * 3, 2) * 0.2);
 
            ctx.save();
            ctx.translate(pt.x, pt.y);
            ctx.rotate(angle);
 
            const grad = ctx.createLinearGradient(0, 0, len, 0);
            grad.addColorStop(0, 'rgba(55, 140, 45, 0.8)');
            grad.addColorStop(1, 'rgba(75, 170, 60, 0.4)');
            ctx.fillStyle = grad;
            ctx.shadowBlur = 4;
            ctx.shadowColor = 'rgba(80, 200, 60, 0.25)';
 
            ctx.beginPath();
            ctx.moveTo(0, 0);
            ctx.quadraticCurveTo(len * 0.5, -10, len, -1);
            ctx.quadraticCurveTo(len * 0.5, 10, 0, 0);
            ctx.fill();
 
            // Leaf vein
            ctx.strokeStyle = 'rgba(90, 180, 70, 0.3)';
            ctx.lineWidth = 0.8;
            ctx.beginPath();
            ctx.moveTo(3, 0);
            ctx.lineTo(len * 0.8, 0);
            ctx.stroke();
 
            ctx.restore();
        }
    }

Each leaf shape is just two quadratic curves mirrored around a center line — a simple technique that works for lots of organic leaf/petal shapes.

Step 11: Procedural Tulip Petals

This is the heart of the visual — a tulip-style flower head built from six petals across two layers (back and front), plus a stamen cluster that appears once the flower is partially open.

js
    // ----- Flower Head (Tulip facing upward) -----
    drawFlowerHead(cx, cy, bloom, windAngle, scale) {
        // Boost scale as it blooms to make it feel more dynamic and organic
        const bloomScaleFactor = 1.0 + bloom * 0.18;
        const adjustedScale = scale * bloomScaleFactor;
        const ctx = this.ctx;
 
        ctx.save();
        ctx.translate(cx, cy);
 
        // --- Ambient glow behind flower ---
        const glowR = (60 + bloom * 120) * adjustedScale;
        if (bloom > 0.02) {
            const glow = ctx.createRadialGradient(0, -glowR * 0.4, 0, 0, -glowR * 0.4, glowR);
            glow.addColorStop(0, `rgba(255, 80, 130, ${0.4 * bloom})`);
            glow.addColorStop(0.5, `rgba(255, 50, 100, ${0.2 * bloom})`);
            glow.addColorStop(1, 'rgba(255, 30, 70, 0)');
            ctx.fillStyle = glow;
            ctx.beginPath();
            ctx.arc(0, -glowR * 0.4, glowR, 0, Math.PI * 2);
            ctx.fill();
        }
 
        // Base color definitions (tulip uses beautiful pink/coral/yellow tones)
        const hue = 345; // Pink/crimson base
        const sat = 85;
        const light = 55;
 
        // --- Tulip Petal Layers ---
        // Back/outer layer (drawn first)
        // Center back, left back, right back
        const backPetals = [
            { angle: 0, lengthMul: 1.0, widthMul: 0.4, hueOffset: 0, lightOffset: -4 },
            { angle: -0.15 - bloom * 0.7, lengthMul: 0.95, widthMul: 0.38, hueOffset: 10, lightOffset: -2 },
            { angle: 0.15 + bloom * 0.7, lengthMul: 0.95, widthMul: 0.38, hueOffset: 10, lightOffset: -2 }
        ];
 
        // Front/inner layer (drawn on top)
        // Left front, right front, center front
        const frontPetals = [
            { angle: -0.05 - bloom * 0.55, lengthMul: 0.9, widthMul: 0.35, hueOffset: 5, lightOffset: 2 },
            { angle: 0.05 + bloom * 0.55, lengthMul: 0.9, widthMul: 0.35, hueOffset: 5, lightOffset: 2 },
            { angle: 0, lengthMul: 0.85, widthMul: 0.32, hueOffset: -5, lightOffset: 5 }
        ];
 
        const maxPetalLen = 85 * adjustedScale;
 
        // Draw back petals
        for (const p of backPetals) {
            const flutter = this.noise.get(this.time * 1.2 + p.angle * 10, 3) * 0.04 * (1 + bloom);
            const finalAngle = p.angle + flutter + windAngle * 0.1;
            const len = maxPetalLen * p.lengthMul;
            const wid = maxPetalLen * p.widthMul * (0.6 + bloom * 0.8);
 
            this.drawTulipPetal(ctx, finalAngle, len, wid, hue + p.hueOffset, sat, light + p.lightOffset, bloom);
        }
 
        // Draw center details (stamen/pistil) if open
        if (bloom > 0.15) {
            ctx.save();
            ctx.shadowBlur = 0;
            // Center pistil (greenish yellow)
            ctx.fillStyle = `rgba(180, 220, 100, ${bloom})`;
            ctx.beginPath();
            ctx.arc(0, -maxPetalLen * 0.2, 5 * adjustedScale, 0, Math.PI * 2);
            ctx.fill();
 
            // Stamens around pistil
            const stamenCount = 4;
            for (let i = 0; i < stamenCount; i++) {
                const a = (i / stamenCount) * Math.PI * 2 + this.time * 0.5;
                const r = 8 * adjustedScale * bloom;
                const sx = Math.cos(a) * r;
                const sy = -maxPetalLen * 0.2 + Math.sin(a) * r;
 
                // filament
                ctx.strokeStyle = `rgba(220, 200, 80, ${bloom * 0.7})`;
                ctx.lineWidth = 1.5 * adjustedScale;
                ctx.beginPath();
                ctx.moveTo(0, -maxPetalLen * 0.1);
                ctx.lineTo(sx, sy);
                ctx.stroke();
 
                // anther
                ctx.fillStyle = `rgba(255, 235, 120, ${bloom})`;
                ctx.beginPath();
                ctx.arc(sx, sy, 2.5 * adjustedScale, 0, Math.PI * 2);
                ctx.fill();
            }
            ctx.restore();
        }
 
        // Draw front petals
        for (const p of frontPetals) {
            const flutter = this.noise.get(this.time * 1.4 + p.angle * 10, 4) * 0.03 * (1 + bloom);
            const finalAngle = p.angle + flutter + windAngle * 0.05;
            const len = maxPetalLen * p.lengthMul;
            const wid = maxPetalLen * p.widthMul * (0.65 + bloom * 0.75);
 
            this.drawTulipPetal(ctx, finalAngle, len, wid, hue + p.hueOffset, sat, light + p.lightOffset, bloom);
        }
 
        ctx.restore();
    }

And the actual petal shape — two mirrored bezier curves forming a teardrop, with a gradient and glow that both respond to the bloom value:

js
    drawTulipPetal(ctx, angle, length, width, hue, sat, light, bloom) {
        ctx.save();
        ctx.rotate(angle);
 
        // Gradient from base (darker/coral) to top (bright pink/yellow)
        const grad = ctx.createLinearGradient(0, 0, 0, -length);
        grad.addColorStop(0, `hsla(${hue + 25}, ${sat}%, ${light - 8}%, 0.9)`);
        grad.addColorStop(0.4, `hsla(${hue}, ${sat}%, ${light}%, 0.85)`);
        grad.addColorStop(0.85, `hsla(${hue - 10}, ${sat + 10}%, ${light + 10}%, 0.85)`);
        grad.addColorStop(1, `hsla(${hue - 20}, ${sat + 15}%, ${light + 18}%, 0.95)`);
 
        ctx.fillStyle = grad;
        // Outer glow
        ctx.shadowBlur = 12 + bloom * 18;
        ctx.shadowColor = `hsla(${hue}, 100%, 65%, ${0.25 + bloom * 0.4})`;
 
        // Tulip petal shape pointing straight up (-Y direction)
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.bezierCurveTo(
            -width * 1.1, -length * 0.3,
            -width * 0.9, -length * 0.85,
            0, -length
        );
        ctx.bezierCurveTo(
            width * 0.9, -length * 0.85,
            width * 1.1, -length * 0.3,
            0, 0
        );
        ctx.fill();
 
        // Subtle petal vein
        ctx.shadowBlur = 0;
        ctx.strokeStyle = `hsla(${hue + 15}, ${sat}%, ${light + 15}%, 0.25)`;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.lineTo(0, -length * 0.85);
        ctx.stroke();
 
        ctx.restore();
    }

Every petal is drawn in local space (starting at the origin, pointing up), then positioned using ctx.rotate() before drawing. This is a core Canvas pattern: model your shape once assuming it's at (0,0) facing a fixed direction, then let transforms (translate/rotate/scale) handle positioning — much simpler than computing rotated coordinates by hand.

Step 12: Side Branches & Sub-Blooms

Four smaller branches sprout off the main stem, each ending in its own miniature flower head — same drawFlowerHead function, just called with a smaller scale.

js
    // ----- Side Branches -----
    drawBranch(startX, startY, baseAngle, length, windAngle, scale) {
        const ctx = this.ctx;
        const segs = 12;
        const segL = length / segs;
        const pts = [{ x: startX, y: startY }];
 
        for (let i = 1; i <= segs; i++) {
            const t = i / segs;
            const windBend = windAngle * t * t * 15;
            const sway = this.noise.get(this.time * 0.8 + i * 0.3, 5) * 4 * t;
            const angle = baseAngle + windBend * 0.02 + sway * 0.01;
 
            pts.push({
                x: pts[pts.length - 1].x + Math.cos(angle) * segL + windBend * 0.3,
                y: pts[pts.length - 1].y + Math.sin(angle) * segL
            });
        }
 
        // Draw the branch stem
        ctx.save();
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
 
        // Outer glow
        ctx.lineWidth = 4 * scale;
        ctx.strokeStyle = 'rgba(40, 120, 35, 0.2)';
        ctx.shadowBlur = 6;
        ctx.shadowColor = 'rgba(80, 180, 60, 0.25)';
        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
        ctx.stroke();
 
        // Core branch
        ctx.lineWidth = 2.5 * scale;
        ctx.strokeStyle = '#3a8a30';
        ctx.shadowBlur = 0;
        ctx.beginPath();
        ctx.moveTo(pts[0].x, pts[0].y);
        for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
        ctx.stroke();
 
        ctx.restore();
 
        // Draw a leaf on the branch
        this.drawBranchLeaves(pts, scale);
 
        return pts[pts.length - 1]; // return branch tip
    }
 
    drawBranchLeaves(branchPts, scale) {
        const ctx = this.ctx;
        if (branchPts.length < 5) return;
        
        // Leaf in the middle of the branch
        const midIdx = Math.floor(branchPts.length * 0.5);
        const pt = branchPts[midIdx];
        const prevPt = branchPts[midIdx - 1];
        if (!pt || !prevPt) return;
        
        const angle = Math.atan2(pt.y - prevPt.y, pt.x - prevPt.x) + Math.PI / 2;
        const len = 12 * scale * (1 + this.growth);
 
        ctx.save();
        ctx.translate(pt.x, pt.y);
        ctx.rotate(angle);
 
        const grad = ctx.createLinearGradient(0, 0, len, 0);
        grad.addColorStop(0, 'rgba(55, 140, 45, 0.8)');
        grad.addColorStop(1, 'rgba(75, 170, 60, 0.4)');
        ctx.fillStyle = grad;
 
        ctx.beginPath();
        ctx.moveTo(0, 0);
        ctx.quadraticCurveTo(len * 0.5, -5, len, -1);
        ctx.quadraticCurveTo(len * 0.5, 5, 0, 0);
        ctx.fill();
 
        ctx.restore();
    }

drawBranch returns its own tip coordinate, which the animation loop then feeds straight into drawFlowerHead — that's how each branch ends up with its own bloom instead of just being a bare stick.

Step 13: The HUD Overlay

A small heads-up display in the corner shows live Bloom, Grow, and Wind values — genuinely useful for debugging gesture sensitivity while you tune the app.

js
    // ----- HUD Overlay -----
    drawHUD() {
        const ctx = this.ctx;
        const cw = this.canvas.width;
 
        ctx.save();
 
        // Counter-flip to undo the CSS scaleX(-1) so text is readable
        ctx.translate(cw, 0);
        ctx.scale(-1, 1);
 
        ctx.font = '600 15px Inter, sans-serif';
        ctx.textAlign = 'left';
 
        // Position in screen-space top-right (which is canvas top-left after flip)
        const px = 20;
        const py = 22;
 
        // Background pill
        ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
        ctx.beginPath();
        ctx.roundRect(px - 10, py - 14, 138, 78, 10);
        ctx.fill();
 
        // Bloom
        ctx.fillStyle = 'rgba(255, 170, 200, 0.95)';
        ctx.shadowBlur = 6;
        ctx.shadowColor = 'rgba(255, 100, 150, 0.4)';
        ctx.fillText(`Bloom: ${this.bloom.toFixed(2)}`, px, py + 6);
 
        // Growth
        ctx.fillStyle = 'rgba(140, 255, 140, 0.95)';
        ctx.shadowColor = 'rgba(80, 255, 80, 0.4)';
        ctx.fillText(`Grow: ${this.growth.toFixed(2)}`, px, py + 28);
 
        // Wind
        ctx.fillStyle = 'rgba(140, 200, 255, 0.95)';
        ctx.shadowColor = 'rgba(80, 150, 255, 0.4)';
        ctx.fillText(`Wind: ${this.windForce.toFixed(2)}`, px, py + 50);
 
        ctx.restore();
    }

Same counter-flip trick from Step 9 shows up again here — any time you draw readable text on this canvas, you'll reach for translate(cw, 0) + scale(-1, 1) first.

Step 14: Post-Processing Glow

A cheap "bloom lighting" effect using globalCompositeOperation = 'screen' — this makes bright colors layer additively, giving the flower a soft neon halo without needing an actual WebGL post-processing pipeline.

js
    // ----- Post-process Glow -----
    drawPostGlow(cx, cy) {
        if (this.bloom < 0.05) return;
        const ctx = this.ctx;
 
        ctx.save();
        ctx.globalCompositeOperation = 'screen';
        const r = 120 + this.bloom * 160;
        const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
        g.addColorStop(0, `rgba(255, 110, 150, ${this.bloom * 0.18})`);
        g.addColorStop(0.5, `rgba(255, 70, 110, ${this.bloom * 0.08})`);
        g.addColorStop(1, 'rgba(255, 50, 80, 0)');
        ctx.fillStyle = g;
        ctx.beginPath();
        ctx.arc(cx, cy, r, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
    }

The early return when bloom < 0.05 is a small but meaningful performance guard — no point running a radial gradient fill on every frame for a flower that's still closed.

Step 15: The Animation Loop

Everything converges here. This method runs every frame via requestAnimationFrame, and does five things in order: smooth the gesture values, compute wind, clear the canvas, draw hands/particles, then draw the stem + branches + flower heads.

js
    // =============================================================
    // ANIMATION LOOP
    // =============================================================
    animate(timestamp) {
        const dt = this.lastTimestamp ? (timestamp - this.lastTimestamp) / 16.67 : 1; // normalised to ~60fps
        this.lastTimestamp = timestamp;
        this.time += 0.016 * dt;
 
        const ctx = this.ctx;
        const cw = this.canvas.width;
        const ch = this.canvas.height;
 
        // ---- Smooth interpolation ----
        const lerpSpeed = 0.07 * dt;
        this.bloom += (this.targetBloom - this.bloom) * lerpSpeed;
        this.growth += (this.targetGrowth - this.growth) * 0.05 * dt;
        this.windForce += (this.targetWindForce - this.windForce) * 0.06 * dt;
 
        // Natural wind always present
        const naturalWind = this.noise.get(this.time * 0.7, 1) * 0.12;
        const totalWind = naturalWind + this.windForce * 0.18;
 
        // ---- Clear ----
        ctx.clearRect(0, 0, cw, ch);
 
        // ---- Draw hand skeletons ----
        for (let i = 0; i < this.handLandmarks.length; i++) {
            const lm = this.handLandmarks[i];
            const handedness = this.handHandedness[i];
            this.drawHandSkeleton(lm, handedness);
        }
 
        // ---- Particles ----
        for (const p of this.particles) {
            p.update(totalWind, dt);
            p.draw(ctx);
        }
 
        // ---- Main flower ----
        if (this.growth > 0.005) {
            const stemBaseX = cw * 0.75;
            const stemBaseY = ch * 0.95;
            const stemH = ch * 0.45 * this.growth;
            const flowerScale = 1.25 * this.growth;
 
            const stemData = this.drawStem(stemBaseX, stemBaseY, stemH, totalWind);
            const tip = stemData.tip;
            const pts = stemData.pts;
 
            // Draw 4 branches and their sub-flowers
            const branchConfigs = [
                {
                    heightRatio: 0.3,
                    direction: -1, // left
                    lengthFactor: 0.16,
                    scaleFactor: 0.42
                },
                {
                    heightRatio: 0.45,
                    direction: 1, // right
                    lengthFactor: 0.14,
                    scaleFactor: 0.45
                },
                {
                    heightRatio: 0.6,
                    direction: -1, // left
                    lengthFactor: 0.12,
                    scaleFactor: 0.48
                },
                {
                    heightRatio: 0.75,
                    direction: 1, // right
                    lengthFactor: 0.1,
                    scaleFactor: 0.4
                }
            ];
 
            for (const config of branchConfigs) {
                const idx = Math.floor(pts.length * config.heightRatio);
                if (idx > 0 && idx < pts.length) {
                    const pt = pts[idx];
                    const prevPt = pts[idx - 1] || pt;
                    const tangent = Math.atan2(pt.y - prevPt.y, pt.x - prevPt.x);
                    
                    const branchAngle = tangent + (config.direction * 0.75);
                    const branchLength = ch * config.lengthFactor * this.growth;
                    
                    const branchTip = this.drawBranch(pt.x, pt.y, branchAngle, branchLength, totalWind, this.growth);
                    const subFlowerScale = flowerScale * config.scaleFactor;
                    
                    this.drawFlowerHead(branchTip.x, branchTip.y, this.bloom, totalWind, subFlowerScale);
                    this.drawPostGlow(branchTip.x, branchTip.y);
                }
            }
 
            // Main flower head
            this.drawFlowerHead(tip.x, tip.y, this.bloom, totalWind, flowerScale);
 
            // ---- Post-process glow ----
            this.drawPostGlow(tip.x, tip.y);
        }
 
        // ---- HUD ----
        this.drawHUD();
 
        requestAnimationFrame((ts) => this.animate(ts));
    }
}

Two things worth internalizing from this loop if you're building your own real-time Canvas app:

  1. Delta-time normalization (dt = (timestamp - lastTimestamp) / 16.67) keeps motion speed consistent even if the frame rate dips from 60fps to 30fps — without it, animations speed up or slow down depending on device performance.
  2. Branch configs as data, not hardcoded draw calls. The four branches are defined as an array of plain objects (heightRatio, direction, lengthFactor, scaleFactor) and looped over. Want a fifth branch? Add one object to the array — no new function needed.

Step 16: Booting the App

The very last few lines simply wait for the DOM to be ready, then construct the app:

js
// =============================================================
// BOOT
// =============================================================
window.addEventListener('DOMContentLoaded', () => {
    new FlowerBloomApp();
});

That's the entire application — one class-based file, no build tooling, no external state management, just DOM APIs and Canvas.


Running It Locally

  1. Save the three files (index.html, style.css, app.js) into one folder.
  2. Serve the folder with any static server — camera access is blocked on file:// URLs. Quick options:
    • npx serve .
    • VS Code's "Live Server" extension
    • python -m http.server 8000
  3. Open the served URL (e.g. http://localhost:8000) and allow camera access when prompted.
  4. Pinch your left hand thumb and index finger together, then spread them apart — watch Bloom respond.
  5. Do the same with your right hand to control Growth.
  6. Sway either hand side to side to kick up wind.

Common Issues & Fixes

  • Camera won't start / blank screen: you're probably opening the file directly (file://...). Serve it over localhost or https://.
  • Hands not detected: make sure lighting is decent and your hand is fully in frame — MediaPipe needs a clear view of the wrist and knuckle landmarks to compute handedness reliably.
  • Flower feels laggy on lower-end devices: try dropping modelComplexity to 0 in initHandTracking(), and/or reducing the particle count in initParticles().
  • Text looks mirrored: if you add your own labels anywhere on the canvas, remember the counter-flip pattern from Step 9 — forgetting it is the single most common bug when building mirrored webcam overlays.

Ideas to Extend This Project

  • Swap flower species (rose, lily, sunflower) using a fist gesture to cycle through presets
  • Add a "record" button that exports a few seconds of canvas frames as a shareable GIF/MP4
  • Extract the gesture-detection layer into a reusable hook/module so it can plug into other creative-coding experiments — the same pattern that powers HandConnect
  • Swap the hardcoded pinch thresholds for a small calibration step that adapts to each user's hand size and camera distance

Wrapping Up

That's the full Flower Bloom build — three files, zero backend, one webcam. If you're a CS/IT student looking to stand out with something beyond another CRUD project, cloning and remixing this is a solid weekend build that touches real-time computer vision, procedural generative art, and Canvas performance patterns all at once.

If you build your own version or swap in a different flower/creature, tag me — I'd love to see what you come up with.


More webcam and gesture-based build-logs like HandConnect and Flower Bloom are going up regularly — follow along on @code.akshat.in.

Akshat Singh

Written by Akshat Singh

35K+ followers
code

Hey, I'm Akshat — a full-stack dev, AI tinkerer, and relentless builder who documents every step of the journey. I share what I learn in real-time — dev tutorials, design insights, and AI + tech news.

← Older
30 Government Internships for BTech Students in 2026 (With Direct Links)

Comments

progress_activityLoading comments…