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

Typing Practice: 9 Canvas Scenes

How does typing practice turn keystrokes into a score? 01 counts recent seconds for words per minute, 03 lines each key up with the target to mark misses, 07

Typing Practice Is Not a Picture of Letters Being Typed but a Device That Computes Speed, Accuracy and Weak Keys From Two Values, the Press Time and the Target Character: Free Typing Practice Starts by Knowing Which Value Sets the Score

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

The nine scenes take the same input. One key press is three values, the press time t, the key k and ok, which says whether it matches the target in that place, and the words per minute, misses, heatmap, combo and ghost on screen are all computed from that list strokes and the typed string buf. The auto demo only replays the record of a virtual typist built from a seed, so the same seed makes the same record.

Each scene has the board that computes correctly on the left and a contrast board on the right that takes the same record. The order starts from speed and widens to mistakes: 01 and 02 cover words per minute and line movement, 03 and 04 cover miss cells and weak keys, 05 and 06 cover Hangul composition and the combo rule, 07 and 08 cover a past run and key gaps, and 09 covers falling words. For a typing practice online page, 01 and 03 are the pair to read first, since speed and misses are what a learner looks at first.

Typing Practice: 9 Scenes — Words per Minute Counted on a Recent Window, a Line Scroll Pushed Ahead of the Caret, a Miss Diff Lined Up With the Target, a Key Press Count Heatmap, the State of a Letter Mid Composition, a Combo Multiplier for Clean Runs, a Ghost Caret Replaying a Past Run, a Key Gap Histogram and Falling Words Picked by Prefix

What nine renders measured for area and motion

Scene Cumulative area Moving frames Mean intensity
01 Recent window rate 22.0% 23/23 76.4
02 Line scroll 10.0% 23/23 97.4
03 Miss diff 38.9% 18/23 52.0
04 Key heatmap 33.5% 22/23 53.8
05 Hangul composition 21.7% 9/23 28.7
06 Combo multiplier 25.2% 23/23 83.4
07 Ghost caret 14.6% 23/23 73.0
08 Gap histogram 1.1% 15/23 76.2
09 Falling words 12.7% 23/23 53.0

03 covers the most area at 38.9% because both panels tint together, and 08 covers the least at 1.1% because only bars and a line change. Scenes 01, 02, 06, 07 and 09 moved in 23 of 23 frame gaps while 05 moved the least, in 9 of 23, and mean intensity is highest for 02 at 97.4.

01Words per Minute Counted on a Recent Window

Matching characters divided by CHARS_PER_WORD 5 count as words, and only the characters inside the last WINDOW_SEC 5 seconds are turned into a per minute value instead of dividing by the whole elapsed time. Right after the start the divisor stays at or above MIN_SPAN 2 seconds, and the right board averages from the first key, so it trails a change of pace by a wide margin. On the same record the 8 second mark reads 86 on the left against an average of 69, and the 12 second mark, after the pace dropped, reads 43 against 57.

Words per Minute Counted on a Recent Window
Open in a new tab

01 · Words per Minute Counted on a Recent Window — Measured area 21.981% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WINDOW_SECCHARS_PER_WORDrateNow
    function rateNow(t, win) {
        if (strokes.length === 0) {
            return 0;
        }
        let n = 0;
        for (let i = 0; i < strokes.length; i += 1) {
            const s = strokes[i];
            if (s.ok && s.t > t - win && s.t <= t) {
                n += 1;
            }
        }
        const span = clamp(t - strokes[0].t, MIN_SPAN, win);
        return (n / CHARS_PER_WORD) * (60 / span);
    }
    // Average since the start: divide by the whole elapsed time
    function avgRate(t) {
        if (strokes.length === 0) {
            return 0;
        }
        const span = Math.max(t - strokes[0].t, 1);
        return (okCount() / CHARS_PER_WORD) * (60 / span);
    }

02A Line Scroll Pushed Ahead of the Caret

