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

Virtual Joystick JavaScript: 9 Touch Scenes

Virtual joystick javascript: A character that moves at the slightest tilt shows hand tremor.

Raw Tilt Used as Speed Moves Your Hand Tremor Too: A Virtual Joystick Starts by Rereading the Thumb Position

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

All nine scenes start from one number pair: the thumb offset divided by the base radius R, clamped to a length of 1. 01 to 04 change how that pair is read and shown, 05 decides where the base sits, and 06 to 09 turn the corrected pair into the motion of a map, a car, a ship and a drone. A seeded virtual thumb replays the same path every loop, so a copy with one number changed can sit next to the original.

Why a trembling thumb makes the screen twitch

In the 0.3 to 0.7 second stretch of the 01 demo, the thumb stays at the center, yet the input strength climbs to 0.101 at its peak. Without the 0.15 deadzone that wobble would become output as is, and the character would flinch in place. Both the round and the square output stayed at 0 for all 97 frames of that stretch.

A single 1.2-second frame pushes the floor plan 581.6px

At 1.25 seconds, scene 06 inserts one frame that lasts 1.2 seconds on purpose. A copy that spends the received time in one step jumps 581.6px and overshoots the edge by up to 440.8px, while the copy that clamps to 0.05 seconds and walks in 1/120-second pieces moves only 24.4px. Rereading frame time matters as much in this post as rereading the thumb.

Where this differs from the neighboring posts

The starfield parallax post splits movement by depth to get perspective, and scene 09 here runs the same division, focal length over depth, again every frame from a position and heading that two sticks set. The swipe decision in the swipe-merge game post picks one of four directions once after the finger lifts, whereas a joystick reads direction and strength without a pause while the thumb stays down. How a pressed value returns to rest is covered in the button press physics post, the pointer-following 3D tilt lives in the pointer scene post, and the yardstick for calculated scene effects is the water drop and black hole canvas post.

Virtual Joystick JavaScript: 9 Scenes — Round Deadzone with Output Rescaled from Its Edge, A Released Knob That Springs Past Center, with Shadow Height, Response Curve Crosshair That Refines Small Tilts, Eight-Way Tile Walk with Sticky Sector Borders, Floating Base Dragged Along by the Thumb, Floor Plan Panned with Inertia and Rubber-Band Edges, Skid Marks Left by Sideways Slip, Ship Flown by Thrust and Turn Inertia, with Exhaust Particles and Dual-Stick Drone View Scene

01 and 03 cut and bend the same thumb value, 02 shows a released knob coming home, and 04 and 05 handle the borders of a sector decision and a base position. 06 to 08 feed the stick in as acceleration so a map, a car and a ship carry weight, and 09 flies a drone view with two sticks. Each scene draws on a single 2D canvas with no library, WebGL, image file or web font, and the only random source is one seeded generator.

What a 2-second capture of the 480×300 stage measured

Every scene was rendered for 2 seconds at 480×300 and 24 frames were captured, which leaves 23 neighbor differences to count.

Scene Area covered Frames that moved Mean intensity
01 Round and square deadzone output map 28.8% 23/23 41.7
02 Knob that overshoots and returns 20.3% 23/23 69.5
03 Response curve crosshair 15.3% 23/23 59.7
04 Sticky eight-way walk 17.5% 23/23 70.0
05 Dragged base 30.2% 23/23 64.4
06 Inertial floor plan 65.6% 23/23 75.2
07 Sliding car and skid marks 13.8% 23/23 80.7
08 Thrust ship 21.8% 23/23 65.8
09 Dual-stick drone view 52.0% 23/23 37.6

The floor plan sliding as a whole in 06 covers the most at 65.6%, while 07, where one car and its marks change, covers 13.8% yet has the highest mean intensity at 80.7. In 09 the ground grid and fog fill most of the stage, so it covers 52.0% with the lowest intensity of 37.6, which reads as a wide area changing a little at a time.

01Round Deadzone with Output Rescaled from Its Edge

A deadzone preview for a mobile game settings screen: distance inside radius 0.15 gives zero, and outside it the output is stretched back to 0–1 from the edge with its direction kept. A square deadzone sits beside it, and at strength 0.3 its output leans up to 30° toward an axis while the round one stays at 0°. The share of each disc that stands still is 2.3% for the round one and 2.9% for the square one.

