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

Stopwatch JavaScript: 9 Canvas Timing Scenes

How does a stopwatch turn time into digits and a hand? 01 sets the second hand from one integer count, 03 computes laps and splits in one table, and 08 reads

A Stopwatch Is Not Reading a Clock but Counting Elapsed Time as One Integer and Computing Every Digit and Hand Angle From It: Building a Stopwatch Starts by Fixing One Unit

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

The nine scenes count time the same way. Elapsed time is one integer of centiseconds, a hundredth of a second, and the minutes, seconds and centiseconds on the readout, the angle of a dial hand and every lap segment are computed from that integer alone. The time source is a single scene clock, sceneT, which adds up the difference dt between requestAnimationFrame timestamps and cuts any one frame at DT_MAX 0.05 seconds.

If you want stopwatch javascript code you can paste, that one integer is the whole contract between the clock and the screen. The order runs from one hand to records. 01 and 02 cover the hand and the readout, 03 and 04 cover laps as a table and as bars, 05 and 06 cover the bounce on stop and the sub dials that carry past an hour, and 07, 08 and 09 cover the rings, the ghost hand and the scoreboard that stacked laps build.

Stopwatch in JavaScript: 9 Scenes — A Dial With a Continuous Second Hand, a Minute Second Centisecond Readout, a Lap and Split Table, Lap Length Bars, a Spring Hand That Rebounds on Stop, a Sub Dial for Minutes and Hours, a Trail Ring That Keeps an Arc per Lap, a Ghost Hand of a Past Run and a Scoreboard Hero

What nine renders measured for area and motion

Scene Cumulative area Moving frames Mean intensity
01 Continuous hand 9.9% 23/23 43.9
02 Centisecond readout 12.3% 23/23 68.1
03 Lap and split table 11.1% 23/23 56.9
04 Lap bars 8.9% 23/23 49.6
05 Spring stop 21.0% 23/23 95.7
06 Sub dials 11.1% 23/23 39.7
07 Trail ring 9.2% 23/23 102.4
08 Ghost hand 18.3% 23/23 90.6
09 Scoreboard 20.6% 23/23 95.7

05 covers the most area at 21.0% because the hand swings past the target and both panels flash at the stop, and 04 covers the least at 8.9% because only bars grow. Mean intensity is highest for 07 at 102.4 and lowest for 06 at 39.7, and all nine scenes moved in 23 of 23 frame gaps, so nothing sits still for a stretch.

01A Second Hand That Sweeps Without a Break

The hand angle comes from one centisecond integer through HAND_ANGLE(cs) and makes one turn at TURN_CS 6000. There are TICK_COUNT 60 ticks, heavier at every MAJOR_EVERY 5th, and the right board sets its hand from stepCs(), which is cut to whole seconds, so it jerks once per second while a ripple spreads from the hub at each new second. Workout, experiment and cooking dials use this.

A Dial With a Continuously Sweeping Second Hand
Open in a new tab

01 · A Dial With a Continuously Sweeping Second Hand — Measured area 9.893% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TURN_CSHAND_ANGLETICK_COUNT
    const TURN_CS = 6000;
    const TICK_COUNT = 60;
    const MAJOR_EVERY = 5;
    const WINDOW_S = 4;
    const LAP_S = 15.0;
    const START_S = 3.7;
    const REST_S = 3.7;
    const RESULT_S = 13.0;
    let hist = [];
    function build() {
        swReset();
        hist = [];
    }
    function update(dt) {
        if (dt < 0) {
            return;
        }
        hist.push({ t: sceneT, cs: swCs(sceneT) });
        while (hist.length > 1 && hist[0].t < sceneT - WINDOW_S) {
            hist.shift();
        }
    }
    function handCs() { return swCs(sceneT); }
    function stepCs() { return Math.floor(handCs() / CS_PER_SEC) * CS_PER_SEC; }
    function HAND_ANGLE(cs) { return dialAng(cs, TURN_CS); }

02A Readout Split Into Minutes, Seconds and Centiseconds

splitCs divides the centisecond count by 6000 for minutes, divides the remainder by 100 for seconds and keeps what is left as centiseconds, with integer division only. The right board adds 0.01 once per centisecond, and 0.01 added 11 times gives 0.10999999999999999, so it reads 10 instead of 11 and 1498 of 1700 cells come out off. Contest and measuring screens, where digits must not wobble after the decimal point, take the integer route.

