Speed reader
Paste anything and read it one word at a time
Words flash in one spot with a single letter pinned in red, so your eyes never have to move. Paste in a brief, an email or an article, pick a speed and press play. Plain HTML, CSS and JavaScript, no libraries.
Free to use in personal and client work. No credit needed. Put the three files in one folder and open index.html, or copy them into your project below.
Want all of them, plus React versions? Join the Motion Pack waitlist.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Speed reader</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="reader">
<section class="stage" aria-live="off" title="Click to play or pause">
<div class="guide" aria-hidden="true"></div>
<p class="word"><span class="before"></span><span class="focus"></span><span class="after"></span></p>
<div class="guide" aria-hidden="true"></div>
</section>
<div class="bar">
<button class="play" type="button" aria-label="Play">
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l10.5-6.5z" /></svg>
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" /></svg>
</button>
<button class="restart" type="button" aria-label="Back to the start">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12a7 7 0 1 0 2.05-4.95M5 4v4h4" /></svg>
</button>
<input class="seek" type="range" min="0" max="0" value="0" aria-label="Position" />
<span class="left">0:00 left</span>
<label class="speed">
<input type="range" min="150" max="900" step="25" value="350" aria-label="Words per minute" />
<output>350 wpm</output>
</label>
</div>
<label class="paste">
<span class="paste-head"><span>Paste anything</span><span class="count">0 words</span></span>
<textarea spellcheck="false">This is a speed reader. Every word shows up in the same spot, and one letter stays pinned in red, so your eyes never have to move. Paste anything in here, a long brief, a client email, the terms you were never going to read. Press play, start slow, then nudge the speed up. Most people can read a lot faster than they think.</textarea>
</label>
<p class="keys">Space to play · ← → to step · ↑ ↓ for speed</p>
</main>
<script src="script.js"></script>
</body>
</html>style.css
:root {
--ground: #f4f0ea;
--ink: #1d1b24;
--muted: rgba(29, 27, 36, 0.55);
--line: rgba(29, 27, 36, 0.12);
--focus: #e5484d;
--card: rgba(255, 255, 255, 0.72);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(50% 40% at 50% 30%, rgba(255, 196, 170, 0.35), transparent 70%),
radial-gradient(40% 35% at 80% 90%, rgba(185, 160, 255, 0.18), transparent 70%),
var(--ground);
color: var(--ink);
font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
.reader { width: min(720px, 100%); display: grid; gap: 14px; }
/* The word sits in a three-column grid, so the red letter is always in the exact center. */
.stage {
position: relative;
display: grid;
grid-template-rows: 1fr auto 1fr;
height: 200px;
padding: 0 28px;
border-radius: 28px;
background: var(--card);
box-shadow: 0 0 0 1px var(--line), 0 30px 60px -36px rgba(60, 40, 30, 0.45);
cursor: pointer;
user-select: none;
}
.guide { position: relative; }
.guide::before {
content: "";
position: absolute;
left: 18%;
right: 18%;
height: 1px;
background: var(--line);
}
.guide::after {
content: "";
position: absolute;
left: 50%;
width: 2px;
height: 14px;
margin-left: -1px;
border-radius: 2px;
background: var(--focus);
opacity: 0.55;
}
.guide:first-child::before { bottom: 18px; }
.guide:first-child::after { bottom: 4px; }
.guide:last-child::before { top: 18px; }
.guide:last-child::after { top: 4px; }
.word {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
margin: 0;
font-size: clamp(34px, 7vw, 58px);
font-weight: 500;
letter-spacing: -0.01em;
line-height: 1.1;
white-space: pre;
}
.before { text-align: right; }
.focus { color: var(--focus); }
.bar { display: flex; align-items: center; gap: 10px; }
.play, .restart {
flex: none;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
cursor: pointer;
}
.play { width: 46px; height: 46px; background: var(--ink); color: #fff; }
.play svg { width: 20px; height: 20px; fill: currentColor; }
.play .icon-pause, .reader.is-playing .play .icon-play { display: none; }
.reader.is-playing .play .icon-pause { display: block; }
.restart { width: 38px; height: 38px; background: var(--card); color: var(--ink); box-shadow: 0 0 0 1px var(--line); }
.restart svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.play:focus-visible, .restart:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
input[type="range"] { accent-color: var(--ink); margin: 0; }
.seek { flex: 1; min-width: 60px; }
.left { flex: none; min-width: 64px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
.speed { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: var(--card); box-shadow: 0 0 0 1px var(--line); }
.speed input { width: 110px; }
.speed output { min-width: 62px; font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; }
.paste { display: grid; gap: 6px; transition: opacity 0.3s; }
.reader.is-playing .paste { opacity: 0.45; }
.paste-head { display: flex; justify-content: space-between; font-size: 13px; color: var(--muted); padding: 0 4px; }
textarea {
width: 100%;
min-height: 120px;
padding: 14px 16px;
border: 0;
border-radius: 18px;
background: rgba(255, 255, 255, 0.55);
box-shadow: 0 0 0 1px var(--line);
color: var(--ink);
font: inherit;
resize: vertical;
}
.keys { margin: 0; text-align: center; font-size: 12.5px; color: var(--muted); }
@media (max-width: 560px) {
.stage { height: 160px; padding: 0 16px; }
.bar { flex-wrap: wrap; }
.speed { order: 5; width: 100%; justify-content: space-between; }
.speed input { flex: 1; }
.keys { display: none; }
}script.js
const reader = document.querySelector(".reader");
const stage = document.querySelector(".stage");
const before = document.querySelector(".before");
const focus = document.querySelector(".focus");
const after = document.querySelector(".after");
const playButton = document.querySelector(".play");
const restart = document.querySelector(".restart");
const seek = document.querySelector(".seek");
const left = document.querySelector(".left");
const speed = document.querySelector(".speed input");
const speedOut = document.querySelector(".speed output");
const input = document.querySelector("textarea");
const count = document.querySelector(".count");
let words = [];
let index = 0;
let playing = false;
let wait = 0;
let last = null;
// The letter your eye lands on, a little left of center.
function pivot(letters) {
const lead = letters.findIndex((ch) => /[\p{L}\p{N}]/u.test(ch));
const core = letters.filter((ch) => /[\p{L}\p{N}]/u.test(ch)).length;
const spot = core < 2 ? 0 : core < 6 ? 1 : core < 10 ? 2 : core < 14 ? 3 : 4;
return Math.min(Math.max(lead, 0) + spot, letters.length - 1);
}
// Linger on punctuation and long words so sentences still breathe.
function pause(word) {
const beat = 60000 / Number(speed.value);
if (word.end) return beat * 3;
if (/[.!?]["')\]]*$/.test(word.text)) return beat * 2.4;
if (/[,;:—–]["')\]]*$/.test(word.text)) return beat * 1.6;
return word.text.length > 8 ? beat * 1.3 : beat;
}
function show(i) {
index = Math.min(Math.max(i, 0), Math.max(words.length - 1, 0));
const letters = Array.from(words[index]?.text ?? "");
const at = pivot(letters);
before.textContent = letters.slice(0, at).join("");
focus.textContent = letters[at] ?? "";
after.textContent = letters.slice(at + 1).join("");
seek.value = String(index);
const seconds = Math.round(((words.length - index) * 60) / Number(speed.value));
left.textContent = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} left`;
}
function frame(time) {
requestAnimationFrame(frame);
if (!playing) return;
wait -= last === null ? 0 : time - last;
last = time;
while (playing && wait <= 0) {
if (index >= words.length - 1) return setPlaying(false);
show(index + 1);
wait += pause(words[index]);
}
}
function setPlaying(on) {
playing = on && words.length > 0;
if (playing) {
if (index >= words.length - 1) show(0);
wait = pause(words[index]);
last = null;
}
reader.classList.toggle("is-playing", playing);
playButton.setAttribute("aria-label", playing ? "Pause" : "Play");
}
function load() {
words = input.value
.split(/\n\s*\n/)
.flatMap((paragraph) => {
const list = paragraph.split(/\s+/).filter(Boolean).map((text) => ({ text, end: false }));
if (list.length) list[list.length - 1].end = true;
return list;
});
seek.max = String(Math.max(words.length - 1, 0));
count.textContent = `${words.length} ${words.length === 1 ? "word" : "words"}`;
setPlaying(false);
show(0);
}
function setSpeed(value) {
speed.value = String(value);
speedOut.textContent = `${speed.value} wpm`;
show(index);
}
playButton.addEventListener("click", () => setPlaying(!playing));
stage.addEventListener("click", () => setPlaying(!playing));
restart.addEventListener("click", () => {
setPlaying(false);
show(0);
});
seek.addEventListener("input", () => show(Number(seek.value)));
speed.addEventListener("input", () => setSpeed(speed.value));
input.addEventListener("input", load);
document.addEventListener("keydown", (event) => {
if (event.target.closest("input, textarea, button") || event.metaKey || event.ctrlKey) return;
const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
if (event.key === " ") setPlaying(!playing);
else if (step) {
setPlaying(false);
show(index + step);
} else if (event.key === "ArrowUp") setSpeed(Math.min(900, Number(speed.value) + 25));
else if (event.key === "ArrowDown") setSpeed(Math.max(150, Number(speed.value) - 25));
else return;
event.preventDefault();
});
load();
requestAnimationFrame(frame);