Round Deadzone with Output Rescaled from Its Edge
Open in a new tab

01 · Round Deadzone with Output Rescaled from Its Edge — Measured area 28.839% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DEADZONE_RADIUSSQUARE_DEADZONEHEAT_CELLS
    function axisCut(v, dz) {
        const a = Math.abs(v);
        return a <= dz ? 0 : Math.sign(v) * (a - dz) / (1 - dz);
    }
    function squareDeadzone(x, y, dz) {
        let ox = axisCut(x, dz);
        let oy = axisCut(y, dz);
        const m = Math.hypot(ox, oy);
        if (m > 1) {
            ox /= m;
            oy /= m;
        }
        return [ox, oy];
    }

02A Released Knob That Springs Past Center, with Shadow Height

A control guide on a web game's start screen: after release, each 1/240-second substep adds a pull of 210 times the position and damping of 12 times the speed, so the knob overshoots once or twice and rests. The damping ratio of 0.414 gives a first swing of 23.4% on the other side at 0.238 seconds, and the knob stays inside the 2% band from 0.571 seconds on. The farther it is from center, the flatter it tilts along its direction and the shadow height falls from 16px to 5.6px.

A Released Knob That Springs Past Center, with Shadow Height
Open in a new tab

02 · A Released Knob That Springs Past Center, with Shadow Height — Measured area 20.33% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

KNOB_STIFFNESSKNOB_DAMPINGTILT_MAX
    function springStep(s, h) {
        vx += (-KNOB_STIFFNESS * s.kx - KNOB_DAMPING * vx) * h;
        vy += (-KNOB_STIFFNESS * s.ky - KNOB_DAMPING * vy) * h;
        s.kx += vx * h;
        s.ky += vy * h;
    }

03Response Curve Crosshair That Refines Small Tilts

A fine aiming pad on a remote camera screen: the strength after a 0.08 deadzone is raised to the power 1.8, so at input 0.2 the crosshair crawls at 7.7px per second and reaches 300px per second only at full tilt. At input 0.3 it moves at 31.8% of the straight-ratio speed, and a pale coral crosshair driven straight by the same stick sits beside it. A dot on the corner graph follows the current input and output together along y = x^1.8.

Response Curve Crosshair That Refines Small Tilts
Open in a new tab

03 · Response Curve Crosshair That Refines Small Tilts — Measured area 15.269% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

RESPONSE_EXPAIM_SPEEDAIM_DEADZONE
    // Response curve: the direction stays, only the strength is bent
    function responseCurve(x, y) {
        const o = radialDeadzone(x, y, AIM_DEADZONE);
        const m = Math.hypot(o[0], o[1]);
        if (m === 0) {
            return [0, 0, 0];
        }
        const c = Math.pow(m, RESPONSE_EXP);
        return [o[0] / m * c, o[1] / m * c, m];
    }

04Eight-Way Tile Walk with Sticky Sector Borders

For retro tile games and grid menu navigation: the stick angle is cut into eight sectors of 45°, and the current sector keeps the thumb out to 22.5° plus an 8° margin, so a new sector needs a thumb 8° past the border. Two walkers share one stick, a mint one with the 8° margin and a coral one with 0°. While the thumb trembles ±6° over the 22.5° border from 0.42 to 2.3 seconds, mint changes direction 0 times and coral 7 times.

Eight-Way Tile Walk with Sticky Sector Borders
Open in a new tab

04 · Eight-Way Tile Walk with Sticky Sector Borders — Measured area 17.483% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HYSTERESIS_DEGWALK_DEADZONETILE_STEP_S
    // Sector pick: a version with a margin angle hyst (degrees), sticky, and one with 0, bare
    function pickSector(x, y, cur, hyst) {
        if (Math.hypot(x, y) < WALK_DEADZONE) {
            return -1;
        }
        const th = Math.atan2(y, x);
        if (cur >= 0 && Math.abs(toDeg(wrapAngle(th - cur * Math.PI / 4))) <= 22.5 + hyst) {
            return cur;
        }
        return sectorOf(x, y);
    }
    function makeWalker(c, r) { return { c: c, r: r, tc: c, tr: r, t: 0, moving: false, dir: -1, changes: 0, trail: [], face: 0 }; }
    const sticky = makeWalker(2, 2);
    const bare = makeWalker(2, 6);
    function decide(w, hyst) {
        const s = sticks[0];
        const d = pickSector(s.rx, s.ry, w.dir, hyst);
        if (d >= 0 && w.dir >= 0 && d !== w.dir) {
            w.changes += 1;
        }
        w.dir = d;
    }

