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

Walking Animation Cycle: 9 Calculated Scenes

A walk is legs taking turns catching a body tipping forward — 02 splits a step into five phases, 03 sets the speed by the stride angle, and 07 leans downhill

A Character Walking Animation Is Not a Reel of Poses Flipped in Order but a Calculation That Makes Walking Look Real by Solving, Every Frame, the Angles of Two Legs Taking Turns Pressing the Floor Under a Body Tipping Forward: It All Starts From One Cycle Phase

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

The nine scenes share one walk clock and one leg solver, and each scene differs only in the value it examines. The character walks in place at the center of the screen while the floor ticks and the background hills and clouds drift back at the groundV speed. If you want a walking animation reference you can read in one sitting, this is its shape: one phase, a leg solver and a canvas.

The walk state comes only from the gait calculation

There are two clocks. walkT sets the walk phase and sceneT runs the timetable for turns and slope changes. Leg angles, foot positions and arm swing all solve out of (walkT * STEP_HZ) % 1, and no separate walk pose is stored.

    function advance(dt) {
        sceneT += dt;
        walkT += dt;
        if (!manual) {
            while (evAt < script.length && script[evAt].t <= sceneT) {
                const ev = script[evAt];
                evAt += 1;
                ev.f();
            }
            if (sceneT >= LAP_S) {
                lap += 1;
                begin();
                return;
            }
        }
        update(dt);
    }

begin() resets both clocks to 0 at the start of each lap, so the same timetable gives the same walk. None of the nine index.html files contains Math.random, Date, performance or fetch.

A fixed 1/60 second step

The frame gap is cut at DT_MAX 0.05 s, stacked in acc and handed to advance in STEP_S 1/60 s pieces by while (acc >= STEP_S). When the tab hides the loop stops, and on return acc is emptied and the first frame's dt is thrown away, so a few seconds do not pile into one frame and make the walk jump.

Walking Animation Cycle in JavaScript: 9 Scenes — a Seamless Phase Wrap, Five Stance Phases With a Flat Sole, Stride by Angle, a Head Bob and Gaze, a Counter-Swinging Elbow, a Torso Rock, a Slope Adaptation, an Arc Correction for Foot Travel and a Foot Plant on the Ground

Area, frames and intensity from the latest render

Here are the cumulative area, the moving frames out of 23 and the mean intensity read off the nine renders.

Scene Area Moving frames Intensity
01 Phase wrap 21.4% 23/23 78.4
02 Five stance phases 20.3% 23/23 62.0
03 Stride angle 13.5% 23/23 62.6
04 Head and gaze 14.3% 23/23 70.5
05 Elbow 15.6% 23/23 78.9
06 Torso twist 14.9% 23/23 73.3
07 Slope adaptation 20.2% 23/23 63.1
08 Arc correction 14.1% 23/23 64.9
09 Foot plant 16.1% 23/23 115.8

Scene 01 covers the widest area at 21.4% and 03 the narrowest at 13.5%. All nine scenes moved in 23 of 23 frames, and mean intensity is highest in 09 at 115.8 and lowest in 02 at 62.0.

01A Cycle Phase That Wraps Without a Seam

Walking is taken as two steps per full turn, so cyclePhase() returns (walkT * STEP_HZ) % 2 and legPhase() adds half a step to the right leg only, which keeps the legs half a step apart. Cutting the phase with a modulo leaves no jump between the last pose and the first at each step. The seam jump counter in the right panel counts frames where the sin value leaps by more than 0.35, and running the same formula in Node for 3.75 s (225 frames) gave 0.

A Cycle Phase That Wraps Without a Seam
Open in a new tab

01 · A Cycle Phase That Wraps Without a Seam — Measured area 21.353% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

