GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

JavaScript 3D Animation: 9 Pointer-Led Models

A 3D website can run on point coordinates and one projection formula — 02 twists a knot tube toward the pointer, 05 springs a box's flaps open, and 08

Every model below is drawn on a <canvas> with the 2D context. Rotation matrices, a perspective divide and a far-to-near sort decide where each polygon, dot and line lands, while the stylesheet only holds the 480:300 stage, the buttons and the focus ring. No library, CDN or WebGL is loaded.

Spin Nine Pointer-Led 3D Models Right on This Page

Auto-plays · click a tile to jump to its section · all nine in one zip

JavaScript 3D Animation: 9 Models — Polyhedron, Knot, Point Sphere, Gears, Box, Helix, Rings, Voxels and Morphing Points

The order opens with a polyhedron made of lines alone (01), moves on to a tube with painted faces (02), then drops faces entirely for a sphere of dots (03). Gears that mesh (04) and a box whose flaps swing on hinges (05) deal with parts that depend on one another. In 06 and 07 the pointer sets the spin speed itself, and in 08 and 09 the pieces that make up the model fly apart or regroup into a different shape. None of the nine reads pixels back with getImageData; they fill paths and stroke arcs and lines, and that is all.

Five calculations the code does, and three it leaves out

The toolkit comes down to five jobs: multiply each point by a rotation matrix, divide by depth to get screen coordinates, sort what gets painted from far to near, skip faces whose normal points away from the viewer, and set brightness from the dot product of a face normal and the light direction. Each model uses the jobs it needs, and the depth section below says which ones use which. There is no per-pixel depth buffer, no shadow casting and no texture mapping, so reading this code as a general-purpose 3D engine would set the wrong expectations. What the nine models do show is that those five jobs are enough to turn one object on a landing page.

What the two-second capture recorded for each model

Each model was captured on a 480×300 stage as 24 frames spread over two seconds, and motion is counted between neighboring frames, so the moving-frame column runs out of 23.

Model Cumulative area Moving frames Mean intensity
01 Inertia wireframe 21.1% 23/23 83.3
02 Torus knot tube 20.7% 23/23 62.2
03 Dented point sphere 24.1% 23/23 85.4
04 Meshing gears 10.6% 23/23 93.6
05 Spring-flap box 20.7% 23/23 62.7
06 Twisting double helix 18.1% 23/23 81.3
07 Flicked nested rings 23.7% 23/23 84.6
08 Scattering voxels 11.9% 23/23 58.2
09 Morphing points 20.5% 23/23 84.7

All nine changed across every one of the 23 gaps, and no model fell below 10% area. Even the widest, 03 at 24.1%, stays short of the 30% that the canvas scene-effects post aims for in full scenes, because each model fills only part of the stage and the empty background around it does not change. 04 and 08 sit lowest: in 04 only the tooth edges of gears turning in place change pixels, and in 08 the heart barely moves except in the moments when its voxels scatter.

Picking a model for the page it will sit on

For a landing page that needs one object to stand for the brand, the 01 polyhedron is the lightest option, while a portfolio that sells form itself suits the 02 knot. The 03 point cloud fits the hero of a data or AI product, and the 04 gears belong in a "how it works" section. A product reveal or a gift campaign lines up with the 05 box, and the 06 helix is a direct fit for a biotech or healthcare front page. 07 invites a flick, which makes it good decoration for a waiting screen, 08 suits a game intro or an event page, and 09 works behind a section that steps through the stages of a service.

When a 3d website design calls for a single object in the hero, these files are already sized for it: one model sits on the right side of the stage, centered at (300, 150), so it clears the buttons at the top left and the status line at the bottom left.

How Does a Dot on a Flat Screen Read as a Model With Depth?

All nine share one coordinate system. Right on the screen is +x, down is +y and into the screen is +z, so a face looking at the viewer has a normal with a negative z. Points are turned by three rotation matrices, rotX, rotY and rotZ, and the view matrix viewMatrix(yaw, pitch) spins around the vertical axis first and then tilts around the horizontal one.

Dividing by depth shrinks far points and pulls them inward

Moving a rotated point onto the screen is the job of the single project function below. The scale is s = focal ÷ (focal + z), so a larger z gives a smaller s and pulls the screen position toward the model center. With 01's default FOCAL_PX of 600 and a polyhedron whose circumscribed radius is 110px, the nearest vertex is drawn at 600/490 = 1.224× and the farthest at 600/710 = 0.845×, a 1.45× difference between front and back.

    // Perspective divide — screen scale s = focal / (focal + z). The larger the depth z (farther in), the smaller the point and the closer it pulls to the center
    function project(p, focal) {
        const s = focal / (focal + p[2]);
        return { x: CX + p[0] * s, y: CY + p[1] * s, s: s, z: p[2] };
    }
    // Light — shines from the upper left, in front
    const LIGHT = vnorm([-0.45, -0.62, -0.64]);
    function lambert(n) { return Math.max(0, dot(n, LIGHT)); }