05Floating Base Dragged Along by the Thumb

A movement pad that starts anywhere on the left half of a phone screen: the base appears where the touch lands, and once the thumb is more than 42px from it, the base moves toward the thumb by the excess so the knob stays on the rim. A dashed circle marks the real base while the drawn one trails it at a follow rate of 12. The right half keeps a fixed base 240px over and feeds it the same thumb path, and a label shows the knob gap in px.

Floating Base Dragged Along by the Thumb
Open in a new tab

05 · Floating Base Dragged Along by the Thumb — Measured area 30.213% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BASE_RADIUSBASE_FOLLOW_RATEleashBase
    // Dragged base: move the base toward the thumb by the distance beyond the radius
    function leashBase(s) {
        const dx = s.tx - s.bx;
        const dy = s.ty - s.by;
        const d = Math.hypot(dx, dy);
        if (d > s.R) {
            const k = 1 - s.R / d;
            s.bx += dx * k;
            s.by += dy * k;
        }
    }

06Floor Plan Panned with Inertia and Rubber-Band Edges

Browsing a store layout or an exhibition hall map: the stick output is added as acceleration and the velocity is multiplied by 0.05 each second, applied in 1/120-second steps, so a release at 500px per second glides 164.8px at 30fps, 164.8px at 60fps and 165.2px at 144fps. Past the plan's edge, the drawn overshoot follows 60 × (1 − 1 / (o / 60 + 1)), so 120px of overshoot shows as 40px and the drawing stays under 60. Pushing steadily against an edge settles at 53.3px of real overshoot, drawn as 28.2px.

Floor Plan Panned with Inertia and Rubber-Band Edges
Open in a new tab

06 · Floor Plan Panned with Inertia and Rubber-Band Edges — Measured area 65.642% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PAN_DAMPINGRUBBER_LIMITPAN_DT_CAP
    function overOf(v, max) { return v < 0 ? v : (v > max ? v - max : 0); }
    function rubber(o) {
        const a = Math.abs(o);
        return Math.sign(o) * RUBBER_LIMIT * (1 - 1 / (a / RUBBER_LIMIT + 1));
    }
    // What is drawn = the spot clamped inside the edge + a rubber-band share of the overshoot
    function shownX(p) { return clamp(p.x, 0, MAX_X) + rubber(overOf(p.x, MAX_X)); }
    function shownY(p) { return clamp(p.y, 0, MAX_Y) + rubber(overOf(p.y, MAX_Y)); }
    function accelOf(p, ox, oy) {
        const ex = overOf(p.x, MAX_X);
        const ey = overOf(p.y, MAX_Y);
        return [ox * PAN_ACCEL - (ex ? EDGE_STIFFNESS * ex + EDGE_DAMPING * p.vx : 0),
            oy * PAN_ACCEL - (ey ? EDGE_STIFFNESS * ey + EDGE_DAMPING * p.vy : 0)];
    }

07Skid Marks Left by Sideways Slip

A parking practice simulator screen: the horizontal stick value becomes a steering angle up to 32°, and the turning radius is 30 divided by the tangent of that angle, which is 213.5px at 0.25 and 48px at full lock. The sideways speed a turn creates is trimmed by a grip ratio every 0.15 seconds, and while what remains exceeds 30px per second, the rear wheels draw skid marks. One demo loop leaves marks for 18.8% of its time, 187.9px in total, with a peak sideways speed of 38.2px per second.

Skid Marks Left by Sideways Slip
Open in a new tab