lineShift computes a target shift that puts the current character at FOCUS_X 0.5 of the box width, followStep closes only part of the gap on each frame, and FOLLOW_EASE sets how fast it follows. The right board uses pageShift and flips a page only when the caret enters the last three cells, so the eye keeps jumping. Assuming a 7.7 px monospace cell, the average distance from the center is 16.4 px against 47.7 px, and the right board flips 4 times in one run.

A Line Scroll Pushed Ahead of the Caret
Open in a new tab

02 · A Line Scroll Pushed Ahead of the Caret — Measured area 10.049% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOCUS_XFOLLOW_EASElineShift
    // Target shift: the value that puts the current character at the FOCUS_X point of the box width, held at 0 or above near the start of the line
    function lineShift(caretX, viewW) {
        return Math.max(0, caretX - FOCUS_X * viewW);
    }
    // Exponential approach that follows at the same speed regardless of the frame length: it closes only (1 − e^(−FOLLOW_EASE·dt)) of the gap
    function followStep(cur, goal, dt) {
        return cur + (goal - cur) * (1 - Math.exp(-FOLLOW_EASE * dt));
    }
    // Page flip: when the caret enters the last three cells on the right, the whole page is flipped at once
    function pageShift(caretX, viewW, cw) {
        const page = viewW - 3 * cw;
        return Math.floor(caretX / page) * page;
    }

03A Miss Diff Lined Up With the Target

missCells compares the typed string with the target TARGET_TEXT cell by cell and collects the numbers of the cells that differ, and a wrong cell shows the target character underneath on a pink ground. Deleting and retyping revives the cell at once, while the right board only flashes the whole panel for 0.7 seconds at a miss and does not say which cell. Up to 3 cells were wrong at once in one run and the end shows 0 of them, and 40 of 46 keys matched, so accuracy stays at 87.0%.

A Miss Diff Lined Up With the Target
Open in a new tab

03 · A Miss Diff Lined Up With the Target — Measured area 38.865% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

TARGET_TEXTmissCellsMISS_TINT
    // Wrong cells: the list of cell numbers where the typed character differs from the target character in the same place
    function missCells() {
        const out = [];
        for (let i = 0; i < buf.length; i += 1) {
            if (buf.charAt(i) !== target.charAt(i)) {
                out.push(i);
            }
        }
        return out;
    }

04A Key Press Count Heatmap

countKey raises the press count of each key and, on a miss, raises the miss count of the target key that should have been pressed. heatLevel paints the share of the largest miss count in HEAT_STEP 4 steps and topKeys picks the top WEAK_TOP 3 weak keys, while the right board paints press counts only and buries the weak keys. In the 61 character text, b, p and x with 2 misses each get step 4 and q, z and j with 1 miss each get step 2, and the most pressed keys are a, e and o at 5 presses each.

A Key Press Count Heatmap
Open in a new tab

04 · A Key Press Count Heatmap — Measured area 33.541% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

KEY_ROWSHEAT_STEPWEAK_TOP
    // Color step: split the ratio (0 to 1) into HEAT_STEP steps, rounding up; a key with even one miss stays at step 1 or above
    function heatLevel(v, vmax) {
        if (v <= 0 || vmax <= 0) {
            return 0;
        }
        return Math.max(1, Math.ceil((v / vmax) * HEAT_STEP));
    }
    // The top n keys by value (by letter when equal), leaving out the space bar
    function topKeys(m, n) {
        const ks = Object.keys(m).filter(function (k) { return m[k] > 0 && k !== ' '; });
        ks.sort(function (a, b) { return m[b] - m[a] || (a < b ? -1 : 1); });
        return ks.slice(0, n);
    }

05The State of a Letter Mid Composition

compKey takes one jamo and turns the cell from an initial only, to initial and vowel, to a block with a final, and when a vowel follows a block that has a final, the final is handed over as the next initial. A composing block is set apart by a dotted underline and commitLen counts confirmed blocks only, while the right board adds one per key and so counts mid composition too. With a Korean input method on, some browsers report Process in e.key instead of a letter, so this demo maps the pressed spot e.code to a jamo through the two-set Korean keyboard table DUBEOL, and 26 inputs that make eleven characters, three of them spaces, give 11 confirmed against 24 on the key counting board, 2.2 times as many.