cyclePhaselegPhaseSTEP_HZwalkT
    const GAIT = { stepHz: STEP_HZ, strideDeg: STRIDE_DEG, legPx: LEG_PX, legRatio: LEG_RATIO, contactShare: CONTACT_SHARE };
    // walkT is a clock (seconds) that rises with walk time only. phase is one step 0-1, cyclePhase is two steps 0-2
    let walkT = 0;
    function stepPhase() { return (walkT * GAIT.stepHz) % 1; }
    function cyclePhase() { return (walkT * GAIT.stepHz) % 2; }
    function stepIndex() { return Math.floor(walkT * GAIT.stepHz); }
    // Per-leg step phase: the left leg sits on whole steps, the right leg is half a step off
    function legPhase(right) { return ((walkT * GAIT.stepHz) + (right ? 0.5 : 0)) % 1; }

02Five Stance Phases Where the Sole Stays on the Floor

One step is split into contact 12%, resistance 18%, mid 15%, push-off 15% and lift 40%, and inside CONTACT_SHARE 0.6 (inStance) solveFoot freezes the ankle at floor height. The ankle touches at -14 degrees, lies flat to 0, then rolls from 0.45 of a step onward up to 16 degrees as the toe pushes. Of the 0.63 s step, 0.37 s is stance and 0.25 s is swing.

Five Stance Phases Where the Sole Stays on the Floor
Open in a new tab

02 · Five Stance Phases Where the Sole Stays on the Floor — Measured area 20.344% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PH5inStanceCONTACT_SHAREsolveFoot
    function drawStanceFoot(hipX, hipY, ph) {
        const fs = solveFoot(hipX, hipY, ph, plantXOf(0, hipX), 0, GROUND_Y);
        // Stance phase: roll with the sole on the floor, interpolating ankleDeg by ph (heel strike to toe push)
        const roll = ph < 0.12 ? lerp(-14, 0, ph / 0.12) : (ph > 0.45 ? lerp(0, 16, (ph - 0.45) / (GAIT.contactShare - 0.45)) : 0);
        footShape(fs.ax, fs.ay, fs.planted ? roll : fs.ankleDeg, MINT);
        return roll;
    }

03Setting the Walk Speed by the Stride Angle

At STRIDE_DEG 28 degrees one step pushes out 2 * LEG_PX * sin(28 degrees), which is 84.5 px, and multiplying by STEP_HZ 1.6 gives 135.2 px/s, which feeds groundV so the floor ticks drift at that speed. The mismatched run below holds the body at 0.82 times that, 110.9 px/s, and draws the trail of a planted foot crawling by drift. During the 0.37 s of stance the body passes 50.7 px.

Setting the Walk Speed by the Stride Angle
Open in a new tab

03 · Setting the Walk Speed by the Stride Angle — Measured area 13.483% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STRIDE_DEGstepDistMISMATCHgroundV
    function build() {
        stepDist = 0;
        vOk = 0;
    }
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        // One step distance: front foot reach (forward spread) plus back foot push (backward spread)
        stepDist = Math.sin(rad(GAIT.strideDeg)) * GAIT.legPx * 2;
        vOk = stepDist * GAIT.stepHz;
        groundV = vOk;
    }

04A Head That Bobs With Each Step and a Steady Gaze

The body bob that hipHeight makes has an amplitude of 7.20 px, and the head subtracts 82% of it in the opposite direction in headY, so the eye swing shrinks to 1.3 px and the eye line looks nearly level. At 0.4 s before the turn at 2.4 s the gaze gazeX moves first from 320 to 120 to signal the next direction.

A Head That Bobs With Each Step and a Steady Gaze
Open in a new tab

04 · A Head That Bobs With Each Step and a Steady Gaze — Measured area 14.278% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

hipHeightbobNowgazeXdirChangeAt
        const ph = legPhase(false);
        // Body bob: hipHeight is already a sine of ph. The head cancels it with a phase half a turn off
        bobNow = hipHeight(ph);
        headY = bobNow - 0.82 * (hipHeight(ph) - GAIT.legPx * 0.93);
        // Direction change: the gaze moves to the target 0.4 s before the turn
        if (dirChangeAt > 0 && sceneT > dirChangeAt - 0.4 && sceneT < dirChangeAt) {
            const k = (sceneT - (dirChangeAt - 0.4)) / 0.4;
            gazeX = lerp(320, 120, k);
        }
        if (dirChangeAt > 0 && sceneT >= dirChangeAt) {
            gazeX = 120;
            dirChangeAt = -1;
        }