07 · Skid Marks Left by Sideways Slip — Measured area 13.785% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WHEELBASETIRE_GRIPSKID_MIN
    function carStep(c, throttle, h) {
        let fx = Math.cos(c.psi);
        let fy = Math.sin(c.psi);
        let u = c.vx * fx + c.vy * fy;
        u += (throttle * ENGINE_ACCEL - u * ROLL_DRAG) * h;
        // Turning radius from the wheelbase and steering angle: the heading turns along that circle
        if (Math.abs(c.steer) > 1e-4) {
            c.psi += u * Math.tan(c.steer) / WHEELBASE * h;
        }
        const w0 = c.vx * -fy + c.vy * fx;
        // The heading turned, so the same speed is split again into new forward and sideways parts: the turned amount moves into sideways speed
        const vx = fx * u + -fy * w0;
        const vy = fy * u + fx * w0;
        fx = Math.cos(c.psi);
        fy = Math.sin(c.psi);
        const u2 = vx * fx + vy * fy;
        let w = vx * -fy + vy * fx;
        w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S);
        c.slip = Math.abs(w);
        c.u = u2;
        c.vx = fx * u2 + -fy * w;
        c.vy = fy * u2 + fx * w;
        c.x += c.vx * h;
        c.y += c.vy * h;
    }

08Ship Flown by Thrust and Turn Inertia, with Exhaust Particles

A hero on a web game landing page: the angle difference to the stick is wrapped into −180° to 180° and turned by a spring of 40 with damping of 9, so a turn from 170° to −170° covers 20° instead of 340°. Thrust is 300 times the tilt strength and drag is 1.1 times the speed, which caps the speed at 272.7px per second. Exhaust particles are born at up to 90 per second, inherit the ship's velocity and number 64 at most in the demo loop.

Ship Flown by Thrust and Turn Inertia, with Exhaust Particles
Open in a new tab

08 · Ship Flown by Thrust and Turn Inertia, with Exhaust Particles — Measured area 21.767% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TURN_STIFFNESSSHIP_DRAGEXHAUST_SPEED
    function shipStep(h, m, dirA) {
        if (m > 0) {
            ship.aim = dirA;
        }
        const diff = wrapAngle(ship.aim - ship.th);
        ship.om += (TURN_STIFFNESS * diff - TURN_DAMPING * ship.om) * h;
        ship.th += ship.om * h;
        ship.thrust = m;
        const fx = Math.cos(ship.th);
        const fy = Math.sin(ship.th);
        ship.vx += (fx * m * MAX_THRUST - ship.vx * SHIP_DRAG) * h;
        ship.vy += (fy * m * MAX_THRUST - ship.vy * SHIP_DRAG) * h;
        ship.x += ship.vx * h;
        ship.y += ship.vy * h;
        spawnCarry += m * EXHAUST_RATE * h;
        while (spawnCarry >= 1) {
            spawnCarry -= 1;
            const spread = (rng08() - 0.5) * 0.9;
            const sp = EXHAUST_SPEED * (0.7 + rng08() * 0.6);
            const bx = Math.cos(ship.th + Math.PI + spread);
            const by = Math.sin(ship.th + Math.PI + spread);
            sparks.push(ship.x - fx * 14, ship.y - fy * 14, ship.vx + bx * sp, ship.vy + by * sp, EXHAUST_LIFE_S * (0.6 + rng08() * 0.4));
        }
    }

09Dual-Stick Drone View Scene

A hero for a drone filming service landing page: the left stick moves forward at 28 and sideways at 18, while the right stick turns at 1.1 radians per second and climbs at 9 within altitudes 3 to 24. On-screen size is focal length 250 over depth, so an object of height 10 is 250px at depth 10 and 125px at 20, and the fog share 1 − e^(−depth × 0.011) reaches 0.585 at depth 80. Trees and boxes repeat over a 5×5 block of cells around the drone.

Dual-Stick Drone View Scene
Open in a new tab

09 · Dual-Stick Drone View Scene — Measured area 51.987% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOCAL_LENGTHFOG_DENSITYNEAR_Z
    function fogOf(z) { return 1 - Math.exp(-z * FOG_DENSITY); }
    // One point to the screen: [screen x, screen y, depth], or null when it is in front of the near plane
    function project(X, Y, Z) {
        const dx = X - drone.x;
        const dz = Z - drone.z;
        const s = Math.sin(drone.yaw);
        const c = Math.cos(drone.yaw);
        const side = dx * c - dz * s;
        const depth = dx * s + dz * c;
        if (depth <= NEAR_Z) {
            return null;
        }
        return [W / 2 + FOCAL_LENGTH * side / depth, HORIZON_Y + FOCAL_LENGTH * (drone.alt - Y) / depth, depth];
    }

