Portfolio Project · Vanilla JS · Zero Dependencies

Read faster.
Retain more.

A browser-based speed reading tool that uses the Optimal Recognition Point algorithm to train your eyes — one word at a time, at precisely the speed you set.

playing 142 / 3,847
conCept
reading quickly through concept allows your brain
300 wpm

How RSVP works

Traditional reading is slowed by saccades — the back-and-forth eye jumps that scan across a line of text. RSVP eliminates them entirely.

01
📄
Parse your document
Upload a PDF, DOCX, TXT, or Markdown file. The parser extracts clean text using PDF.js and Mammoth.js — entirely client-side, nothing leaves your browser.
02
🎯
ORP is calculated
For each word, the Optimal Recognition Point is computed. This is the single letter your eyes fixate on to decode the whole word fastest — position varies with word length.
03
Words flash, one at a time
Words display sequentially at your chosen WPM. The focal letter is highlighted in gold. A context strip keeps you spatially oriented as you read.
04
📈
Speed up gradually
Average reader: 200–300 WPM. With practice, 500–700 WPM is achievable with full comprehension. Start slow, then push your ceiling over time.
Algorithm

The ORP lookup table

The focal position is mapped from word length to character index. Punctuation is stripped before measurement so "running," is treated as 7 characters, not 8.

Word Length Focal Position Example Highlighted
1–2 chars Position 0 (1st letter) it it
3–5 chars Position 1 (2nd letter) read read
6–9 chars Position 2 (3rd letter) faster faster
10–13 chars Position 3 (4th letter) understand understand
14+ chars Position 4 (5th letter) implementation implementation

Skills demonstrated

Every engineering decision in this project has a deliberate rationale. Here's what's under the hood.

Algorithms
ORP Positional Mapping
Custom lookup algorithm that maps semantic word length (punctuation-stripped) to an optimal focal character index. Handles edge cases like contractions and hyphenated words.
string processing regex lookup tables
Scheduling
Recursive setTimeout Loop
Chose recursive setTimeout over setInterval to prevent callback stacking. Dynamic delay recalculation on each word enables live WPM changes without restarting playback.
async JS event loop timing API
Browser APIs
File Parsing Pipeline
Routes .pdf files through PDF.js (ArrayBuffer → page text content items), .docx through Mammoth.js, and plain text through the native File.text() API.
FileReader API ArrayBuffer async/await
DOM
High-Frequency Rendering
Single innerHTML write per word (not N individual insertions). requestAnimationFrame defers focal guide positioning until after paint for accurate getBoundingClientRect measurements.
DOM manipulation rAF layout thrashing
CSS
Animation Reflow Trick
Reading void el.offsetWidth forces a synchronous browser reflow, allowing CSS keyframe animations to replay on each word instead of running only once.
CSS animations reflow custom properties
Architecture
Zero-Dependency Design
Core read/display loop has zero runtime dependencies. PDF.js and Mammoth are optional CDN enhancers — the app degrades gracefully to TXT mode if they fail to load.
graceful degradation portability CDN strategy
UX Engineering
Keyboard-First Navigation
Full keyboard control with an active-element guard that prevents shortcut capture during text input. Space, arrows, and modifier keys all handled without a UI framework.
accessibility KeyboardEvent event delegation
CSS Architecture
Design Token System
All colors reference CSS custom properties — zero hardcoded hex in component styles. Fluid font scaling via clamp(). Pure-CSS toggle switches with no JS for appearance.
CSS variables clamp() sibling selectors
Code

Annotated walkthrough

Three core functions that make the app work. Every line has a reason.

speed-reader.html JavaScript
/*
 * ORP ALGORITHM
 * Maps word length → focal letter index (0-based, alpha chars only).
 * Stripping punctuation first ensures "running," is measured as 7,
 * not 8 — so the focal point doesn't shift right for a comma.
 */
function getFocalIndex(word) {
  const clean = word.replace(/[^a-zA-Z0-9]/g, '');
  const len   = clean.length;

  if (len <= 1)  return 0;
  if (len <= 5)  return 1;
  if (len <= 9)  return 2;
  if (len <= 13) return 3;
  return 4;
}
Why these breakpoints? They derive from the Spritz patent (US20140270580) and eye-tracking research on saccadic fixation. The ORP sits at ~30% into the word for short words, drifting toward ~25% for long words — because peripheral vision covers more letters as the fixation point moves right.
speed-reader.html JavaScript
/*
 * PLAYBACK SCHEDULER
 * Recursive setTimeout (not setInterval) ensures the next word is
 * scheduled AFTER the current render completes — never stacks up.
 */
function showWord() {
  if (idx >= words.length) { stopPlayback(); return; }

  renderWord(words[idx]);    // Paint the word
  renderContext();           // Update surrounding words strip
  updateProgress();          // Progress bar + ETA

  const delay = getDelay(words[idx]);
  idx++;   // Advance BEFORE scheduling so pause lands at next word
  timer = setTimeout(showWord, delay);
}

// Hot WPM change: cancel in-flight timer, reschedule at new rate
wpmSlider.addEventListener('input', () => {
  wpm = parseInt(wpmSlider.value);
  if (playing) {
    clearTimeout(timer);
    timer = setTimeout(showWord, 60000 / wpm);
  }
});
The idx-before-schedule pattern: Incrementing idx before calling setTimeout means if the user presses Pause at any point, the cleared timer leaves idx pointing to the next unshown word — so Resume continues correctly without repeating or skipping a word.
speed-reader.html JavaScript
/*
 * ANIMATION REFLOW TRICK
 * CSS keyframe animations only replay when the element re-enters
 * the animated state. Removing + re-adding the class in the same
 * JS microtask does nothing — the browser batches style changes.
 *
 * Reading offsetWidth forces a synchronous layout flush, committing
 * the class removal before we re-add it → fresh animation every word.
 */
wordDisplay.classList.remove('flash');
void wordDisplay.offsetWidth;   // ← intentional reflow
wordDisplay.classList.add('flash');

/*
 * FOCAL GUIDE POSITIONING
 * getBoundingClientRect must run after the browser paints the new word.
 * requestAnimationFrame defers until the next paint frame — without
 * this, measurements would be stale (from the previous word).
 */
requestAnimationFrame(() => {
  const focal     = wordDisplay.querySelector('.focal');
  const stageRect = wordDisplay.parentElement.getBoundingClientRect();
  const focalRect = focal.getBoundingClientRect();
  const relX      = focalRect.left - stageRect.left + focalRect.width / 2;
  focalGuide.style.left = relX + 'px';
});
Why not just use CSS left: 50%? Words have different lengths and the focal letter is not centered — it's at a calculated position that shifts per word. The guide line must track the actual pixel position of a specific letter, which requires measuring the rendered DOM after paint.
Downloads

Get the files

Everything you need is in the GitHub repository. The app itself is a single HTML file.

speed-reader.html
The complete application. Open in any modern browser — no server, no install.
Single file · ~25 KB
📖
README.md
Full project documentation, quick-start guide, feature list, and architecture overview.
Markdown · ~8 KB
🏗️
ARCHITECTURE.md
Annotated deep-dive into every engineering decision with code excerpts and trade-off analysis.
Markdown · ~12 KB
📦
Full Repository
Clone or download the complete repo including GitHub Actions deploy workflow.
GitHub · ZIP or git clone