How to Create a Typing Game Using HTML, CSS and JavaScript

How to Create Typing Game Using HTML, CSS and JS

Here’s something most of us never stop to think about: we type every single day of our lives, yet almost none of us ever actually practice it. We settle into whatever speed we stumbled into years ago and never look back. That’s exactly what makes a typing game such a rewarding project to build. It’s genuinely useful, it’s weirdly fun to play, and along the way it quietly teaches you a solid chunk of JavaScript without you even realizing it.

In this tutorial, we’ll build a typing speed game from scratch using nothing but plain HTML, CSS, and vanilla JavaScript. No libraries. No frameworks. No bloated dependencies. Just the three core technologies every front-end developer needs to master anyway. By the time you’re finished, you’ll have a clean, responsive game that measures typing speed, tracks accuracy, runs on a countdown timer, and serves up fresh words to the player in real time.

Why Bother Building This?

You might be wondering why a typing game is worth your time when there are a hundred fancier project ideas floating around. The answer is simple: this project hits the sweet spot between easy enough to finish and hard enough to actually learn something.

A lot of beginner tutorials have you build things that look impressive but teach you almost nothing β€” you copy some code, it works, and you walk away no smarter than before. This is the opposite. Nearly every core JavaScript concept you’ll use in real projects shows up here: capturing keyboard input, updating the page as the user types, working with timers, generating random content, and doing live calculations on the fly. It’s small, but the skills scale up beautifully.

What We’re Actually Building

The concept is refreshingly simple, and that simplicity is part of the appeal. A word appears on the screen, the player starts typing it, and the moment their very first keystroke lands, the timer kicks off. From that point on, the game quietly keeps score in the background β€” how many words per minute they’re hitting, how accurately they’re typing, and how much time is left on the clock.

As they type, each letter gives instant visual feedback. Correct letters light up one way, mistakes light up another, so the player always knows exactly where they stand. When the countdown finally hits zero, the game freezes and shows them their results. It’s fast, it’s clean, and it’s honestly a little addictive once you start chasing a higher score.

And because we’re building it responsively from the very beginning, it works just as smoothly on a phone or tablet as it does on a full desktop.

The Core Features

Let’s break down everything the finished game includes:

  • A modern, distraction-free interface that keeps the focus on typing and nothing else.
  • Real-time feedback as you type, with correct and incorrect characters highlighted instantly.
  • A live words-per-minute (WPM) counter that updates continuously.
  • Accuracy calculation that runs on every keystroke.
  • A countdown timer that adds just enough pressure to keep things exciting.
  • Random word generation so no two rounds ever feel the same.
  • A fully responsive layout that adapts gracefully to any screen size.
  • Clean, well-organized source code that’s easy to read, easy to follow, and easy to extend later on.

The Technology Stack

There’s nothing exotic under the hood here, and that’s completely intentional:

  • HTML5 handles the structure β€” the input field, the word display, the stat counters, and the layout.
  • CSS3 takes care of the styling, the animations, and making everything responsive across devices.
  • JavaScript (ES6) does all the heavy lifting: managing timers, capturing keystrokes, running the scoring logic, and updating the DOM in real time.

If you can comfortably read and understand this stack, you already have everything you need to build the whole thing.

How the Logic Comes Together

Let’s talk about the actual thinking behind the game, because this is where the real learning happens.

The heart of the project is keyboard event handling. Every time the player presses a key, JavaScript listens for it, compares what they typed against the target word, and decides whether it’s a match. That single comparison drives almost everything else β€” the highlighting, the accuracy score, and the word progression.

Then there’s the timer. We use JavaScript’s built-in timing functions to count down second by second. The trick is starting the timer only when the player actually begins typing, not the instant the page loads. That small detail makes the whole experience feel fair and intentional.

Random word generation keeps the game fresh. Instead of showing the same words in the same order, we pull words randomly from an array, so every session feels new. This also teaches you how to work with arrays and randomization, which comes up constantly in real-world development.