Scale alone is not enough, so order and brightness join in

Scaling by itself can let the back cover the front wherever parts overlap. The eight models other than 09 therefore sort by depth before painting and fill from the far side forward (04 sorts twice, once for side walls and once for the two wheels), and 01, 02, 04, 05 and 08 skip faces whose normal points away from the screen. 07 keeps the inner faces of its rings and paints them darker instead of dropping them. Each painted face takes its brightness from the diffuse term max(0, n·L), the dot product of its normal with the light LIGHT from the upper left front, clipped at zero, so the same color reads bright where a face meets the light and darker where it turns aside.

From Pointer Position to Rotation Angle, and a Model That Eases After It

The pointer position is turned into a ratio inside the canvas with getBoundingClientRect and scaled to the 480×300 scene. In 01, 02, 04, 05, 08 and 09, how far that point lies from the model center horizontally, as a share of half the stage clamped to −1 to 1, is multiplied by YAW_RANGE of 1.1 rad, and the vertical share by PITCH_RANGE of 0.8 rad; those products are the target angles, though 04 first scales the offset by 0.45 and 05 and 08 by 0.4 so they turn less. 03 uses the pointer for the dent, 06 for the twist speed and 07 for dragging, and 03 calls aimAt only for the virtual pointer of its self-running demo. In the seven models other than 06 and 07 the view does not jump to its target angles, because viewFollow closes only a fraction k of the remaining gap on each 16ms step.

    function viewFollow(v, k) {
        v.yaw += (v.yawTo - v.yaw) * k;
        v.pitch += (v.pitchTo - v.pitch) * k;
    }
    function viewSnap(v) { v.yaw = v.yawTo; v.pitch = v.pitchTo; }
    function viewHome(v) { v.yawTo = v.yaw0; v.pitchTo = v.pitch0; }
    function viewGap(v) { return Math.hypot(v.yawTo - v.yaw, v.pitchTo - v.pitch); }
    // Pointer → target angles. Horizontal offset from the model center sets yaw, vertical offset sets pitch (point right and the front face turns right)
    const YAW_RANGE = 1.1;
    const PITCH_RANGE = 0.8;
    function aimAt(v, px, py) {
        v.yawTo = v.yaw0 - clamp((px - CX) / (W / 2), -1, 1) * YAW_RANGE;
        v.pitchTo = v.pitch0 + clamp((py - CY) / (H / 2), -1, 1) * PITCH_RANGE;
    }

Counting in fixed 16ms steps rather than in frames

The time differences handed over by requestAnimationFrame pile up (capped at 100ms per frame), and every time 16ms can be taken out of the pile, step() runs once, up to six times per frame, before draw() is called. That keeps follow speed and friction independent of the display's refresh rate. The arrow keys move the same target angles: in 01 to 05, 08 and 09 each press turns the view by KEY_STEP of 0.3 rad, while 06 puts the arrows to work on twist speed and tilt, and 07 uses only ←/→, for flicks.

01A Wireframe Polyhedron That Eases Toward the Pointer and Fades With Depth

Three sets of (0, ±1, ±φ) give the 12 vertices, the 30 pairs sitting 2 apart in those raw coordinates become edges and the 20 triples of mutual neighbors become faces, while the model closes 8% of the gap to its target angle every 16ms and needs 28 steps, 448ms, to cut that gap to 10%. Edges are drawn far to near with their width and opacity trimmed by depth, so where the depth value reaches its far end both drop to 25%, and the width is also multiplied by the perspective scale. It suits a brand object in a landing hero, and the render recorded 21.1% cumulative area, 83.3 mean intensity and 23/23 moving frames.

A Wireframe Polyhedron That Eases Toward the Pointer and Fades With Depth
Open in a new tab

01 · A Wireframe Polyhedron That Eases Toward the Pointer and Fades With Depth — Measured area 21.065% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROT_LERPFOCAL_PXDEPTH_FADE
    // 12 vertices — rotate (0, ±1, ±φ) through the x·y·z slots to make three sets
    function icosaRaw() {
        const out = [];
        for (let i = 0; i < 4; i += 1) {
            const a = i & 1 ? 1 : -1;
            const b = i & 2 ? PHI : -PHI;
            out.push([0, a, b]);
            out.push([a, b, 0]);
            out.push([b, 0, a]);
        }
        return out;
    }
    const RAW = icosaRaw();
    const VERTS = RAW.map(function (p) { return vscale(vnorm(p), R); });
    // 30 edges — vertex pairs that sit 2 apart in the raw coordinates
    const EDGES = [];
    for (let i = 0; i < RAW.length; i += 1) {
        for (let j = i + 1; j < RAW.length; j += 1) {
            const d = vsub(RAW[i], RAW[j]);
            if (Math.abs(dot(d, d) - 4) < 1e-6) {
                EDGES.push([i, j]);
            }
        }
    }

