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

Traffic Light CSS: 9 Demos With Queues and Waves

A traffic light css built as a color-swapping timer lets cars roll through red, signals drift out of step and pedestrians never see the seconds left — scenes

See All Nine Intersection Scenes Running on This Page

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

Traffic Light CSS: 9 Demos Where Gaps, Offsets and Sensors Are Computed

All nine scenes share one layout: a 480 by 300 canvas (W and H), a one-line status text, a single button and a caption. The main function, which owns input and time, and the Lane class definition, which feeds cars down the road, are the same text in every file, so the main thing that changes from scene to scene is makeScene. One call is one frame and time is chopped into 16 ms steps (FIXED_DT). Wherever a scene draws random numbers (02, 03, 04, 06, 07 and 09), they come from mulberry32 with seed 446, so reopening the page seats the same cars in the same spots; 01, 05 and 08 do not use the seed.

A timer that only switches red, yellow and green on a schedule leaves the road blind. Cars and pedestrians never learn what the lamp says, and two lamps never learn when the other one started. This page adds three calculations on top of the timer. One turns the gap to the car ahead and the distance to the stop line into an acceleration, one delays each signal's start by a fixed offset, and one sets the green length from the gap between cars crossing a sensor line.

The car math lives in a single method, Lane.accelFor. It sets a desired gap s = g0 + max(0, v·headway + v·(v − vL) ÷ (2·√(accel·brake))) and returns accel·(1 − (v ÷ vmax)^δ − (s ÷ s)²) as the acceleration, where headway, accel and brake are parameters each scene passes to Lane (the constants HEADWAY, ACCEL and BRAKE_DECEL exist only in 02, and 03, 07 and 09 pass their own values). The car takes the smaller of the value from the car ahead and the value from the stop line, which is treated as a stopped car, and the result never drops below −3·brake (−0.21 in 02, where BRAKE_DECEL is 0.07). The exponent δ is 4 in 02, 07 and 09 and 40 in 03, which keeps a platoon at cruising speed until the last moment so it does not spread out. Only four scenes create Lane instances (02, 03, 07 and 09), even though every file carries its definition, while 01, 04, 05, 06 and 08 solve their problems with a timetable, an arc, a viewing angle, a reflection and a conflict table.

None of Math.random, Date, fetch, localStorage, navigator, AudioContext or getImageData appears in the nine files, and WebGL is not used. If you want a curved line through chart points, CSS Chart Design: 9 SVG Graphs, No Library covers it with SVG, and scene 07 here applies the same midpoint idea to a canvas line drawn over signal colors.

Code for Each Signal Technique

Every snippet is a run of consecutive lines taken from one scene's index.html, cut at 25 lines or fewer. Treat them as traffic light css code you can paste and try, and open the zip for each file from top to bottom.

01A Signal Cycling Green, Yellow and Red on a Fixed Timetable

phaseAt divides the frame number by one cycle (green 100, yellow 30, red 100) and picks the signal from the remainder, while relax pulls the three lens brightness values toward their targets by only LIT_RISE 0.16 per frame. The glow radius is 78 at full brightness and the alpha is brightness to the power LENS_GAMMA 1.6, so a lens swells as it lights and settles as it dims. Measured at 45.4% accumulated area and 23/23 moved frames.

A Signal Cycling Green, Yellow and Red on a Fixed Timetable
Open in a new tab

01 · A Signal Cycling Green, Yellow and Red on a Fixed Timetable — Measured area 45.421% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PHASE_FRAMESYELLOW_FRAMESBLOOM_RADIUSLENS_GAMMA
    function phaseAt(f, g, y, r) {
        const total = g + y + r;
        const m = ((f % total) + total) % total;
        return m < g ? SIG_GREEN : (m < g + y ? SIG_YELLOW : SIG_RED);
    }
    function stateText(state) {
        return state === SIG_GREEN ? TXT_GO : (state === SIG_YELLOW ? TXT_READY : TXT_STOP);
    }
        // a lens swells as it lights and settles as it dims, moving LIT_RISE toward the target per frame
        function relax() {
            const t = litOf(state());
            for (let i = 0; i < 3; i += 1) {
                lit[i] += (t[i] - lit[i]) * (reduce ? 1 : LIT_RISE);
            }
        }