Finally, there’s the math β€” calculating words per minute and accuracy. WPM is worked out based on how many words the player completes within the time limit, while accuracy compares correct keystrokes against total keystrokes. It sounds intimidating, but once you break it down, it’s just a couple of simple formulas doing their job in the background.

What You’ll Walk Away Knowing

This is the part that matters most. By the time you finish this project, you won’t just have a game β€” you’ll have hands-on experience with skills you’ll reuse in nearly every front-end project you ever build:

  • Manipulating the DOM dynamically as the user interacts with the page
  • Handling and responding to keyboard events in real time
  • Generating and managing random content from arrays
  • Building and controlling countdown timers
  • Calculating live statistics like speed and accuracy
  • Designing responsive interfaces that work on any device

These aren’t throwaway skills. They’re the exact tools you’ll reach for when building forms, games, dashboards, and interactive apps down the line.

Who This Project Is Perfect For

Honestly, anyone who wants to get more confident with front-end development will get something out of this. If you’re a beginner hunting for a project that actually does something instead of just sitting there looking pretty, this is a great pick. If you’re a student who needs a solid, impressive assignment, it checks all the boxes. And if you’re a developer who just wants a fun afternoon build to drop into your portfolio, it fits perfectly there too.

Because the code is structured cleanly and thoughtfully, it also works brilliantly as a foundation. Once you’ve got the basics running, you can layer on new features β€” difficulty levels, sound effects, a leaderboard, different word categories, or even a dark mode. The project grows with you.

Final Thoughts

A typing game might seem like a small thing, but it’s one of those projects that punches well above its weight. It’s practical enough to use, simple enough to finish in a sitting, and educational enough to genuinely level up your JavaScript. Whether you’re building it to learn, to teach, to showcase, or just to see how fast your own fingers can move, it’s time well spent.

The complete source code is clean, well-commented, and easy to follow from top to bottom β€” so feel free to study it, tweak it, break it, rebuild it, and eventually make it entirely your own.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inkwell β€” Typing Speed Test | By Coding_Stellix</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
/* ═══════════ TOKENS ═══════════ */
:root{
  --ink:#120e2a;          /* deep midnight violet */
  --ink-2:#1c1642;        /* raised panel */
  --ink-3:#241c55;        /* hover / border glow */
  --line:rgba(255,255,255,.08);
  --txt:#efecff;
  --dim:#6f6899;          /* untyped words */
  --muted:#9a92c4;
  --violet:#8b6cff;       /* primary accent */
  --violet-2:#b79cff;
  --mint:#46e3b8;         /* success / speed */
  --rose:#ff5d8f;         /* errors */
}
*{margin:0;padding:0;box-sizing:border-box;}
body{
  background:
    radial-gradient(820px 460px at 85% -6%,rgba(139,108,255,.20),transparent 62%),
    radial-gradient(640px 400px at 6% 104%,rgba(70,227,184,.10),transparent 62%),
    var(--ink);
  color:var(--txt);
  font-family:'Jost',system-ui,sans-serif;
  min-height:100vh;display:flex;flex-direction:column;overflow-x:hidden;
}

