Typing Speed Test with HTML, CSS & JavaScript
20 DAYS 20 PROJECT CHALLENGE
Day #10
Project Overview
A compact Typing Speed Test that measures typing speed (WPM) and accuracy over a fixed duration (default 60 seconds). It shows live stats while the user types, highlights correct/incorrect characters, and produces final results when the timer ends. This demonstrates timers (setInterval), string comparison, DOM events, and basic UX patterns (start/pause/reset, selectable duration, sample texts).
Key Features
- Choose test duration (15 / 30 / 60 / 120 seconds).
- Start / Restart controls and auto-start on first keypress.
- Live stats: WPM (words per minute), CPM (characters per minute), Accuracy %, Errors, Elapsed / Remaining time.
- Character-level highlighting (correct / incorrect / current).
- Several sample texts (randomized each run).
- Final result panel with summary and option to retry.
- Keyboard-accessible and responsive layout.
HTML Code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Day 10 — Typing Speed Test</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="card" role="main" aria-labelledby="title">
<header>
<div class="logo">TS</div>
<div>
<h1 id="title">Day 10: Typing Speed Test</h1>
<p class="lead">Measure typing speed (WPM) and accuracy. Start typing or press Start to begin the timer.</p>
</div>
</header>
<section class="controls">
<div class="left">
<label class="small">Duration:
<select id="duration">
<option value="15">15s</option>
<option value="30">30s</option>
<option value="60" selected>60s</option>
<option value="120">120s</option>
</select>
</label>
<button id="startBtn" class="btn">Start</button>
<button id="restartBtn" class="btn secondary">Restart</button>
</div>
<div class="stats" aria-live="polite">
<div class="stat"><div class="label">Time</div><div id="time" class="value">00:00</div></div>
<div class="stat"><div class="label">WPM</div><div id="wpm" class="value">0</div></div>
<div class="stat"><div class="label">CPM</div><div id="cpm" class="value">0</div></div>
<div class="stat"><div class="label">Accuracy</div><div id="accuracy" class="value">100%</div></div>
<div class="stat"><div class="label">Errors</div><div id="errors" class="value">0</div></div>
</div>
</section>
<section class="test-area">
<div id="quote" class="quote" aria-hidden="false"></div>
<label for="input" class="sr-only">Typing input</label>
<textarea id="input" placeholder="Start typing here..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
<div class="hint muted">Tip: Backspace is allowed — accuracy is calculated based on typed characters vs correct characters.</div>
</section>
<section id="final" class="final hidden" role="status" aria-live="polite">
<h3>Results</h3>
<div class="final-grid">
<div><strong>WPM</strong><div id="finalWpm" class="big">0</div></div>
<div><strong>Accuracy</strong><div id="finalAcc" class="big">0%</div></div>
<div><strong>CPM</strong><div id="finalCpm" class="big">0</div></div>
<div><strong>Errors</strong><div id="finalErr" class="big">0</div></div>
</div>
<button id="tryAgain" class="btn">Try again</button>
</section>
<details style="margin-top:14px">
<summary>How it works (short)</summary>
<p class="small">The script renders a sample text as individual character spans. As the user types, each input char is compared against the reference char at the same position and receives a class: correct / incorrect / pending. A countdown timer runs for the chosen duration. WPM = (correctChars / 5) / minutes; accuracy = (correctChars / typedChars) * 100. Results shown when time ends.</p>
</details>
</main>
<script src="script.js"></script>
</body>
</html>
CSS Code
:root {
--bg: #0f1724;
--card: #071027;
--accent: #7c3aed;
--muted: #9aa4b2;
--white: #e6eef6;
--danger: #ef4444;
--ok: #10b981;
font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, Arial;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: #002252;
color: var(--white);
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
}
.card {
width: min(900px, 96%);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01));
border-radius: 12px;
padding: 20px;
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.6);
border: 1px solid rgba(255, 255, 255, 0.03);
}
header {
display: flex;
gap: 12px;
align-items: center;
}
.logo {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), #22c1c3);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 18px;
}
.lead {
margin: 4px 0 12px;
color: var(--muted);
font-size: 14px;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
gap: 12px;
flex-wrap: wrap;
}
.left {
display: flex;
align-items: center;
gap: 10px;
}
.small {
font-size: 13px;
color: var(--muted)
}
.btn {
padding: 10px 14px;
border-radius: 10px;
border: 0;
background: linear-gradient(90deg, var(--accent), #22c1c3);
color: white;
font-weight: 600;
cursor: pointer
}
.btn.secondary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--muted)
}
.stats {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.03)
}
.label {
font-size: 11px;
color: var(--muted)
}
.value {
font-weight: 700;
font-family: ui-monospace, monospace
}
.test-area {
margin-top: 18px;
display: flex;
flex-direction: column;
gap: 12px
}
.quote {
padding: 18px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.01), rgba(255, 255, 255, 0.005));
border: 1px solid rgba(255, 255, 255, 0.03);
min-height: 92px;
line-height: 1.7;
font-size: 18px;
font-family: ui-serif, Georgia, 'Times New Roman', serif;
color: var(--white);
user-select: none;
white-space: pre-wrap;
}
.quote .char {
padding: 0 1px;
}
.quote .char.current {
background: rgba(124, 58, 237, 0.12);
border-radius: 4px;
}
.quote .char.correct {
color: var(--ok);
}
.quote .char.incorrect {
color: var(--danger);
text-decoration: underline wavy rgba(239, 68, 68, 0.25);
}
textarea#input {
width: 100%;
min-height: 110px;
resize: vertical;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.04);
background: transparent;
color: var(--white);
outline: none;
font-size: 16px;
font-family: inherit;
}
textarea#input:focus {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.06);
border-color: rgba(124, 58, 237, 0.6);
}
.hint {
font-size: 13px;
color: var(--muted)
}
.final {
margin-top: 18px;
padding: 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.03)
}
.hidden {
display: none
}
.final-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin: 12px 0
}
.final .big {
font-size: 22px;
font-weight: 700;
font-family: ui-monospace, monospace
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0
}
@media (max-width:720px) {
.stats {
gap: 8px
}
.final-grid {
grid-template-columns: repeat(2, 1fr)
}
.quote {
font-size: 16px
}
} Javascript Code
// Day 10 — Typing Speed Test
// Concepts used: setInterval, clearInterval, event listeners, string comparison
// Sample texts (short list; add more as desired)
const SAMPLES = [
"The quick brown fox jumps over the lazy dog.",
"Practice makes progress. Keep typing to improve your speed and accuracy.",
"Web development combines creativity with logic — build small projects every day.",
"Typing fast is great, but typing accurately will get you further in the long run."
];
// DOM
const quoteEl = document.getElementById('quote');
const inputEl = document.getElementById('input');
const startBtn = document.getElementById('startBtn');
const restartBtn = document.getElementById('restartBtn');
const durationSelect = document.getElementById('duration');
const timeEl = document.getElementById('time');
const wpmEl = document.getElementById('wpm');
const cpmEl = document.getElementById('cpm');
const accuracyEl = document.getElementById('accuracy');
const errorsEl = document.getElementById('errors');
const finalPanel = document.getElementById('final');
const finalWpm = document.getElementById('finalWpm');
const finalAcc = document.getElementById('finalAcc');
const finalCpm = document.getElementById('finalCpm');
const finalErr = document.getElementById('finalErr');
const tryAgainBtn = document.getElementById('tryAgain');
// State
let reference = ''; // text user must type
let timer = null;
let duration = 60; // seconds (default)
let remaining = 60;
let started = false;
let startTime = 0;
let typedChars = 0; // total typed (including corrections)
let correctChars = 0; // chars currently correct (position-wise)
let errors = 0; // positions currently incorrect
let lastInput = ''; // last input string
// Initialize a test
function pickSample(){
const idx = Math.floor(Math.random()*SAMPLES.length);
return SAMPLES[idx];
}
function renderReference(text){
reference = text;
quoteEl.innerHTML = '';
for(let i=0;i<text.length;i++){
const span = document.createElement('span');
span.className = 'char';
span.textContent = text[i];
span.dataset.index = i;
quoteEl.appendChild(span);
}
highlightCurrent(0);
}
// Highlight current index
function highlightCurrent(pos){
const spans = quoteEl.querySelectorAll('.char');
spans.forEach(sp => {
sp.classList.remove('current');
});
const cur = quoteEl.querySelector(`.char[data-index="${pos}"]`);
if(cur) cur.classList.add('current');
}
// Reset stats & UI
function resetTest(autoFocus=true){
clearInterval(timer);
timer = null;
started = false;
duration = parseInt(durationSelect.value,10) || 60;
remaining = duration;
startTime = 0;
typedChars = 0;
correctChars = 0;
errors = 0;
lastInput = '';
updateStatsDisplay(0,0,100,0);
timeEl.textContent = formatTime(remaining);
inputEl.value = '';
finalPanel.classList.add('hidden');
// new sample
renderReference(pickSample());
inputEl.disabled = false;
if(autoFocus) inputEl.focus();
}
// Format seconds -> mm:ss
function formatTime(sec){
const s = Math.max(0, Math.floor(sec));
const mm = Math.floor(s / 60);
const ss = s % 60;
return String(mm).padStart(2,'0') + ':' + String(ss).padStart(2,'0');
}
// Start timer
function startTest(){
if(started) return;
started = true;
startTime = Date.now();
// ensure duration updated from selector (in case changed)
duration = parseInt(durationSelect.value,10) || 60;
remaining = duration;
timeEl.textContent = formatTime(remaining);
timer = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000;
const rem = Math.max(0, Math.round((duration - elapsed) * 10) / 10);
remaining = rem;
timeEl.textContent = formatTime(rem);
// update live WPM/accuracy each tick
updateLiveStats();
if(rem <= 0){
endTest();
}
}, 100); // 100ms tick gives responsive UI without heavy CPU
}
// End test
function endTest(){
clearInterval(timer);
timer = null;
started = false;
inputEl.disabled = true;
// final stats
const minutes = (duration - remaining) / 60 || (duration/60);
const finalWPM = Math.round( (correctChars / 5) / ( (duration - remaining) / 60 || (duration/60) ) ) || 0;
const finalCPM = Math.round( (correctChars) / ( (duration - remaining) / 60 || (duration/60) ) ) || 0;
const acc = typedChars > 0 ? Math.round((correctChars / typedChars) * 100) : 100;
finalWpm.textContent = finalWPM;
finalCpm.textContent = finalCPM;
finalAcc.textContent = acc + '%';
finalErr.textContent = errors;
// show final panel
finalPanel.classList.remove('hidden');
// update live display once more
updateStatsDisplay(finalWPM, finalCPM, acc, errors);
}
// Compute and display live stats
function updateLiveStats(){
// elapsed = duration - remaining
const elapsedSeconds = Math.max(0, duration - remaining);
const minutes = Math.max( (elapsedSeconds / 60), 1/60 ); // avoid division by zero early
const liveWPM = Math.round( (correctChars / 5) / minutes ) || 0;
const liveCPM = Math.round( correctChars / minutes ) || 0;
const acc = typedChars > 0 ? Math.round((correctChars / typedChars) * 100) : 100;
updateStatsDisplay(liveWPM, liveCPM, acc, errors);
}
// Update stats UI
function updateStatsDisplay(wpm, cpm, acc, err){
wpmEl.textContent = wpm;
cpmEl.textContent = cpm;
accuracyEl.textContent = acc + '%';
errorsEl.textContent = err;
}
// Handle typing input
inputEl.addEventListener('input', (e) => {
const value = inputEl.value;
// Auto-start on first real input
if(!started && value.length > 0){
startTest();
}
// typedChars counts every character the user has typed (including corrections)
// We'll approximate typedChars by the total length typed (including backspaces we cannot directly count),
// so we track previous value length and difference.
// A simple approach: increment typedChars by the number of characters added since last input.
// If user deleted (new length < last length), we won't increment typedChars.
if(value.length > lastInput.length){
typedChars += (value.length - lastInput.length);
}
lastInput = value;
// Compare each typed character with reference
correctChars = 0;
errors = 0;
const spans = quoteEl.querySelectorAll('.char');
for(let i=0;i<spans.length;i++){
const ch = spans[i].textContent;
const typed = value[i];
spans[i].classList.remove('correct','incorrect','current');
if(typed == null || typed === ''){
// not typed yet
} else if(typed === ch){
spans[i].classList.add('correct');
correctChars++;
} else {
spans[i].classList.add('incorrect');
errors++;
}
}
// if user typed beyond length, count those as errors but do not render spans
if(value.length > reference.length){
// extra chars typed
const extra = value.length - reference.length;
errors += extra;
// typedChars already included them
}
// highlight current position (cursor)
const pos = Math.min(value.length, reference.length - 1);
highlightCurrent(pos + (value.length === 0 ? 0 : 0)); // simple highlight logic
// update live stats
updateLiveStats();
});
// Start / Restart / Try Again handlers
startBtn.addEventListener('click', () => {
if(!started){
inputEl.focus();
// If no typing yet, start immediately and don't clear typed input
startTest();
}
});
restartBtn.addEventListener('click', () => {
resetTest(true);
});
tryAgainBtn.addEventListener('click', () => {
resetTest(true);
});
// Accessibility: pressing Enter in textarea does not submit anything; we keep normal typing behavior
// Initial setup
resetTest(true);
// Optional: allow pressing Escape to reset quickly
document.addEventListener('keydown', (e) => {
if(e.key === 'Escape'){
resetTest(true);
}
});
Related Projects
Day 8 : Movie Search App
Search movies and display results with posters using an API.
Concepts: API integration, async/await.
Day 12 : QR Code Generator
Generates a QR code from text input.
Concepts: Third-party API (QRServer API), fetch().
Day 13 : Currency Converter
Converts one currency to another using real-time exchange rates.
Concepts: API fetch, DOM updates.