A Readout Split Into Minutes, Seconds and Centiseconds
Open in a new tab

02 · A Readout Split Into Minutes, Seconds and Centiseconds — Measured area 12.269% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CS_PER_MINsplitCsSTEP_F
    function build() {
        swReset();
        errs = [0];
        sums = ['0'];
        bad = [0];
        let acc = 0;
        for (let n = 1; n <= MAX_CS; n += 1) {
            acc += STEP_F;
            errs.push(n - Math.floor(acc * CS_PER_SEC));
            sums.push(acc.toPrecision(17));
            bad.push(bad[n - 1] + (errs[n] !== 0 ? 1 : 0));
        }
    }
    function update(dt) { if (dt < 0) {
        return;
    } }
    function nowCs() { return Math.min(swCs(sceneT), MAX_CS); }
    function floatCs() { return nowCs() - errs[nowCs()]; }

03A Table That Computes Laps and Splits Together

Running splits and talk time budgets need lapSpan(i), which subtracts the previous total from the current total to get the segment and leaves the total in the next column. The first run totals 200 452 714 936 1208 1410 1712 centiseconds, so its segments are 200 252 262 222 272 202 302. The right board writes the press time into the segment column and sums it, so its total swells to 6632 centiseconds.

A Table That Computes Laps and Splits Together
Open in a new tab

03 · A Table That Computes Laps and Splits Together — Measured area 11.107% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PRESS_ATlapSpanTABLE_ROWS
    let pressT = [];
    function build() {
        swReset();
        pressT = [];
        for (let i = 0; i < PRESS_AT.length; i += 1) {
            const t = PRESS_AT[i] + ((lap * 7 + i * 3) % 5) * 0.04;
            pressT.push(t);
            script.push({ t: t, f: function () { pressLap(t); } });
        }
    }
    function update(dt) { if (dt < 0) {
        return;
    } }
    function lastPress() { return marks.length > 0 ? pressT[marks.length - 1] : -9; }
    function liveSpan() { return swCs(sceneT) - (marks.length > 0 ? marks[marks.length - 1] : 0); }

04Lap Bars That Mark the Best and the Worst

BAR_RANGE(i) sets a bar to (segment − base) ÷ (max − base), and baseOf() puts the base BASE_PAD 0.25 of the range below the minimum. The first run's segments are 324 247 320 264 298 239 279 centiseconds (the first lap is long because counting starts 0.4 seconds in), the minimum 239 row gets a mint best band, the maximum 324 row a pink worst band and the dashed line is the average 281.57. A pace check per segment keeps that margin, since a base at the minimum would empty the shortest bar.

Lap Bars That Mark the Best and the Worst
Open in a new tab

04 · Lap Bars That Mark the Best and the Worst — Measured area 8.885% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BASE_PADBAR_RANGESPANS
    function baseOf() { return minOf() - (maxOf() - minOf()) * BASE_PAD; }
    // Bar length ratio from 0 to 1; with a single lap the range is 0, so it is a fixed value
    function BAR_RANGE(i) {
        const hi = maxOf();
        const lo = baseOf();
        return hi - lo > 0 ? (lapSpan(i) - lo) / (hi - lo) : 0.6;
    }
    function update(dt) {
        const k = 1 - Math.exp(-EASE_RATE * dt);
        for (let i = 0; i < marks.length; i += 1) {
            if (i >= disp.length) {
                disp.push(0);
            }
            disp[i] += (BAR_RANGE(i) - disp[i]) * k;
        }
    }

05A Spring Stop Where the Hand Rebounds Once

On stop the digits freeze at that value and KICK_V 3.6 goes into vel, so the hand passes the target angle by OVERSHOOT() and returns along a spring of SPRING_K 420 and DAMPING_C 14. The first peak is 6.18 degrees at 0.067 seconds, the swing back reaches −1.93 degrees at 0.217 seconds and the hand settles within 0.5 degrees at 0.417 seconds, while the right board halts on the spot and looks cut. Stop buttons with some feel and gauge hands use it.

A Spring Stop Where the Hand Rebounds Once
Open in a new tab