The State of a Letter Mid Composition
Open in a new tab

05 · The State of a Letter Mid Composition — Measured area 21.652% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

JAMO_SEQCOMPOSE_MARKcommitLen
    const DUBEOL = {
        KeyR: 'ㄱ', KeyS: 'ㄴ', KeyE: 'ㄷ', KeyF: 'ㄹ', KeyA: 'ㅁ', KeyQ: 'ㅂ', KeyT: 'ㅅ', KeyD: 'ㅇ', KeyW: 'ㅈ', KeyC: 'ㅊ',
        KeyZ: 'ㅋ', KeyX: 'ㅌ', KeyV: 'ㅍ', KeyG: 'ㅎ', KeyK: 'ㅏ', KeyO: 'ㅐ', KeyI: 'ㅑ', KeyJ: 'ㅓ', KeyP: 'ㅔ', KeyU: 'ㅕ',
        KeyH: 'ㅗ', KeyY: 'ㅛ', KeyN: 'ㅜ', KeyB: 'ㅠ', KeyM: 'ㅡ', KeyL: 'ㅣ',
    };
    const DUBEOL_SHIFT = { KeyQ: 'ㅃ', KeyW: 'ㅉ', KeyE: 'ㄸ', KeyR: 'ㄲ', KeyT: 'ㅆ', KeyO: 'ㅒ', KeyP: 'ㅖ' };
    keyMap = function (e) {
        if (e.code === 'Backspace') {
            return BS;
        }
        if (e.code === 'Space') {
            return ' ';
        }
        if (e.shiftKey && DUBEOL_SHIFT[e.code]) {
            return DUBEOL_SHIFT[e.code];
        }
        return DUBEOL[e.code] || '';
    };

06A Combo Multiplier for Clean Runs

multOf raises the multiplier in stairs from the streak divided by COMBO_STEP 10, plus 1, and stops at MULT_MAX 5, and afterMiss lowers it by only MISS_DROP 1 step on a miss. The right board resets the streak to 0 at each miss, so one slip wipes out the whole multiplier that was built up. On the same key record with 3 misses the left board ends at 1900 points and the right at 1280.

A Combo Multiplier for Clean Runs
Open in a new tab

06 · A Combo Multiplier for Clean Runs — Measured area 25.165% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

COMBO_STEPMULT_MAXMISS_DROP
    // Multiplier: the quotient of the streak ÷ step plus 1, stopping at MULT_MAX
    function multOf(streak, step) {
        return Math.min(MULT_MAX, 1 + Math.floor(streak / step));
    }
    // Streak after a miss: lower the current multiplier by drop steps and go back to the value where that multiplier starts
    function afterMiss(streak, step, drop) {
        const m = Math.max(1, multOf(streak, step) - drop);
        return (m - 1) * step;
    }

07A Ghost Caret Replaying a Past Run

ghostPos finds with a binary search how many entries of pressTimes, the press time of each character in a past run, are at or below the current time and puts a faint caret there, while linearPos is a straight caret at the average pace. The gap in characters between your caret and the ghost shows as a bar, and the right board hides the stretch where the past run stalled. The past run ends its 72 characters at 13.65 seconds and the current run at 12.95, and your caret trails by up to 7 characters for about the first 7 seconds, then catches up where the ghost stalls and leads by up to 5.

A Ghost Caret Replaying a Past Run
Open in a new tab

07 · A Ghost Caret Replaying a Past Run — Measured area 14.556% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GHOST_GAINpressTimesGAP_BAR
    // Ghost position: find the number of characters pressed up to time t × gain in pressTimes (binary search)
    function ghostPos(t, gain) {
        const g = t * gain;
        let lo = 0;
        let hi = pressTimes.length;
        while (lo < hi) {
            const mid = (lo + hi) >> 1;
            if (pressTimes[mid] <= g) {
                lo = mid + 1;
            }
            else {
                hi = mid;
            }
        }
        return lo;
    }
    // Average pace caret: moves in a straight line at the speed of the total characters divided by the last character time
    function linearPos(t) {
        const n = pressTimes.length;
        return n === 0 ? 0 : Math.min(n, Math.floor((t / pressTimes[n - 1]) * n));
    }

