A zero-dependency, vanilla-JS approach to measuring WPM, accuracy, and keystrokes — plus the UX lessons I learned while building a typing test that supports short bursts and long endurance runs.

A few weeks ago I noticed that most online typing tests either force you into a 60-second sprint or feel too bare-bones. I wanted something that let users pick their own test length (15s, 30s, 60s, 3m, or 5m) while still running 100% in the browser with no upload, no signup, and no tracking beyond standard analytics.
That became the ToolKnit Typing Speed Test. Here's how I built it, the engineering choices I made, and the UI fixes I had to iterate on before it felt right.
What we are building
A typing test that:
- Generates a random word list from a curated bank of common English words.
- Renders each character as a separate
<span>so we can style correct / incorrect / current states. - Listens to real keyboard events, starts the timer on the first character, and ignores modifier keys.
- Calculates live WPM, accuracy, and character count.
- Supports 5 durations (15s, 30s, 60s, 3m, 5m) with different word counts.
- Horizontally scrolls the prompt for long tests so the current character always stays visible.
Try it first if you want to follow along: ToolKnit Typing Speed Test
The core idea: render text as individual characters
The trick to making the prompt feel responsive is splitting every character into its own element.
function renderText(text) {
textChars = text.split('');
var html = '';
for (var i = 0; i < textChars.length; i++) {
var cls = i === 0 ? 'char current' : 'char';
var ch = textChars[i] === ' ' ? ' ' : textChars[i];
html += '<span class="' + cls + '" data-idx="' + i + '">' + ch + '</span>';
}
typingArea.innerHTML = html;
}
Each span starts with a dim color. When the user types it, we add correct or incorrect. The upcoming character gets current and a blinking caret border.
This gives us three things for free:
- Visual feedback — the user always sees exactly what is correct, wrong, and upcoming.
- Precise metrics — because we know the index of every character, we can count correct and incorrect characters individually.
- Auto-scroll — we can scroll the typing area to the exact position of the current span.
Handling keyboard input correctly
The UI listens to a real keydown event on a hidden input that is focused when the user clicks the typing area. That avoids the quirks of trying to capture keystrokes directly on a div.
hiddenInput.addEventListener('keydown', handleInput);
Inside handleInput, we ignore modifier keys, then decide whether the user is typing or deleting.
if (e.key === 'Backspace') {
if (charIndex > 0) {
charIndex--;
var prevSpan = spans[charIndex];
if (prevSpan.classList.contains('correct')) correctChars--;
if (prevSpan.classList.contains('incorrect')) incorrectChars--;
prevSpan.classList.remove('correct', 'incorrect', 'current');
prevSpan.classList.add('current');
if (spans[charIndex + 1]) spans[charIndex + 1].classList.remove('current');
scrollToCurrent();
}
return;
}
Backspace is surprisingly important for UX. Users expect to correct mistakes, and supporting it cleanly also lets us keep the accuracy metric honest.
Calculating WPM and accuracy
The standard WPM formula is:
WPM = (correct characters / 5) / elapsed minutes
We divide by 5 because "one word" is traditionally defined as 5 characters. Accuracy is simply:
Accuracy = correct / (correct + incorrect) * 100
function updateLiveStats() {
if (!startTime) return;
var elapsed = (Date.now() - startTime) / 1000;
if (elapsed < 0.5) return;
var minutes = elapsed / 60;
var wpm = Math.round((correctChars / 5) / minutes);
var total = correctChars + incorrectChars;
var acc = total > 0 ? Math.round((correctChars / total) * 100) : 100;
document.getElementById('live-wpm').textContent = wpm;
document.getElementById('live-acc').textContent = acc + '%';
document.getElementById('live-chars').textContent = total;
}
I run the timer at a 200ms interval so the live stats update smoothly without hammering the DOM.
Scaling the prompt length for 3m and 5m tests
The hardest UX problem was not the WPM math — it was the prompt length. A 5-minute test needs roughly 800 words to stay ahead of a fast typist.
var wordCount = duration <= 15 ? 60
: duration <= 30 ? 120
: duration <= 60 ? 200
: duration <= 180 ? 500
: 800;
That creates a single line of text far wider than the screen. The first fix was to allow horizontal scrolling. The second fix — the one that actually solved the problem — was to keep the current character anchored at a fixed position (20% from the left) rather than letting it drift to the right edge as the user types.
function scrollToCurrent() {
var curSpan = spans[charIndex];
if (!curSpan) return;
var target = curSpan.offsetLeft - typingArea.clientWidth * 0.2;
typingArea.scrollLeft = target;
}
I also added 40% right padding so the final characters never get stuck flush against the right edge.
#typing-area {
white-space: nowrap;
overflow-x: auto;
overflow-y: hidden;
display: flex;
align-items: center;
padding-right: 40%;
}
This is the difference between a test that "works" and a test that feels good to use.
Accessibility and mobile gotchas
- Use a hidden input for keyboard capture instead of
contenteditableto avoid the virtual keyboard pulling focus weirdly on mobile. - Hide the scrollbar so the long prompt still looks clean, but keep the scroll behavior intact.
- The current character gets a blinking border-left caret, so users always know where they are even when the text is dim.
- Ignore
Shift,Control,Alt,Tab, etc., so the timer does not start on a modifier-only keypress.
Why this is privacy-first
Everything happens in the browser. There is no backend that stores your text, your keystrokes, or your results. The word list is baked into the JS, the random generation is Math.random(), and the stats are calculated live in memory. If you refresh, the test resets and nothing is sent anywhere.
You can read more about the privacy model in the ToolKnit Typing Speed Test.
Full source and live demo
- Live tool: https://toolknit.com/tools/typing-test.html
- Tool page: https://toolknit.com/tools/typing-test.html
- Related reading: How Many Paragraphs Is 2000 Characters?
Conclusion
Building a typing test is a great exercise in vanilla JavaScript because the logic is simple but the UX edge cases are not. The biggest lesson for me was that scroll anchoring matters more than raw WPM calculation — if the user cannot see what they are supposed to type, the test is broken.
If you want a quick, private, browser-only typing test with 15s, 30s, 60s, 3m, and 5m modes, give the ToolKnit Typing Speed Test a try.
Top comments (0)