/* ═══════════ HEADER ═══════════ */
header{
  position:sticky;top:0;z-index:50;
  display:flex;align-items:center;gap:16px;flex-wrap:wrap;
  padding:15px 28px;
  background:rgba(18,14,42,.8);backdrop-filter:blur(18px);
  border-bottom:1px solid var(--line);
}
.brand{display:flex;align-items:center;gap:11px;}
.nib{
  width:38px;height:38px;flex-shrink:0;display:grid;place-items:center;
  border-radius:12px 12px 12px 3px;   /* pen-nib corner */
  background:linear-gradient(140deg,var(--violet),var(--violet-2));
  box-shadow:0 10px 26px rgba(139,108,255,.45);
  position:relative;
}
.nib::after{                          /* ink drop */
  content:"";position:absolute;width:6px;height:6px;border-radius:50%;
  background:var(--mint);right:-2px;bottom:-2px;
  box-shadow:0 0 10px var(--mint);
  animation:drip 2.6s ease-in-out infinite;
}
@keyframes drip{0%,100%{transform:translateY(0);opacity:1}50%{transform:translateY(4px);opacity:.45}}
.nib svg{width:17px;height:17px;fill:#fff;}
.brand h1{font-size:17px;font-weight:900;letter-spacing:-.3px;line-height:1.1;}
.brand small{display:block;font-size:9.5px;font-weight:700;letter-spacing:1.8px;
  text-transform:uppercase;color:var(--muted);margin-top:1px;}

.modes{display:flex;gap:6px;margin-left:auto;background:rgba(255,255,255,.04);
  border:1px solid var(--line);border-radius:12px;padding:4px;}
.mode{
  font-family:'Jost',sans-serif;border:none;background:transparent;
  color:var(--muted);font-size:12px;font-weight:800;letter-spacing:.6px;
  padding:8px 16px;border-radius:9px;cursor:pointer;transition:.22s;
}
.mode:hover{color:var(--txt);}
.mode[aria-pressed="true"]{
  background:linear-gradient(135deg,var(--violet),var(--violet-2));
  color:#fff;box-shadow:0 6px 18px rgba(139,108,255,.4);
}

/* ═══════════ MAIN ═══════════ */
main{flex:1;width:100%;max-width:900px;margin:0 auto;padding:44px 22px 56px;}

.hero{text-align:center;margin-bottom:30px;}
.hero h2{font-size:clamp(28px,5.5vw,46px);font-weight:900;letter-spacing:-1.4px;line-height:1.08;}
.hero h2 em{
  font-style:normal;position:relative;
  background:linear-gradient(120deg,var(--violet-2),var(--mint));
  -webkit-background-clip:text;background-clip:text;color:transparent;
}
.hero p{color:var(--muted);font-size:14px;font-weight:500;margin-top:9px;}

/* live readout */
.readout{
  display:flex;align-items:center;justify-content:center;gap:34px;
  margin-bottom:20px;flex-wrap:wrap;
}
.rd b{
  display:block;font-size:38px;font-weight:900;line-height:1;
  font-variant-numeric:tabular-nums;letter-spacing:-1px;
}
.rd:nth-child(1) b{color:var(--violet-2);}
.rd:nth-child(2) b{color:var(--mint);}
.rd:nth-child(3) b{color:var(--txt);}
.rd:nth-child(3) b.low{color:var(--rose);}
.rd span{
  display:block;margin-top:5px;font-size:9.5px;font-weight:800;letter-spacing:1.5px;
  text-transform:uppercase;color:var(--muted);text-align:center;
}
.rd b i{font-style:normal;font-size:17px;color:var(--muted);}

/* ── the sheet (signature: ink-fill words) ── */
.sheet{
  position:relative;
  background:linear-gradient(180deg,var(--ink-2),rgba(28,22,66,.6));
  border:1px solid var(--line);border-radius:20px;
  padding:34px 32px;
  box-shadow:0 30px 80px rgba(0,0,0,.45);
  overflow:hidden;
}
.sheet::before{                         /* progress ink line */
  content:"";position:absolute;left:0;top:0;height:3px;width:var(--p,0%);
  background:linear-gradient(90deg,var(--violet),var(--mint));
  box-shadow:0 0 16px rgba(139,108,255,.8);transition:width .25s ease;
}
.words{
  font-size:24px;line-height:1.95;font-weight:500;
  color:var(--dim);
  min-height:150px;
  user-select:none;cursor:text;
  letter-spacing:.2px;
}
.w{display:inline-block;margin-right:12px;position:relative;transition:color .18s;}
.w .c{transition:color .12s,background .12s;}
.w .c.ok{color:var(--txt);}
.w .c.no{color:var(--rose);background:rgba(255,93,143,.14);border-radius:3px;}
/* finished word gets an ink underline in mint (clean) or rose (had a typo) */
.w.past::after{
  content:"";position:absolute;left:0;right:12px;bottom:2px;height:2px;border-radius:2px;
  background:var(--mint);opacity:.5;
}
.w.past.dirty::after{background:var(--rose);opacity:.55;}
.w.now{color:var(--txt);}
/* caret */
.caret{
  position:absolute;width:2.5px;border-radius:2px;
  background:var(--violet-2);box-shadow:0 0 12px var(--violet);
  transition:left .09s ease,top .09s ease;
  animation:pulse 1.05s step-end infinite;
  pointer-events:none;
}
@keyframes pulse{50%{opacity:0;}}

/* overlay */
.veil{
  position:absolute;inset:0;z-index:5;display:grid;place-items:center;
  text-align:center;padding:24px;
  background:rgba(22,17,52,.93);backdrop-filter:blur(4px);
}
.veil.hide{display:none;}
.veil h3{font-size:27px;font-weight:900;letter-spacing:-.6px;margin-bottom:6px;}
.veil h3 em{font-style:normal;color:var(--violet-2);}
.veil p{color:var(--muted);font-size:13px;font-weight:600;margin-bottom:20px;}
.kbd{
  display:inline-block;padding:3px 9px;border-radius:6px;margin:0 2px;
  background:rgba(255,255,255,.08);border:1px solid var(--line);
  font-size:11px;font-weight:800;color:var(--txt);
}
.badge{
  display:inline-block;margin-bottom:14px;padding:6px 17px;border-radius:99px;
  font-size:11px;font-weight:900;letter-spacing:1.5px;text-transform:uppercase;
  background:rgba(70,227,184,.12);border:1px solid rgba(70,227,184,.4);color:var(--mint);
}
.final{display:flex;gap:32px;justify-content:center;margin-bottom:22px;flex-wrap:wrap;}
.final div b{display:block;font-size:36px;font-weight:900;line-height:1;
  font-variant-numeric:tabular-nums;}
.final div:nth-child(1) b{color:var(--violet-2);}
.final div:nth-child(2) b{color:var(--mint);}
.final div:nth-child(3) b{color:var(--txt);}
.final div span{display:block;margin-top:6px;font-size:9.5px;font-weight:800;
  letter-spacing:1.4px;text-transform:uppercase;color:var(--muted);}

/* buttons */
.btn{
  font-family:'Jost',sans-serif;border:none;cursor:pointer;
  display:inline-flex;align-items:center;gap:9px;
  padding:14px 30px;border-radius:12px;
  font-size:12.5px;font-weight:900;letter-spacing:1.3px;text-transform:uppercase;
  background:linear-gradient(135deg,var(--violet),var(--violet-2));color:#fff;
  box-shadow:0 12px 32px rgba(139,108,255,.4);
  transition:transform .25s,box-shadow .25s;
}
.btn:hover{transform:translateY(-3px);box-shadow:0 18px 44px rgba(139,108,255,.55);}
.btn:active{transform:translateY(-1px);}
.btn svg{width:15px;height:15px;stroke:#fff;stroke-width:2.4;fill:none;}
.btn.ghost{
  background:transparent;border:1px solid var(--line);color:var(--muted);
  box-shadow:none;padding:12px 24px;font-size:11.5px;
}
.btn.ghost:hover{color:var(--txt);border-color:var(--ink-3);
  background:rgba(255,255,255,.03);transform:translateY(-2px);}
.btn.ghost svg{stroke:currentColor;}
.tools{display:flex;justify-content:center;gap:10px;margin-top:20px;}

#cap{position:absolute;left:-9999px;opacity:0;}

/* ═══════════ FOOTER ═══════════ */
footer{
  border-top:1px solid var(--line);background:rgba(28,22,66,.45);
  padding:26px 28px;
  display:flex;align-items:center;justify-content:space-between;
  gap:16px;flex-wrap:wrap;
}
footer .fb{display:flex;align-items:center;gap:11px;}
footer .fb .nib{width:32px;height:32px;border-radius:10px 10px 10px 3px;}
footer .fb .nib::after{display:none;}
footer .fb .nib svg{width:14px;height:14px;}
footer p{font-size:12px;color:var(--muted);font-weight:600;line-height:1.5;}
footer p b{color:var(--txt);font-weight:800;}
.keys{display:flex;gap:16px;flex-wrap:wrap;}
.keys span{font-size:11px;color:var(--muted);font-weight:600;}

/* ═══════════ RESPONSIVE ═══════════ */
@media(max-width:780px){
  header{padding:13px 16px;}
  .modes{margin-left:0;width:100%;justify-content:center;}
  main{padding:30px 14px 44px;}
  .readout{gap:24px;}
  .rd b{font-size:31px;}
  .sheet{padding:26px 20px;border-radius:17px;}
  .words{font-size:20px;line-height:1.9;min-height:135px;}
}
@media(max-width:480px){
  .brand h1{font-size:15px;}
  .mode{padding:7px 12px;font-size:11px;}
  .readout{gap:18px;}
  .rd b{font-size:26px;}
  .rd span{font-size:8.5px;letter-spacing:1.1px;}
  .words{font-size:17px;line-height:1.85;min-height:120px;}
  .w{margin-right:9px;}
  .w.past::after{right:9px;}
  .veil h3{font-size:21px;}
  .final{gap:20px;}
  .final div b{font-size:28px;}
  .btn{padding:13px 22px;font-size:11.5px;}
  footer{justify-content:center;text-align:center;padding:22px 16px;}
}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important;}}
:focus-visible{outline:2px solid var(--violet-2);outline-offset:3px;border-radius:6px;}
</style>
</head>
<body>

