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);
  }
});
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments

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.