02Cars That Stop Behind the Car Ahead With a Gap at Red

accelFor keeps the smaller of the car-ahead value and the stop-line value, and on the first frame the signal is no longer green it checks once whether v² ÷ (2·BRAKE_DECEL) exceeds the distance to the line, which decides stop or go. Over a 6000-frame run in a simulation (not a browser measurement), 0 of 64 cars crossed the stop line on red. Measured at 10.2% accumulated area and 23/23 moved frames.

Cars That Stop Behind the Car Ahead With a Gap at Red
Open in a new tab

02 · Cars That Stop Behind the Car Ahead With a Gap at Red — Measured area 10.192% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SAFE_GAPBRAKE_DECELSTOP_LINE_XACCEL
                    else {
                        if (!c.decided) {
                            c.decided = true;
                            c.go = c.v * c.v / (2 * p.brake) > sg.x - c.x;
                        }
                        if (!c.go) {
                            acc = Math.min(acc, this.accelFor(c.v, sg.x - c.x - 2, 0, p.lineGap));
                            clampX = sg.x - 1.5;
                        }
                    }
                acc = Math.max(acc, -p.brake * 3);
                const prevX = c.x;
                c.v = Math.max(0, c.v + acc);
                c.x += c.v;
                if (c.x > clampX && prevX <= clampX) {
                    c.x = clampX;
                    c.v = 0;
                }
            // a press moves the signal straight on to its next phase
            tap: function () {
                const m = (((f + skew) % cycle) + cycle) % cycle;
                const target = sig.state === SIG_RED ? 0 : (sig.state === SIG_GREEN ? GREEN_LEN : GREEN_LEN + YELLOW_LEN);
                skew += ((target - m) % cycle + cycle) % cycle;
                tickSignal();
            },

03Four Signals With Per-Block Offsets and a Platoon on a Green Wave

Signal k asks phaseAt with f - k * OFFSET_PER_BLOCK, so each signal starts 90 frames after the one before it, and a block of 100 px at 1.15 px per frame takes 87.0 frames, which lets a platoon of 3 cars clear all four signals without stopping. The offset runs 3.0 frames per block behind the cars, so the fourth signal is 9 frames late in total, which the 150-frame green absorbs. Measured at 12.7% accumulated area and 23/23 moved frames.

Four Signals With Per-Block Offsets and a Platoon on a Green Wave
Open in a new tab

03 · Four Signals With Per-Block Offsets and a Platoon on a Green Wave — Measured area 12.669% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CYCLE_LENOFFSET_PER_BLOCKPLATOON_SPEEDBLOCK_LEN
        // signal k starts the same cycle OFFSET_PER_BLOCK frames after the one before it
        function stateOf(k) {
            return phaseAt(f - k * OFFSET_PER_BLOCK, GREEN_LEN, YELLOW_LEN, CYCLE_LEN - GREEN_LEN - YELLOW_LEN);
        }
        function progress(k) {
            return ((((f - k * OFFSET_PER_BLOCK) % CYCLE_LEN) + CYCLE_LEN) % CYCLE_LEN) / CYCLE_LEN;
        }
                const m = f % CYCLE_LEN;
                if (m === PLATOON_AT) {
                    toSpawn = PLATOON_SIZE;
                }
                if (toSpawn > 0 && lane.maybeSpawn(f, 0, 0, PLATOON_SPEED)) {
                    toSpawn -= 1;
                }
                lane.step(sigs, none, -1);

04A Walk Signal Showing the Seconds Left as a Shrinking Arc

