Typing Speed Test with HTML, CSS & JavaScript

20 DAYS 20 PROJECT CHALLENGE

Day #10

Project Overview

Typing Speed Test is a beginner-friendly project built using HTML, CSS, and JavaScript. It calculates and displays your typing speed in a specific amount of time. This web application compares all characters in the text you type with the sample text. It also highlights correct and incorrect characters.

The application can be used for 15, 30, 60, or 120 seconds. It displays some statistics such as Words Per Minute (WPM), Characters Per Minute (CPM), accuracy level, number of errors, and remaining time at the top of the application. This project can be a good example for the beginner since it covers many basic topics like timers, string comparison, working with the DOM, and event handling.

Key Features

  • Multiple Test Durations: Users can choose from 15, 30, 60, or 120 seconds typing test duration. Thus, the users can quickly decide whether they want to do a short or a long test.
  • Live Typing Speed: The application calculates and shows Words Per Minute in real-time to let the users know their progress during the test.
  • Accurate Typing: The application shows the percentage of accuracy based on the sample text. All mistakes are highlighted in the relevant section.
  • Error Tracking: Incorrect characters are counted as errors and displayed in the live statistics. This helps users understand how accurately they are typing.
  • Character-by-Character Breakdown: The application shows the current character, the correct one, the one typed by the user, and the position. These elements change in real-time.
  • Random Sample Texts: The application selects a sample text from multiple available sentences. This helps provide a different typing experience when the test is restarted.
  • Automatic Test Start: Users can begin the test by clicking the Start button or by simply starting to type in the input area.
  • Countdown Timer: Countdown to the end of the test is managed with JavaScript. This script constantly updates the time left.
  • Final Results: When the timer ends, the application displays a summary containing the final WPM, CPM, accuracy percentage, and number of errors.
  • Restart and Retry: At any point during the test, the users can cancel it and restart it again. They can also retry the whole test in the same tab.
  • Keyboard Shortcuts: The users can type on their keyboards to perform an action. To cancel the test, they can hit the Escape key on their keyboards.
  • Responsive Design: The Typing Speed Test application has a responsive design that fits all screen sizes and devices.

What You'll Learn

  • Understand how to use setInterval() and clearInterval() in JavaScript.
  • Practice comparing strings character by character.
  • Understand how to calculate typing speed in Words Per Minute.
  • Learn how to calculate Characters Per Minute.
  • Practice calculating accuracy percentages.
  • Understand how to track typing errors dynamically.
  • Learn how to update HTML elements using JavaScript.
  • Practice creating HTML elements dynamically with createElement().
  • Understand how CSS classes can provide correct and incorrect typing feedback.
  • Learn how event listeners respond to user input.
  • Practice using arrays and randomly selecting data.

HTML Code

HTML creates the complete structure of the Typing Speed Test. The main content is placed inside the .card container, which holds the project heading, duration controls, live statistics, typing area, and final results, #duration select element allows users to choose the test duration and #startBtn button starts the typing test, while #restartBtn resets the application and prepares a new test.

The statistics section contains separate elements for displaying the remaining time, WPM, CPM, accuracy, and errors. Also, the #quote element displays the sample text that the user needs to type. JavaScript later divides this text into individual character elements so that each character can receive its own typing status,  #input textarea is where users enter the sample text and the #final section remains hidden until the typing test ends and then displays the final WPM, CPM, accuracy, and error count. Finally, script.js is linked at the bottom of the <body> so, JavaScript can access the HTML elements and provide the application’s functionality.

<!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

CSS controls the appearance and layout of the Typing Speed Test. The :root section defines reusable values for the main background, card colors, accent colors, muted text, correct characters, and incorrect characters,  .card class styles the main application container with padding, rounded corners, a border, and a shadow. Flexbox is used in the .controls and .stats sections to organize the buttons and live statistics. While, the .quote class styles the sample text that users need to type. Each character inside the text receives the .char class. JavaScript can add the .correct, .incorrect, or .current classes to these characters, allowing CSS to show typing progress visually.

The textarea#input selector styles the typing area and provides a focus effect when the user starts entering text. The .final section styles the results panel, while the .hidden class keeps it invisible until the test is completed and the media query adjusts the results layout and text size on smaller screens, helping the Typing Speed Test remain comfortable to use on mobile devices.

: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

JavaScript provides the main functionality of the Typing Speed Test.  SAMPLES array stores multiple sample texts that can be used during the typing test. The code then selects all required HTML elements, including the typing input, buttons, timer, statistics, and results panel. Although, pickSample() function randomly selects a text from the available sample texts, renderReference() function then divides the selected text into individual <span> elements so that every character can be checked separately and the startTest() function starts the countdown timer using setInterval(). As the timer runs, JavaScript updates the remaining time and recalculates the live WPM, CPM, accuracy, and error count.

When the user types inside the textarea, the input event compares every typed character with the matching character in the reference text. Correct characters receive the correct class, while incorrect characters receive the incorrect class. The current typing position is also highlighted and updateLiveStats() function calculates the current typing statistics. WPM is calculated using the number of correct characters, with five characters representing one word. Accuracy is calculated by comparing correct characters with the total typed characters.

When the countdown reaches zero, the endTest() function stops the timer, disables the input field, and displays the final results. The Restart, Try Again, and Escape key controls allow users to quickly begin another typing test.

// 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.