02A Torus Knot Tube That Twists to Follow the Pointer

At 132 points along the (2, 3) knot curve the code sets up a frame from the tangent, the normal toward the curvature and a third axis perpendicular to both, then rings each point with 10 vertices at a 13px radius for a tube of 1,320 faces. Faces turned away from the screen are dropped and the rest are painted in order of average depth, so the default view draws only 683 of them, and a press on the canvas or the Another knot button rebuilds the tube as (3, 5) or (2, 5). Its shape is the whole show, which fits a portfolio hero, and the measured values are 20.7% cumulative area, 62.2 mean intensity and 23/23 moving frames.

A Torus Knot Tube That Twists to Follow the Pointer
Open in a new tab

02 · A Torus Knot Tube That Twists to Follow the Pointer — Measured area 20.738% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

KNOT_PKNOT_QTUBE_R
    function knotAt(p, q, phi) {
        const r = 2 + Math.cos(q * phi);
        return [r * Math.cos(p * phi) * KNOT_SCALE, r * Math.sin(p * phi) * KNOT_SCALE, -Math.sin(q * phi) * KNOT_SCALE];
    }
    // Build the tube — at each point set up a frame from the tangent T, the normal N toward the curvature and B perpendicular to both, then wrap a ring of points around it
    function buildTube(p, q) {
        const verts = [];
        const ringDir = [];
        const h = TAU / SEG_U / 4;
        for (let i = 0; i < SEG_U; i += 1) {
            const phi = (i / SEG_U) * TAU;
            const c = knotAt(p, q, phi);
            const a = knotAt(p, q, phi - h);
            const b = knotAt(p, q, phi + h);
            const tng = vnorm(vsub(b, a));
            const accel = vadd(vsub(b, c), vsub(a, c));
            const nrm = vnorm(vsub(accel, vscale(tng, dot(accel, tng))));
            const bin = cross(tng, nrm);
            for (let j = 0; j < SEG_V; j += 1) {
                const v = (j / SEG_V) * TAU;
                const dir = vadd(vscale(nrm, Math.cos(v)), vscale(bin, Math.sin(v)));
                ringDir.push(dir);
                verts.push(vadd(c, vscale(dir, TUBE_R)));
            }
        }
        const m = matMul(viewMatrix(view.yaw, view.pitch), rotZ(spin));
        const rv = tube.verts.map(function (p) { return apply(m, p); });
        const pv = rv.map(function (p) { return project(p, FOCAL); });
        const faceN = [];
        const faceZ = [];
        const order = [];
        for (let i = 0; i < SEG_U; i += 1) {
            const i2 = (i + 1) % SEG_U;
            for (let j = 0; j < SEG_V; j += 1) {
                const j2 = (j + 1) % SEG_V;
                const f = i * SEG_V + j;
                const n = apply(m, tube.normals[f]);
                faceN.push(n);
                faceZ.push((rv[i * SEG_V + j][2] + rv[i * SEG_V + j2][2] + rv[i2 * SEG_V + j][2] + rv[i2 * SEG_V + j2][2]) / 4);
                // Skip faces turned away from the screen (normal z > 0) so the inner side of the tube does not show through
                if (n[2] < 0.05) {
                    order.push(f);
                }
            }
        }

03A Point-Cloud Sphere That Dents Where the Pointer Touches and Springs Back

Its 720 points cover the sphere by splitting the height evenly and turning each point 137.508°, the golden angle, past the one before, and only screen-side points whose projection lands within 66px of the pointer shrink their radius, by up to 34%. The pull back comes from a spring with a damping ratio of 0.38, so the deepest point overshoots its rest radius by 26.1% and bulges outward before it settles at 880ms. A data or AI product hero is a natural home for it, and the render gave 24.1% cumulative area, 85.4 mean intensity and 23/23 moving frames.

A Point-Cloud Sphere That Dents Where the Pointer Touches and Springs Back
Open in a new tab

03 · A Point-Cloud Sphere That Dents Where the Pointer Touches and Springs Back — Measured area 24.134% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

POINT_COUNTDENT_RADIUSDENT_SPRING
    // Golden-angle spiral — split the height evenly and turn each point by the golden angle
    function goldenSphere(n) {
        const out = [];
        for (let i = 0; i < n; i += 1) {
            const y = 1 - (2 * (i + 0.5)) / n;
            const r = Math.sqrt(1 - y * y);
            const th = i * GOLDEN_ANGLE;
            out.push([r * Math.cos(th), y, r * Math.sin(th)]);
        }
        return out;
    }
    // Pointer proximity → target radius. Screen-side (z < 0) points only, deeper the closer they are
    function dentTarget(p, front, ax, ay) {
        if (ax < 0 || !front) {
            return 1;
        }
        const d = Math.hypot(p.x - ax, p.y - ay);
        if (d >= DENT_RADIUS) {
            return 1;
        }
        return 1 - DENT_DEPTH * smooth(1 - d / DENT_RADIUS);

043D Gears That Mesh at Their Tooth Ratio While the Pointer Moves the View

The outlines of an 18-tooth gear and a 10-tooth gear are pushed 9px forward and back into 18px-thick solids, and the small gear's angle is the large gear's angle times 18/10 with the sign flipped, so one tooth on the large gear (20°) turns the small gear −36°, exactly one of its own teeth. The small gear starts half a tooth (18°) out of phase so that the spot facing the large gear is a gap, and on the pitch circles both gears move at the same 56.7px/s. A section that explains how a product works is the obvious slot, and the measurement came to 10.6% cumulative area, 93.6 mean intensity and 23/23 moving frames.

3D Gears That Mesh at Their Tooth Ratio While the Pointer Moves the View
Open in a new tab

04 · 3D Gears That Mesh at Their Tooth Ratio While the Pointer Moves the View — Measured area 10.555% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TEETH_ATEETH_BTOOTH_DEPTH
    // Gear outline — four points per tooth: root left, tip left, tip right, root right (a trapezoid narrower at the tip than at the root)
    function gearOutline(teeth, r, phase) {
        const out = [];
        const per = TAU / teeth;
        const r0 = r - TOOTH_DEPTH / 2;
        const r1 = r + TOOTH_DEPTH / 2;
        for (let k = 0; k < teeth; k += 1) {
            const a = phase + k * per;
            const spots = [[-0.28, r0], [-0.15, r1], [0.15, r1], [0.28, r0]];
            for (let s = 0; s < spots.length; s += 1) {
                const th = a + spots[s][0] * per;
                out.push([Math.cos(th) * spots[s][1], Math.sin(th) * spots[s][1]]);
            }
        }
        return out;
    }
    const GEARS = [
        { teeth: TEETH_A, r: RA, cx: SHIFT, outline: gearOutline(TEETH_A, RA, 0), col: PAL[0] },
        { teeth: TEETH_B, r: RB, cx: SHIFT + RA + RB, outline: gearOutline(TEETH_B, RB, Math.PI + HALF_TOOTH), col: PAL[4] },
    ];
    // Small gear angle — multiply the large gear angle by the tooth ratio and flip the sign
    function angleB(a) { return -a * TEETH_A / TEETH_B; }
    function report() { say(running ? TXT_RUN : TXT_STOP); }
    function step() {
        viewFollow(view, VIEW_LERP);
        if (running) {
            angA += dir * SPIN_A * FIXED_DT / 1000;
        }
    }

05A 3D Box Whose Four Flaps Spring Open One After Another on Click

The lid is the top face cut into four triangles meeting at the center, each flap lifts around its own hinge axis, and its opening angle is integrated by a spring with a damping ratio of 0.527, with starts booked 110ms apart that land on the 16ms step grid at 16, 112, 224 and 336ms. One flap runs past its 210° target to 236.7° (12.7% overshoot) at 368ms before swinging back, and the four settle within ±2° at 880, 976, 1,088 and 1,200ms, gaps of 96, 112 and 112ms. Product reveals and gift-campaign pages get the most out of it, with render figures of 20.7% cumulative area, 62.7 mean intensity and 23/23 moving frames.

A 3D Box Whose Four Flaps Spring Open One After Another on Click
Open in a new tab

05 · A 3D Box Whose Four Flaps Spring Open One After Another on Click — Measured area 20.651% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LID_OPEN_DEGLID_SPRINGFLAP_STAGGER
    // Flap tip — lift the vertex that reaches S inward from the hinge by θ around the hinge axis
    function flapTip(f, th) {
        return vadd(f.hinge, vadd(vscale(f.inward, S * Math.cos(th)), [0, -S * Math.sin(th), 0]));
    }
    function setOpen(v) {
        open = v;
        for (let i = 0; i < flaps.length; i += 1) {
            // When closing, start with the flap that opened last
            const order = open ? i : flaps.length - 1 - i;
            flaps[i].start = lidClock + order * FLAP_STAGGER;
        }
        if (lidBtn) {
            lidBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
        }
    }
    function step() {
        viewFollow(view, VIEW_LERP);
        lidClock += FIXED_DT;
        gemSpin += 1.2 * FIXED_DT / 1000;
        for (let i = 0; i < flaps.length; i += 1) {
            const f = flaps[i];
            if (f.start >= 0 && lidClock >= f.start) {
                f.spring.to = open ? LID_OPEN_DEG * DEG : 0;
            }
            springStep(f.spring, LID_SPRING, LID_DAMP, FIXED_DT / 1000);
            // A flap that overshoots while closing does not sink into the box; it bounces lightly off the top
            if (f.spring.x < 0) {
                f.spring.x = 0;
                f.spring.v = -f.spring.v * 0.3;
            }
        }
    }

06A Double Helix of Beads Whose Twist Speed Follows the Pointer's Horizontal Position

Two strands of 34 beads wind 2.5 turns, 180° apart at each height, with a rung across every third pair for 12 rungs in all. The pointer's horizontal position sets the twist speed anywhere from −2.0 rad/s at the left edge through 0.6 in the middle to 3.2 at the right, its vertical position sets the forward tilt, and beads, backbone segments and rungs share one list sorted by depth. It ties straight into a biotech or healthcare front page, measured at 18.1% cumulative area, 81.3 mean intensity and 23/23 moving frames.

A Double Helix of Beads Whose Twist Speed Follows the Pointer's Horizontal Position
Open in a new tab

06 · A Double Helix of Beads Whose Twist Speed Follows the Pointer's Horizontal Position — Measured area 18.058% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HELIX_TURNSRUNG_EVERYTWIST_GAIN
    // Pointer → target twist speed and tilt
    function aimTwist(x, y) {
        twistTo = BASE_TWIST + TWIST_GAIN * clamp((x - CX) / (W / 2), -1, 1);
        tiltTo = clamp((y - CY) / (H / 2), -1, 1) * TILT_MAX;
    }
    function step() {
        twist += (twistTo - twist) * TWIST_LERP;
        tilt += (tiltTo - tilt) * TWIST_LERP;
        if (!paused) {
            phase += twist * FIXED_DT / 1000;
        }
    }
    function settle() { twist = twistTo; tilt = tiltTo; }
        // phase handles the twist around the vertical axis, a horizontal-axis rotation the forward tilt, and a depth-axis rotation the slight sideways lean
        const m = matMul(rotZ(-0.32), rotX(tilt));
        const bs = beads();
        const rv = bs.map(function (b) { return apply(m, b.p); });
        const pv = rv.map(function (p) { return project(p, FOCAL); });
        const cols = [PAL[1], PAL[2]];
        const items = [];
        let rungs = 0;
        for (let i = 0; i < bs.length; i += 1) {
            items.push({ z: rv[i][2], kind: 0, a: pv[i], b: pv[i], ca: cols[bs[i].strand], cb: cols[bs[i].strand] });
            // Backbone segment to the next bead on the same strand
            if (i + 2 < bs.length) {
                items.push({ z: (rv[i][2] + rv[i + 2][2]) / 2 + 0.5, kind: 1, a: pv[i], b: pv[i + 2], ca: cols[bs[i].strand], cb: cols[bs[i].strand] });
            }
        }
        for (let i = 0; i < BEADS; i += RUNG_EVERY) {
            const a = 2 * i, b = 2 * i + 1;
            items.push({ z: (rv[a][2] + rv[b][2]) / 2 + 1, kind: 2, a: pv[a], b: pv[b], ca: cols[0], cb: cols[1] });
            rungs += 1;
        }
        // Depth order — farthest beads and rungs first
        items.sort(function (p, q) { return q.z - p.z; });

07Three Nested Rings That Keep Spinning After a Flick and Slowly Stop

The hand speed at release becomes an angular velocity that is multiplied by 0.96 on every 16ms step afterward, and the outer ring spends that angle on the vertical axis, the middle ring on the horizontal axis and the inner ring on the depth axis. Thrown at 0.1 rad per step, the rings turn another 130.8° during the 74 steps (1.18 s) it takes the speed to fall to 5%, and 136.8° before they stop, close to the 2.4 rad given by the closed geometric-series form w·F/(1 − F). Fidget-friendly hero art or a waiting-screen ornament suits it, and the render returned 23.7% cumulative area, 84.6 mean intensity and 23/23 moving frames.

Three Nested Rings That Keep Spinning After a Flick and Slowly Stop
Open in a new tab

07 · Three Nested Rings That Keep Spinning After a Flick and Slowly Stop — Measured area 23.661% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLICK_GAINSPIN_FRICTIONRING_COUNT
    // One drag step's movement → signed speed. Follows the horizontal direction when it is larger, otherwise the vertical one
    function signedSpeed(dx, dy) { return Math.abs(dx) >= Math.abs(dy) ? dx : dy; }
    // One step after release — multiply the angular velocity by the friction factor
    function coast(v) {
        const n = v * SPIN_FRICTION;
        return Math.abs(n) < STOP_W ? 0 : n;
    }
    function step() {
        if (dragging) {
            // While dragging, hand speed goes straight into angular velocity — mixed in only halfway to filter out jitter
            const target = clamp(signedSpeed(px - lastX, py - lastY) * FLICK_GAIN, -MAX_SPIN, MAX_SPIN);
            w = lerp(w, target, 0.5);
            lastX = px;
            lastY = py;
        }
        else {
            w = coast(w);
        }
        angle += w;
        turned += Math.abs(w);
    }
    // Pose of ring i — a gimbal with the middle ring hung inside the outer one and the inner ring inside that. Each uses the same angle on a different axis
    function axisRot(axis, a) { return axis === 'y' ? rotY(a) : (axis === 'x' ? rotX(a) : rotZ(a)); }
    function ringMatrix(i) {
        let m = axisRot(RING_AXIS[0], angle);
        for (let k = 1; k <= i; k += 1) {
            m = matMul(m, axisRot(RING_AXIS[k], angle));
        }
        return m;
    }

08A Model Built From Small Cubes That Scatters Under the Pointer and Reassembles

The 46 # cells of a 9×8 character map are stacked two layers deep into a heart of 92 voxels, and only voxels whose projection newly enters a 64px radius around the pointer get an outward kick before a spring with a damping ratio of 0.406 pulls them home. A voxel pushed once travels out to 38px at 192ms and stays within 1px of home from 1,520ms on, and each cube paints only its screen-facing sides, each at its own brightness. It fits an event page or a game intro hero, with measured values of 11.9% cumulative area, 58.2 mean intensity and 23/23 moving frames.

A Model Built From Small Cubes That Scatters Under the Pointer and Reassembles
Open in a new tab

08 · A Model Built From Small Cubes That Scatters Under the Pointer and Reassembles — Measured area 11.915% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

VOXEL_SIZESCATTER_FORCEREFORM_K
    // Voxels that just entered the pointer radius — push them away from the pointer on screen and kick them forward or back by their seed value
    function scatter(m) {
        for (let i = 0; i < voxels.length; i += 1) {
            const v = voxels[i];
            const q = project(apply(m, v.pos), FOCAL);
            const d = Math.hypot(q.x - px, q.y - py);
            const inside = px >= 0 && d < SCATTER_RADIUS;
            if (inside && !v.inside) {
                const dx = d > 1e-3 ? (q.x - px) / d : 0;
                const dy = d > 1e-3 ? (q.y - py) / d : -1;
                // Turn the screen direction back into model coordinates — the transpose of the rotation matrix
                const dir = applyT(m, vnorm([dx, dy, -0.6 + 0.4 * v.kick]));
                const k = SCATTER_FORCE * (1 - 0.5 * d / SCATTER_RADIUS);
                v.vel = vadd(v.vel, vscale(dir, k));
            }
            v.inside = inside;
        }
    }
    function step() {
        viewFollow(view, VIEW_LERP);
        wobble += 0.9 * FIXED_DT / 1000;
        const m = matrix();
        scatter(m);
        const dt = FIXED_DT / 1000;
        for (let i = 0; i < voxels.length; i += 1) {
            const v = voxels[i];
            // Spring return — acceleration = −k(position − home) − d·velocity
            const a = vsub(vscale(vsub(v.pos, v.home), -REFORM_K), vscale(v.vel, REFORM_DAMP));
            v.vel = vadd(v.vel, vscale(a, dt));
            v.pos = vadd(v.pos, vscale(v.vel, dt));
        }
    }

09A Point Cloud That Morphs From Cube to Sphere to Ring as It Turns

Cube, sphere and ring surfaces each get 520 seeded points, lined up by height so that points of the same rank pair off and blend position and color over 900ms. The wobble laid on top has an amplitude of 14·sin(π·progress), so it is zero at departure and at arrival, 2.73px at 25% of the elapsed time and 14px at 50%, while the pointer turns the view of the whole cloud. It works as a backdrop for a section that steps through the stages of a service, and the render shows 20.5% cumulative area, 84.7 mean intensity and 23/23 moving frames.

A Point Cloud That Morphs From Cube to Sphere to Ring as It Turns
Open in a new tab

09 · A Point Cloud That Morphs From Cube to Sphere to Ring as It Turns — Measured area 20.519% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MORPH_MSSHAPE_ORDERNOISE_AMP
    function sample(name, seed) {
        const rnd = makeRng(seed);
        const out = [];
        for (let i = 0; i < N; i += 1) {
            if (name === 'cube') {
                const f = Math.floor(rnd() * 6);
                const u = rnd() * 2 - 1, v = rnd() * 2 - 1, s = f % 2 ? 1 : -1;
                const a = SIZE * 0.78;
                out.push(f < 2 ? [u * a, v * a, s * a] : (f < 4 ? [s * a, u * a, v * a] : [u * a, s * a, v * a]));
            }
            else if (name === 'sphere') {
                const y = 1 - (2 * (i + 0.5)) / N;
                const r = Math.sqrt(1 - y * y);
                const th = i * Math.PI * (3 - Math.sqrt(5));
                out.push([r * Math.cos(th) * SIZE, y * SIZE, r * Math.sin(th) * SIZE]);
            }
            else {
                const u = rnd() * TAU, v = rnd() * TAU;
                const big = SIZE * 0.8, small = SIZE * 0.3;
                out.push([(big + small * Math.cos(v)) * Math.cos(u), small * Math.sin(v), (big + small * Math.cos(v)) * Math.sin(u)]);
            }
        }
        out.sort(function (a, b) { return a[1] - b[1]; });
        return out;
    }
    // Integer hash per point → −1 to 1. The same point and the same node give the same value
    function hash(i, k) {
        let h = Math.imul(i, 374761393) ^ Math.imul(k, 668265263);
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        return (((h ^ (h >>> 16)) >>> 0) / 4294967296) * 2 - 1;
    }
    // Value noise — join two node values with smoothstep
    function noise(i, ms) {
        const x = ms / NOISE_STEP;
        const k = Math.floor(x);
        return lerp(hash(i, k), hash(i, k + 1), smooth(x - k));
    }

Piling On Points, Pushing the Camera Close, Back Faces Swinging Forward — When the Depth Illusion Breaks

The figures in this section come from calling the source functions directly or from working the formulas in the source by hand. How long one frame takes on a given phone or laptop is outside what was tested here.

More points means more projection and more sorting

03 projects all 720 of its points and sorts them by depth on every draw, while 09 draws its 520 points with no sort at all and lets opacity and size alone suggest front and back. Push the count into the thousands and the projection work and the sort comparisons grow together, so dropping the sort the way 09 does is the first lever worth pulling. The price is that overlapping points lose their order.

A focal length close to the model radius makes the scale explode

The scale's denominator is focal + z, and the nearest vertex sits at z = −R, so shrinking FOCAL_PX toward the 110px radius drives that denominator toward zero and the near-side scale climbs out of control. Already at 300 the nearest vertex is drawn at 1.579×, standing out far more than the 1.224× it gets at 600.

Multiplying per frame instead of per step ties speed to the refresh rate

If 01's follow or 07's friction were applied on every animation frame, a display with twice the frame rate would follow twice as fast, and a flicked ring would coast a different distance. This source multiplies only on fixed 16ms steps, so that gap does not open up.

Sorting by average depth gets the order wrong where shapes cut into each other

The sort works on the average depth of one face or one object and compares nothing per pixel. 04 sorts each gear as a single block, so at views where the axles tilt far enough for the gears to cross front and back, the meshing spot can show in the wrong order, and in 08 scattering voxels that pass through each other can swap order for a frame. Take the sort out of 02 and the back strands paint over the front strands wherever the knot crosses itself.

Back-face culling by itself leaves an open box hollow

If 05 had only outer walls and culled the faces turned away, opening the flaps would reveal an empty box with the background showing through. So four inner walls set 0.6px inside, plus an inner floor, carry inward-facing normals as separate faces, and the default open view draws 13 of the 26 faces. 06 takes the opposite route and culls nothing, adding +1 to the depth of each rung and +0.5 to each backbone segment so that ties with beads at the same depth do not flicker.

Measuring distance from the moved position makes points jitter

If 03 measured the pointer distance from the dented positions, a dented point would drift away from the pointer, pop back out, come close again and dent again in a loop. Distance is therefore taken from the projection at the undented radius R. In 08, adding speed on every step while a voxel sat inside the radius would make voxels accelerate without end under a resting pointer, so the push happens only at the moment of entry, and the screen direction is turned back into model coordinates with applyT, the transpose of the rotation.

Pairing the wrong way tangles both meshing and morphing

Drop the half-tooth phase from 04 and the teeth collide head-on and dig into each other. In 09, pairing the points of two shapes in arbitrary order sends them across the screen until they tangle into one cloud, which is why both lists are sorted by height before pairing. The (p, q) in 02 has to pair up as well: 2 and 4 share a factor, so the curve does not close as one strand and splits into several loops.

The vanilla index.html and style.scss for all nine models, plus React versions that port the same math to tsx, ship together in one archive, and the password it asks for is ndnyxvuv. Matching the model center CX and CY and a few constant lines to your own layout is the main edit before dropping a model into a page.

Changing Follow Speed, Focal Length and Friction, Plus Accessibility Without a Mouse

Variant Value Feel
Raise the follow speed 01 ROT_LERP 0.08 → 0.2 Closing 20% of the gap per step instead of 8% cuts the time to reach 10% of the starting gap from 28 steps (448ms) to 11 steps (176ms). The model sticks close to the pointer, and more hand jitter leaks through with it.
Shorten the focal length 01 FOCAL_PX 600 → 300 On the 110px-radius model the near and far vertex scales move from 1.224× and 0.845× to 1.579× and 0.732×, so the front-to-back gap grows from 1.45× to 2.16×. Perspective turns exaggerated and the near edges look as if they push out of the screen.
Lower the friction 07 SPIN_FRICTION 0.96 → 0.99 For a flick of 0.1 rad per step, reaching 5% speed stretches from 74 steps (1.18 s) and 130.8° to 299 steps (4.78 s) and 539°, and the total turn before stopping grows from 136.8° to 564°. A single flick keeps the rings going for more than one and a half turns.

Keyboard and screen reader control

Each of the nine canvases has tabindex="0" and an English aria-label, so it can take focus, and every model also has two real <button> elements. The arrow keys turn the view in 01 to 05, 08 and 09; in 06, ←/→ change the twist speed by ±0.8 and ↑/↓ the tilt by ±0.2, and in 07, ←/→ add ±0.12 to the angular velocity as a flick while ↑/↓ move the view. Turn off depth fade in 01, Reverse in 04, Open or close in 05 and Pause the twist in 06 report their on and off state through aria-pressed. The status line stays quiet with aria-live="off" while the automatic demo runs and switches to polite the first time someone touches the pointer, a key or a button, so only later changes are read out.

What reduced motion changes

All nine read prefers-reduced-motion through matchMedia and attach a change listener to it, so flipping the system setting with the page open takes effect on the spot. When the setting turns on, onMotionPref stops the running frame loop, settle() builds the end state and one draw follows; when it turns off, the loop starts again. Input keeps working in the reduced state, and on each press after() computes the finished result and repaints at once, so a turned view or an opened box stays on screen without any motion in between.

The self-running parts, the knot spin in 02, the sphere spin in 03, the gears in 04, the twist in 06 and the spin in 09, stay still in this state. To keep input visible, Enter in 04 advances the gears by half a tooth, ←/→ in 06 shift the phase by ±0.4 and a scatter in 08 turns the view by +0.5 yaw, while 07 turns as far as you drag and, on release, adds the remaining coast as one geometric-series sum.

Does JavaScript 3D Rendering Need a Library at All?

For one model turning toward the pointer, it does not. The nine models here load no library, CDN or WebGL and use only the path fills, arcs and lines of CanvasRenderingContext2D, requestAnimationFrame and pointer events, with rotation and projection reduced to 3×3 matrix products and a single division. Here viewMatrix applies the turn around the vertical axis first and the tilt around the horizontal axis second, and the order of matrix products is something you can explore further in MDN's Matrix math for the web.

Where that changes lines up with the limits in the section on where the depth illusion breaks. A scene with thousands of faces cutting into each other, one that needs depth compared per pixel, or one that wraps photos over its surfaces goes beyond what these five calculations cover. If the 3d websites examples you are collecting show one object reacting to the pointer with a face count in this range, a single source file means fewer downloads and every line you might edit in plain view.

Turning cards and cubes with CSS transform alone is covered in nine CSS 3D scenes that follow the mouse, whole canvas worlds built on projection are in 3D scenes that turn with the pointer, and reading the top face off a rotation matrix is in nine 3D dice rolls. Layered depth backgrounds live in depth parallax 3D backgrounds, 3D drawn by measuring distance per pixel is in nine ray marching scenes, and the computed scenes this post uses as its quality bar are the canvas scene effects.

Questions People Ask First Before Putting a 3D Model on Their Landing Page

How do I turn a JavaScript 3D model into my logo shape?

08 takes the least work. Swap the # cells in the HEART character map for your logo's outline and a voxel stacks up in each cell, with the scatter and the return coming along unchanged. For a logo made of lines, replace the vertex list that 01's icosaRaw returns and the edge pairs in EDGES with your own coordinates, and for a logo filled with dots, add one more shape to sample in 09.

Does the model turn under a finger on a phone?

It does. All nine read input through pointermove and pointerdown, which carry both mouse and touch, and touch-action: pan-y in style.css leaves vertical drags to page scrolling. 07 holds on to the finger with setPointerCapture so a drag is not lost when it slides off the canvas.

What happens when the pointer leaves the canvas?

01, 02, 04, 05, 08 and 09 handle pointerleave by calling viewHome and ease back to their default view, with 08 also clearing the point it scatters from. 03 only clears the pointer position, so the dent springs outward and settles while the view stays where it is, and 06 calls goHome(), which brings back both its starting twist speed of 0.6 rad/s and its starting tilt of 0.25. 07 has no leave handler because coasting after release is the whole behavior of that model, so it spins as far as it was thrown until friction stops it.

Enter the archive password

The password is inside this article. You will find it as you read.