<!-- ════ HEADER ════ -->
<header>
  <div class="brand">
    <span class="nib">
      <svg viewBox="0 0 24 24"><path d="M20.7 3.3a2.5 2.5 0 0 0-3.5 0l-11 11L4 21l6.7-2.2 11-11a2.5 2.5 0 0 0 0-3.5zM7.6 17.6l-1.2-1.2 9-9 1.2 1.2-9 9z"/></svg>
    </span>
    <div>
      <h1>Inkwell</h1>
      <small>Typing speed test</small>
    </div>
  </div>
  <div class="modes" id="modeGroup">
    <button class="mode" data-mode="time" aria-pressed="true">Time</button>
    <button class="mode" data-mode="words" aria-pressed="false">Words</button>
  </div>
  <div class="modes" id="modes">
    <button class="mode" data-t="15" aria-pressed="false">15s</button>
    <button class="mode" data-t="30" aria-pressed="true">30s</button>
    <button class="mode" data-t="60" aria-pressed="false">60s</button>
  </div>
  <div class="modes" id="wordModes" style="display:none;">
    <button class="mode" data-w="25" aria-pressed="true">25</button>
    <button class="mode" data-w="50" aria-pressed="false">50</button>
    <button class="mode" data-w="100" aria-pressed="false">100</button>
  </div>