05 · A Spring Stop Where the Hand Rebounds Once — Measured area 20.954% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_KDAMPING_COVERSHOOT
    function build() {
        swReset();
        stopT = 4.0 + 0.43 * (lap % 3);
        ov = 0;
        vel = 0;
        trace = [];
        script.push({ t: stopT, f: function () { swStop = stopT; vel = KICK_V; } });
    }
    // Integrate each frame in slices of at most 0.004 seconds so a long frame cannot blow up the spring
    function update(dt) {
        const n = Math.max(1, Math.ceil(dt / 0.004));
        const h = dt / n;
        for (let i = 0; i < n; i += 1) {
            vel += (-SPRING_K * ov - DAMPING_C * vel) * h;
            ov += vel * h;
        }
        if (sceneT >= stopT - 0.3) {
            trace.push({ t: sceneT, o: ov });
        }
        while (trace.length > 1 && trace[0].t < sceneT - TRACE_S - 0.3) {
            trace.shift();
        }
    }

06Sub Dials That Count Minutes and Hours

vcs() multiplies scene time by SCALE 300 and feeds the same swCs, MIN_QUOTIENT() is that value divided by 6000 and HOUR_QUOTIENT() is the minute quotient divided by 60. All three hand angles come from the quotients and remainders of one value, and a sub dial rim flashes when its quotient grows: at 12.00 seconds the value is 360000 centiseconds and the hour quotient becomes 1, and at 17.0 seconds it reaches 510000, which is 1 hour 25 minutes. Marathons, long tasks and long experiments need this.

Sub Dials That Count Minutes and Hours
Open in a new tab

06 · Sub Dials That Count Minutes and Hours — Measured area 11.069% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MIN_QUOTIENTHOUR_QUOTIENTSUBDIAL_ANGLE
    function vcs() { return swCs(sceneT * SCALE); }
    function MIN_QUOTIENT() { return Math.floor(vcs() / CS_PER_MIN); }
    function HOUR_QUOTIENT() { return Math.floor(MIN_QUOTIENT() / 60); }
    function update(dt) {
        if (dt < 0) {
            return;
        }
        if (MIN_QUOTIENT() !== prevMin) {
            prevMin = MIN_QUOTIENT();
            flashMin = sceneT;
        }
        if (HOUR_QUOTIENT() !== prevHour) {
            prevHour = HOUR_QUOTIENT();
            flashHour = sceneT;
        }
    }
    function SUBDIAL_ANGLE(frac) { return frac * TAU - PI / 2; }

07A Trail Ring That Keeps a Colored Arc per Lap

ARC_SPAN(c0, c1) cuts the span from the previous lap point at a turn boundary of TURN_CS 1200 (12 seconds) into (ring number, start angle, end angle), and the ring radius shrinks by RING_STEP 17 per ring from RING_R0 100. The 11 lap totals 250 515 735 1050 1220 1465 1755 1990 2300 2505 2765 centiseconds stack up to ring 3, the 5th and 10th laps are cut in two at a boundary, and each press flashes the dial background in the lap color. Summary rings for segment shares and workout record screens use it.

A Trail Ring That Keeps a Colored Arc per Lap
Open in a new tab

07 · A Trail Ring That Keeps a Colored Arc per Lap — Measured area 9.233% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ARC_SPANRING_STEPTURN_CS
    function ARC_SPAN(c0, c1) {
        const out = [];
        let s = c0;
        while (s < c1) {
            const ring = Math.floor(s / TURN_CS);
            const e = Math.min(c1, (ring + 1) * TURN_CS);
            out.push({ ring: ring, a0: dialAng(s, TURN_CS), a1: ((e - ring * TURN_CS) / TURN_CS) * TAU - PI / 2 });
            s = e;
        }
        return out;
    }
    function paintSpan(c0, c1, col, a) {
        const arcs = ARC_SPAN(c0, c1);
        for (let i = 0; i < arcs.length; i += 1) {
            arcBand(CX, CY, RING_R0 - arcs[i].ring * RING_STEP, arcs[i].a0, arcs[i].a1, ARC_W, col, a);
        }
    }
    function ringOf(cs) { return Math.floor((cs - 1) / TURN_CS) + 1; }

08A Ghost Hand of a Past Run to Race Against

progress(m, t) returns how far along the points of a time list m a run is at t seconds, and reachAt is its inverse, the time a run reaches progress p. GAP_SEC(t) is the ghost reach time at the same progress minus the current time, and SIGN_COLOR paints a positive gap mint and a negative one pink. In the first run the gap at each point is −0.18 +0.54 −0.14 +0.98 +1.20 seconds, so the sign flips 3 times over time, which suits a chase after a personal best.