In What Order Does a Joystick UI Design Turn Thumb Coordinates into Motion?

The stage box receives pointerdown, turns the screen position into 480×300 stage coordinates, and lets the scene's mapPointer pick which stick owns that spot before setPointerCapture holds the pointer. A captured pointer keeps sending pointermove and pointerup to the box even when the thumb slides off the stage, so the knob is not abandoned on the rim. Each pointer is held by its pointerId, which is how two fingers take one stick each in 09.

    pad.addEventListener('pointerdown', function (e) {
        const p = toStage(e);
        const g = mapPointer(p[0], p[1]);
        if (g[0] < 0) {
            return;
        }
        const s = sticks[g[0]];
        takeOver();
        pad.setPointerCapture(e.pointerId);
        grabbed[String(e.pointerId)] = g[0];
        s.hdown = true;
        s.hx = g[1];
        s.hy = g[2];
        showNow();
    }, { signal: off.signal });

After the grab, readRaw builds the clamped (thumb − base) ÷ R value, and each scene runs it through the cut, bend and weight steps of the next section. Screen y grows downward, so stick y is positive downward too.

Putting a mobile touch joystick into a web mini game

touch-action: none sits on the stage box only, so a mobile touch joystick placed in the middle of a long page still lets a swipe outside the stage scroll the page. Pairing 02's returning knob with 05's base, which starts anywhere on the left half of the screen, probably makes the stick easier to find without looking down. If the game moves its character one tile at a time, 05's output can feed 04's pickSector so the direction does not flicker at a border.

Panning maps and floor plans

Wayfinding and floor-by-floor guides that scan a large picture on a small screen suit 06. Change only the plan size and the RUBBER_LIMIT of 60, and a hard push shows no more than 60px past the edge, so the end of the map is easy to notice. One second after release the speed is 0.05 of what it was, so the view has mostly stopped before the next touch.

Remote control screens

A control screen that aims a camera or a robot arm needs fine small tilts, so pick 03's curve first. If the two axes belong to different hands, split the sticks in two as 09 does, and the status line's left and right direction and strength sentence can be copied straight into an operation log.

Trim with a Deadzone, Bend with a Power Curve, Add Weight with Springs and Drag: How Stick Values Become Motion

The joystick deadzone meaning fits one sentence: a ring around the base center where any tilt counts as nothing. Cutting only that ring would make the output jump from 0 to 0.15 the instant it passes the ring, so radialDeadzone stretches the remaining 0.85 back to 0–1 and joins the two. The table lists the four steps a thumb value passes through before it moves anything on screen.

Step What it does Function Scenes using it
1 Read (thumb − base) ÷ R, clamped to length 1 readRaw 01 to 09
2 Cut Zero inside the deadzone, stretched back to 0–1 outside radialDeadzone 01, 03, 06, 08, 09 (04 treats strength under 0.3 as no direction)
3 Bend Strength raised to the power 1.8 responseCurve 03
4 Weight Each substep fixes velocity first, then moves position with it springStep, stepPan, carStep, shipStep 02, 07, 08 (1/240 s) and 06 (1/120 s)
    function readRaw(s) {
        if (!s.down) {
            s.rx = 0;
            s.ry = 0;
            return;
        }
        let x = (s.tx - s.bx) / s.R;
        let y = (s.ty - s.by) / s.R;
        const m = Math.hypot(x, y);
        if (m > 1) {
            x /= m;
            y /= m;
        }
        s.rx = x;
        s.ry = y;
    }
    // Round deadzone: inside length m ≤ dz gives 0; outside, stretch (m − dz)/(1 − dz) back to 0–1 with the direction kept
    function radialDeadzone(x, y, dz) {
        const m = Math.hypot(x, y);
        if (m <= dz || m === 0) {
            return [0, 0];
        }
        const k = clamp((m - dz) / (1 - dz), 0, 1) / m;
        return [x * k, y * k];
    }

Without the stretch the first stretch of tilt vanishes

A deadzone that only cuts starts its output at 0.15 the moment the tilt passes 0.15, which throws away the fine range just below. Stretched back, a tilt of 0.2 gives 0.059 and 0.3 gives 0.176, rising smoothly from 0, and the end value 1 stays 1. With the demo's key thumb tilted −8° at 0.646, the round zone outputs 0.584 at −8° while the square one outputs 0.576 at 0°, so only the square one bends the direction toward the axis.