The 12 seconds are cut into arc pieces of RING_CIRCUMFERENCE ÷ 12 = 30.4 px minus the SEG_GAP of 5, which leaves 25.4 px dashes erased one at a time, and once the time left reaches FLASH_AT 5 the arc alternates red and yellow every 9 frames while the number alternates red and white. A walker already on the road speeds up to HURRY_SPEED 1.5, and anyone still waiting stays put once the flashing starts. Measured at 13.6% accumulated area and 14/23 moved frames.

A Walk Signal Showing the Seconds Left as a Shrinking Arc
Open in a new tab

04 · A Walk Signal Showing the Seconds Left as a Shrinking Arc — Measured area 13.559% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

WALK_SECONDSFLASH_ATRING_CIRCUMFERENCETICK_STEP
        function inWalk() { return (f % cycle) >= WAIT_FRAMES; }
        function secondsLeft() {
            if (!inWalk()) {
                return 0;
            }
            return (cycle - (f % cycle)) / SECOND_FRAMES;
        }
        function flashing() { return inWalk() && secondsLeft() <= FLASH_AT; }
                for (let i = 0; i < peds.length; i += 1) {
                    const p = peds[i];
                    if (!p.crossing) {
                        if (inWalk() && !flashing() && f >= p.startAt) {
                            p.crossing = true;
                        }
                    }
                    else {
                        p.x += p.dir * (flashing() || !inWalk() ? HURRY_SPEED : WALK_SPEED);
                        const done = p.dir > 0 ? p.x >= ROAD_X1 + 12 : p.x <= ROAD_X0 - 12;
                        if (done) {
                            p.crossing = false;
                            p.dir = -p.dir;
                            p.x = p.dir > 0 ? ROAD_X0 - 12 : ROAD_X1 + 12;
                            p.startAt = lastWalkStart + 99999;
                        }
                    }
                }

05A Signal Whose Visor Shadow Covers the Lens by Viewing Angle

The difference between the eye and the lens center, divided by EYE_DISTANCE 190 and multiplied by VISOR_DEPTH 24, slides the visor's opening circle over each lens, and a 9 by 9 grid (SAMPLE_GRID) counts what share of the lens stays inside the opening as a percent per signal. With no pointer present, autoPath moves the eye on a 5200 ms loop that overlays a horizontal sine of amplitude 214 and a vertical sine of amplitude 96 running 1.7 times faster. Measured at 18.5% accumulated area and 23/23 moved frames.

A Signal Whose Visor Shadow Covers the Lens by Viewing Angle
Open in a new tab

05 · A Signal Whose Visor Shadow Covers the Lens by Viewing Angle — Measured area 18.474% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

VISOR_DEPTHVIEW_TILT_MAXSHADOW_BLURLENS_OFFSET
        // shift of the visor opening seen from the eye; an eye on the right pushes the opening left and covers the right of the lens
        function shiftOf(hx, ly) {
            const dx = (ex - hx) / EYE_DISTANCE;
            const dy = (ey - ly) / EYE_DISTANCE;
            const lim = VIEW_TILT_MAX / VISOR_DEPTH;
            return [-VISOR_DEPTH * clamp(dx, -lim, lim), -VISOR_DEPTH * clamp(dy, -lim, lim)];
        }
        // sweep the lens on a grid and take the share of points inside the opening
        function visibleOf(hx, ly) {
            const s = shiftOf(hx, ly);
            let hit = 0;
            let all = 0;
            for (let i = 0; i < SAMPLE_GRID; i += 1) {
                for (let j = 0; j < SAMPLE_GRID; j += 1) {
                    const x = (i + 0.5) / SAMPLE_GRID * 2 - 1;
                    const y = (j + 0.5) / SAMPLE_GRID * 2 - 1;
                    if (x * x + y * y > 1) {
                        continue;
                    }
                    all += 1;
                    const ax = x * LENS_R - s[0];
                    const ay = y * LENS_R - s[1];
                    if (ax * ax + ay * ay <= rimRadius() * rimRadius()) {
                        hit += 1;
                    }
                }
            }
            return hit / all;
        }

06Signal Light Streaking Down a Wet Road