A Ghost Hand of a Past Run to Race Against
Open in a new tab

08 · A Ghost Hand of a Past Run to Race Against — Measured area 18.317% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GAP_SECSIGN_COLORGHOST
    // Progress along the time list m: straight lines between points, and it waits after the last point
    function progress(m, t) {
        if (t <= 0) {
            return 0;
        }
        for (let k = 0; k < m.length - 1; k += 1) {
            if (t < m[k + 1]) {
                return k + (t - m[k]) / (m[k + 1] - m[k]);
            }
        }
        return LEGS;
    }
    // The time a run reaches progress p, the inverse of progress
    function reachAt(m, p) {
        const k = Math.min(Math.floor(p), LEGS - 1);
        return m[k] + (p - k) * (m[k + 1] - m[k]);
    }
    function GAP_SEC(t) {
        const tn = Math.min(t, nowMarks[LEGS]);
        return reachAt(GHOST, progress(nowMarks, tn)) - tn;
    }
    function gapCs(t) { return Math.round(GAP_SEC(t) * CS_PER_SEC); }
    function SIGN_COLOR(g) { return g >= 0 ? MINT : ROSE; }
    function signed(cs) { return (cs >= 0 ? '+' : '−') + (Math.abs(cs) / CS_PER_SEC).toFixed(2); }

09A Scoreboard Hero With Big Digits and a Lap Feed

Each frame update compares the fmtCs string with the previous one and writes the change time chAt, and for FLIP_S 0.07 seconds from then the top half folds down and the bottom half unfolds (the last centisecond digit changes too fast to fold, so it just changes). A new lap enters at the top and the rest step down over SLIDE_S 0.25 seconds; the left list drops the oldest row past LIST_MAX 5, while the right board has no limit, so from lap 8 the bottom of its digit board is cut and from lap 10 it is pushed out of the panel. Event landing pages and score areas use it.

A Scoreboard Hero With Big Digits and a Lap Feed
Open in a new tab

09 · A Scoreboard Hero With Big Digits and a Lap Feed — Measured area 20.579% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LIST_MAXFLIP_SSLIDE_S
    function update(dt) {
        if (dt < 0) {
            return;
        }
        const s = fmtCs(swCs(sceneT));
        for (let i = 0; i < s.length; i += 1) {
            if (s[i] !== curCh[i]) {
                prevCh[i] = curCh[i];
                curCh[i] = s[i];
                chAt[i] = sceneT;
            }
        }
    }

When You Write Stopwatch Timer Code, How Many Values Decide One Digit and One Hand?

One digit comes from one centisecond integer

With the integer cs in hand, splitCs splits it into minutes, seconds and centiseconds by integer division. Before the start it is 0, and after a stop the value stays at the stop moment because swStop is read inside swCs(t). No code in a stopwatch timer touches time apart from these two functions.

    const CS_PER_SEC = 100;
    const CS_PER_MIN = 6000;
    let swStart = 0;
    let swStop = -1;
    // Elapsed centiseconds at scene time t: 0 before the start, and frozen at the stop value after a stop
    function swCs(t) {
        const e = Math.min(t, swStop >= 0 ? swStop : 1e9) - swStart;
        return e <= 0 ? 0 : Math.floor(e * CS_PER_SEC + 1e-6);
    }
    // Laps: the list of running centisecond totals at each press; computed from scene time t, so frame length does not matter
    let marks = [];
    function pressLap(t) { marks.push(swCs(t)); }
    // Lap segment = this total - the previous total
    function lapSpan(i) { return marks[i] - (i > 0 ? marks[i - 1] : 0); }
    function pad2(n) { return n < 10 ? '0' + n : String(n); }

One hand comes from two values, the count and the length of a turn