05An Elbow That Swings Against the Legs

solveArm swings the shoulder by STRIDE_DEG * 0.6, up to 16.8 degrees, using armPh, which sits half a step off the same-side leg, and folds the elbow by 70 * (0.4 + LEG_RATIO), up to 61.6 degrees. Lowering LEG_RATIO to 0.42 makes the same formula return 57.4 degrees, so changing the leg proportion moves the arm feel with it.

An Elbow That Swings Against the Legs
Open in a new tab

05 · An Elbow That Swings Against the Legs — Measured area 15.59% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

solveArmLEG_RATIOARM_LbendBase
    // Arm solve: shoulder angle (proportional to the leg, opposite phase) and elbow bend (largest on the forward swing). The bend scale is LEG_RATIO
    function solveArm(shoulderX, shoulderY, side) {
        const ph = legPhase(side === 1);
        const armPh = (ph + 0.5) % 1;
        const swing = Math.sin(armPh * TAU);
        const deg = swing * GAIT.strideDeg * 0.6;
        const bendBase = Math.max(0, Math.sin(armPh * Math.PI)) * 70;
        const bend = bendBase * (0.4 + GAIT.legRatio);
        const a = rad(deg);
        const ex = shoulderX + Math.sin(a) * ARM_L;
        const ey = shoulderY + Math.cos(a) * ARM_L;
        const fa = a + rad(bend) * (Math.sin(a) >= 0 ? 1 : 0.6);
        const hx = ex + Math.sin(fa) * FORE_L;
        const hy = ey + Math.cos(fa) * FORE_L;
        return { ex: ex, ey: ey, hx: hx, hy: hy, bend: bend, deg: deg };
    }

06A Torso and Hips That Turn With Each Step

The hips turn by hipTwist = sin(ph * TAU) * amp and the shoulders turn the other way at 1.2 times that (shTwist), with amp equal to 6 * (STEP_HZ / 1.6), which gives hips of ±6 degrees and shoulders of ±7.2 degrees at 1.6 Hz. At 2.4 Hz these grow to ±9 and ±10.8 degrees, so the faster the walk, the more the torso twists.

A Torso and Hips That Turn With Each Step
Open in a new tab

06 · A Torso and Hips That Turn With Each Step — Measured area 14.933% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

hipTwistshTwistSTEP_HZlegPhase
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        // Hip and shoulder rotation: proportional to the sine of the walk phase, sized by the step rate (±6 degrees at 1.6 Hz)
        const ph = legPhase(false);
        const amp = 6 * (GAIT.stepHz / 1.6);
        hipTwist = Math.sin(ph * TAU) * amp;
        shTwist = -Math.sin(ph * TAU) * amp * 1.2;
        if (Math.floor(sceneT * 30) % 2 === 0) {
            hipHist.push(hipTwist);
            shHist.push(shTwist);
            if (hipHist.length > 90) {
                hipHist.shift();
                shHist.shift();
            }
        }
    }

07An Adaptive Walk Where the Body Leans on a Slope

leanDeg multiplies the slope by 0.5 downhill and 0.25 uphill to tilt the body, so on a 14 degree downhill the body leans 7 degrees forward and baseStepHz + max(0, -lean) * 0.06 lifts the step rate from 1.6 Hz to about 2.02 Hz. On a 10 degree uphill the body leans 2.5 degrees back and the step rate stays at 1.6 Hz. Planted foot height comes from the groundYAt slope equation and the ankle folds by the slope angle.

An Adaptive Walk Where the Body Leans on a Slope
Open in a new tab

07 · An Adaptive Walk Where the Body Leans on a Slope — Measured area 20.211% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

leanDeggroundYAtsolveFootSlopeSLOPES
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        slopeNow = slopeAt();
        // Raise the step rate downhill: the larger the forward lean (-lean), the higher stepHz
        const lean = leanDeg(slopeNow);
        GAIT.stepHz = clamp(baseStepHz + Math.max(0, -lean) * 0.06, 0.8, 2.8);
    }