08A Key Gap Histogram

gapList collects the time difference of neighboring keys and binCounts drops each gap into 12 bins of BIN_MS 50 milliseconds, drawn as bar heights. With both mean gaps matched at 0.256 seconds, steady typist A forms one peak and burst and pause typist B forms two, while the right board draws only the mean line and makes them look the same. The longest pause is 0.311 seconds for A and 0.972 for B, and A crowds into three adjacent bins with 20, 22 and 5 while B splits into 34 hits near 0.10 to 0.15 seconds and 9 hits at 0.55 seconds or more.

A Key Gap Histogram
Open in a new tab

08 · A Key Gap Histogram — Measured area 1.08% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

BIN_MSBIN_NgapList
    function gapList(ts) {
        const out = [];
        for (let i = 1; i < ts.length; i += 1) {
            out.push(ts[i] - ts[i - 1]);
        }
        return out;
    }
    function binCounts(gaps) {
        const c = [];
        for (let i = 0; i < BIN_N; i += 1) {
            c.push(0);
        }
        for (let i = 0; i < gaps.length; i += 1) {
            c[Math.min(BIN_N - 1, Math.floor((gaps[i] * 1000) / BIN_MS))] += 1;
        }
        return c;
    }

09Falling Words Picked by Prefix

While words fall, pressing a first letter makes lockIdx pick the lowest word that starts with it, and keyLeft stacks the later letters on that word only. keyRight on the right board ignores the first letter and takes only the lowest word, so a letter that starts another word is dropped, and this falling scene also works as a landing hero intro. With the same 40 keys and 10 words falling at FALL_SPEED 40, the left board catches all 10 while the aimless right board catches only 3, lets 7 reach the floor and drops 28 keys.

Falling Words Picked by Prefix
Open in a new tab

09 · Falling Words Picked by Prefix — Measured area 12.7% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WORD_LISTFALL_SPEEDlockIdx
    // Left board: without a lock, pick by the first letter; after the lock, take only the next letter of that word
    function keyLeft(s, k, t) {
        if (s.lock < 0) {
            const i = lockIdx(s, k, t);
            if (i < 0) {
                s.miss += 1;
                return;
            }
            s.lock = i;
        }
        const w = WORD_LIST[s.lock];
        if (w.charAt(s.prog[s.lock]) !== k) {
            s.miss += 1;
            return;
        }
        s.prog[s.lock] += 1;
        if (s.prog[s.lock] >= w.length) {
            finish(s, s.lock, t);
            s.lock = -1;
        }
    }

When You Code Typing Practice, How Many Values Turn One Key Press Into a Score?

One key is three values, the time, the key and a match flag

press(k, t) removes the last character of the typed string when the key is Backspace, and otherwise compares the key with the target character in that place to set ok and pushes { t, k, ok } onto strokes. Accuracy is the number of matching letter keys divided by the number of letter keys, and delete keys are not counted.

    // Take one key: delete removes the last character, and a letter is compared with the target in that place to set ok
    function press(k, t) {
        if (k === BS) {
            if (buf.length > 0) {
                buf = buf.slice(0, -1);
            }
            strokes.push({ t: t, k: k, ok: false });
            return;
        }
        if (buf.length >= target.length) {
            return;
        }
        const ok = k === target.charAt(buf.length);
        buf += k;
        strokes.push({ t: t, k: k, ok: ok });
    }

The numbers on screen all come from the record

Words per minute, miss cells, the heatmap, the combo multiplier and the ghost position are not stored counters but values read by scanning strokes. Only the per key counts of 04 and the score and streak of 06 build up from the same input. So when the same record is fed to a different rule, the contrast boards show right where they part ways.