</header>

<!-- ════ MAIN ════ -->
<main>
  <div class="hero">
    <h2>How fast can you <em>fill the page?</em></h2>
    <p>Type the words. Clean words get a mint underline β€” typos leave a rose one.</p>
  </div>

  <div class="readout">
    <div class="rd"><b id="wpm">0</b><span>Words / min</span></div>
    <div class="rd"><b id="acc">100<i>%</i></b><span>Accuracy</span></div>
    <div class="rd"><b id="time">30<i>s</i></b><span id="timeLabel">Time left</span></div>
  </div>

  <div class="sheet" id="sheet">
    <div class="words" id="words"></div>
    <div class="caret" id="caret"></div>

    <div class="veil" id="veil">
      <div id="veilBody">
        <h3>Ready when <em>you are</em></h3>
        <p>Start typing to begin the clock.</p>
        <button class="btn" id="startBtn">
          <svg viewBox="0 0 24 24"><path d="M5 3l14 9-14 9V3z"/></svg>
          Start test
        </button>
      </div>
    </div>
  </div>

  <div class="tools">
    <button class="btn ghost" id="resetBtn">
      <svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9M3 3v6h6"/></svg>
      Restart
    </button>
    <button class="btn ghost" id="newBtn">
      <svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7M21 3v6h-6"/></svg>
      New words
    </button>
  </div>

  <input id="cap" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" aria-hidden="true">