08Shaving the Arc the Foot Travels

Without correction ankleY draws a low arc GROUND_Y - sin(PI * k) * LEG_PX * 0.09, while the corrected one holds a minimum height of ARC_MIN_PX * clamp(1.6 / STEP_HZ, 0.5, 2) up to k 0.85 and drops only in the last 15%. That minimum is 20 px at 1.6 Hz, 13.3 px at 2.4 Hz and 40 px at 0.8 Hz. By an approximation that samples the swing at 100 points, the share within 4 px of the floor falls from 34% to 3%, and the on-screen scuffRaw and scuffFix count frames separately.

Shaving the Arc the Foot Travels
Open in a new tab

08 · Shaving the Arc the Foot Travels — Measured area 14.109% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ARC_MIN_PXankleYswingKscuffRaw
    function swingK() {
        const ph = legPhase(true);
        if (inStance(ph)) {
            return -1;
        }
        return (ph - GAIT.contactShare) / (1 - GAIT.contactShare);
    }
    // Ankle y: raw (plain arc) vs corrected (minimum height)
    function ankleY(k, corrected) {
        const half = rad(GAIT.strideDeg);
        const ang = lerp(half, -half, k);
        const rawY = GROUND_Y - Math.sin(Math.PI * k) * GAIT.legPx * 0.09;
        if (!corrected) {
            return rawY;
        }
        const minH = ARC_MIN_PX * clamp(1.6 / GAIT.stepHz, 0.5, 2);
        const drop = k > 0.85 ? (k - 0.85) / 0.15 : 0;
        const lift = Math.max(minH * (1 - drop), Math.sin(Math.PI * Math.min(k / 0.85, 1)) * minH * 1.2);
        return rawY - lift;
    }

09A Sole Locked to the Floor and a Finished Scene

The three walkers run at 28 degrees and 1.6 Hz, 18 degrees and 2.0 Hz, and 40 degrees and 1.2 Hz, giving step distances of 84.5, 55.6 and 115.7 px and speeds of 135.2, 111.3 and 138.8 px/s. A planted foot is a screen coordinate solved from the phase and a fixed body x, so it stays put while the body wobbles by sin(sceneT * 3.1) * 3 px. The maxSlip figure of 0.27 px comes from a formula for how far a ±12% body speed jitter would push the foot in one frame if the coordinate were re-solved, so it is an estimate and not a rendered measurement.

A Sole Locked to the Floor and a Finished Scene
Open in a new tab

09 · A Sole Locked to the Floor and a Finished Scene — Measured area 16.135% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

lockPlantsplantXOfWALKERSmaxSlip
    function update(dt) {
        if (dt <= 0) {
            return;
        }
        // Screen travel of a planted foot: 0 when the plant is stored, moves by the jitter when re-solved
        maxSlip = 0;
        for (let i = 0; i < WALKERS.length; i += 1) {
            const wk = WALKERS[i];
            for (let r = 0; r < 2; r += 1) {
                const ph = ((walkT * wk.hz) + (r === 1 ? 0.5 : 0)) % 1;
                if (ph < 0.6) {
                    const noSave = Math.sin(rad(wk.stride)) * GAIT.legPx * 2 * wk.hz * Math.cos(sceneT * 3.1) * 0.12 * (1 / 60);
                    maxSlip = Math.max(maxSlip, Math.abs(noSave));
                }
            }
        }
    }

When You Build a Walking Character in Code, How Many Values Decide One Step?

Five values decide one step, and the rest of the numbers follow from them by calculation.

Five source constants are all there is

Constant Value What it sets
STEP_HZ 1.6 Steps per second. One step 0.63 s, a two-step cycle 1.25 s
STRIDE_DEG 28 The half angle the legs spread fore and aft
LEG_PX 90 Leg length from hip to ankle
LEG_RATIO 0.48 The shin to thigh proportion
CONTACT_SHARE 0.6 The share of one step spent in stance

How one step divides its time