dialAng computes the angle from cs and turnCs, the centiseconds in one turn. 01 and 05 use a turnCs of 6000 (60 seconds) and 07 uses 1200 (12 seconds), so the same function draws different dials. Once laps join, the previous total marks[i−1] is one more value, and lapSpan subtracts the two to get a segment.

    // Centiseconds -> [minutes, seconds, centiseconds], integer division only
    function splitCs(cs) {
        const m = Math.floor(cs / CS_PER_MIN);
        const r = cs - m * CS_PER_MIN;
        const s = Math.floor(r / CS_PER_SEC);
        return [m, s, r - s * CS_PER_SEC];
    }
    function fmtCs(cs) {
        const p = splitCs(cs);
        return pad2(p[0]) + ':' + pad2(p[1]) + '.' + pad2(p[2]);
    }
    // Dial angle in radians, with 12 o'clock at -π/2; one turn per turnCs centiseconds
    function dialAng(cs, turnCs) { return ((cs % turnCs) / turnCs) * TAU - PI / 2; }

Count Elapsed Time as Integer Centiseconds, Split It Into Minutes, Seconds and Centiseconds, Turn It Into a Hand Angle and Subtract the Previous Mark on a Lap: the Order in Which One Stopwatch Frame Is Computed

The order of one frame

One frame finishes in four steps.

  1. frame(ts) takes the difference between this timestamp and the last, cuts it at DT_MAX 0.05 seconds and passes it to advance(dt).
  2. advance adds dt to sceneT and runs the timetable events whose time has passed, such as a lap press or a stop.
  3. draw() gets the centisecond integer from swCs(sceneT), then splitCs gives the readout and dialAng gives the hand angle.
  4. If a lap was pressed, lapSpan subtracts the previous value from marks, which was computed at the press time t.
    const DT_MAX = 0.05;
    function showNow() {
        draw();
        pushStatus(statusText());
        checkDone();
    }
    let rafId = 0;
    let lastTs = -1;
    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) {
            advance(dt);
        }
        showNow();
        rafId = window.requestAnimationFrame(frame);
    }

Why a lap value does not depend on frame length

pressLap(t) calls swCs with the press time t and not with a rendered frame, so a long frame still records the same centisecond integer. That is also why the lap times in 03, 2.00, 4.52 and 7.14 seconds, do not line up with frame gaps.

Keeping a Stopwatch Accurate: Building Without a Library, Why Time Drifts When a Tab Hides and Returns and How Many Laps to Keep

A real app reads performance.now as its time source

The demo uses neither Date nor performance and only adds up requestAnimationFrame timestamp differences, so the same demo replays in the same order on any device. A real stopwatch saves performance.now() at the start and recomputes the elapsed time each frame as performance.now() − start. Adding 10 ms in a setInterval lets the timer slip so error piles up, and it also stops while the tab is hidden.

Most javascript stopwatch milliseconds questions come down to this step. Convert the elapsed milliseconds with Math.floor(ms / 10) into the centisecond integer, and from there splitCs, dialAng and lapSpan of this source apply unchanged. On a stop you save that moment's value separately, which swStop does in the demo. The specs are in the MDN pages for requestAnimationFrame and the Page Visibility API.

When the tab hides and returns

The demo stops its loop when the tab hides and discards the first dt after it returns, because pushing the hidden time into one frame would make the hand jump. A real app that subtracts from a start time counts the hidden time as elapsed anyway, so it does not need this step, but it must keep the stored value exact at each stop and resume.

Where it fits

Workout records suit the segment table in 03 and the bars in 04, and a talk time budget can read the gap to a past rehearsal from the totals in 03 and the ghost hand in 08. A study timer gets its past-an-hour records from the sub dials in 06, and the time display on a first screen belongs to the scoreboard in 09. The nine sources contain 0 libraries, 0 WebGL and 0 CDN calls, so one copied file runs.

How many laps to keep

The table in 03 drops old rows at TABLE_ROWS 6 and the list in 09 at LIST_MAX 5. The right grid in 09 has no limit and pushes the digit board away as laps pile up, so decide how many rows the screen shows and send the rest to a scroll or a separate record view.

Where a Stopwatch Breaks: Digits That Wobble From Accumulated Decimal Error, Laps That Mix Press Time With Total Time and a Stop Where the Hand Halts at Once and Looks Cut

Digits that wobble from accumulated decimal error

Adding 0.01 once per centisecond drifts soon. The sum after 11 additions is 0.10999999999999999, so the centiseconds read 10, and 298 of the first 500 centisecond cells and 1498 of 1700 come out off. The error is a constant 1 centisecond, which is easy to miss, and the sum after 100 additions, 1.0000000000000007, happens to floor to 100, so it is hard to predict which cell is wrong. At the 6.55 second start point of 02 the integer board reads 00:06.55 and the summed board reads 00:06.54.