</main>

<!-- ════ FOOTER ════ -->
<footer>
  <div class="fb">
    <span class="nib">
      <svg viewBox="0 0 24 24"><path d="M20.7 3.3a2.5 2.5 0 0 0-3.5 0l-11 11L4 21l6.7-2.2 11-11a2.5 2.5 0 0 0 0-3.5zM7.6 17.6l-1.2-1.2 9-9 1.2 1.2-9 9z"/></svg>
    </span>
    <p><b>Inkwell</b><br>A quiet place to find your typing rhythm.</p>
  </div>
  <div class="keys">
    <span><span class="kbd">Space</span> next word</span>
    <span><span class="kbd">Backspace</span> fix</span>
    <span><span class="kbd">Esc</span> restart</span>
  </div>
</footer>

<script>
(function(){
  /* ═══ WORD POOL ═══ */
  const POOL = ("the quick brown fox jumps over a lazy dog while morning light spills across "+
  "quiet rooms and rivers move slow beneath old stone bridges people walk home with warm bread "+
  "under their arms children chase paper boats through shallow water the kettle sings again "+
  "someone opens a window and lets the garden in books lean together on a crowded shelf a cat "+
  "sleeps in the only patch of sun music drifts from an upstairs flat rain taps the roof like "+
  "small polite knuckles the city hums but never quite shouts a train pulls out of the station "+
  "steam curls above a cup letters wait to be answered wind turns the page for you clouds gather "+
  "then forget why summer holds its breath a bicycle leans against a fence salt air stiffens the "+
  "washing line lamps flicker on street by street the moon climbs without hurry your hands know "+
  "the way now keep going find the rhythm let the words carry you forward one line at a time").split(/\s+/);

  const $ = id => document.getElementById(id);
  const wordsEl=$('words'), caret=$('caret'), veil=$('veil'), veilBody=$('veilBody'), cap=$('cap');

  let mode='time';                 // 'time' = timed session, 'words' = non-timing session
  let limit=30, left=30, words=[], wi=0, ci=0;
  let wordTarget=25, wordsLeft=25;
  let started=false, done=false, tick=null, t0=0;
  let typed=0, wrong=0, correctChars=0, cleanWords=0;

  const rnd = n => Array.from({length:n},()=>POOL[Math.floor(Math.random()*POOL.length)]);

  function build(){
    words = rnd(60);
    wordsEl.innerHTML='';
    words.forEach((w,i)=>{
      const el=document.createElement('span');
      el.className='w'+(i===0?' now':'');
      for(const ch of w){
        const c=document.createElement('span');
        c.className='c'; c.textContent=ch;
        el.appendChild(c);
      }
      wordsEl.appendChild(el);
    });
  }

  function moveCaret(){
    const w=wordsEl.children[wi];
    if(!w){caret.style.opacity=0;return;}
    caret.style.opacity=1;
    const chars=w.querySelectorAll('.c');
    const sheet=$('sheet').getBoundingClientRect();
    let x,y,h;
    if(ci<chars.length){
      const r=chars[ci].getBoundingClientRect();
      x=r.left-sheet.left; y=r.top-sheet.top; h=r.height;
    }else{
      const r=chars[chars.length-1].getBoundingClientRect();
      x=r.right-sheet.left; y=r.top-sheet.top; h=r.height;
    }
    caret.style.left=x+'px';
    caret.style.top=(y+2)+'px';
    caret.style.height=(h-4)+'px';
  }

  function setRead(w,a,t){
    $('wpm').textContent=w;
    $('acc').innerHTML=a+'<i>%</i>';
    if(mode==='time'){
      $('timeLabel').textContent='Time left';
      $('time').innerHTML=t+'<i>s</i>';
      $('time').classList.toggle('low', t<=5 && started && !done);
      $('sheet').style.setProperty('--p',((limit-t)/limit*100)+'%');
    }else{
      $('timeLabel').textContent='Words left';
      $('time').innerHTML=wordsLeft+'<i></i>';
      $('time').classList.toggle('low', wordsLeft<=5 && started && !done);
      $('sheet').style.setProperty('--p',((wordTarget-wordsLeft)/wordTarget*100)+'%');
    }
  }

  function reset(fresh){
    clearInterval(tick);
    if(fresh!==false) build();
    wi=0; ci=0; left=limit; wordsLeft=wordTarget; started=false; done=false;
    typed=0; wrong=0; correctChars=0; cleanWords=0;
    [...wordsEl.children].forEach((w,i)=>{
      w.className='w'+(i===0?' now':'');
      w.querySelectorAll('.c').forEach(c=>c.className='c');
    });
    setRead(0,100,limit);
    veilBody.innerHTML=
      '<h3>Ready when <em>you are</em></h3>'+
      '<p>Start typing to begin the clock.</p>'+
      '<button class="btn" id="startBtn"><svg viewBox="0 0 24 24"><path d="M5 3l14 9-14 9V3z"/></svg>Start test</button>';
    $('startBtn').addEventListener('click',begin);
    veil.classList.remove('hide');
    requestAnimationFrame(moveCaret);
  }

  function stats(){
    const mins=Math.max((Date.now()-t0)/60000,1/60);
    const wpm=Math.max(0,Math.round((correctChars/5)/mins));
    const acc=typed?Math.max(0,Math.round(((typed-wrong)/typed)*100)):100;
    return {wpm,acc};
  }

  function begin(){
    if(started||done) return;
    started=true; t0=Date.now();
    veil.classList.add('hide');
    cap.focus({preventScroll:true});
    tick=setInterval(()=>{
      const {wpm,acc}=stats();
      if(mode==='time'){
        left--;
        setRead(wpm,acc,Math.max(left,0));
        if(left<=0) finish();
      }else{
        setRead(wpm,acc,0);
      }
    },1000);
    moveCaret();
  }

  function finish(){
    done=true; clearInterval(tick);
    caret.style.opacity=0;
    const {wpm,acc}=stats();
    setRead(wpm,acc,mode==='time'?0:left);
    const badge = wpm>=80&&acc>=97 ? 'Effortless' :
                  wpm>=60 ? 'In the flow' :
                  wpm>=40 ? 'Finding rhythm' : 'Warming up';
    veilBody.innerHTML=
      '<div class="badge">'+badge+'</div>'+
      '<h3>Page <em>filled</em></h3>'+
      '<div class="final">'+
        '<div><b>'+wpm+'</b><span>Words / min</span></div>'+
        '<div><b>'+acc+'%</b><span>Accuracy</span></div>'+
        '<div><b>'+cleanWords+'</b><span>Clean words</span></div>'+
      '</div>'+
      '<button class="btn" id="againBtn"><svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-3-6.7M21 3v6h-6"/></svg>Go again</button>';
    $('againBtn').addEventListener('click',()=>reset(true));
    veil.classList.remove('hide');
  }

  function nextWord(){
    const w=wordsEl.children[wi];
    if(!w) return;
    const chars=w.querySelectorAll('.c');
    let dirty=false;
    chars.forEach(c=>{
      if(!c.classList.contains('ok')) dirty=true;
      if(c.classList.contains('no')) dirty=true;
    });
    w.classList.remove('now');
    w.classList.add('past');
    if(dirty) w.classList.add('dirty'); else cleanWords++;

    wi++; ci=0;
    if(mode==='words'){
      wordsLeft=Math.max(0,wordTarget-wi);
      const {wpm,acc}=stats();
      setRead(wpm,acc,0);
      if(wordsLeft<=0){ finish(); return; }
    }
    const nx=wordsEl.children[wi];
    if(nx) nx.classList.add('now');
    if(wi>=words.length-5){                 /* refill so it never runs out */
      rnd(30).forEach(word=>{
        const el=document.createElement('span'); el.className='w';
        for(const ch of word){
          const c=document.createElement('span'); c.className='c'; c.textContent=ch;
          el.appendChild(c);
        }
        wordsEl.appendChild(el); words.push(word);
      });
    }
    /* keep current line in view */
    const cur=wordsEl.children[wi];
    if(cur && cur.offsetTop > 76) wordsEl.style.transform='translateY(-'+(cur.offsetTop-8)+'px)';
    moveCaret();
  }

  function type(ch){
    if(done) return;
    if(!started) begin();
    const w=wordsEl.children[wi];
    if(!w) return;
    const chars=w.querySelectorAll('.c');

    if(ch===' '){
      if(ci<chars.length) return;           /* word not fully/correctly typed yet β€” don't skip ahead */
      nextWord();
      return;
    }
    typed++;
    if(ci<chars.length){
      const c=chars[ci];
      if(ch===words[wi][ci]){
        c.classList.add('ok'); correctChars++; ci++;
      }else{
        wrong++;
        c.classList.remove('ok'); c.classList.add('no');
        setTimeout(()=>{ if(c.classList.contains('no')) c.classList.remove('no'); },160);
        /* caret stays put β€” must type the correct letter before moving on */
      }
    }else{
      wrong++;                              /* extra chars = error */
      ci++;
    }
    const {wpm,acc}=stats();
    setRead(wpm,acc,Math.max(left,0));
    moveCaret();
  }

  function back(){
    if(done||ci===0) return;
    ci--;
    const chars=wordsEl.children[wi].querySelectorAll('.c');
    if(chars[ci]) chars[ci].className='c';
    moveCaret();
  }

  /* input */
  document.addEventListener('keydown',e=>{
    if(e.key==='Escape'){e.preventDefault();reset(false);return;}
    if(e.ctrlKey||e.metaKey||e.altKey) return;
    if(e.key==='Backspace'){e.preventDefault();back();return;}
    if(e.key===' '){e.preventDefault();type(' ');return;}
    if(e.key==='Enter'&&!started&&!done){e.preventDefault();begin();return;}
    if(e.key.length===1){e.preventDefault();type(e.key);}
  });
  cap.addEventListener('input',()=>{ for(const ch of cap.value) type(ch); cap.value=''; });
  wordsEl.addEventListener('click',()=>cap.focus({preventScroll:true}));

  /* modes */
  $('modeGroup').addEventListener('click',e=>{
    const b=e.target.closest('.mode'); if(!b) return;
    mode=b.dataset.mode;
    $('modeGroup').querySelectorAll('.mode').forEach(x=>x.setAttribute('aria-pressed',String(x===b)));
    $('modes').style.display = mode==='time' ? 'flex' : 'none';
    $('wordModes').style.display = mode==='words' ? 'flex' : 'none';
    wordsEl.style.transform='none';
    reset(true);
  });
  $('modes').addEventListener('click',e=>{
    const b=e.target.closest('.mode'); if(!b) return;
    limit=+b.dataset.t;
    $('modes').querySelectorAll('.mode').forEach(x=>x.setAttribute('aria-pressed',String(x===b)));
    wordsEl.style.transform='none';
    reset(true);
  });
  $('wordModes').addEventListener('click',e=>{
    const b=e.target.closest('.mode'); if(!b) return;
    wordTarget=+b.dataset.w;
    $('wordModes').querySelectorAll('.mode').forEach(x=>x.setAttribute('aria-pressed',String(x===b)));
    wordsEl.style.transform='none';
    reset(true);
  });
  $('resetBtn').addEventListener('click',()=>{wordsEl.style.transform='none';reset(false);});
  $('newBtn').addEventListener('click',()=>{wordsEl.style.transform='none';reset(true);});
  window.addEventListener('resize',moveCaret);

  build(); reset(false);
})();
</script>
</body>
</html>

Leave a Reply

Your email address will not be published. Required fields are marked *

SHARE:-

Trending Post

Latest Post