Skip to main content Accessibility Feedback

How to build a vanilla JS speed reader app

Inspired by a tweet from Chris C on Twitter, today I wanted to look at how to build a speed reader app with vanilla JS.

Let’s dig in.

The starting HTML

This app has two sections.

I’m using the details and summary elements to hold my app settings. This is where users can paste in the text to read, set the number of words-per-minute they’d like to read, and start and stop the reader.

<details id="settings">
	<summary>Settings</summary>

	<label for="text">Text to Read</label>
	<textarea id="text"></textarea>

	<label for="wpm">Words Per Minute</label>
	<input type="tel" id="wpm" min="1" value="250">

	<button id="start">Start</button> <button id="stop">Stop</button>
</details>

There’s also a #reader section where the actual words will get displayed. I’ve wrapped in a #reader-wrapper element that I’m going to use for styling purposes.

Because we want screen readers to announce the text, I’ve added [aria-live="assertive"] to the #reader.

<div id="reader-wrapper">
	<div id="reader" aria-live="assertive"></div>
</div>

In addition to some basic form input styles, I want my words to be displayed centered both vertically and horizontally on the page. I’m using display: flex for that.

#reader-wrapper {
	align-items: center;
	display: flex;
	font-size: 2.5em;
	justify-content: center;
	min-height: 70vh;
}

With that out of the way, let’s dig into the actual code.

Getting all the things

The first step for this project is to find the elements we’re going to be using and cache them to variables.

I want the #text and #wpm elements, as well as the #reader element where we’ll be displaying the words. But I also want the #settings element, because I plan to close it when the user starts the reader.

var settings = document.querySelector('#settings');
var text = document.querySelector('#text');
var wpm = document.querySelector('#wpm');
var reader = document.querySelector('#reader');

Next, I want to set up some “placeholder variables” that have no defined value yet, but will be assigned one later. We’ll get into what each of these is shortly.

// Placeholders for words, speed, current word, and interval
var words, speed, current, interval;

Listening for clicks

I want to start my reader when someone clicks the #start button, and stop it when they click the #stop button.

Rather than setup two event listeners, I want to use a technique called event delegation. First, let’s setup a click event listener. I’m going to use a named function, clickHandler(), as my callback function.

document.addEventListener('click', clickHandler);

Inside the clickHandler() function, I’m going to run two functions: start() and stop(). I’ll pass the event object into each one.

/**
 * Handle click events
 * @param  {Event} event The event object
 */
var clickHandler = function (event) {
	start(event);
	stop(event);
};

Inside the start() function, I’m going to check if the event.target, the thing clicked, has the #start ID. If not, I’ll use the return operator to end the function.

Inside the stop() function, I’ll do the same thing with the #stop ID.

/**
 * Stop the reader
 * @param  {Event} event The event object
 */
var stop = function (event) {

	// Only run on #stop button
	if (event.target.id !== 'stop') return;

};

/**
 * Start the reader
 * @param  {Event} event The event object
 */
var start = function (event) {

	// Only run on #start button
	if (event.target.id !== 'start') return;

};

Processing the user settings

Now that we’re listening for when the user tries to start or stop the reader, we can process their settings and do things with them.

Inside the start() method, I want to make sure the text element has actual text in it. If not, I can again use the return operator to quit early.

/**
 * Start the reader
 * @param  {Event} event The event object
 */
var start = function (event) {

	// Only run on #start button
	if (event.target.id !== 'start') return;

	// If there's no text to read, do nothing
	if (!text.value.length) return;

};

Next, I want to break the text up into an array of words.

I’ll use the Array.split() method to create an array of words, using a space () to mark the break between each word. If there’s more than one space together between words, we may end up with empty strings in our array. To fix that, we’ll also use the Array.filter() method to remove any word that has no length.

/**
 * Start the reader
 * @param  {Event} event The event object
 */
var start = function (event) {

	// Only run on #start button
	if (event.target.id !== 'start') return;

	// If there's no text to read, do nothing
	if (!text.value.length) return;

	// Get the words
	words = text.value.split(' ').filter(function (word) {
		return word.length;
	});

};

Next, I want to figure out how often to update the word shown on screen based on the user’s specified words-per-minute.

I’m going to divide 60 (the number of seconds in a minute) by the number of words. I use the parseInt() method to convert the string that the wpm.value returns into a number.

Finally, I multiply the result by 1000 to get that number in milliseconds (which we’ll use for updating our view) and assign the result to the speed variable.

/**
 * Start the reader
 * @param  {Event} event The event object
 */
var start = function (event) {

	// Only run on #start button
	if (event.target.id !== 'start') return;

	// If there's no text to read, do nothing
	if (!text.value.length) return;

	// Get the words
	words = text.value.split(' ').filter(function (word) {
		return word.length;
	});

	// Get the words-per-minute
	speed = (60 / parseInt(wpm.value, 10)) * 1000;

};

Now we’re ready to run our speed reader. I set the index for the current word to 0. Then, I’m going to use another method, run(), to actually start the reader.

/**
 * Start the reader
 * @param  {Event} event The event object
 */
var start = function (event) {

	// Only run on #start button
	if (event.target.id !== 'start') return;

	// If there's no text to read, do nothing
	if (!text.value.length) return;

	// Get the words
	words = text.value.split(' ').filter(function (word) {
		return word.length;
	});

	// Get the words-per-minute
	speed = (60 / parseInt(wpm.value, 10)) * 1000;

	// Set the current item to the first word
	current = 0;

	// Run the reader
	run();

};

Running the reader

If the settings element is open, I want to close it. I use the removeAttribute() method to remove the open attribute from the details element, which closes it.

/**
 * Start the interval
 */
var run = function () {

	// Close settings
	settings.removeAttribute('open');

};

Now, I can start showing words in the UI. To do that, I’m going to use the setInterval() method.

Because I want to clear it when the user stops the reader, I assign it to the interval variable. Inside the callback function, I set the reader.textContent to the current item in the words array. Then I increase the current index by 1.

I use the speed in milliseconds as the frequency.

/**
 * Start the interval
 */
var run = function () {

	// Close settings
	settings.removeAttribute('open');

	// Run the reader
	interval = setInterval(function () {

		// Show the word
		reader.textContent = words[current];

		// Go to the next word
		current++;

	}, speed);

};

If the reader goes through all of the words in the text, I want it to stop. Before updating the UI in my callback function, I’m going to check if words[current] exists. If not, I’m going to run an end() function and return.

/**
 * Start the interval
 */
var run = function () {

	// Close settings
	settings.removeAttribute('open');

	// Run the reader
	interval = setInterval(function () {

		// If there are no more words, stop
		if (!words[current]) {
			end();
			return;
		}

		// Show the word
		reader.textContent = words[current];

		// Go to the next word
		current++;

	}, speed);

};

Stopping the app

Inside my end() function, I use the clearInterval() method to stop my interval.

/**
 * Clear the interval
 */
var end = function () {
	clearInterval(interval);
};

Now that we’ve got this method set up, we can also use it to stop the app when the user clicks the #stop button. Inside the stop() function, I can run my end() function, too.

/**
 * Stop the reader
 * @param  {Event} event The event object
 */
var stop = function (event) {

	// Only run on #stop button
	if (event.target.id !== 'stop') return;

	// End the interval
	end();

};

Putting it all together

Here’s a demo you can play with. You can also view the full, completed source code.

I’ve used Moby Dick as demo text, because it’s in the Creative Commons now.

One thing our app doesn’t currently support is the ability to pause and resume from the spot where you left off. That might be a fun thing to add, if you want to practice with this app yourself.