Take the Pressed Character and Its Time, Compare It With the Target, Add It to the Window Log When It Matches and Mark the Miss Cell When It Does Not: the Order in Which One Typing Practice Input Is Computed

The order in which one input is computed

One input finishes in four steps.

  1. press takes the key and its time, edits the typed string and sets ok.
  2. That one value is pushed onto the end of the strokes list.
  3. The calculation function of the scene (rateNow, missCells, multOf, ghostPos and so on) recomputes its value from the list and the current time sceneT.
  4. The screen draws the value, and the result notice is written once, only when the run ends.

A virtual typist with the same seed makes the same record

genStrokes draws the gap between letters from a seeded linear congruential generator as 0.8 to 1.2 times pace(now) and makes a typo only at the places it is told to. One typo runs as one or more wrong neighbor keys from the same row, the extra letters typed before noticing, a pause of 0.26 to 0.42 seconds, the deletes and the retype. Time is the single sceneT that adds up frame gaps cut at DT_MAX 0.05 seconds, and the nine sources contain 0 Date and 0 random calls, so the values can be reproduced.

Putting Rules on Top Like Typing Practice Games Online: Building Without a Library, Writing Practice Paragraphs as a String and When to Show the Result

A real app reads performance.now on keydown

This demo uses neither performance.now() nor Date. A real app reads performance.now() on keydown as the key time, and e.timeStamp shares the same origin. When a person types directly, the demo adds the gap between e.timeStamp and the last rAF time, within DT_MAX, to sceneT and uses it as the key time. The specs are in the MDN KeyboardEvent page and the requestAnimationFrame page.

    // The event time e.timeStamp and the rAF time share one origin, so subtracting gives the gap since the last frame
    function liveKey(k, stamp) {
        let t = sceneT;
        if (still) {
            if (lastStamp >= 0) {
                sceneT += clamp((stamp - lastStamp) / 1000, 0, 2);
            }
            lastStamp = stamp;
            t = sceneT;
        }
        else if (lastTs >= 0) {
            t = sceneT + clamp((stamp - lastTs) / 1000, 0, DT_MAX);
        }
        onKey(k, t);
        if (still) {
            update(0);
            showNow();
        }
    }

Count Hangul only when composition ends

Scene 05 does not use compositionstart or compositionend. With a Korean input method on, some browsers send Process instead of a letter in e.key, so the demo maps the pressed spot e.code through the DUBEOL table. A real service should mark the composition span with compositionstart, compositionupdate and compositionend, raise the count only at compositionend, and take the letter the input method made from the input event.

Where it fits

On the main screen of a typing practice online page, the words per minute of 01 and the miss cells of 03 go in first, and the heatmap of 04 and the gap histogram of 08 fit the weak spot analysis after practice. A typing speed check for a job application or an online class can show only 01 and accuracy, without a score rule like the combo of 06. When onboarding teaches keyboard shortcuts, the prefix picking of 09 and the past run race of 07 can be used. The nine sources have no outside dependency, so one copied file runs.

Keeping the practice text as one string

Keep the text as one string like TARGET_TEXT. The comparison only checks the same place of the typed string, so the rule stays as it is when the text changes, and typing practice paragraphs can be swapped without touching the scoring. The practice texts in the demo are all sentences written for it.

When to show the result

Words per minute can change all through practice, but accuracy and the final score read better when they are shown once at the end of a run. The demo does the same: the status line changes on each frame, and the result notice is written once to a single cell at the end.

Where Typing Practice Breaks: a Trap of Words per Minute That Spike in the First Seconds, Accuracy That Rises Backward After Deleting Misses and a Composing Letter That Gets Counted Twice

Words per minute spike in the first seconds

When the first key comes at 0.85 seconds, the second at 1.30 and the third at 1.77, dividing by the elapsed time from the first key (0.92 seconds) without MIN_SPAN gives 39 per minute. A floor of 2 seconds gives 18, and this typist's real starting pace was about 24. A value computed from three characters makes the gauge shake right after the start.

