Project 15 · Advanced
Random Quote
A quote card with a New quote button. It asks a free public API for a fresh quote, fades the old one while it waits, and lets you copy the quote in one click.
- 2 HTML ideas
- 2 CSS ideas
- 6 JavaScript ideas
Why build it
Loading data from an API with fetch lets you build a weather widget, a news feed, a currency converter with live rates, or a page that lists your latest GitHub repositories. You'll also learn to keep the page usable while it waits, and when the network is slow or fails, so what you build still works for people on a bad connection.
Skills you'll earn
- Request data from an API with fetch
- Wait for an answer without freezing the page, with async and await
- Check every response and handle errors with try, catch, and finally
- Show loading and error states that screen readers announce too
- Copy text for someone with the Clipboard API
01
Try it
Play with it first, then read the code that makes it work.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Random Quote</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="card">
<h1 class="card-title">Quote of the moment</h1>
<!-- aria-live reads each new quote; aria-busy holds it back while loading. -->
<figure class="quote" id="quote" aria-live="polite">
<blockquote class="quote-text" id="quote-text">
<p>Loading a quote…</p>
</blockquote>
<figcaption class="quote-author" id="quote-author"></figcaption>
</figure>
<!-- role="alert" reads an error out straight away. -->
<p class="error" id="error" role="alert" hidden></p>
<div class="actions">
<button type="button" class="button button-primary" id="new-quote">
New quote
</button>
<button type="button" class="button" id="copy">Copy</button>
</div>
<p class="copied" id="copied" role="status"></p>
</main>
<script src="script.js"></script>
</body>
</html>02
How it works
The ideas behind it, one at a time, each next to the code that does it.
HTMLthe structure
- 01
Mark up a quote with
<blockquote>and<figcaption><blockquote>marks text as a quotation, and<figcaption>gives a caption, here the name of who said it. Wrapping both in a<figure>ties the caption to the quote, so browsers and screen readers know they belong together. The figure also hasaria-live="polite", so each new quote is read out when it appears.<!-- aria-live reads each new quote; aria-busy holds it back while loading. --> <figure class="quote" id="quote" aria-live="polite"> <blockquote class="quote-text" id="quote-text"> <p>Loading a quote…</p> </blockquote> <figcaption class="quote-author" id="quote-author"></figcaption> </figure> - 02
Announce errors with
role="alert"role="alert"makes an element an urgent live region: screen readers read its text the moment it appears, even interrupting what they're already reading. That's too pushy for normal updates, so it's kept for problems people need to know about now. Here it's the error message, which startshiddenand only shows when a quote fails to load.<!-- role="alert" reads an error out straight away. --> <p class="error" id="error" role="alert" hidden></p>
CSSthe look
- 03
Add decoration screen readers skip
The
::beforepseudo-element adds content before an element using only CSS, here a big quote mark above the quote. Screen readers can still read CSS content, so they'd say "left double quotation mark" every time. Writingcontent: "“" / ""gives it empty alternative text after the slash, so it's shown but not read out./* A big decorative quote mark, drawn by CSS so screen readers skip it. */ .quote-text::before { content: "“" / ""; display: block; height: 2.5rem; font-family: Georgia, serif; font-size: 4rem; line-height: 1; color: #d4d4da; } - 04
Show loading with
aria-busyaria-busy="true"tells screen readers an area is being updated, so they can wait for the new content before reading it. CSS can select elements by attribute, so.quote[aria-busy="true"]uses the same attribute to fade the old quote to 40% opacity. One attribute tells both screen reader users and sighted users that something's happening./* While a quote loads, fade the old one so it's clear something is happening. */ .quote[aria-busy="true"] { opacity: 0.4; transition: opacity 0.2s ease; }
JavaScriptthe behaviour
- 05
Track the loading state
isLoadingis aletvariable that remembers whether a quote is on its way.setLoading()updates it, setsaria-busyon the quote, and switches the button text to "Loading…" and back. At the start ofloadQuote(),if (isLoading) return;ignores extra clicks. The button isn't disabled, because a disabled button loses keyboard focus.let currentQuote = null; let isLoading = false; // Fade the old quote and change the button text while waiting. function setLoading(loading) { isLoading = loading; quote.setAttribute("aria-busy", loading); newQuoteButton.textContent = loading ? "Loading…" : "New quote"; } function showQuote({ text, author }) { const paragraph = document.createElement("p"); paragraph.textContent = text; quoteText.replaceChildren(paragraph); quoteAuthor.textContent = author; } // async lets the function wait for the network with await, and the page // keeps working in the meantime. async function loadQuote() { // Ignore extra clicks while a quote is already on its way. if (isLoading) return; setLoading(true); - 06
Wait for the network with
asyncandawaitAsking a server for data takes time, so
fetch()doesn't return the answer straight away. It returns a promise: a placeholder for a value that arrives later. Inside a function markedasync,awaitpauses that function until the promise settles, then hands back the value. The rest of the page keeps working while it waits.// async lets the function wait for the network with await, and the page // keeps working in the meantime. async function loadQuote() { // Ignore extra clicks while a quote is already on its way. if (isLoading) return; setLoading(true); errorMessage.hidden = true; try { const response = await fetch(API_URL); - 07
Check
response.okbefore using the answerfetch()only throws an error when the request can't reach the server at all, like when you're offline. If the server answers with an error, such as 404 (not found) or 500 (server error), it still counts as a response.response.okis true only for success codes, so the code checks it and throws its own error when it's false.// fetch only fails on a network error; a 404 or 500 still arrives, so check. if (!response.ok) { throw new Error(`The server answered ${response.status}`); } - 08
Read the data with
.json()JSON is a text format for sending data that looks like JavaScript objects. The response body arrives as that text, and
response.json()reads it and turns it into a real object. Reading the body takes time too, so it's awaited. After that,data.quoteanddata.authorcan be read like any other object's properties.// The body arrives as text; .json() turns it into an object. const data = await response.json(); currentQuote = { text: data.quote, author: data.author }; showQuote(currentQuote); - 09
Handle failure with
try,catch, andfinallytryruns code that might fail. If anything inside it throws, such as a network error or the error thrown for a bad response, the code jumps tocatch, which shows a friendly message instead of leaving the page stuck.finallyruns afterwards either way, sosetLoading(false)always ends the loading state.try { const response = await fetch(API_URL); // fetch only fails on a network error; a 404 or 500 still arrives, so check. if (!response.ok) { throw new Error(`The server answered ${response.status}`); } // The body arrives as text; .json() turns it into an object. const data = await response.json(); currentQuote = { text: data.quote, author: data.author }; showQuote(currentQuote); } catch { errorMessage.textContent = "Couldn't load a quote. Check your connection and try again."; errorMessage.hidden = false; } finally { // finally runs whether it worked or not, so loading always ends. setLoading(false); } - 10
Copy text with the Clipboard API
The Clipboard API lets a page copy text for you.
navigator.clipboard.writeText()returns a promise, so it's awaited. The browser can refuse, for example when the page isn't served securely, so it has its owntryandcatch, with a message for each case in arole="status"element.setTimeout()clears that message after 2.5 seconds.copyButton.addEventListener("click", async () => { if (!currentQuote) return; try { await navigator.clipboard.writeText( `"${currentQuote.text}" — ${currentQuote.author}`, ); copiedMessage.textContent = "Copied to your clipboard."; } catch { copiedMessage.textContent = "Couldn't copy. Select the quote and copy it instead."; } // Clear the message after a moment, ready for the next copy. setTimeout(() => { copiedMessage.textContent = ""; }, 2500); });
03
Wrap up
Track your progress, then go deeper or keep going.
Take it further
Once it works, try these on your own. Each one uses what you've just learned, plus one new idea.
- Challenge 01
Add a Share button that uses the Web Share API, with Copy as the fallback.
- Challenge 02
Keep the last five quotes so people can go back to one.
- Challenge 03
Let people save favourite quotes in localStorage and list them under the card.
Finished Random Quote?
Mark it done to track your progress.
Go deeper
The full article explains each idea in more depth, why the code is written this way, and more than fits on this page.
Article coming soon. See the series so far (opens in a new tab)