CONTACT_SHARE 0.6 splits into 0.37 s of stance and 0.25 s of swing, and because the legs sit half a step apart, both feet are on the floor for 0.2 of a step, 0.13 s. The body height hipHeight takes the form LEG_PX * (0.93 + 0.04 * bob) and moves between 80.10 px and 87.30 px.

Push the Cycle Phase With Time, Solve the Leg Angles With Trigonometry and Move the Joints to Screen Coordinates to Draw: the Order in Which One Step of Walking Animation Is Handled

The phase comes first and the drawing follows

When advance raises walkT, legPhase produces both leg phases and inStance decides stance or swing. Only then does solveFoot place the foot and limb draw the joints on the canvas.

The knee is solved by the law of cosines

The leg is two links, a thigh L1 of 60.81 px and a shin L2 of 29.19 px, which sum to the LEG_PX 90 px. The distance d from hip to ankle is cut at L1 + L2 - 0.01, the knee angle is solved by the law of cosines, and of the two bend solutions only the base - elbow side is used.

Putting a Walking Character on a Real Screen: Building Without a Library, Turning Direction and Setting the Step Count, and Situations Where the Body Rides Low

When you attach it to a character animation, a game movement effect or a loading mascot, tie groundV to the character's travel speed and STEP_HZ to progress. A walking animation gif or a pixel art walk repeats frames drawn in advance, while this code gives a different walk the moment STEP_HZ changes.

Building without a library

The canvas is fixed at 480×300 internally and devicePixelRatio is multiplied by at most 2 through Math.min. With no library, WebGL, CDN or external image, pasting the one file into a static page is enough to run it. The character is drawn from circles and rounded bars as an original shape.

Turning direction and setting the step count

Speed changes through STEP_HZ, and when direction changes you turn the gaze first as 04 does. Scene 07 raises STEP_HZ by itself downhill, so a structure where the terrain changes the walk comes out of the same formula. For keys, the left and right arrows move STEP_HZ by 0.4 within 0.8 to 2.8.

When the body rides low

hipHeight is lowest at 80.10 px at the start of a step. Scene 07 multiplies it by Math.cos(rad(slopeNow)) to lower the body further on a slope.

Where this demo does not carry over

The foot is a two-link approximation on one plane. If foot positions must be re-solved for each terrain height, swap groundYAt in 07 for a terrain function, a leg with three or more links or an ankle joint needs an iterative solver because one law of cosines no longer solves it, and the switch from walking to standing lies outside this constant-speed cycle, so you must set a rule that stops the phase. This post draws a 2D side view only, so a walking animation 3d rig is outside what was tested.

How this differs from the neighbors

The cursor creature post has legs chase a target point, while this post lets the walk clock push a phase that fixes the legs. To see frames drawn one sheet at a time, the flipbook post is the place.

Where a Walking Cycle Breaks: a Trap of Soles That Appear to Slide Along the Floor, of Both Legs Snapping to the Same Phase and of Feet Sinking Into the Ground on a Slope

A sole slides along the floor

Scene 03's mismatched run is the real thing. When the body speed of 110.9 px/s falls short of the 135.2 px/s the formula asks for, the planted foot crawls by drift and leaves a line on the floor. This source freezes the planted foot in screen coordinates and only ticks the floor marks with groundV, so in a scene where the world itself moves you should store the planted position in world coordinates and move it with the camera.

Both legs run in the same phase

Without the 0.5 step added to the right leg, the two curves in 01 overlap, and both legs land and lift together. The single line legPhase(right) makes this difference.

A foot sinks into the ground on a slope

Setting the planted foot height to the flat value makes the foot sink into the ground or hover on a slope. Scene 07 avoids this by solving the planted foot's y with groundYAt(x, BASE_Y, slope) and the ankle angle with -slopeNow.

The full source of the nine scenes and the editable files come in the zip card below, and the password that opens it is yts6py5y.

Change the Step Rate, the Stride Angle and the Leg Length Ratio and How Much Does the Feel Shift? STEP_HZ, STRIDE_DEG and LEG_RATIO Compared