Accuracy rises backward after deleting misses

If accuracy is counted from the string currently on screen, the value rises the moment a miss is deleted and retyped, and the wrong record disappears. This demo counts okCount() ÷ keyCount(), so even after all 3 wrong cells of 03 are fixed, accuracy stays at 87.0%.

A composing letter gets counted twice

Raising the count by one on each key press counts eleven characters as 24. Each moment a final moves to the next block adds one more count, and 05 counts only confirmed characters. If Hangul character counts feed a score, that gap is an inflated speed.

The other trap is resetting the streak to 0 at each miss. On the same record in 06 the score falls from 1900 to 1280 and the multiplier graph turns from stairs into a sawtooth.

The HTML sources and the React versions of all nine scenes are bundled in the zip below, and the password to open it is 5d7f5ctn.

Change the Window Length, the Combo Steps and the Ghost Speed and How Much Does the Feel Shift? WINDOW_SEC, COMBO_STEP and GHOST_GAIN Compared

Name Changed value Feel
A longer window 01 WINDOW_SEC 5 → 15 The peak of the fast stretch falls from 86.4 to 72.2 words per minute, and the 12 second value, after the pace dropped, stays high at 57 instead of 43
Tighter combo steps 06 COMBO_STEP 10 → 5 The final score on the same key record rises from 1900 to 2800, the top multiplier reaches the MULT_MAX of x5 instead of x4, and the multiplier at a streak of 20 goes from x3 to x5
A faster ghost 07 GHOST_GAIN 1.0 → 1.3 The ghost finishes its 72 characters at 10.55 seconds instead of 13.65, the share of time your caret leads drops from 41 percent to 0, and the worst deficit widens from 7 to 18 characters

What to look at before changing a value

These three are constants of 01, 06 and 07, so change one value in one scene to keep the comparison fair. In a 12 second run the 15 second window is not full yet, so the 15 second value of 01 equals the average since the start, and the effect of a longer window shows only when practice runs longer than the window. All the figures above come from rerunning the same first record with only the constant changed.

Reduced Motion Leaves One Frozen Practice Screen: Accessibility Through a Sentence for the Scene and the Current Speed and Accuracy

Type on the stage and restart with Esc

The stage box in all nine scenes is role="application" with tabindex 0, so it takes focus. A letter key starts direct practice on the spot, and the first key removes the virtual typist and starts from an empty board. Backspace deletes and Esc restarts from the beginning, the pause button stops key input while it is held, and the step button sends the timetable on to its next key.

The result is read only at the end

The status line data-role="status" is aria-live="off" during the demo, so per-frame changes 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 a run ends. The drawing area 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 the timetable is run to its end to draw one result screen. A key that arrives then adds the gap between e.timeStamp values, up to 2 seconds, and redraws the values. All nine scenes listen for visibilitychange, stop the loop when the tab hides and drop the dt of the first frame on return.

Typing Practice FAQ: Building Without a Library, How Words per Minute Is Counted and What You Need Before Putting It on the Web

Can I use the time source of this demo on a real site?

No. The sceneT of the demo adds up frame gaps so the demo replays the same way, and a real site should read performance.now() on keydown as the key time and attach the calculation functions of this source after that.

Can I build typing practice free of any library?

Yes. Canvas 2D alone draws it, the nine sources contain 0 libraries, 0 WebGL and 0 CDN calls, and with 0 random calls the same seed replays the same record.

How is words per minute counted?

A run of CHARS_PER_WORD 5 characters counts as one word, only matching characters are counted, and typos and delete keys are not. The window is the last WINDOW_SEC seconds, and right after the start the divisor stays at or above MIN_SPAN 2 seconds.

For neighbors, see typing indicator UI patterns for the state shown while someone types, a CSS typing text effect for letters that appear one by one, nine stopwatch scenes for counting time as an integer and a keyboard shortcut UI for key hints, and read the MDN Page Visibility API page for the hidden tab.

Enter the archive password

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