The scene is first drawn off screen, and each road row then pulls a flipped source row stretched by REFLECT_STRETCH 1.7, shakes every 20 px strip sideways with vnoise and fades with distance. One frame issues 90 rows × 24 strips = 2160 drawImage calls. Measured at 45.0% accumulated area and 23/23 moved frames.

Signal Light Streaking Down a Wet Road
Open in a new tab

06 · Signal Light Streaking Down a Wet Road — Measured area 45.003% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

REFLECT_STRETCHRIPPLE_AMPFADE_RATENOISE_SCALE
                // reflection: each road row takes a flipped source row, shakes sideways with noise and fades with distance
                const depth = H - HORIZON;
                const t = reduce ? 0 : f * 0.06;
                for (let dy = 0; dy < depth; dy += ROW_STEP) {
                    const sy = HORIZON - 1 - dy / REFLECT_STRETCH;
                    if (sy < 0) {
                        break;
                    }
                    const near = dy / depth;
                    const alpha = Math.max(0, 0.82 - near * FADE_RATE * 0.8);
                    if (alpha <= 0.02) {
                        continue;
                    }
                    ctx.globalAlpha = alpha;
                    for (let sx = 0; sx < W; sx += SEG_W) {
                        const n = vnoise(sx * 0.031 + t * 0.7, dy * NOISE_SCALE + t) * 2 - 1;
                        const dx = n * RIPPLE_AMP * (0.35 + near * 1.1);
                        ctx.drawImage(src, sx, Math.floor(sy), SEG_W, 1, sx + dx, HORIZON + dy, SEG_W, ROW_STEP + 0.6);
                    }
                }

07A Live Graph of Queue Length Rising and Falling Each Cycle

Every 6 frames (SAMPLE_EVERY) it counts the cars waiting at the stop line, keeps 56 points (GRAPH_POINTS) and joins them with quadratic curves through neighboring midpoints, where LINE_SMOOTH 0.5 sets the bend. Each point carries the signal color of that moment behind it, so you can read which signal made the line grow or shrink. Measured at 29.7% accumulated area and 23/23 moved frames.

A Live Graph of Queue Length Rising and Falling Each Cycle
Open in a new tab

07 · A Live Graph of Queue Length Rising and Falling Each Cycle — Measured area 29.664% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SAMPLE_EVERYGRAPH_POINTSY_MAX_CARSLINE_SMOOTH
        function sample() {
            hist.push(lane.waiting(STOP_LINE_X));
            histState.push(sig.state);
            if (hist.length > GRAPH_POINTS + 1) {
                hist.shift();
                histState.shift();
            }
        }
        function px(i, shift) {
            return GRAPH_X1 - (hist.length - 1 - i + shift) * (GRAPH_X1 - GRAPH_X0) / GRAPH_POINTS;
        }
        function py(v) {
            return GRAPH_Y0 - clamp(v / Y_MAX_CARS, 0, 1) * (GRAPH_Y0 - GRAPH_Y1);
        }
                    for (let i = 1; i < n; i += 1) {
                        const bx = px(i, shift);
                        const by = py(hist[i]);
                        const ex = i < n - 1 ? (bx + px(i + 1, shift)) / 2 : bx;
                        const ey = i < n - 1 ? (by + py(hist[i + 1])) / 2 : by;
                        ctx.quadraticCurveTo(lerp((sx + ex) / 2, bx, LINE_SMOOTH), lerp((sy + ey) / 2, by, LINE_SMOOTH), ex, ey);
                        sx = ex;
                        sy = ey;
                    }

08Arrows Lit in Groups of Non-Conflicting Directions at a Four-Way Crossing

For each of the 8 directions a bit table (CONFLICT_MASK) lists the directions it collides with, and adding each direction to the first group it does not conflict with yields four phases (PHASE_ORDER). Every phase runs 72 green frames followed by an all-red gap of 16 frames (ALL_RED_GAP), so one full cycle is 352 frames. Measured at 20.4% accumulated area and 20/23 moved frames.