A curve knows nothing about time but weight does

03's curve returns the same output for the same input, so the crosshair stops on the frame the hand lifts. 02, 06, 07 and 08 add the output to a velocity or a force, so they keep moving for several frames after release, and how the step length enters the math lets frame rate change the result. That is why 06 writes its damping as Math.pow(PAN_DAMPING, h).

Drawing far things first in the drone scene

09 sorts the 7 trees and 3 boxes repeated over the surrounding 5×5 cells by depth and draws from the largest down, so a near tree covers a far building. Anything closer than depth 36 fades and is gone at 14, which keeps one passing object from covering the whole stage.

        items.sort(function (a, b) { return b.z - a.z; });
        for (const it of items) {
            // Objects closer than NEAR_FADE_Z + NEAR_FADE_LEN start to fade and vanish at NEAR_FADE_Z, so a passing drone is not covered by one object
            ctx.globalAlpha = clamp((it.z - NEAR_FADE_Z) / NEAR_FADE_LEN, 0, 1);
            if (it.kind === 0) {
                drawTree(it.i, it.ox, it.oz);
            }
            else {
                drawBlock(it.i, it.ox, it.oz, it.z);
            }
        }

Where a Virtual Joystick Breaks: Eight-Way Flicker at Sector Borders, a Thumb That Leaves the Base and Inertia Piled Up After a Hidden Tab

A thumb trembling on the 22.5° border

The 04 demo holds the thumb on the 22.5° border and shakes it ±6° from 0.42 to 2.3 seconds. In that stretch the mint walker with an 8° margin changes direction 0 times, and the coral walker with 0° changes 7 times, zigzagging as it walks. Stretching the same tremor to 10 seconds gives 38 changes at 0°, 30 at 2°, 10 at 4° and 0 at 8° and 12°, so the flicker seems to vanish once the margin passes the tremor width of 6°. The fix is the 22.5 + hyst condition in pickSector.

A knob caught on the rim

With a fixed base, a thumb that went 3R from the center must retrace 126.5px before the output turns around. At 1.5R that is 63.5px and at 2R it is 84.5px, growing with the distance traveled, while the dragged base turns around after only 42.5px no matter how far the thumb went. In the demo the thumb starts back at 1.0 seconds, the dragged copy turns at 1.167 and the fixed copy at 1.367, a gap of 0.2 seconds.

A copy that spends 1.2 seconds in one step

naiveStep moves the position with the previous velocity first, so a 1.2-second frame paints that velocity times 1.2 seconds on screen at once. The repaired stepPan clamps to 0.05 seconds and fixes velocity first in every 1/120-second piece, so the same frame moves 24.4px. The demo overlays both copies for 1.7 seconds as a coral dotted line and on the small map.

    // Fixed version: clamp dt, then split into substeps; each step fixes velocity first and then moves the position
    function stepPan(p, ox, oy, dt) {
        let left = Math.min(dt, PAN_DT_CAP);
        while (left > 1e-9) {
            const h = Math.min(PAN_SUBSTEP, left);
            const a = accelOf(p, ox, oy);
            const keep = Math.pow(PAN_DAMPING, h);
            p.vx = (p.vx + a[0] * h) * keep;
            p.vy = (p.vy + a[1] * h) * keep;
            p.x += p.vx * h;
            p.y += p.vy * h;
            left -= h;
        }
    }
    // Broken version: use the received dt in one step. The position moves with the previous velocity first, so a long frame jumps by that velocity × dt
    function naiveStep(p, ox, oy, dt) {
        const a = accelOf(p, ox, oy);
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        const keep = Math.pow(PAN_DAMPING, dt);
        p.vx = (p.vx + a[0] * dt) * keep;
        p.vy = (p.vy + a[1] * dt) * keep;
    }

A real hidden tab is caught by the loop all nine scenes share. It stops the loop when the tab hides, sets lastTs back to −1 so the hidden time is not handed to the first frame, and still clamps any long frame to DT_MAX of 0.05 seconds.

    function frame(ts) {
        if (still || paused) {
            rafId = 0;
            return;
        }
        if (lastTs < 0) {
            lastTs = ts;
        }
        const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
        lastTs = ts;
        if (dt > 0) {
            rampKeys(dt);
            advance(dt);
        }
        draw();
        pushStatus(statusText());
        rafId = window.requestAnimationFrame(frame);
    }