Laps that mix press time with the segment

Writing the press time marks[i] straight into the segment column only makes the values grow row by row. The wrong board in 03 writes 200 452 714 into the segment column and sums that into the total column, so the total reaches 6632 centiseconds on row 7. The right answer subtracts the previous total from the same press time and gives 200 252 262 222 272 202 302 centiseconds.

A stop where the hand halts at once

The digits stop at the stop moment, but if the hand also stops on the spot the stop looks cut. The right board in 05 does that, while the left board overshoots to the first peak of 6.18 degrees with KICK_V 3.6 and settles within 0.5 degrees at 0.417 seconds. The single flash of both panels at the stop makes the difference easier to see.

The finished HTML and React files of all nine scenes are in the zip below, and the password for unzipping it is vj4wavrb.

Change the Lap Baseline, the Stop Kick and the Turn Length and How Much Does the Feel Shift? BASE_PAD, KICK_V and TURN_CS Compared

Name Changed value Feel
Baseline at the minimum 04 BASE_PAD 0.25 → 0 The baseline sits at the minimum 239, so the bar of the shortest lap goes from 0.200 to 0 and disappears, and the second shortest lap (247) drops from 0.275 to 0.094. The gaps among short laps look larger, but the shortest lap looks empty
A gentler push at the stop 05 KICK_V 3.6 → 2.0 The first peak falls from 6.18 to 3.43 degrees (the peak time stays 0.067 seconds) and the time to settle within 0.5 degrees falls from 0.417 to 0.283 seconds, so the rebound is smaller and dies out sooner
A shorter turn 07 TURN_CS 1200 → 600 One turn drops from 12 seconds to 6, so over the same 11 laps the highest ring number rises from 3 to 5 and the laps cut in two at a boundary go from 2 to 4, and arcs continue onto the inner rings more often

Before you change a value

Each of these three is a constant of 04, 05 or 07, so change one value in one scene to keep the comparison fair. In 04 the press times differ from run to run, so the first run (lap 0) is the baseline for the numbers above. In 05, leaving DAMPING_C 14 and SPRING_K 420 alone and changing only KICK_V also confirms that the peak time of 0.067 seconds stays put. In 07 the background track has only three rings, so at 600 the 4th and 5th rings show arcs without a track behind them.

One Frozen Dial Under Reduced Motion: Accessibility Through a Sentence for the Scene and the Current Time

Enter, Space and R restart, and P and N drive the stage

The stage box in all nine scenes is role="application" and its aria-label lists the controls. Enter, Space, R and a click restart from the beginning, P pauses and N steps once to the next event in the timetable, and in 01, 02 and 06, whose timetable is empty, it goes straight to a restart. There are no lap or reset buttons, because the timetable presses laps and stops by itself.

The status line stays quiet during the demo

The status line data-role="status" is aria-live="off" during the demo so per-frame updates are not read, and it turns polite the moment a person first touches the stage. The result notice is written once to data-role="done" (role="status") when RESULT_S seconds pass. The canvas is role="img" and its aria-label holds the scene description.

Reduced motion and a hidden tab

With prefers-reduced-motion on there is no loop and only one scene frame at the REST_S second mark is drawn. All nine items listen for visibilitychange and stop the loop when the tab hides.

Stopwatch FAQ: Building Without a Library, Why Time Drifts When a Tab Hides and Returns and What You Need Before Putting It on the Web

Can I use the time source of this javascript stopwatch example on a real site?

No. The sceneT of the demo adds up requestAnimationFrame timestamp differences so the demo replays the same way, and a real site should save performance.now() at the start, turn performance.now() − start into the centisecond integer and attach the calculations of this source after that.

Does a stopwatch need a library?

No. Canvas 2D alone draws it, the nine sources contain 0 libraries, 0 WebGL and 0 CDN calls, and with 0 random or Date calls the same demo replays in the same order.

How many laps should I keep?

Decide how many rows the screen shows first. 03 drops old rows at 6 and 09 at 5, and without a limit the list grows and pushes the digit board out, so store the rest elsewhere and show only the latest rows.

For neighbors, see the clock face designs in nine analog clocks, timer rings and the hourglass on the countdown side and number rolling for digits that turn over, and read the MDN page for the canvas arc method.

Enter the archive password

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