Arrows Lit in Groups of Non-Conflicting Directions at a Four-Way Crossing
Open in a new tab

08 · Arrows Lit in Groups of Non-Conflicting Directions at a Four-Way Crossing — Measured area 20.43% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

PHASE_ORDERARROW_LENALL_RED_GAPCONFLICT_MASK
        function collides(a, b) {
            if (a.approach === b.approach) {
                return true;
            }
            if ((a.approach + 2) % 4 === b.approach) {
                return a.left !== b.left;
            }
            return true;
        }
        // phase grouping: add a direction to the first group that holds nothing it conflicts with
        const PHASE_ORDER = [];
        for (let i = 0; i < moves.length; i += 1) {
            let placed = false;
            for (let g = 0; g < PHASE_ORDER.length && !placed; g += 1) {
                let ok = true;
                for (let k = 0; k < PHASE_ORDER[g].length; k += 1) {
                    if ((CONFLICT_MASK[i] >> PHASE_ORDER[g][k]) & 1) {
                        ok = false;
                    }
                }
                if (ok) {
                    PHASE_ORDER[g].push(i);
                    placed = true;
                }
            }
            if (!placed) {
                PHASE_ORDER.push([i]);
            }
        }

09A Responsive Signal That Ends Green When Cars Stop Coming and Extends It While They Do

Each time a car crosses the sensor line while gapTimer is under 60, the green budget grows to the green time so far plus EXTEND_STEP 50, capped at MAX_GREEN 300. Once MIN_GREEN 90 has passed and gapTimer reaches 60, the budget is cut to the present moment and green ends. Measured at 10.0% accumulated area and 23/23 moved frames.

A Responsive Signal That Ends Green When Cars Stop Coming and Extends It While They Do
Open in a new tab

09 · A Responsive Signal That Ends Green When Cars Stop Coming and Extends It While They Do — Measured area 10.003% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAP_THRESHOLDEXTEND_STEPMAX_GREENDETECTOR_X
        function onDetect(c, x) {
            detLit = 8;
            if (sig.state !== SIG_GREEN) {
                return;
            }
            if (gapTimer < GAP_THRESHOLD) {
                budget = Math.min(MAX_GREEN, Math.max(budget, greenElapsed + EXTEND_STEP));
                pips.push(greenElapsed);
            }
            gapTimer = 0;
        }
                if (sig.state === SIG_GREEN) {
                    greenElapsed += 1;
                    gapTimer += 1;
                    if (greenElapsed >= MIN_GREEN && gapTimer >= GAP_THRESHOLD && greenElapsed < budget) {
                        budget = greenElapsed;
                        ended = 'gap';
                    }
                    if (greenElapsed >= MAX_GREEN) {
                        budget = greenElapsed;
                        ended = 'max';
                    }
                    if (greenElapsed >= budget) {
                        sig.state = SIG_YELLOW;
                        phaseF = 0;
                        if (ended === '') {
                            ended = 'gap';
                        }
                    }
                }

Where the Values Change the Scene — Stop Gap, Signal Offset, Green Cap

Three values change the character of a scene: the stop gap SAFE_GAP in 02, the signal offset OFFSET_PER_BLOCK in 03 and the green cap MAX_GREEN in 09. The numbers below were counted by running the same source headlessly in Node for 6000 to 9000 frames, so they are simulation values and not something measured on screen in a browser. To get a feel for traffic light animation css, change one of these three and watch only that scene.

Shrinking the stop gap lengthens the queue

At SAFE_GAP 24 the cars waiting at the line sit 18.8 to 19.1 px apart and at most 3 of them stand ahead of the line. At 12 the spacing drops to 10.1 to 10.3 px and up to 5 stand there (6000-frame simulation).

Moving the offset away from 87.0 makes cars stop

At OFFSET_PER_BLOCK 90, none of the 62 cars stops (stopFrames is 0). At 40, 38 cars stop, up to 2 wait at the same time and the stopped-frame total is 3915.