The broken and repaired copies sit together in the single index.html of the 06 folder. Unzip the archive with the password 97ynwj4h, open that file, and pause at 1.25 seconds to hold the moment the two copies split and compare them side by side.

Widen the Deadzone and How Much Tremor Disappears? Deadzone Radius, Response Exponent and Tire Grip Compared

Name Changed value Feel
A wider deadzone 01 DEADZONE_RADIUS 0.15 → 0.3 Still share of the disc 2.3% → 9%, output at tilt 0.5 from 0.412 → 0.286 and at 0.8 from 0.765 → 0.714
A steeper response curve 03 RESPONSE_EXP 1.8 → 3 At input 0.3, 22.8 → 4.1px per second (60px takes 2.63 s → 14.63 s), half-speed strength 0.42 → 0.707
Less tire grip 07 TIRE_GRIP 0.85 → 0.5 At full lock the slip angle 12.1° → 25.5°, skid marks per loop 187.9px → 460.3px, time with marks 18.8% → 53.4%

Numbers to check before changing a value

Widening the deadzone to 0.3 takes the output at tilt 0.3 from 0.176 to 0, and the 01 key thumb at strength 0.646 falls from 0.584 to 0.495. The demo's tremor peak of 0.101 already sits inside 0.15, so if tremor is the goal, keeping 0.15 and slowing small tilts with 03's curve probably costs less speed near the end. An exponent of 3 cuts 193px per second at input 0.8 down to 143.8px, so pick between 1.8 and 3 according to how often the screen needs fast moves.

TIRE_GRIP is the share trimmed, not the share kept. At 0.85 only 15% of the sideways speed survives each 0.15 seconds, and at 0.5 half survives, so the car slides more. Reading 0.85 as "85% stays" flips the direction of every change you make, so check the line w *= Math.pow(1 - TIRE_GRIP, h / GRIP_TIME_S) in the source first.

All three constants can be found by name in each scene folder's index.html, and after you edit one number and reopen the file in a browser, the difference in the table shows up directly.

The Same Tilt from Arrow Keys: Accessibility Through a Direction Sentence and Inertia That Settles Under Reduced Motion

Arrow keys in place of a thumb

The stage box carries tabindex="0" and role="application" with a label that says to press it and tilt with arrow keys. Holding a key fills the stick value at a ramp rate of 7 up to full, and releasing returns it at the same rate, so a short tap gives a small tilt. The default arrow behavior is blocked only inside the box and only without Alt, Ctrl or Meta, so arrows outside the stage scroll the page in all nine scenes. The status line at the top left starts as off, switches to polite after the first touch, and reads "direction {d} · strength {s}%" in 5% steps.

Inertia that settles under reduced motion

With prefers-reduced-motion: reduce the loop stops and each scene draws one still frame with the knob and its target already in place, with no overshoot, inertia, particles or drifting fog. Two frames captured at 0.4 and 1.2 seconds differed by 0 pixels in all nine scenes, and one arrow press still redraws a still frame moved to its result position without overshoot, so the screen changes. The pause button at the top right sets aria-pressed to true when pressed. The pointer event order is in MDN Pointer events, capture is in MDN setPointerCapture, and the scroll rule is in MDN touch-action.

Virtual Joystick JavaScript FAQ: Mouse Input, Two Fingers at Once and Uses Beyond Games

Can a javascript joystick control built with pointer events also take a mouse

Mouse drags come in through the same pointer path as fingers. Press and drag on the stage and setPointerCapture holds that pointer, so the knob follows even when the cursor leaves the stage, and for keyboard only, focus the stage and tilt with arrow keys.

What happens when two fingers press at once

Each pointer is captured separately by its pointerId, so in 09 one finger takes the movement stick while another takes the direction and altitude stick. On a keyboard, holding Shift with an arrow key tilts the right stick in 09.

What else is a virtual joystick used for besides games

Panning a large floor plan on a small screen as in 06, or fine-aiming a remote camera as in 03, are two uses. Each scene passes its stick values to the target inside update, so swap only that target for your own map or camera and the deadzone and response curve come along unchanged.

Enter the archive password

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