Name Changed value Feel
A faster step rate 01 STEP_HZ 1.6 → 2.4 One step shortens from 0.63 s to 0.42 s and the stance from 0.37 s to 0.25 s, while body speed grows from 135.2 px/s to 202.8 px/s for a hurried, near running feel. The hip twist goes from ±6 to ±9 degrees and the arc minimum from 20 px to 13.3 px.
A narrower stride 03 STRIDE_DEG 28 → 14 One step falls from 84.5 px to 43.5 px, 51.5% of the base, and at 1.6 Hz the speed drops from 135.2 px/s to 69.7 px/s for a shuffling gait.
A shorter shin 05 LEG_RATIO 0.48 → 0.42 The shin shortens from 29.2 px to 26.6 px and the thigh lengthens from 60.8 px to 63.4 px, while the maximum elbow bend in 05 drops from 61.6 degrees to 57.4 degrees.

One value spreads over several scenes. STEP_HZ reaches the hip twist in 06 and the minimum height in 08, STRIDE_DEG reaches the shoulder angle in 05 and groundV, and LEG_RATIO touches the knee position and the elbow in 05, so change one at a time.

One Standing Pose Under Reduced Motion: Accessibility Through a Sentence for the Cycle Phase and the Current Step Count

Keys touch the walk

The stage of each of the nine scenes is role="application" with tabindex="0". The left and right arrows move STEP_HZ by 0.4 within 0.8 to 2.8, in 03 they move STRIDE_DEG by 2 within 10 to 44, in 05 up and down move LEG_RATIO by 0.02 within 0.40 to 0.56, and in 09 left and right change the three walkers' speed by ×1.15 and ×0.87. Enter, Space and P pause, N takes the next timetable event one step and R restarts from the beginning, and touching the controls stops the timetable while the calculation keeps running.

Reading out only when the mode changes

The on-screen status line is aria-live="off", so it does not read out value changes. say() writes a sentence into the role="status" cell only right after a touch and when the scene mode changes, and writes nothing while the auto demo runs. The aria-label of the canvas carries what the scene shows and its key values.

Reduced motion and a hidden tab

When prefers-reduced-motion is on, the loop does not run and showRest() sets walkT and sceneT to REST_S to draw one standing frame where the walk has come to rest. After input afterInput() redraws at once, and the stage carries touch-action: none so a touch does not drag the page.

    // Reduced motion: one lap played through to REST_S seconds of the timetable in one go, with no loop. The character stands where that moment puts it, with no stride interpolation
    function showRest() {
        stopLoop();
        lap = 0;
        begin();
        walkT = REST_S;
        while (evAt < script.length && script[evAt].t <= REST_S) {
            const ev = script[evAt];
            evAt += 1;
            sceneT = ev.t;
            ev.f();
        }
        sceneT = REST_S;
        update(0);
        showNow();
    }

Walking Animation FAQ: How Many Frames Suit a Walk, Which Value to Fix When the Foot Slides and What Separates a Walk From a Run

How many walking animation frames suit a walk

This post uses a continuous phase in place of a frame count. At 1.6 Hz a two-step cycle lasts 1.25 s, so it is drawn 75 times at 60 fps, and to turn it into a few drawings you divide the frames of one step by the ratios 12%, 18%, 15%, 15% and 40%.

Which value fixes a foot that looks like it slides

Match the body speed to 2 * LEG_PX * sin(STRIDE_DEG) * STEP_HZ. Putting 135.2 px/s next to the mismatched 0.82 times, 110.9 px/s, in 03 shows the gap at once, and once the foot coordinate in stance is solved and stored, the foot stays put even when the body speed wobbles.

What separates a walk from a run

The CONTACT_SHARE of 0.6 here leaves 0.2 of a step where both feet touch. With the legs half a step apart, a stance share below 0.5 would put a moment where both feet are in the air, but this post ran only 0.6 and did not check below 0.5.

Sources: CanvasRenderingContext2D.lineTo, requestAnimationFrame, prefers-reduced-motion

Enter the archive password

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