Lowering the green cap makes cut-offs more frequent

Over 9000 frames green comes on 25 times, lasting 90 at least, 204 on average and 300 at most, with 9 greens cut off at the cap and 16 ended because the cars stopped coming. Setting MAX_GREEN to 150 gives 31 greens, an average of 143, 26 cap hits and 5 early endings.

The table below holds what the renders measured. No scene falls under 10%, the smallest being 09 at 10.0% and 02 at 10.2%, while 01 and 06 sit near 45%. Scene 04 moved in 14 of 23 frames and 08 in 20 of 23, and the other seven moved in all 23, so the picture almost never rests while you watch.

Scene Accumulated area Mean intensity Moved frames
01 Timetable signal 45.4% 109.2 23/23
02 Cars stopping with a gap 10.2% 48.2 23/23
03 Green wave 12.7% 51.2 23/23
04 Walk countdown arc 13.6% 40.5 14/23
05 Visor shadow by view angle 18.5% 50.5 23/23
06 Wet road reflection 45.0% 33.4 23/23
07 Queue length graph 29.7% 46.3 23/23
08 Crossing phase arrows 20.4% 71.4 20/23
09 Responsive green extension 10.0% 63.3 23/23

Where It Breaks — Narrow Screens, Cars Past the Stop Line, Color-Only Signals

Opposing left-turn curves almost touch at the corners

The collides table does not treat two opposing left turns as a conflict, yet the two drawn curves come close to touching at the lower right and upper left corners of the middle of the crossing (about ±10 px). The table is the answer and the drawing is a simplification. The dots running inside the crossing are decoration drawn only while their phase is lit, so they vanish on the spot when the phase ends, and they are not car physics.

A walker who starts after walk frame 153 is still on the road at red

Scene 04 stops starting new walkers from 182 frames into the walk (5 seconds left). Crossing the 218 px road at 0.8 px per frame takes 272.5 frames, and at 1.5 px per frame after the flashing starts it takes 145.3. So anyone who sets off after walk frame 153 is still on the road when the 312-frame walk ends. For the last allowed start at frame 181 that is 14 frames (0.2 seconds) of red. Waiting walkers whose start time falls after frame 182 are carried over to the next walk, and one on-screen second is 26 frames (0.42 seconds in real time), so the 12-second walk is squeezed into about 5 real seconds.

Parked cars sit about 5 px closer than SAFE_GAP

Scene 02 aims at a SAFE_GAP of 24, but the measured spacing between parked cars is 18.8 to 19.1 px. The deceleration floor of −0.21 and the clamp that sets speed to 0 at 1.5 px before the stop line are two candidates, and we did not split the causes apart to confirm either one.

The reflection loses the red lens at a larger stretch

At REFLECT_STRETCH 1.7 the 180 road rows of scene 06 cover 106 source rows (from y 14 down), which brings the nearest signal's red lens (source y 24.5) into the reflection. At 2.1 the same 180 rows cover only 86 source rows (from y 34 down), so that lens drops out and only the green lens remains at the very bottom. The sideways shake works in 20 px strips, so you can see a misalignment at strip edges, and on the nearest road it reaches about ±10 px.

Arrow keys block page scrolling

When the canvas has focus, each file calls preventDefault on the arrow keys and on Enter and Space, even in scenes that do not use them. Only 05 reacts to the arrows (through point); in the other eight those keys do nothing except stop the page from scrolling, so before pasting this into a long page, check whether keyboard scrolling freezes while the canvas is focused. Only 02 and 09 define tap: there Enter and Space trigger a signal change or a car drop, and in the other seven they pause or resume, as the button does.

Do not let the signal rely on color alone

The lenses sit in a fixed order from the top, red then yellow then green (LENS_RGBS), and the state is also written as words in an aria-live region. Position and text carry the state for people who cannot tell the colors apart, and the three signal colors (GREEN_RGB, YELLOW_RGB, RED_RGB) are functional colors, so they stay separate from the background palette.

