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.
Traditional reading is slowed by saccades — the back-and-forth eye jumps that scan across a line of text. RSVP eliminates them entirely.
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 |
Every engineering decision in this project has a deliberate rationale. Here's what's under the hood.
void el.offsetWidth forces a synchronous browser reflow, allowing CSS keyframe animations to replay on each word instead of running only once.Three core functions that make the app work. Every line has a reason.
/* * 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; }
/* * 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); } });
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.
/* * 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'; });
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.
Everything you need is in the GitHub repository. The app itself is a single HTML file.