Narrow screens and moved frames

The canvas scales its width only, through an aspect-ratio of 480 / 300, and the overflow and phone-width overflow checks for all nine scenes came back negative. At 320 px and below the caption, button and status text shrink to 10 px and the padding drops to $sp-2. We did not find out why the scenes that moved fewer than 23 frames did so.

How to find the edges by hand

Change SAFE_GAP in 02, OFFSET_PER_BLOCK in 03, REFLECT_STRETCH in 06 and MAX_GREEN in 09 one at a time and look for the values where the edges above appear. To watch exactly where each one gives way, open the file below with the password 5d7zsm93 and edit those constants in place.

Three Variants and Accessibility

Name Value Feel
Change the stop gap 02 SAFE_GAP 24 to 12 Cars waiting at the line sit 19.0 px apart at 24 and 10.2 px at 12, and the queue ahead of the line grows from at most 3 cars to 5 (measured in a simulation).
Change the offset 03 OFFSET_PER_BLOCK 90 to 40 The wave falls out of step, and of the 62 cars made in 6000 frames the number that stop rises from 0 to 38 (measured in a simulation).
Change the green cap 09 MAX_GREEN 300 to 150 Greens cut off at the cap go from 9 to 26 and the average green shrinks from 204 to 143 frames (measured in a simulation).

With prefers-reduced-motion set to reduce, none of the nine scenes starts a requestAnimationFrame loop. They run the start scene for 75 steps (1200 ms) and draw a single still, and the button becomes a next-scene control that advances 56 steps (900 ms) per press. Pointer behavior differs by scene. Scene 05 skips the lerp 0.16 easing of the eye and uses the input at once, in 02 a press changes the signal immediately while the cars stay frozen until the next-scene button is pressed, and in 01 relax uses a blend factor of 1 so the brightness jumps straight to its target.

The canvas has tabindex 0, so it takes focus, and the arrow keys move a virtual pointer by KEY_STEP 12 px, although only 05 responds. The status text is rewritten only when the scene's wording changes, so the same line is not read out again and again.

Is Swapping Colors on a Timer Enough for a Traffic Light HTML CSS Page?

For a single signal that is only an illustration, yes. In 01 a phaseAt line and a brightness blend are all there is, and the moment a car or a person has to react to the lamp, you need offset, gap and sensor math. Lamps that only switch colors never know when the neighbor started, so a green wave like 03 has to be tuned with numbers.

Where each scene fits differs. 01 suits a signal explainer or guide screen, 02 a traffic-flow explainer scene, 03 a city-traffic hero section, 04 a crosswalk guidance widget and 05 a product detail viewer scene. 06 fits a night-city backdrop, 07 a traffic dashboard metric card, 08 an intersection structure diagram and 09 a smart-traffic service intro. Because 06 draws 2160 times per frame it is the heaviest scene, so look at its frame cost first before using it as a page background.

For shadow shapes computed from a light source, CSS Shadow Effects: 9 Demos That Compute the Cast is the next stop, and the visor shadow in 05 counts the same idea on a grid inside the lens.

Frequently Asked Questions Before Building a Traffic Light in CSS

Where should I start reading to move cars in traffic light using css?

Start with Lane and accelFor in 02. The first three values to change are SAFE_GAP, FREE_SPEED and BRAKE_DECEL, and since cars stop and start in front of one signal, it is a good read before moving on to 03, 07 and 09.

How do I change the colors or the cycle to fit my page?

For colors, change the three constants GREEN_RGB, YELLOW_RGB and RED_RGB, and for the cycle, change the green, yellow and red frame counts you pass to phaseAt. The constant names differ by scene, with 01 using PHASE_FRAMES and YELLOW_FRAMES and 02 using GREEN_LEN, YELLOW_LEN and RED_LEN.

Do I need a library or images?

No. None of the nine files makes an outside request, and every scene is drawn with the canvas 2d API, so a single file runs on its own when moved.

Enter the archive password

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