💻 Computer Science · High School · WEB 100

High School Web Design & Development

You will build one website across this whole course: a portfolio that begins as a single text file in the first module and ends, in the last, published at a free web address you can send to anyone. Every module adds to it. You write the HTML that gives it structure and meaning, the CSS that lays it out with flexbox and grid and makes it work on a phone, the colour and type that make it look…

Start the interactive course (quizzes, progress, videos) →

Free forever. No sign-up, no ads. 31 lessons. The full lesson text is below so you can read it right here.

Module 1: How the Web Works, and Your First Page

Follow one request from the address bar to the screen, write your first web page in a plain text file and in a browser editor, and learn what the browser does when your HTML has mistakes in it. The module ends with the first file of your portfolio site.

The Second After You Press Enter

  • Name the parts of a URL and say what each one tells the browser.
  • Describe the four steps between pressing Enter and seeing a page: DNS lookup, request, response, and building the page.
  • Read a status code and a few response headers, and watch real requests in the browser's developer tools.

Type the address info.cern.ch/hypertext/WWW/TheProject.html into a browser and press Enter. A computer run by CERN, the European physics laboratory near Geneva, sends back a reply. These are the first lines of that reply as they arrived on 21 October 2026, with a few technical lines left out:

HTTP/1.1 200 OK
Server: Apache
Last-Modified: Thu, 03 Dec 1992 08:37:20 GMT
Content-Length: 2217
Content-Type: text/html

Read it like the label on a parcel. The first line says the request worked. Last-Modified says the file inside was last changed on 3 December 1992. Content-Length says the file is 2,217 bytes long, roughly two thousand characters. Content-Type says what kind of file it is: a web page. What follows those lines is a copy of the first website ever made. CERN's own history says that info.cern.ch was the address of the world's first website and web server, running on a NeXT computer, and that Tim Berners-Lee had the first server and browser working by the end of 1990.

The page that appears has a large heading reading World Wide Web, two short paragraphs, and a list of blue underlined links with a line of description under each. It holds 165 words. It looks plain because it is plain, and that makes it the best possible page for watching what a browser does. This lesson follows that single request from the moment you press Enter to the moment the words appear. Every page you build in this course, starting with your portfolio in the next lesson, travels the same road.

An address in parts

The text you typed is a URL, short for Uniform Resource Locator: the full address of one thing on the web. Your browser quietly added the first part for you. Written out in full, the address has three parts, and each answers a different question.

PartIn this addressWhat it tells the browser
SchemehttpsHow to talk to the server. https is the encrypted version of the web's protocol.
Host, or domain nameinfo.cern.chWhich computer to ask.
Path/hypertext/WWW/TheProject.htmlWhich file to ask that computer for.

The domain name is the human-friendly name of a site. Read it from the right: ch is Switzerland's country ending, cern.ch belongs to CERN, and info is one machine name inside it. The path looks like folders and a file name because, on this old server, that is exactly what it is.

Two more parts turn up in many addresses. After a question mark comes a query, extra information for the server, such as ?q=flexbox on a search page. After a hash sign comes a fragment, such as #sources, which points at one spot inside a page. MDN's guide to URLs notes that the fragment is never sent to the server at all: the browser keeps it and uses it to scroll to the right place. You will build links with fragments in Lesson 5.

Step one: turning a name into a number

Computers on the internet find each other by number, not by name. Each one has an IP address, a number that works like a postal address for machines. Names such as info.cern.ch exist for people. So before your browser can ask for anything, it has to find the number that goes with the name.

That job belongs to the Domain Name System, or DNS, which MDN describes as an address book for websites. Your browser asks a DNS server, and the answer comes back in a few thousandths of a second. You can ask the same question yourself. Windows and macOS both include a command called nslookup, and this is what it printed for info.cern.ch on 21 October 2026:

Name:    webafs902.cern.ch
Addresses:  2001:1458:201:a4::100:1a0
          188.184.67.127
Aliases:  info.cern.ch

Three facts are packed in there. The Aliases line says info.cern.ch is a second name for a machine whose main name is webafs902.cern.ch. The number 188.184.67.127 is an IPv4 address: four numbers from 0 to 255, separated by dots. The long one with colons is an IPv6 address, from the newer numbering scheme that was created because the world was running out of IPv4 numbers. Your browser connects to one of them. You never see this step, partly because it is fast and partly because the answer is remembered for a while, so your next visit can skip it.

The address-book comparison is useful, but it stops working in one place. There is no single book. DNS is spread across many servers around the world, each responsible for part of the naming tree, so a lookup may pass through several of them before an answer comes back.

Step two: a request written in plain words

With the number in hand, the browser opens a connection to that computer. Because the address starts with https, the two machines first agree on encryption, using a system called TLS. After that, nobody on the path between you and CERN can read or change what passes between you. Then the browser sends its request.

The rules for requests and replies are called HTTP, the Hypertext Transfer Protocol. MDN calls it a protocol for fetching resources such as HTML documents, in which the browser always speaks first. In its classic form, the heart of the request is short enough to read:

GET /hypertext/WWW/TheProject.html HTTP/1.1
Host: info.cern.ch

GET is the method, and it means send me this. Then come the path from the URL and the version of HTTP being used. The Host line names the site, because one server computer can hold many websites at once. A real browser adds more lines, called headers, which say things like which languages you read and which kinds of file it can display. Many browsers and servers now pack the same information into a compact binary form called HTTP/2 or HTTP/3, but the parts do not change: a method, a path and some headers. CERN's 1992 page still arrives over HTTP/1.1, exactly as written above.

What the padlock does and does not promise

HTTPS is HTTP sent through that encrypted connection. Browsers have long shown it with a padlock beside the address. The padlock means that people on the network between you and the site cannot read your traffic or tamper with it. It says nothing about whether the site itself is honest.

Google found out how widely this is misunderstood. In 2023 the Chrome team wrote that its 2021 research showed only 11 percent of study participants understood what the lock icon precisely meant, and that nearly all phishing sites, the fake sites built to steal passwords, use HTTPS and so show the padlock too. In the same post, Chrome announced that it would replace the padlock with a neutral settings icon. So when you visit a page, the encryption protects the conversation. Deciding whether to trust whoever is at the other end is still your job.

Step three: the answer, and the number at the top

The server reads the path, finds the file, and sends a response. It starts with a status line, then headers, then a blank line, then the file itself, which is called the body. The number in the status line is the status code, and it is the quickest way to know what happened. The official HTTP standard, RFC 9110, sorts status codes into five classes by their first digit:

ClassMeaningAn example you will meet
1xxInformational: wait, more is comingRarely seen in a browser
2xxSuccessful200 OK: here is the file
3xxRedirection: look somewhere else301 Moved Permanently
4xxClient error: the request asked for something wrong404 Not Found
5xxServer error: the server broke500 Internal Server Error

You can produce a 404 from CERN in a few keystrokes. Ask for info.cern.ch/hypertext/WWW/NoSuchPage.html and the reply begins HTTP/1.1 404 Not Found, followed by a small page with the heading Not Found. Notice what that tells you. The server was reached, it understood the request, and it looked. It simply had no file at that path. A 404 is a precise message, and you will learn to read it as one: check the path, letter by letter.

Step four: from text to a page

What arrives is text. The 2,217 bytes from CERN are characters, starting with this:

<HEADER>
<TITLE>The World Wide Web project</TITLE>
<NEXTID N="55">
</HEADER>
<BODY>
<H1>World Wide Web</H1>The WorldWideWeb (W3) is a wide-area ...

The words in angle brackets are HTML tags. HTML, the HyperText Markup Language, marks up text to say what each piece is: this is the title, this is a level-one heading, this is a link. The browser reads the tags and builds the page from them. It turned that H1 line into the big heading at the top. Notice the NEXTID tag: a leftover from Berners-Lee's own editor that no modern browser understands. The browser skipped it without complaint, which is a habit you will meet again in Lesson 3.

A page from 1992 is one file. A page from today is a crowd of them. As the browser reads the HTML it finds links to other files it needs, such as stylesheets, images, fonts and scripts. It sends a separate request for each one, then puts them all together. MDN's overview of HTTP describes exactly this: one request for the HTML, then more requests for the pieces it names. The HTTP Archive's Web Almanac, which measures millions of real sites, reported that the median home page in 2025 made 72 requests on a phone and weighed 2.56 MB. CERN's first page made one request and weighed about 2 kilobytes.

Those files fall into three languages, each with its own job:

  • HTML says what the content is and how it is structured: headings, paragraphs, lists, links, images, forms.
  • CSS, Cascading Style Sheets, says how it should look: colours, fonts, spacing, and where things sit on the screen.
  • JavaScript says how it should behave: what happens when someone clicks, types or scrolls.

Images, video, sound and documents are not languages. MDN calls them assets: files the browser shows but does not read as instructions. Modules 1 and 2 of this course are HTML, Modules 3 to 6 are mostly CSS, and Module 9 is your first JavaScript.

Your portfolio will be a static site: a folder of ready-made files that the server hands out unchanged to everyone who asks. Big sites such as a social network are dynamic: their servers run programs that build a different page for each visitor. Static sites are simpler and cheaper to run, and several hosts serve them for free. You will publish yours on one of them, GitHub Pages, in Module 10.

Watching it happen in your own browser

You do not have to take any of this on trust. Every desktop browser can show you the conversation.

  1. Open the CERN page. Press Ctrl+U on Windows, Linux or a Chromebook, or Cmd+Option+U on a Mac, to see the page source: the raw text the server sent.
  2. Open the developer tools. In Chrome and Edge, press F12 or Ctrl+Shift+I on Windows or Linux, or Cmd+Option+I on a Mac. You can also right-click anything on a page and choose Inspect.
  3. Choose the Network panel, then reload the page. Each row is one request. The columns show its status code, its type and its size.
  4. Click the row for TheProject.html and open its Headers tab. The response headers from the top of this lesson are there.

When this course loaded the CERN page in Chrome, the Network data showed one document, status 200, with 2,517 bytes transferred: the 2,217-byte file plus about 300 bytes of headers. Some school computers have the developer tools switched off by an administrator. If yours does, do the Network steps on another computer, or just study the numbers given here.

Common misconceptions

  • "The internet and the web are the same thing." The internet is the worldwide network of connected computers. The web is one service that runs over it, built from URLs, HTTP and HTML. Email and online games also use the internet without being the web.
  • "The server sends a picture of the page." It sends text and files. Your browser builds the picture. That is why the same page looks different on a phone and on a laptop, and why your CSS can change it.
  • "The padlock means the site is safe." It means the connection is encrypted. A phishing site can have one, and nearly all of them do.
  • "A 404 means the website is down." A website that is down usually sends nothing at all, so the browser shows its own error page. A 404 means the server answered and had nothing at that path.

Where this leaves us

Key idea: A web page arrives as text in answer to a request. The browser looks up the server's number, asks for a path, reads the status code, and then builds the page from HTML, asking again for every other file the HTML names.

You can now take a URL apart into scheme, host and path, and explain what DNS, HTTP and HTTPS each do. You can read a status line and tell a 200 from a 404 from a 500, and you know where the developer tools show it all happening. You have also seen that a web page is, underneath, a plain text file with tags in it. The next lesson takes that literally. You will write one in a text editor, then in a browser-based editor, and it will become the first file of your portfolio.

Sources

  1. CERN. (n.d.). A short history of the Web. home.cern
  2. MDN contributors. (2026). How the web works. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). An overview of HTTP and What is a URL? Mozilla. developer.mozilla.org
  4. Fielding, R., Nottingham, M., and Reschke, J. (Eds.). (2022). RFC 9110: HTTP semantics, Section 15: Status codes. Internet Engineering Task Force. rfc-editor.org
  5. Chrome team. (2023, May 2). An update on the lock icon. Chromium Blog. blog.chromium.org
  6. Barret, R., and Indigo, J. (2026). Page weight. In The 2025 Web Almanac. HTTP Archive. almanac.httparchive.org
Key terms
URL
Uniform Resource Locator: the full address of one thing on the web, made of a scheme, a host and a path, and sometimes a query and a fragment.
Domain name
The human-friendly name of a site, such as info.cern.ch, read from the right: country or type, then organisation, then machine.
IP address
The number that identifies a computer on the internet, such as 188.184.67.127 (IPv4) or a longer IPv6 number with colons.
DNS
The Domain Name System, which looks up the IP address that belongs to a domain name before a browser can connect.
HTTP request
The message a browser sends to a server: a method such as GET, a path, a version, and headers.
HTTPS
HTTP sent over an encrypted connection (TLS), so others on the network cannot read or change it. It does not prove a site is honest.
Status code
The three-digit number at the top of a response: 2xx success, 3xx redirection, 4xx client error such as 404, 5xx server error.
Static site
A website made of ready-made files that a server hands out unchanged, with no program building each page.

A Web Page Is a Text File: Your First One, Two Ways

  • Create, save and open a complete HTML file with a plain text editor and with a browser-based editor.
  • Explain what each line of the standard HTML page skeleton does, and what goes wrong without it.
  • Name web files so they work on a server, and set up the folder that will hold your portfolio.

Here is a complete web page. It is twelve lines long, and saved on a computer it takes up 332 bytes:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Rowan's portfolio</title>
  </head>
  <body>
    <h1>Hi, I'm Rowan</h1>
    <p>I am learning to build websites. This page is the first file of my portfolio.</p>
  </body>
</html>

Open it in Chrome and the tab at the top reads Rowan's portfolio. The page itself shows a large bold heading, Hi, I'm Rowan, in 32-pixel Times New Roman, and under it one line of ordinary 16-pixel text. Nothing else: no colours, no layout, black on white. That plain look is the browser's own default style, and in Module 3 you will replace it with yours.

The file is just text. No special program made it, and no special program is needed to read it. This lesson shows you two ways to write it, one with nothing but the computer you already have and one with a free editor that runs in a browser tab. Then it goes through the twelve lines one at a time, because every page you ever write will start with the same skeleton. Rowan is the made-up student whose portfolio this course builds as a worked example. You will build your own beside it.

Way one: a plain text editor and a browser

A plain text editor saves exactly the characters you type and nothing else: no fonts, no bold, no hidden formatting. Every Windows computer and every Mac comes with one.

On Windows, use Notepad:

  1. Open Notepad from the Start menu and type or paste the twelve lines above.
  2. Choose File, then Save as. Make a new folder called portfolio, in Documents or wherever you keep schoolwork, and open it.
  3. In the Save as type box choose All files. Type the file name index.html. Check that the Encoding box says UTF-8. Save.
  4. Windows hides the ends of file names unless you ask to see them. In File Explorer, choose View, then Show, then File name extensions, and confirm the file is called index.html and not index.html.txt.
  5. Double-click the file. It opens in your default browser.

On a Mac, use TextEdit, with one important change first. TextEdit normally writes rich text, the formatted kind, and Apple's guide warns that formatted text uses curly quotation marks. Curly quotes break HTML. When this course gave a link the address about.html inside curly quotes instead of straight ones, Chrome treated the curly marks as part of the file name and went looking for a file whose name began and ended with them. So:

  1. Open TextEdit, choose File, then New, and then choose Format, then Make Plain Text.
  2. Type the twelve lines and save the file as index.html in a new folder called portfolio. If TextEdit asks which ending to use, choose .html.
  3. So that TextEdit shows you the code next time rather than the finished page, choose TextEdit, then Settings, then Open and Save, and tick Display HTML files as HTML code instead of formatted text.
  4. Double-click the file to open it in Safari.

Look at the browser's address bar. It starts with file:/// and then the path to the folder on your disk. That means the browser read the page straight from your own computer. No server was involved and nobody else can see it. This is the normal way to build a site: you work on files privately and publish them only when they are ready, which you will do in Module 10.

Do not use a word processor such as Microsoft Word or Google Docs for this. They save their own formats full of formatting information, and when they do save HTML, they wrap your few lines in hundreds of lines of their own.

Way two: Phoenix Code, an editor that runs in a browser tab

A plain text editor works, but it does not help you. It will not colour your tags or show you the page as you type. A code editor does both. Phoenix Code is a free, open-source code editor that runs inside a browser tab, so there is nothing to install. When this course opened it on 21 October 2026, it asked for no account and no sign-in. It opened straight into a practice project holding an index.html, a styles.css, a script.js and an images folder, with a live preview of the page on the right.

That practice project is called the Default Project, and Phoenix describes it as a scratch pad. It lives in your browser's own storage, which disappears if you clear your browsing data. Phoenix even warns that Safari deletes the stored data of sites you have not visited for seven days. For your portfolio, use a real folder instead:

  1. Open web.phcode.dev in Chrome, Edge or Opera, on Windows, a Mac, Linux or a Chromebook. Close the welcome window. If an offer of a free Phoenix Pro trial appears, close that too: nothing in this course needs it.
  2. Choose File, then Open Folder, and pick the portfolio folder you made, or make a new one. The browser will ask whether to let the site view and edit the files in that folder. Allow it. Phoenix can now read and write only that folder.
  3. Choose File, then New File, or press Ctrl+1, and name the file index.html. Phoenix uses Ctrl+1 because in a browser tab Ctrl+N belongs to the browser, which opens a new window.
  4. Type the twelve lines. Notice the colours: tag names, attribute names and attribute values each get their own, which makes a typing mistake stand out.
  5. Press Ctrl+S to save. The preview panel shows the page and updates as you type.

Phoenix's own message for other browsers says opening local folders needs Chrome, Edge or Opera. In Firefox or Safari you can still try things in the Default Project, but keep the portfolio itself in a folder edited with Notepad or TextEdit. On a Chromebook, Phoenix with Open Folder is the simplest route of all.

Two more things about Phoenix before you rely on it. Its privacy policy says the browser editor keeps your code and files on your own device, and uploads them only if you use a feature that needs it, such as sharing a preview. And its terms of service, effective 1 January 2026, set no minimum age but say you must be able to form a binding contract, which in most countries means being an adult. Its privacy policy has a section on use by schools. If you are under 18, read the terms with a parent, guardian or teacher before you start, and let them decide.

Reading the twelve lines

Every line of the skeleton has a job. Here they are in order.

<!DOCTYPE html> must come first. The HTML standard, written by a group called the WHATWG, calls it a required preamble, kept for historical reasons. Without it, browsers fall back to an older way of drawing pages, which this course tested below.

<html lang="en"> opens the html element, which holds everything else, and its lang attribute says the page is in English. An attribute is an extra piece of information written inside a start tag, as a name, an equals sign and a value in quotation marks. The standard says the language helps speech tools choose pronunciations and translation tools choose rules. A screen reader, the software that reads pages aloud to blind users, uses it to pick the right voice. Use es for Spanish, fr for French, and so on.

<head> holds information about the page rather than the page itself. Nothing in the head appears in the window.

<meta charset="utf-8"> tells the browser how the bytes in the file map to characters. UTF-8 covers every writing system and every emoji, so an accented name or a Chinese character arrives intact. The standard requires this line to sit completely within the first 1024 bytes of the file, which is why it goes near the top.

<meta name="viewport" content="width=device-width, initial-scale=1"> tells phones to lay the page out at their own width instead of pretending to be a desktop screen. It does nothing visible on a laptop. Lesson 14 shows what goes wrong on a phone without it.

<title> sets the text in the browser tab, in bookmarks and in search results. The standard asks authors to write titles that still make sense out of context, for example in someone's history or bookmarks. Rowan's portfolio passes that test. A title like Home does not.

<body> holds everything you see. Inside it, <h1> marks the main heading and <p> marks a paragraph. Then the closing tags, each with a slash, shut the body and the whole document.

The spaces at the start of lines, called indentation, mean nothing to the browser. They are there for you. Each element sits two spaces further in than the element that contains it, so you can see at a glance what is inside what. That habit will save you hours once your pages grow.

What the first line is for: a test

To see the doctype's job, this course made a copy of the page with the first line deleted and opened it in Chrome. It looked identical. The difference showed up only when the browser was asked which mode it was in: the full page reported standards mode, CSS1Compat, and the copy reported BackCompat, which is what browsers call quirks mode. In quirks mode a browser copies some layout bugs of the browsers of the late 1990s, so that very old pages keep working. MDN's guide to the two modes explains that some sizes and layouts then come out differently. You would not notice on a page this simple. You would notice later, as a layout that behaves strangely for no visible reason.

The W3C's free Nu HTML Checker catches it at once. Paste the copy into its text box and it reports an error on line 1: start tag seen without seeing a doctype first. Paste the full twelve lines and it finds nothing wrong. You will use this checker in every module from now on.

Names that work on a web server

The name index.html is not random. When someone visits a folder on a web server without naming a file, most servers send the file called index.html. GitHub Pages, where you will publish, looks for exactly that name for the front page of a site. So your home page is always index.html.

For every other file, three rules prevent trouble later:

  • Lowercase only. Your own computer may treat About.html and about.html as the same file. Many web servers treat them as different files, and Lesson 5 shows a link breaking for exactly that reason.
  • No spaces. A space in a file name becomes %20 in a web address. Use a hyphen instead: my-projects.html.
  • Keep the ending. The ending, called the file extension, tells the computer what kind of file it is: .html for pages, .css for stylesheets, .js for scripts, .jpg or .png for images.

Common misconceptions

  • "You need special software to make a website." You need a way to save plain text and a browser to open it. Editors such as Phoenix Code make the work faster, but the twelve lines above are the whole thing.
  • "An HTML file is a different kind of file from a text file." It is a text file. The .html ending tells the computer to open it in a browser rather than in Notepad.
  • "If the page looks right, the code is right." The page without its doctype looked identical and was not. Browsers repair and forgive a great deal, which is why the validator matters. Lesson 3 is about that forgiveness.
  • "Opening the file in my browser puts it on the internet." The file:/// address means it came from your own disk. Nobody else can see it until you publish it.

What you now know

The point: A web page is a plain text file that starts with a fixed skeleton: a doctype, an html element with a language, a head with the character set, viewport and title, and a body holding everything you see.

You can write that file in Notepad or TextEdit, or in Phoenix Code in a browser tab, save it with a name that will work on a server, and open it in a browser from your own disk. You know what each skeleton line does, and you have seen the validator catch a missing doctype that the eye could not see. Your portfolio now exists: one folder, one file.

In the next lesson you will look closely at the pieces those twelve lines are built from, elements, tags and attributes, and break them on purpose to see what the browser does with your mistakes.

Sources

  1. WHATWG. (2026). HTML Living Standard, Section 13.1.1: The DOCTYPE. html.spec.whatwg.org
  2. WHATWG. (2026). HTML Living Standard, Section 4.2: Document metadata (the title element, the charset declaration) and the lang attribute. html.spec.whatwg.org
  3. MDN contributors. (2026). Quirks mode and standards mode. Mozilla. developer.mozilla.org
  4. Apple. (2026). Work with HTML documents in TextEdit on Mac. TextEdit User Guide. support.apple.com
  5. Core.ai. (2026). Phoenix Code terms of service (effective 1 January 2026) and Privacy policy. phcode.dev. Browser support message from the Phoenix Code source, github.com/phcode-dev/phoenix
Key terms
Plain text editor
A program that saves exactly the characters you type with no hidden formatting, such as Notepad or TextEdit in plain-text mode.
Doctype
The line <!DOCTYPE html> at the very top of a page, which makes browsers use standards mode instead of quirks mode.
Quirks mode
An older way of drawing pages that browsers fall back to without a doctype, copying layout bugs of late-1990s browsers.
Attribute
Extra information inside a start tag, written as a name, an equals sign and a quoted value, such as lang="en".
Character encoding
The rule for turning bytes into characters. UTF-8 covers every writing system and is declared with <meta charset="utf-8">.
File extension
The ending of a file name, such as .html, .css or .jpg, which tells the computer what kind of file it is.
index.html
The file a web server sends when someone visits a folder without naming a file: the home page of a site or folder.

Tags, Elements, Attributes, and What the Browser Does With Your Mistakes

  • Name the parts of an element: start tag, attributes, content and end tag, and recognise void elements.
  • Predict how a browser repairs unclosed, misnested and unquoted markup, and confirm it in the Elements panel.
  • Use the W3C validator and character references to make a page valid, and finish the first file of your portfolio.

Rowan added a note to the projects page. These are three lines of it:

<p>This page is <em>still being built</p>
<p>My first project is a page about volcanoes.</p>
<p>My second project is a recipe card.</p>

In Chrome, the words still being built came out in italics, as intended. So did every word after them. Both later paragraphs, which contain no em at all, were slanted too. One missing piece, the closing </em>, changed text two lines away. This lesson takes that bug apart, then breaks a page three more ways to see what the browser does each time, and ends with a method you can use on any broken page. On the way you will learn the exact names for the pieces HTML is made of.

Tags and elements are not the same thing

People use the two words loosely, but they mean different things, and the difference is the key to this lesson. A tag is the text in angle brackets. An element is the whole unit: a start tag, the content, and an end tag. Here is one link element taken apart:

<a href="about.html">About me</a>
PieceIn this exampleWhat it is
Start tag<a href="about.html">Opens the element and names it: a, for anchor, which is HTML's word for a link.
Attributehref="about.html"Extra information about the element: a name, an equals sign and a value in quotes. href is the address the link goes to.
ContentAbout meWhat sits inside the element. For a link, the words you click.
End tag</a>Closes the element. Same name, with a slash in front.

A start tag can carry several attributes, separated by spaces, in any order. Tag names and attribute names are not case-sensitive, so <P> works, but everyone writes them in lowercase and so should you. Attribute values are a different matter: the value about.html is a file name, and as Lesson 5 shows, About.html may be a different file.

Some elements never close

A few elements cannot have content, so they have no end tag. The HTML standard calls them void elements and lists all thirteen: area, base, br, col, embed, hr, img, input, link, meta, source, track and wbr. You have already used one: <meta charset="utf-8">. You will meet img for images, br for a line break inside a paragraph, link for stylesheets and input for form fields.

You may see void elements written with a slash, such as <br />. That style comes from an older, stricter cousin of HTML called XHTML. Browsers allow the slash and ignore it. This course leaves it out.

Nesting: last opened, first closed

Elements go inside other elements, which is called nesting. The rule is the one you use for brackets in maths: whatever opened last must close first. To make the word hot both bold and italic:

<p><strong>Warning: <em>hot</em></strong> surface ahead.</p>

The em opened inside the strong, so it closes before the strong does. Swap the two end tags and you have misnested them: <strong>Warning: <em>hot</strong> surface</em>. When this course gave that to Chrome, the page looked fine, but the browser had rebuilt it. It closed the em early, closed the strong, then started a brand new em around the word surface. The validator said: End tag strong violates nesting rules. The page you wrote and the page the browser built were no longer the same page.

Debugging the italic page, step by step

Back to Rowan's note. Here is the method, applied to it.

First, look at what the browser built, not what you wrote. Right-click the page, choose Inspect, and the Elements panel shows the browser's version of the page, called the DOM, short for Document Object Model. This is what Chrome built from the three lines:

<p>This page is <em>still being built</em></p>
<em>
  <p>My first project is a page about volcanoes.</p>
  <p>My second project is a recipe card.</p>
</em>

There it is. When the browser reached </p> with the em still open, it closed the em to finish the paragraph. Then it reopened an em of its own around everything that followed. That is why the later paragraphs are italic.

Second, find out why. This is not a Chrome quirk. The WHATWG HTML standard defines the parsing rules for every document, whether it is correct or not, so all modern browsers make the same repairs. One of its worked examples shows how formatting elements such as b and em get collected and continue to be applied even after the elements they sat in are closed. Rowan's bug is that rule at work.

Third, ask the validator. Pasted into the W3C's Nu HTML Checker, the three lines produced five errors. The first two were End tag p seen, but there were open elements, and Unclosed element em, both pointing at the first line. Two more complained about p elements sitting inside an em, which is the browser's repair, not anything Rowan typed. The last said the body ended while an element was still open.

Fourth, fix the first error and check again. Add </em> after the word built. All five errors disappear, because four of them were consequences of the first. That is the most useful single fact about validator reports: fix the first error, check again, and the list often shrinks by more than one.

Three more breakages, and how each one shows itself

Each of these was tested in Chrome and in the Nu HTML Checker. The middle column is what the Elements panel showed.

MistakeWhat Chrome builtWhat the checker said
A list inside a paragraph: <p>Things I can make: <ul>...</ul></p>It closed the paragraph just before the list, because a p cannot contain a list. The final </p> then had nothing to close, so Chrome made a new, empty paragraph.No p element in scope but a p end tag seen.
A missing closing quote: <a href="about.html>about me</a>Everything after the opening quote, to the very end of the file, became part of the address. The page showed only the word Read. The rest of the page was gone.End of file reached when inside an attribute value. Ignoring tag.
A typing slip in a tag name: <pp>Typo tag</pp>An unknown element called pp, with its words shown as ordinary text and none of a paragraph's spacing.The pp element is a completely-unknown element that is not allowed anywhere in any HTML content.

Look at how different the symptoms are. The list mistake changes almost nothing you can see. The quote mistake deletes half the page. The typo looks like a slightly wrong paragraph. Only the validator treats all three the same way: as errors with line numbers.

Characters HTML keeps for itself

Two characters have special jobs in HTML. A less-than sign starts a tag, and an ampersand starts a character reference, a code that stands for a character. So to show either one as ordinary text, you write its reference instead:

To showWrite
<&lt;
>&gt;
&&amp;
" inside an attribute value&quot;

Rowan wants a sentence saying the main heading is marked with h1 in angle brackets. Typed directly, the browser would read the angle brackets as a real tag and start an enormous heading. Written as &lt;h1&gt;, it shows the characters. The pages of this course are full of these references: every tag you have seen in a code box was written that way.

One more piece of syntax is worth knowing now. Text between <!-- and --> is a comment: a note for people reading the code, never shown on the page. Comments are handy for reminders, but anyone can read them with View Source, so never put anything private in one.

Portfolio build 1: a valid home page

Time to finish the first file of your portfolio. Here is Rowan's, which uses every idea in this module:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Rowan's portfolio</title>
  </head>
  <body>
    <!-- Home page of my portfolio. Started in Lesson 2. -->
    <h1>Hi, I'm Rowan</h1>
    <p>I am learning to build websites with HTML &amp; CSS. This page is <em>still being built</em>, and that is the point: it grows every week.</p>
    <p>The first thing I learned is that the main heading of a page is marked with &lt;h1&gt;.</p>
    <p><strong>Coming soon:</strong> a page of my projects and a page about me.</p>
  </body>
</html>

In Chrome it shows the heading, then three paragraphs. The words still being built are italic and nothing else is. The first paragraph reads HTML & CSS with a real ampersand, the second shows the characters <h1> rather than starting a heading, and Coming soon: is bold. The comment does not appear. The Nu HTML Checker reported no errors and no warnings.

Two words in that file are worth a second look. The em and strong elements are not just styling. The standard defines em as stress emphasis, the kind that changes the meaning of a sentence when you say it aloud, and strong as importance, seriousness or urgency. Browsers show them as italic and bold by default. Use them for meaning. If you only want a different look, that is a job for CSS, which starts in Module 3.

Common misconceptions

  • "If the browser shows it correctly, the HTML is correct." The misnested strong and the list inside a paragraph both looked fine and both were errors. Browsers repair silently, and the repairs can surprise you later.
  • "Twelve errors means twelve problems." One unclosed em produced five errors. Fix the first error, check again, and recount.
  • "Every element needs an end tag." Void elements such as img, br, meta and input never have one, and cannot hold content.
  • "You need the slash in <br />." In HTML the slash is allowed but does nothing. It is a leftover from XHTML.

The short version

Remember: An element is a start tag, content and an end tag; attributes live inside the start tag; whatever opens last closes first. Browsers repair broken markup by fixed rules, so the page you see may not be the page you wrote, and the validator and the Elements panel are how you find out.

You can now name every piece of an element, list the void elements, nest correctly, and write the characters HTML reserves with character references. You have a four-step method for a broken page: look at the DOM, understand the repair, read the validator, fix the first error. And your portfolio's home page is finished and valid. Module 2 turns it into a real site, starting with the elements that give content its shape: headings, lists and tables.

Sources

  1. WHATWG. (2026). HTML Living Standard, Section 13.1: Writing HTML documents (elements, void elements, attributes, character references). html.spec.whatwg.org
  2. WHATWG. (2026). HTML Living Standard, Section 13.2: Parsing HTML documents, including 13.2.2 Parse errors and 13.2.10.6 Unclosed formatting elements. html.spec.whatwg.org
  3. W3C. (2026). About the Nu Html Checker. validator.w3.org
  4. MDN contributors. (2026). Basic HTML syntax. Mozilla. developer.mozilla.org
Key terms
Tag
The text in angle brackets that opens or closes an element, such as <p> or </p>.
Element
A whole unit of HTML: a start tag, its content, and an end tag, such as <em>still being built</em>.
Void element
An element that cannot have content and has no end tag. The standard lists thirteen, including img, br, meta, link and input.
Nesting
Putting elements inside other elements. Whatever opened last must close first.
DOM
The Document Object Model: the tree of elements the browser actually builds from your HTML, shown in the Elements panel.
Character reference
A code beginning with & that stands for a character, such as &lt; for < and &amp; for &.
Comment
Text between <!-- and --> that the browser ignores. Anyone can read it with View Source.
Validator
A program that checks HTML against the standard and reports errors with line numbers, such as the W3C Nu HTML Checker.

Module 2: HTML Structure and Meaning

Choose elements for what content means rather than how it looks: headings that form an outline, lists, data tables, links that work on a server and say where they go, and the header, nav, main and footer that turn single pages into a site. The module ends with your portfolio as three linked pages.

Headings, Lists and Tables: Giving Content a Shape the Browser Understands

  • Build a heading outline with h1 to h6 chosen for structure rather than size.
  • Choose between ul, ol and dl, and between strong, b, em and i, by what the content means.
  • Mark up a data table with a caption, header cells and scope, and explain why tables are never used for layout.

In December 2023 and January 2024, the accessibility organisation WebAIM surveyed 1,539 people who use screen readers, the software that reads a page aloud for people who cannot see it. One question asked what they do first when they arrive on a long web page. Read through it from the top? Only 6.4 percent. Use the Find feature? 13.6 percent. Jump through the page's headings? 71.6 percent, by far the largest group.

Think about what that means for your code. A screen reader can only jump to a heading that is marked up as a heading. A line of bold text that merely looks like one is invisible to it. Visually, the two can be identical. To most of the people in that survey, one page has a table of contents and the other is a wall of text. That gap between how content looks and what it is runs through this whole lesson. It compares elements that look alike and mean different things, and shows you how to choose by meaning every time.

Two pages that look almost the same

Here is a section of Rowan's about page written two ways:

<!-- Version A -->
<p><strong>Skills so far</strong></p>

<!-- Version B -->
<h3>Skills so far</h3>

In Chrome, both show the words Skills so far in bold on a line of their own. Version B is a little larger, about 19 pixels against 16. But in the browser's tree of the page they are different things. Version A is a paragraph that happens to contain important text. Version B is a heading at level 3, so it appears when a screen reader lists the page's headings and in the outline the validator can draw. Version A appears in neither.

The opposite mistake is just as common: picking a heading for its size. If Rowan wanted a small bold line and chose h4 because h4 looks small, the page now claims a sub-sub-subsection that does not exist. Size is a matter of style, and style is CSS's job. Choose the element for what the content is, and change how it looks later.

Headings make an outline

The HTML standard gives each of h1 to h6 a heading level, the number in its name: h1 for a top-level section, h2 for a subsection, h3 for a sub-subsection, and so on. Together, a page's headings form an outline, like the contents page of a book. Rowan's about page has this one:

<h1>About me</h1>
  <h2>What I am learning</h2>
    <h3>Skills so far</h3>
    <h3>How I publish a change</h3>
  <h2>Words I keep using</h2>
  <h2>My learning log</h2>

The indentation is only there to show you the shape. In the real file each heading is followed by the content it introduces. Three habits keep an outline sound:

  • One h1, naming what the page is about. On Rowan's about page, that is About me, not the name of the whole site.
  • Do not skip levels on the way down. The W3C's tutorial on headings says skipping ranks can be confusing and should be avoided where possible: an h2 should not be followed directly by an h4. Jumping back up is fine. After an h3, the next section can start with an h2.
  • Every heading introduces content. A heading with nothing under it, or a heading used as a decoration, breaks the contents page.

You do not have to check this by eye. The W3C Nu HTML Checker has an outline option under Show. Tick it, check the page, and the report lists every heading with its level, in order. For the page above it printed h1 About me, then h2 What I am learning, then the two h3 headings, then the last two h2 headings: the shape you intended.

Meaning against look: the comparison

This table is the heart of the lesson. The middle columns set what each element means in the HTML standard against how Chrome shows it when no CSS has been written.

ElementWhat it meansDefault look in ChromeUse it for
h1 to h6A heading for a section, at the level in its nameBold. h1 is 32 pixels, h2 24, h3 about 19, h4 16; h5 and h6 are smaller than body textThe titles of sections, by depth
pA paragraphA block with a line of space above and belowRuns of prose
ulA list whose order does not matterBullets, indented 40 pixelsItems in any order: skills, tools
olA list whose order mattersNumbers, indented 40 pixelsSteps, rankings, instructions
dl, dt, ddGroups of terms and their descriptionsTerm at the margin, description indented 40 pixelsGlossaries, questions and answers
strongStrong importance, seriousness or urgencyBoldA warning, the phrase that matters most
bText drawn to attention with no extra importanceBoldKey words in a summary, product names in a review
emStress emphasis that changes the sentence's meaningItalicThe stressed word in a spoken sentence
iAn alternate voice or moodItalicA technical term, a phrase in another language, a thought, a ship's name
blockquoteA section quoted from another sourceIndented 40 pixels on both sidesA quotation a few lines long
tableData arranged in rows and columnsA grid with no borders, header cells bold and centredAnything you would put in a spreadsheet

Reading the table: three patterns

The look column is only a default. Every entry in it can be changed with CSS. You can make an h2 tiny and red or a paragraph huge and bold. What CSS cannot do is change what an element means. So the meaning column decides which element you write, and the look column decides nothing.

Pairs that look alike mean different things. strong and b are both bold. em and i are both italic. ul and ol differ only in bullets against numbers. In each pair, the standard gives the two elements different jobs. Consider the sentence I said the project was finished. Put em around said and you are insisting that you did say it. Put em around finished and you are disputing what state it was in. That is stress emphasis. The scientific name of a species, written in italics by convention, carries no stress at all: that is a job for i.

Some of the meaning is invisible but used. A description list looks like indented text, but the browser knows which description belongs to which term. An ordered list's numbers are generated by the browser, and the W3C's tutorial on page content notes that assistive technology can tell users how many steps an ordered list contains. Write the steps as separate paragraphs with typed-in numbers and that information is lost.

Lists, three kinds

Here are the three kinds as Rowan used them. Every list item, li, sits inside a ul or an ol. In a description list, each term is a dt and each description a dd:

<h3>Skills so far</h3>
<ul>
  <li>Writing a page skeleton</li>
  <li>Headings and lists</li>
  <li>Checking my code with the validator</li>
</ul>

<h3>How I publish a change</h3>
<ol>
  <li>Edit the file and save it.</li>
  <li>Check it with the validator.</li>
  <li>Upload it to my site.</li>
</ol>

<h2>Words I keep using</h2>
<dl>
  <dt>Element</dt>
  <dd>A start tag, its content and an end tag.</dd>
  <dt>Attribute</dt>
  <dd>Extra information written inside a start tag.</dd>
</dl>

In Chrome the first list has round bullets, the second is numbered 1, 2 and 3, and in the third each description sits indented under its term. CERN's 1992 page from Lesson 1 used exactly this kind of description list for its links and their explanations.

Lists can be nested: put a complete ul or ol inside an li, never directly inside another list. An ol also accepts a start attribute, so <ol start="4"> numbers its first item 4, and a reversed attribute that counts down, handy for a top-ten.

Tables are for data with rows and columns

A data table holds information that has two dimensions: each row is one thing, each column one kind of fact about it. Rowan keeps a learning log:

<table>
  <caption>Hours spent on web design, first three weeks</caption>
  <thead>
    <tr>
      <th scope="col">Week</th>
      <th scope="col">What I learned</th>
      <th scope="col">Hours</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">1</th>
      <td>How a page loads</td>
      <td>3</td>
    </tr>
    <tr>
      <th scope="row">2</th>
      <td>The page skeleton</td>
      <td>4</td>
    </tr>
  </tbody>
</table>

Each piece has a job. table holds it all. caption is the table's title, and the standard defines it as exactly that. thead holds the header row and tbody the body. tr is a table row. Inside a row, th is a header cell and td a data cell. The scope attribute says which way a header points: scope="col" means this heading describes the cells below it, and scope="row" means it describes the cells to its right. The W3C's tutorial on tables says header cells must be th and data cells td for a table to be accessible, because that is how a screen reader can say Hours, 4 rather than just 4.

In Chrome, this table has no borders at all. The caption sits centred above it, the header cells are bold and centred, and the columns are just wide enough for their contents. You will give it borders and spacing with CSS in Module 3.

One rule about tables is absolute. The HTML standard says that tables must not be used as layout aids. In the early web, before CSS could arrange a page, designers built whole pages out of invisible tables, putting the menu in one cell and the article in another. The standard explains why that is non-conforming: tools that extract data get confused, and screen reader users find such pages very difficult to navigate, because every cell is announced as data. Layout is the job of CSS, and Module 4 covers it.

Common misconceptions

  • "Headings are for making text big." They are for structure. Size can be anything you set in CSS, and an h2 can be smaller than an h3 if your design wants it, though it rarely should.
  • "b and i are old tags that no longer belong in HTML." Both are current. The standard gives them their own meanings: attention without importance, and an alternate voice or mood.
  • "An ol is just a ul with numbers." The numbers carry meaning: order matters. Shuffle a recipe's steps and it fails; shuffle a list of hobbies and nothing is lost.
  • "A table is a good way to put things side by side." Only if the things are data. For layout, a table misleads screen readers and breaks the standard. Use CSS.

Putting it together

In short: Choose every element by what the content is, never by how it looks by default. Headings form an outline with no skipped levels, lists are unordered, ordered or descriptive, and tables hold data with a caption and header cells, never layout.

You compared elements that look alike and mean different things, and saw what the difference costs a screen reader user who navigates by headings, as most do. You can now build an outline the validator confirms, choose the right list, and write a data table with caption, th and scope. The next lesson connects pages with links. It starts with a link that worked perfectly on Rowan's laptop and broke the moment it went online.

Sources

  1. WebAIM. (2024). Screen reader user survey #10 results. Institute for Disability Research, Policy, and Practice, Utah State University. webaim.org
  2. WHATWG. (2026). HTML Living Standard, Sections 4.3.6 (the h1 to h6 elements), 4.4 (grouping content) and 4.5 (text-level semantics). html.spec.whatwg.org
  3. WHATWG. (2026). HTML Living Standard, Section 4.9: Tabular data. html.spec.whatwg.org
  4. W3C Web Accessibility Initiative. (2017). Headings. Page structure tutorial. w3.org
  5. W3C Web Accessibility Initiative. (2023). Tables tutorial. w3.org
Key terms
Heading level
The number in h1 to h6, which places a heading in the page's outline: h1 for the top level, h2 for a subsection, and so on.
Semantic HTML
Choosing each element for what the content means rather than how it looks, so browsers, search engines and screen readers can use that meaning.
Unordered list
A ul element: a list whose order does not matter, shown with bullets by default.
Ordered list
An ol element: a list whose order matters, numbered by the browser, with optional start and reversed attributes.
Description list
A dl element holding terms (dt) and their descriptions (dd), such as a glossary.
Data table
A table element holding information in rows and columns, never used for page layout.
Header cell
A th element that labels a row or column of a table; its scope attribute says which way it points.
Caption
The caption element: the title of a table, placed first inside it.

Module 3: CSS: Style, Selectors and the Box

Attach a stylesheet to every page, learn how selectors choose elements and how the cascade settles disagreements, master the box model of content, padding, border and margin, and choose units and custom properties so that one change restyles the whole site. The module ends with your portfolio's first stylesheet.

One Rule, Three Parts: How CSS Reaches Your HTML

  • Name the parts of a CSS rule: selector, declaration block, declaration, property and value.
  • Attach CSS in the three possible places, and explain why one external stylesheet is best for a site.
  • Predict what a browser does with a misspelled property, an invalid value or a missing semicolon, and find it in DevTools.

On 10 October 1994, Håkon Wium Lie posted a proposal titled Cascading HTML Style Sheets. He was working at CERN, the same laboratory where the web itself had started, and his idea was simple: describe how a page should look in a separate set of rules, instead of mixing the description into the HTML. One of the example rules in his proposal read:

h1.font.size = 24pt 100%

The syntax did not survive, but the idea did. Two years later, in December 1996, CSS level 1 became an official W3C Recommendation, according to the W3C's own history of the language, and the first commercial browser to support it, Internet Explorer 3, had come out that August. The same instruction today looks like this:

h1 {
  font-size: 24pt;
}

CSS stands for Cascading Style Sheets. This lesson follows the idea from that first proposal into Rowan's portfolio, which right now looks exactly like CERN's 1992 page: black Times New Roman on white. By the end, one small file will restyle all three pages at once.

One rule, taken apart

Everything in CSS is written as rules. A rule says which elements to style and how. Here is one from Rowan's stylesheet:

h1 {
  color: #8a3b12;
}
PartIn this ruleWhat it does
Selectorh1Chooses which elements the rule applies to: here, every h1.
Declaration blockEverything from { to }Holds one or more declarations.
Declarationcolor: #8a3b12;One instruction: a property, a colon, a value, a semicolon.
PropertycolorWhat to change. color means the colour of the text.
Value#8a3b12What to change it to: a dark rust brown, written as a hexadecimal colour code.

CSS uses American spelling for its property names: color, not colour, and center, not centre. Lesson 17 explains the hexadecimal colour codes. For now, read #8a3b12 as a precise recipe for one colour.

Where Rowan first tried it: three places CSS can live

CSS can reach an element in three ways, and Rowan tried them in order.

First, inside one element. Any element can carry a style attribute holding declarations:

<h1 style="color: #8a3b12;">Hi, I'm Rowan</h1>

That colours one heading, and only that one. Rowan would have to repeat it on every h1 on every page. This is called an inline style.

Second, in the head of one page. A style element can hold whole rules:

<head>
  <style>
    h1 {
      color: #8a3b12;
    }
  </style>
</head>

Now every h1 on that page changes, but the other two pages are untouched.

Third, in a file of its own. Rowan made a new file, styles.css, in the portfolio folder, and added one line to the head of every page:

<link rel="stylesheet" href="styles.css">

The link element is void, so it has no end tag. Its rel attribute says what the linked file is to this page, a stylesheet, and its href is an ordinary relative URL like the ones in Lesson 5. This is an external stylesheet, and it is the right choice for a site. One file styles every page that links to it, so one change restyles the whole site. The browser can also keep its copy of the file after the first page, instead of fetching it again for every page.

Rowan's first stylesheet

Here is the whole of styles.css at this point:

/* styles.css: one stylesheet for every page of Rowan's portfolio */
body {
  font-family: system-ui, sans-serif;
  color: #1f2933;
  background-color: #fdfcf8;
  line-height: 1.5;
}

h1 {
  color: #8a3b12;
}

a {
  color: #1a5a96;
}

The first line is a CSS comment, written between /* and */. Like an HTML comment, it is a note for people and the browser skips it.

When this course loaded index.html with this stylesheet in Chrome, the change was immediate. The text switched from Times New Roman to the computer's own interface font, which on Windows is Segoe UI. The text became a very dark blue-grey, the background a warm off-white, the h1 rust brown and the links a deep blue. The lines spread out, from the default spacing to 24 pixels per line of 16-pixel text. Each text colour was chosen to stand out clearly from the background. The headline colour scores 7.5 to 1 on the contrast measure you will learn in Lesson 18, comfortably above the minimum.

Three of those values need a word of explanation.

  • font-family: system-ui, sans-serif is a list of choices in order of preference. MDN says system-ui takes its letters from the default user interface font on a given platform, so it is Segoe UI on Windows and something different on a Mac or a Chromebook. If that is not available, the browser uses its default sans-serif font, one whose strokes end plainly, without the small finishing strokes called serifs. MDN also advises always ending the list with a generic family like this, because no particular font is guaranteed to be installed.
  • line-height: 1.5 sets the height of each line to one and a half times the font size, with no unit. Lesson 19 explains why that particular number helps readers.
  • background-color is set on body, so it fills the whole window.

Why styling body changed everything

Rowan set the font and text colour only on body, yet every paragraph, list item and heading changed. That is inheritance: some properties, mostly ones about text such as color, font-family and line-height, pass down from an element to the elements inside it unless something else sets them. Others, such as background-color, padding and border, do not pass down. Inheritance is why a few declarations on body can set the tone for a whole page, and why the h1 needed its own rule to be a different colour.

It also explains what Rowan's pages looked like before any CSS. Every browser has a built-in stylesheet of its own, called the user agent stylesheet, which gives h1 its 32-pixel size, lists their 40-pixel indent and the body its 8-pixel margin. Your stylesheet does not replace that one. It is layered on top, and wherever the two disagree, yours wins. Rowan's page still had the 8-pixel margin round the body, because nothing in styles.css changed it. Settling disagreements between rules is what the cascading in Cascading Style Sheets means, and it is the whole subject of the next lesson.

What the browser does with your mistakes

In Lesson 3 you saw that browsers repair broken HTML. With broken CSS they do something simpler: they throw it away and carry on. MDN's page on CSS error handling puts it plainly. Browsers give no alerts and show no errors for bad styles; they discard what they cannot understand and keep reading. To see exactly what gets discarded, this course gave Chrome a deliberately broken rule:

p {
  colour: red;
  color: reed;
  font-size: 20px
  font-weight: bold;
  margin-left: 40px;
}

Chrome kept one declaration out of five. When asked what was left of the rule, it reported only margin-left: 40px. Here is why each of the others went:

  • colour: red has an unknown property name. Ignored.
  • color: reed has a real property but a value that is not a colour. Ignored.
  • font-size: 20px is missing its semicolon, so the browser read on into the next line and saw one declaration: font-size with the value 20px font-weight: bold. That value is nonsense, so both lines were lost. The paragraph stayed 16 pixels and normal weight.

That last one is the most common CSS bug of all, and it teaches the most useful rule: one missing semicolon can cost you two declarations, and the second one is perfectly written.

Because the browser stays silent, you have to look. Open the developer tools, select the paragraph in the Elements panel, and read the Styles pane beside it. Chrome's documentation says the pane crosses out and puts a warning icon beside any declaration whose property is invalid or unknown, and crosses out just the value when the property is fine but the value is not. It also crosses out declarations that another rule has overridden, which is the clue you will follow in the next lesson.

Inline styles beat the stylesheet

One more test shows how the three places rank. An h1 was given style="color: purple" while a style element in the same page said h1 should be green. In Chrome the heading came out purple. A declaration written directly on the element beats a rule from a stylesheet. That sounds like a reason to use inline styles, but it is the opposite. Rules written inside elements are scattered across every file, cannot be reused, and override your stylesheet in ways that are hard to find later. Keep all your CSS in styles.css.

Common misconceptions

  • "CSS is part of HTML." It is a separate language with its own grammar and its own standards. HTML only says where to find it.
  • "If my CSS is wrong, the browser will tell me." It discards invalid declarations silently, by design. The Styles pane in DevTools is where you see what was crossed out.
  • "A missing semicolon only breaks its own line." In the test above it took the next declaration down with it.
  • "Inline styles are the simplest, so they are best." They are the hardest to maintain and they override everything else. A site needs one external stylesheet.

Recap

Bottom line: A CSS rule is a selector plus a block of property: value declarations. Put your rules in one external stylesheet linked from every page, set shared text styles on body so they inherit, and check the Styles pane, because browsers throw away what they do not understand without a word.

From Lie's 1994 proposal you followed the idea to a working stylesheet: you can name every part of a rule, attach CSS in three ways and know why one file wins, explain inheritance and the user agent stylesheet, and predict what Chrome discards. But your rules so far choose elements only by tag name. The next lesson opens with a paragraph that stays black no matter what colour Rowan sets, and the reason is a fight between selectors.

Sources

  1. Lie, H. W. (1994, October 10). Cascading HTML style sheets: A proposal. w3.org
  2. Bos, B. (2016, December 17). A brief history of CSS until 2016. W3C. w3.org
  3. MDN contributors. (2026). CSS error handling. Mozilla. developer.mozilla.org
  4. MDN contributors. (2026). What is CSS? and The link element. Mozilla. developer.mozilla.org
  5. Google. (2026). Find invalid, overridden, inactive, and other CSS. Chrome DevTools documentation. developer.chrome.com
Key terms
CSS rule
A selector followed by a declaration block in curly braces, such as h1 { color: #8a3b12; }.
Selector
The part of a rule that chooses which elements it styles, such as h1, a or body.
Declaration
One instruction inside a rule: a property, a colon, a value and a semicolon.
Property
What a declaration changes, such as color, font-family or line-height. CSS spells them in American English.
External stylesheet
A separate .css file attached to pages with <link rel="stylesheet" href="styles.css">, so one file styles every page.
Inline style
Declarations written in an element's style attribute. They beat stylesheet rules but are hard to maintain.
Inheritance
The passing of some property values, mostly text ones such as color and font-family, from an element to the elements inside it.
User agent stylesheet
The browser's built-in default styles, such as a 32-pixel h1 and an 8-pixel body margin, which your CSS is layered over.

Why Is My Paragraph Still Black? Selectors, Specificity and the Cascade

  • Write type, class, id, attribute, descendant and pseudo-class selectors, and check in DevTools which elements they match.
  • Calculate specificity in the ID-CLASS-TYPE form and use it, then source order, to predict which rule wins.
  • Explain why inherited values lose to any rule aimed at the element, and fix cascade conflicts without !important.

Rowan wanted one sentence on the about page to stand out in the portfolio's rust colour. So Rowan added a class to the paragraph and a rule to the end of styles.css:

<p class="Highlight">I built my first page in one evening.</p>
.highlight {
  color: #8a3b12;
}

The paragraph stayed dark. Not a little dark: exactly the same near-black as every other paragraph, as if the new rule did not exist. Rowan's first guess was that the browser had not loaded the stylesheet. That guess was easy to disprove. The heading was still rust and the links still blue, both set in the same file, so the file was loading. Something else was going on. In fact two things were, one hiding behind the other. This lesson finds both, step by step, and on the way covers how CSS chooses elements and how it settles a fight between rules.

First, the selectors you can write

A selector is the part of a rule that chooses elements. So far you have only used type selectors, which choose every element with a given tag name. There are more, and the class Rowan just tried is the most useful of them:

SelectorExampleChooses
TypepEvery p element
Class.highlightEvery element whose class attribute contains the word highlight
ID#introThe one element with id="intro"
Attribute[aria-current="page"]Elements whose aria-current attribute equals page
Descendantnav aEvery a anywhere inside a nav
Childul > liEvery li directly inside a ul
Pseudo-classa:hoverA link while the pointer is over it
Grouph1, h2Every h1 and every h2

A class is a label you choose and add with the class attribute. One element can have several, separated by spaces, and one class can be used on any number of elements. That makes classes the everyday tool for styling. A pseudo-class, written with a colon, selects an element in a particular state. You will use two of them constantly: :hover, while a pointer is over the element, and :focus-visible, while it has keyboard focus. Lesson 22 is about the second one.

Selectors can be combined. nav a[aria-current="page"] means a link inside a nav that carries aria-current="page", which is exactly the current-page link from Lesson 6.

Step 1 of the debugging: does the rule match at all?

In the developer tools, Rowan selected the stubborn paragraph in the Elements panel and read the Styles pane. It lists every rule that matches the selected element. The .highlight rule was not in the list. So the problem was not that the rule lost a fight. It never matched in the first place.

Look again at the HTML: class="Highlight", with a capital H. The stylesheet says .highlight. When this course tested exactly that pair in Chrome, the paragraph did not match .highlight, and did match .Highlight. Class names are case-sensitive, just like the file names in Lesson 5. Rowan changed the HTML to class="highlight" and reloaded.

Step 2: the rule matches and still loses

The paragraph was still dark. But the Styles pane now told a different story. The .highlight rule was in the list, with its color declaration crossed out. Above it sat a rule Rowan had written weeks earlier, when the about page's opening section was given an id so the home page could link to it:

#intro p {
  line-height: 1.6;
  color: #1f2933;
}

A crossed-out declaration means another rule has overridden it. Both rules match the paragraph, both set color, and #intro p wins.

Rowan's next guess was the most common wrong idea in all of CSS: surely the rule that comes last in the file wins, and .highlight is at the very end. It is a reasonable guess, and the order of rules does matter. But order is only the tie-breaker. Before order, the browser compares how specific the two selectors are.

Specificity, counted in three columns

Specificity is a score the browser gives each selector. MDN describes it as three columns, written ID-CLASS-TYPE:

  • The ID column counts id selectors, such as #intro.
  • The CLASS column counts class selectors, attribute selectors such as [aria-current="page"], and pseudo-classes such as :hover.
  • The TYPE column counts type selectors such as p and nav.

To compare two selectors, compare the columns from left to right, and stop at the first difference. MDN puts it bluntly: the selector with the greater value in the ID column wins no matter what the other columns hold. Here are the scores of every selector in this lesson:

SelectorIDCLASSTYPEWritten as
p0010-0-1
nav a0020-0-2
.highlight0100-1-0
a:hover0110-1-1
nav a[aria-current="page"]0120-1-2
#intro p1011-0-1

Now the mystery is solved. #intro p scores 1-0-1. .highlight scores 0-1-0. The first column differs, 1 against 0, so the id rule wins, and where it sits in the file is never even considered. Order decides only when two selectors score exactly the same. Then, as MDN says, the last declared style gets precedence.

Notice one more thing in the table: a single class, 0-1-0, beats any number of type selectors, so .highlight beats even a selector like main section p, which scores 0-0-3. Ids are so heavy that one id outweighs a hundred classes.

The order the browser actually follows

Put together, MDN's account of the cascade gives a fixed order of questions the browser asks whenever two declarations set the same property on the same element:

  1. Where does each come from, and is either marked !important? Your stylesheet beats the browser's built-in defaults. A declaration ending in !important jumps above normal ones.
  2. Is either one inline? A style attribute on the element beats rules from stylesheets, as Lesson 7 showed.
  3. Which selector is more specific? Compare ID, then CLASS, then TYPE.
  4. Which comes later? Only if everything above is equal.

Modern CSS adds one more step, cascade layers, which let large projects group rules into ranked layers. A single-stylesheet site like your portfolio does not need them.

Three ways to fix it, and the one to choose

This course tried three fixes in Chrome. All three turned the paragraph rust.

  • Shout: change the rule to color: #8a3b12 !important. It works, and it is the worst choice. MDN calls using !important to override specificity a bad practice. The next time something needs to beat that declaration, the only weapon left is another !important, and the stylesheet becomes an arms race.
  • Escalate: write #intro .highlight, which scores 1-1-0 and so beats 1-0-1. It works, but it ties the highlight to one section of one page and raises the stakes for every future rule.
  • Remove the cause: look at why #intro p sets a colour at all. It sets the same colour body already gives every paragraph. The declaration did nothing useful and was only ever going to cause fights. Delete it, keep the line-height, and .highlight wins without any help.

Rowan chose the third. It leads to two habits that prevent this bug altogether. Style with classes, not ids: keep ids for fragment links, where they belong. And keep selectors short and flat, so that most rules score 0-1-0 or 0-1-1 and the order of the file can do its job.

Why the links stayed blue

One more puzzle uses the same ideas. Before Rowan wrote a rule for a, setting body's color to near-black changed every paragraph but not the links. When this course tested it, a link with no rule of its own came out as rgb(0, 0, 238), Chrome's default link blue, while the text around it was dark.

The reason is in how inheritance works. MDN explains that an inherited property takes its parent's value only when no value has been specified on the element itself. The browser's built-in stylesheet does specify a colour for links, directly on the a element. Any rule that targets an element, even the browser's own default, beats a value merely inherited from a parent. So to recolour links, you need a rule that selects a, which is why Rowan's stylesheet has one.

Common misconceptions

  • "The last rule in the file always wins." Only between rules of equal specificity. A 1-0-1 selector at the top beats a 0-1-0 selector at the bottom.
  • "!important is how you fix CSS that will not apply." It hides the cause and starts an arms race. Find the rule that is winning in the Styles pane and remove or simplify it.
  • "Ids are good hooks for styling because they are unique." Their weight makes them hard to override. Use classes for styles and ids for fragment links.
  • "Setting a colour on body colours everything." It colours what inherits it. Anything with its own rule, including links in the browser's default stylesheet, keeps that rule's colour.

Pulling it together

So what? When a style will not apply, ask two questions in order. Does the rule match the element at all, which the Styles pane shows by listing it or not? If it matches but is crossed out, which rule beat it, by origin, inline style, specificity or order? Fix the cause, not the symptom.

You found Rowan's two bugs: a capital letter that stopped the class matching, and an id rule that outscored the class. You can write class, id, attribute, descendant and pseudo-class selectors, score their specificity, and walk through the cascade's questions in order. The next lesson moves from which rule applies to what a rule does to the space an element takes up. It opens with a box that was supposed to be 300 pixels wide and measured 342.

Sources

  1. MDN contributors. (2026). Specificity. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Introduction to the CSS cascade. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). Inheritance. Mozilla. developer.mozilla.org
  4. MDN contributors. (2026). Basic CSS selectors. Learn web development. Mozilla. developer.mozilla.org
  5. W3C. (2026, January 22). Selectors Level 4, Section 15: Calculating a selector's specificity. W3C Working Draft. w3.org
Key terms
Class selector
A selector beginning with a full stop, such as .highlight, that chooses every element whose class attribute contains that word. Case-sensitive.
ID selector
A selector beginning with #, such as #intro, that chooses the one element with that id. Very heavy in specificity.
Attribute selector
A selector in square brackets, such as [aria-current="page"], that chooses elements by an attribute's value.
Pseudo-class
A selector written with a colon that chooses elements in a state, such as :hover or :focus-visible.
Descendant combinator
A space between two selectors, as in nav a, meaning the second anywhere inside the first.
Specificity
A selector's weight, counted as ID-CLASS-TYPE and compared from left to right to decide which rule wins.
Cascade
The browser's fixed order for settling conflicts: origin and importance, inline style, specificity, then order in the file.
!important
A flag on a declaration that lifts it above normal declarations. MDN calls using it to beat specificity a bad practice.

The Box Model: Why a 300-Pixel Box Measured 342

  • Calculate the rendered size of a box from its width, padding and border, under both values of box-sizing.
  • Choose between padding and margin, and predict when vertical margins collapse.
  • Explain how block, inline and inline-block boxes treat width and height, and centre a readable column.

Rowan wanted the project descriptions to sit in neat cards exactly 300 pixels wide, with some space inside and a thin line round the edge. This was the rule:

.card {
  width: 300px;
  padding: 20px;
  border: 1px solid #8c8478;
  background-color: #ffffff;
}

On screen the cards looked wider than expected, so Rowan measured one with the developer tools. It was 342 pixels wide. Not 300, not roughly 300: 342 exactly. The rule says width: 300px in plain words, and the browser drew something 42 pixels wider. Before reading on, try to account for those 42 pixels using only the numbers in the rule. The answer explains how every element on every web page is sized.

Every element is a box in four layers

CSS treats every element as a rectangle made of four layers, nested like the rings of a target. This is the box model. From the inside out:

LayerWhat it isSet with
ContentThe area where the text or image sitswidth and height
PaddingSpace between the content and the border, inside the boxpadding
BorderA line round the paddingborder
MarginSpace outside the border, pushing other boxes awaymargin

A background colour fills the content, the padding and the area under the border, but never the margin. The margin is always transparent: it is space between this box and its neighbours, not part of the box's own look. So if you want more room between a card's edge and its text, that is padding. If you want more room between two cards, that is margin.

Padding, border and margin can each be set for one side at a time, with names such as padding-top or margin-left, or all at once. A shorthand with one value sets all four sides. With two values, the first sets top and bottom and the second sets left and right, so margin: 16px 0 means 16 pixels above and below and none at the sides.

Solving the 342

Here is the arithmetic. MDN's page on box-sizing explains that by default, width sets the size of the content area only. Padding and border are then added outside it:

  300   content width
+  20   padding on the left
+  20   padding on the right
+   1   border on the left
+   1   border on the right
= 342   pixels from one outer edge of the border to the other

That default is called content-box, and it is the value of a property named box-sizing. Nothing was broken. The browser did exactly what the rule asked for: a 300-pixel content area, then padding and border wrapped round it. The surprise came from reading width as the width of the whole box, which is what almost everyone assumes. The height works the same way. The card's height was set by its contents, plus 40 pixels of padding and 2 of border.

The fix nearly every site uses: border-box

The other value, border-box, makes width mean what people expect. MDN describes it as width and height including the content, padding and border, but not the margin. With it, the padding and border are taken out of the 300 pixels instead of added to them:

.card {
  box-sizing: border-box;
  width: 300px;
  padding: 20px;
  border: 1px solid #8c8478;
}

Measured in Chrome, this card was exactly 300 pixels wide. Its content area shrank to 258, which is 300 minus 40 of padding and 2 of border. Most designers find this far easier to plan with, and it is usual to switch every element to it at once, at the very top of the stylesheet:

*, *::before, *::after {
  box-sizing: border-box;
}

The star is the universal selector: it matches every element. The other two parts cover extra boxes CSS can generate before and after an element's content, which you may meet later. One trap is worth knowing: box-sizing is not inherited, so setting it only on body changes the body and nothing inside it. The universal selector is how you reach everything.

Why two margins of 24 pixels make a gap of 24

Rowan gave each card margin: 24px and expected 48 pixels between two cards stacked one above the other, 24 below the first plus 24 above the second. Chrome measured 24. The same happens with paragraphs. Give two paragraphs margin: 16px 0 each and the space between them is 16 pixels, not 32.

This is margin collapsing. MDN describes it: the top and bottom margins of blocks are sometimes combined into a single margin whose size is the largest of the individual margins. It is deliberate. It is why the browser's default paragraph spacing looks even, instead of doubling between paragraphs. Three details keep it from surprising you:

  • Only vertical margins collapse, top and bottom. Left and right margins never do.
  • When two margins meet, the larger one wins. A 24-pixel margin meeting a 16-pixel one leaves a gap of 24.
  • Margins do not collapse inside a flex or grid container, which you will build in Module 4. If spacing changes when you switch layouts, this is why.

Block boxes and inline boxes

Not every box obeys width. Rowan tried giving a link width: 200px and height: 80px. In Chrome the link was 109 pixels wide: the width of its text plus its padding. The width and height were ignored. The reason is the element's display type.

  • Block boxes, such as p, h1, ul, section and article, start on a new line and stretch across the full width available unless you give them a width. They obey width, height, and margins on all four sides.
  • Inline boxes, such as a, em, strong and span, sit inside a line of text and are exactly as wide as their content. Width and height do nothing to them. Padding on the left and right pushes the text apart, and padding on the top and bottom paints a background but does not move the lines above and below.
  • Inline-block boxes sit in the line like inline ones but accept width, height and padding like block ones. With display: inline-block the same link became 220 pixels wide: 200 of content plus 20 of padding, still sitting inside its sentence.

You can change any element's display type with the display property. Module 4 introduces two more values, flex and grid, which change how an element arranges the boxes inside it.

A readable column: max-width and auto margins

On a wide screen, a paragraph stretching the full width of the window makes lines too long to read comfortably, a problem Lesson 19 measures. A common fix uses two parts of the box model at once:

body {
  margin: 0;
  padding: 0 16px;
}

main {
  max-width: 640px;
  margin: 0 auto;
}

max-width lets main shrink on a narrow screen but stops it growing past 640 pixels. A left and right margin of auto tells the browser to share the leftover space equally on both sides. On a 1,000-pixel-wide window Chrome placed main 180 pixels from each edge: centred, 640 wide. On a 400-pixel window it was 368 wide, filling the space inside the body's 16-pixel padding on each side. The padding on body keeps text off the very edge of a phone screen, and the body margin of 0 replaces the browser's default 8 pixels.

Seeing the box for yourself

The developer tools make all of this visible. Select an element in the Elements panel and Chrome draws its box model as a set of nested rectangles labelled margin, border and padding, with the content size in the middle and every number filled in. Hovering over an element in the panel also highlights it on the page itself. When a layout looks wrong, this diagram is the first place to look. It turns guesses like it seems a bit wide into facts like it has 20 pixels of padding you forgot about.

Common misconceptions

  • "width is the width of the whole box." Under the default content-box it is the width of the content only. Padding and border are added outside it, which is where Rowan's 42 extra pixels came from.
  • "Padding and margin are two names for spacing." Padding is inside the border and takes the background colour. Margin is outside, always transparent, and collapses vertically.
  • "Margins between boxes add up." Vertical margins between blocks collapse to the larger one. Two 16-pixel margins leave a 16-pixel gap.
  • "Every element can be given a width." Inline elements such as links ignore width and height until their display type changes.

What to remember

Worth holding on to: Every element is content inside padding inside a border inside a margin. Under content-box, width sizes only the content; under border-box, which most sites set for everything, it sizes the box up to the border. Vertical margins collapse to the larger one, and inline boxes ignore width and height.

You can now account for every pixel of Rowan's 342-pixel card, choose padding or margin on purpose, predict a collapsed margin, and centre a column that stays readable on any screen. One question was left hanging: why pixels? A pixel is not the same size for everyone who reads your site, and some of your visitors have changed their browser's text size. The next lesson compares the units CSS offers and introduces custom properties, which let you define your colours and spacing once and use them everywhere.

Sources

  1. MDN contributors. (2026). The box model. Learn web development. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). box-sizing. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). Mastering margin collapsing. Mozilla. developer.mozilla.org
  4. W3C. (2024). CSS Box Model Module Level 3. W3C Recommendation. w3.org
Key terms
Box model
CSS's picture of every element as content inside padding inside a border inside a margin.
Padding
Space inside an element's border, between the border and the content. It shows the element's background colour.
Border
A line drawn round an element's padding, set with border: width style colour, such as 1px solid #8c8478.
Margin
Transparent space outside an element's border that pushes other boxes away. Vertical margins can collapse.
box-sizing
Property choosing what width means: content-box (the default) sizes the content only; border-box includes padding and border.
Margin collapsing
When the top and bottom margins of blocks meet and combine into one margin the size of the larger.
Display type
Whether an element generates a block box (new line, full width), an inline box (in the text line, ignores width) or an inline-block box.

Units and Custom Properties: px, rem, %, and a Palette Defined Once

  • Compare px, rem, em, %, vw and ch, and choose the right unit for text, spacing, borders and line length.
  • Explain the difference between a reader's zoom and a reader's default text size, and which units follow each.
  • Declare custom properties on :root, use them with var() and a fallback, and build the portfolio's first full stylesheet.

Two paragraphs on a test page looked identical. One was set with font-size: 16px and the other with font-size: 1rem, and in a fresh copy of Chrome both measured 16 pixels. Then this course changed Chrome's default text size from 16 pixels to 24, the setting a reader with low vision might choose in the browser's appearance settings. The rem paragraph grew to 24 pixels. The px paragraph stayed at 16.

Nothing else about the page changed. The only difference was the unit, and it decided whether the page listened to its reader. CSS has many units, and most lessons list them. This one compares them, side by side, under that same test, then shows how custom properties let you choose a value once and use it everywhere. It ends with your portfolio's first complete stylesheet.

The units compared

Every length in CSS is a number and a unit, with no space between them. The table shows six units as Chrome measured them on a 1,000-pixel-wide window, first with the standard 16-pixel default text size and then with the reader's 24-pixel setting.

UnitRelative toExampleMeasured at 16 px defaultAfter reader chose 24 pxGood for
pxNothing: a fixed CSS pixelfont-size: 16px16 px16 pxBorders and fine details
remThe root element's font sizefont-size: 1rem16 px24 pxFont sizes and spacing
emThe element's own font size, or the parent's when setting font-size0.8em inside 0.8em10.24 px15.36 pxSpacing that should follow its own text
%The parent: its width, for widthswidth: 50%500 px of 1,000500 pxWidths in a layout
vw1 percent of the viewport's widthwidth: 50vw500 px500 pxSizes tied to the window
chThe width of the character 0 in the fontmax-width: 65ch520 px780 pxLine length

The viewport in the vw row is the visible area of the page in the browser window. Its height has its own unit, vh.

Reading the table

Text should follow the reader. Only the units tied to font size, rem, em and ch, changed when the reader chose bigger text. Anything that is about reading, such as font sizes, the spacing between paragraphs and the length of lines, should use them, so a reader who needs bigger text gets a page built round it. MDN defines rem as relative to the font size of the root element, the html element, and unless you change it, that is the reader's chosen default.

em multiplies. MDN explains that em means the parent's font size when used for font-size, and the element's own font size for anything else. That makes it compound. A list set to 0.8em inside another list set to 0.8em came out at 0.8 times 0.8 times 16, which is 10.24 pixels, smaller than either author probably meant. Each level of nesting shrinks again. rem has no such chain, because it always looks at the root. That is why this course uses rem for font sizes and saves em for things like the padding inside a button, which should grow with that button's own text.

Pixels are for things that should not grow. A 1-pixel border should stay a hairline at any text size. MDN gives the CSS pixel as 1/96 of an inch, and WCAG defines it as a reference unit distinct from the screen's hardware pixels. On a high-density phone screen one CSS pixel may cover two or three physical ones, which is why a 16-pixel font is not microscopic on a phone.

Layout units answer a different question. Percentages and vw are about the space available, not about text. Neither changed when the reader's text size did, which is right for a layout column and wrong for a font size. The ch unit is the odd one out: it measures characters, so 65ch means about sixty-five characters per line in any font at any size. Lesson 19 explains why that number matters.

Zoom is not the same as text size

There are two ways a reader can make a page bigger. Browser zoom, with Ctrl and the plus key or Cmd and plus on a Mac, enlarges everything, pixel sizes included. WCAG 2.2's Success Criterion 1.4.4, Resize Text, at Level AA, requires that text can be resized up to 200 percent without assistive technology and without loss of content or functionality. Zoom makes that possible even on a page built in pixels, as long as the layout does not break when everything doubles. The default text size setting is the other way, and only relative units follow it. Using rem for text costs you nothing and serves both groups of readers.

Custom properties: a value with a name

Rowan's stylesheet already used the rust colour #8a3b12 in three places and was about to use it in more. Change your mind about that colour and you must find every copy. Custom properties, sometimes called CSS variables, fix that. You declare a value once, with a name you choose that starts with two hyphens, and refer to it with the var() function:

:root {
  --color-accent: #8a3b12;
  --space-m: 1rem;
}

h1 {
  color: var(--color-accent);
}

.card {
  padding: var(--space-m);
}

MDN notes that it is common practice to define custom properties on :root, a pseudo-class that matches the html element, so they can be used anywhere on the page. Change --color-accent there and every rule that uses it changes with it. Four details matter:

  • They inherit. A custom property set on an element is available to everything inside it, which is why :root works for the whole page.
  • They can be overridden in one place. Write .card { --pad: 2rem; } and inside cards var(--pad) means 2rem, while everywhere else it means whatever an outer element set. Chrome measured 32 pixels of padding for a card using it.
  • They take a fallback. In var(--color-missing, #8a3b12), the second value is used if the property was never defined. Chrome used the fallback when this course tested it.
  • Their names are case-sensitive. MDN warns that --my-color and --My-color are two different properties. Pick lowercase with hyphens and keep to it.

Values stored this way are often called design tokens: the small set of colours, fonts and spacings a design is built from. Choosing them deliberately, and then using only them, is a large part of what makes a site look designed rather than assembled.

Portfolio build 3: the first full stylesheet

Here is Rowan's styles.css at the end of Module 3. Every idea from Lessons 7 to 10 is in it.

/* styles.css: Rowan's portfolio, end of Module 3 */

/* 1. Design tokens: change a value here and it changes everywhere */
:root {
  --color-text: #1f2933;
  --color-background: #fdfcf8;
  --color-accent: #8a3b12;
  --color-link: #1a5a96;
  --color-surface: #ffffff;
  --color-border: #8c8478;
  --font-body: system-ui, sans-serif;
  --space-s: 0.5rem;
  --space-m: 1rem;
  --space-l: 2rem;
}

/* 2. Every box measures to its border */
*, *::before, *::after {
  box-sizing: border-box;
}

/* 3. Page basics */
body {
  margin: 0;
  padding: 0 var(--space-m);
  font-family: var(--font-body);
  color: var(--color-text);
  background-color: var(--color-background);
  line-height: 1.5;
}

header, main, footer {
  max-width: 40rem;
  margin: 0 auto;
}

h1 {
  color: var(--color-accent);
}

a {
  color: var(--color-link);
}

a:hover {
  text-decoration-thickness: 3px;
}

nav a[aria-current="page"] {
  font-weight: bold;
  color: var(--color-text);
}

/* 4. Project cards */
.card {
  padding: var(--space-m);
  margin: var(--space-m) 0;
  border: 1px solid var(--color-border);
  background-color: var(--color-surface);
}

With each project's article given class="card", Chrome drew the projects page as a single centred column 640 pixels wide on a 1,000-pixel window, which is 40rem at the default text size. Each project sat in a white card with a thin grey-brown border. In the navigation, the link to the page you are on was bold and dark instead of blue. Hovering a link thickened its underline. With the reader's text size at 24 pixels, the column widened to fill the window instead of staying at 640, and the body padding grew from 16 pixels to 24, because both are measured in rem.

The text colours were chosen to contrast strongly with their backgrounds: the link blue measures 7.1 to 1 against the white cards, which Lesson 18 will teach you to calculate. The border colour is decorative, so no contrast rule applies to it. Notice what the stylesheet does not do yet. The navigation is still a bulleted list stacked down the page. Module 4 fixes that.

Common misconceptions

  • "A CSS pixel is one dot on the screen." It is a reference unit. On a high-density screen one CSS pixel covers several hardware pixels.
  • "em and rem are the same thing." They agree only at the top level. em compounds through nesting; rem always refers to the root.
  • "Pages in pixels cannot be zoomed." Browser zoom enlarges pixels too. What pixel font sizes ignore is the reader's default text size setting.
  • "Custom properties are only for colours." Any value can be stored: spacing, fonts, sizes, even shadows. The name is yours; the value is any valid CSS.

The takeaway

The core of it: Choose units by what should happen when a reader asks for bigger text: rem for type and spacing, which grow; px for hairlines, which do not; percentages and vw for layout; ch for line length. Name the values your design is built from as custom properties on :root, and use var() everywhere else.

Module 3 is complete. Your portfolio has one stylesheet that sets its colours, fonts and spacing from a short list of tokens, sizes every box predictably, centres a readable column and marks the current page. Module 4 is about layout: putting things side by side, starting with that stacked navigation list.

Sources

  1. MDN contributors. (2026). CSS values and units. Learn web development. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Using CSS custom properties (variables). Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). length (including the ch unit). Mozilla. developer.mozilla.org
  4. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criterion 1.4.4 Resize Text and the definition of CSS pixel. W3C Recommendation, 12 December 2024. w3.org
Key terms
CSS pixel
The px unit: a fixed reference length of 1/96 inch, distinct from the screen's hardware pixels, and unaffected by a reader's text size setting.
rem
A unit equal to the font size of the root (html) element, normally the reader's default text size, so it grows when they choose bigger text.
em
A unit equal to the element's own font size, or its parent's when setting font-size, so it compounds through nested elements.
Viewport
The visible area of a page in the browser window; 1vw is 1 percent of its width and 1vh 1 percent of its height.
ch unit
A unit equal to the width of the character 0 in the current font, useful for limiting line length.
Custom property
A value you name with two leading hyphens, such as --color-accent, and use with var(). It inherits and is case-sensitive.
var()
The function that inserts a custom property's value, with an optional fallback: var(--name, fallback).
Design tokens
The small, deliberate set of colours, fonts and spacings a design is built from, often stored as custom properties.

Module 4: Layout With Flexbox and Grid

Arrange boxes side by side and in two dimensions: flexbox for a row of navigation links and the sizing rules that make flex items grow and shrink, then CSS grid for rows and columns at once. The module ends with your portfolio's header bar and a grid of project cards.

Flexbox: Lining Things Up in a Row

  • Turn an element into a flex container and explain which boxes become flex items.
  • Use justify-content and align-items on the main and cross axes, and predict how flex-direction swaps them.
  • Build a header bar with flex-wrap and gap that works at any width, with navigation links large enough to tap.

At the end of Module 3, the top of every page in Rowan's portfolio looked like this in Chrome: the site name on a line of its own, then three links stacked one above the other, each with a bullet in front and indented 40 pixels. A navigation menu built from a list, which is correct HTML, drawn the way browsers draw lists, which is not what anyone wants a menu to look like. The goal for this lesson is a single bar: site name at the left, links in a row at the right, all on one line on a laptop and wrapping neatly on a phone.

It takes about a dozen declarations, and every one of them belongs to a layout system called flexbox. This lesson builds the bar step by step, then changes one value to show you the idea that makes flexbox predictable instead of mysterious.

Why the list stacks: normal flow

Without any layout CSS, a browser places boxes in normal flow. As Lesson 9 showed, block boxes stack downwards, each on its own line and as wide as their container, while inline boxes sit side by side within lines of text. The ul and each li are block boxes, so they stack. To put blocks side by side you need a different layout method, and for a single row or column of items that method is flexbox. MDN describes flexbox as a one-dimensional layout model for distributing space between items: it lays things out along one line at a time.

Step 1: clear the list's default look

First remove what the browser's own stylesheet added to the list: the bullets, the 40-pixel indent and the space above and below.

nav ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

The links now sit flush at the left edge with no bullets, still stacked. The list is still a list in the HTML, so its meaning is untouched. Only its appearance changed.

Step 2: make the list a flex container

Add one declaration to the same rule:

nav ul {
  display: flex;
  gap: var(--space-m);
  list-style: none;
  margin: 0;
  padding: 0;
}

The three links jump into a row. Here is exactly what happened. display: flex turned the ul into a flex container, and each of its children, the three li elements, became a flex item. Only direct children become flex items. The links inside the li elements are not flex items of the ul; they are just content of the items.

The gap property puts space between the items, 1rem here, without adding any outside the first or last one. That is much easier than giving each item a margin and then removing it from the end.

MDN lists what a new flex container does before you set anything else. The items sit in a row, starting from the start edge. They do not stretch along the row but can shrink if space runs short. They stretch to fill the container's height. And they all stay on a single line. Keep that list in mind, because each of the next steps changes one item on it.

Step 3: the header becomes a flex container too

The site name and the nav are the two children of the header. Make the header a flex container and push them apart:

header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: var(--space-m) 0;
}

Two new properties appear here, and they are the heart of flexbox.

justify-content arranges items along the main axis, the direction the items flow, which in a row runs left to right. space-between puts the first item at the start, the last item at the end, and divides the leftover space between them. With two items, the site name goes to the left edge and the nav to the right edge.

align-items arranges items along the cross axis, which runs at right angles to the main axis: top to bottom in a row. Its default is stretch, which makes every item as tall as the tallest. center lines the site name up with the middle of the nav instead.

When this course loaded the page in Chrome on a 1,000-pixel window, the site name sat at the left of the 640-pixel column and the three links sat in a row at its right, all on one line, with the current page's link bold. The header measured 72 pixels tall.

justify-content valueWhat the items do along the main axis
flex-start (the default)Pack together at the start
centerPack together in the middle
flex-endPack together at the end
space-betweenFirst at the start, last at the end, equal gaps between
space-evenlyEqual space before, between and after every item

The idea that makes it predictable: the axes turn with the direction

Now change one input. The property flex-direction sets the main axis. Its default is row, but it can be column, and then everything you just learned rotates. This course built two identical containers, 600 by 200 pixels, each holding three 100-by-40-pixel boxes, with the same two declarations: justify-content: space-between and align-items: center. The only difference was that the second also had flex-direction: column.

  • In the row container, the boxes were spread across: their left edges sat at 1, 251 and 501 pixels, and all three were centred vertically, 80 pixels from the top.
  • In the column container, the boxes were spread down: their tops sat at 1, 81 and 161 pixels, and all three were centred horizontally, 251 pixels from the left.

Same declarations, opposite results. justify-content does not mean horizontal and align-items does not mean vertical. justify-content always works along the main axis and align-items along the cross axis, and flex-direction decides which way the main axis points. Once you think in those two words, main and cross, flexbox stops surprising you.

Step 4: let it wrap

On a phone the bar has a problem. By default flex items stay on one line, so a long site name and three links squeeze together. Add flex-wrap, which lets items move onto a new line when they run out of room, to both containers, and give the header a gap so wrapped lines do not touch:

header {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-s) var(--space-l);
  padding: var(--space-m) 0;
}

nav ul {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-m);
  list-style: none;
  margin: 0;
  padding: 0;
}

With two values, gap sets the space between rows first and between columns second. At a window width of 360 pixels, about the width of a small phone, Chrome put the site name on the first line and the three links together on a second line beneath it, all starting at the left. The page was exactly 360 pixels wide, with nothing pushing it sideways. At 1,000 pixels it looked exactly as before. One set of rules, two sensible layouts, and not a single measurement of the screen. Module 5 builds on exactly that.

Step 5: links big enough to hit

A link is an inline box, only as tall as its text. Give the navigation links some vertical padding, and make them block boxes so the padding counts:

nav a {
  display: block;
  padding: var(--space-s) 0;
}

When this course measured the Home link afterwards, it was 45 pixels wide and 40 tall. WCAG 2.2 added a success criterion about exactly this, 2.5.8 Target Size (Minimum) at Level AA, which asks for targets of at least 24 by 24 CSS pixels unless they have enough space around them. Lesson 22 covers it fully. A navigation bar is where small targets hurt most, so it is worth building it in now.

Common misconceptions

  • "justify-content is horizontal and align-items is vertical." Only in a row. They follow the main and cross axes, and flex-direction: column swaps them.
  • "display: flex goes on the things I want in a row." It goes on their parent. The parent becomes the container and its direct children become the items.
  • "Removing the bullets means the nav should not be a list." The list is still right for the meaning. list-style: none changes only the look.
  • "gap only works with grid." It works with flexbox too, and saves you adding and then removing margins.

What to carry forward

What matters here: A flex container lays its direct children along one axis. justify-content spaces them along the main axis, align-items aligns them on the cross axis, flex-direction decides which axis is which, gap spaces them, and flex-wrap lets them move to a new line when space runs out.

Your portfolio now has a real navigation bar that becomes two tidy lines on a phone, with links tall enough to tap. But the items in it all happened to be small. In the next lesson Rowan puts project cards in a flex row, and one of them refuses to shrink: the start of understanding how flex items decide their size.

Sources

  1. MDN contributors. (2026). Basic concepts of flexbox. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Flexbox. Learn web development. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). gap. Mozilla. developer.mozilla.org
  4. W3C. (2025, October 14). CSS Flexible Box Layout Module Level 1. W3C Candidate Recommendation Draft. w3.org
  5. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criterion 2.5.8 Target Size (Minimum). W3C Recommendation, 12 December 2024. w3.org
Key terms
Normal flow
How boxes are placed with no layout CSS: blocks stack downwards and inline boxes run along lines of text.
Flex container
An element with display: flex, which lays out its direct children along one axis.
Flex item
A direct child of a flex container. Grandchildren are not flex items of that container.
Main axis
The direction flex items flow: along the row by default, down the page with flex-direction: column.
Cross axis
The direction at right angles to the main axis, where align-items works.
justify-content
Arranges flex items along the main axis, for example flex-start, center or space-between.
align-items
Aligns flex items on the cross axis. Its default, stretch, makes items as tall as the tallest in a row.
flex-wrap
Lets flex items move onto a new line when they run out of room. The default, nowrap, keeps them on one line.

The Card That Would Not Shrink: Flex Sizing Debugged

  • Read the flex shorthand as grow, shrink and basis, and predict how items share free space.
  • Explain why a flex item will not shrink below its min-content size, and fix overflow with wrapping or overflow-wrap: anywhere.
  • Explain why order and row-reverse change only what is seen, and why that can break WCAG 2.2 focus order.

Rowan's projects page put three cards in a flex row and gave each card flex: 1. On a 1,000-pixel window it looked exactly right: three cards, each 312 pixels wide, in a neat line. Then Rowan narrowed the window to 360 pixels, the width of a small phone, and the page began to scroll sideways. Chrome reported the page as 418 pixels wide, 58 more than the screen. And the cards were no longer equal: they measured 147, 103 and 120 pixels.

Rowan's explanation was reasonable. flex: 1 is supposed to make items equal, and flex items are supposed to shrink when space runs short, so this must be a browser bug. It is not a bug. The browser did exactly what the rules say, and once you see the rule that stopped the shrinking, you will recognise this problem in almost every layout that breaks on a phone. This lesson traces it, then a second card that refused to shrink for a different reason, and ends with a flexbox feature that looks useful and is usually a trap.

What flex: 1 actually says

The flex property is a shorthand for three properties at once: flex-grow, flex-shrink and flex-basis. MDN's reference spells out what each common value means:

You writeIt means grow, shrink, basisIn words
nothing (the initial value)0 1 autoStart at your content's size, do not grow, may shrink
flex: 11 1 0%Start from nothing, share the space equally, may shrink
flex: auto1 1 autoStart at your content's size, grow and shrink
flex: none0 0 autoStay at your content's size, neither grow nor shrink

flex-basis is the starting size before any space is shared out. flex-grow says how big a share of any leftover space an item takes, which MDN calls positive free space. flex-shrink says how much an item gives up when the items are too big for the row, negative free space. With flex: 1, every card starts from a basis of zero and takes one equal share, which is why they were all 312 pixels at 1,000: the 1,000 pixels minus the page's padding and the two gaps, divided by three.

Tracing the 418

At 360 pixels, the space inside the body's padding was 328 pixels. Take away two 16-pixel gaps and each card's equal share would be about 99 pixels. But none of the cards went that small. The rule that stopped them is in MDN's guide to flex item sizes: items will not shrink to less than their min-content size, the smallest size the item can be if it used every soft wrapping opportunity, that is, every place a line is allowed to break.

In ordinary text the lines break at spaces, so a card's min-content size is set by its longest word, plus its padding and border. Look at the three cards. The first card's heading is the word Volcanoes, which at heading size is 113 pixels wide, so with 32 pixels of padding and 2 of border that card cannot be narrower than 147. The other two had shorter longest words, so they stopped at 103 and 120. Add them up: 147 plus 103 plus 120, plus 32 of gaps, plus the 16-pixel padding at the left of the body, is exactly 418. Every pixel of the overflow is accounted for.

So the cards did shrink, from 312 down to their floors, and then they stopped, because shrinking further would have meant cutting words in half. The browser chose to let the row overflow rather than do that.

Fix 1: let the row wrap

Three cards side by side do not fit on a phone, and no shrinking rule will change that. The honest fix is to let them move onto new lines when there is not room:

.projects {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-m);
}

.card {
  flex: 1 1 15rem;
}

Read flex: 1 1 15rem as: start at 15rem, grow to share leftover space, shrink if you must. Combined with flex-wrap: wrap, the browser fits as many 15rem cards on a line as it can, then wraps the rest. At 1,000 pixels Chrome still put all three in one row, 312 pixels each. At 360 pixels it stacked them, each 328 pixels wide, and the page was exactly 360 pixels wide: no sideways scrolling. Lesson 16 shows a grid version of the same idea.

The second card: a name with nowhere to break

Rowan's volcano card also mentioned the file its data came from: volcano_eruptions_since_1900.csv. With two cards in a row and flex: 1, the page at 360 pixels was 411 pixels wide, and the volcano card was stuck at 271 pixels. This time wrapping the row would not have been enough, because one card on its own would still be too wide for some phones.

Underscores and full stops are not places where a line may break, so that whole file name counts as one long word, and the card's min-content size was the width of the entire name. Rowan's first fix came from a search engine and did not work:

.card {
  flex: 1;
  overflow-wrap: break-word;   /* page still 411 pixels wide */
}

MDN's page on overflow-wrap explains why. Both break-word and anywhere let a long word break at any point to avoid overflowing, but only anywhere counts those new break points when working out the min-content size. With break-word the card's floor stayed at the full width of the name, so it still would not shrink. The fix that worked was one word different:

.card {
  flex: 1;
  overflow-wrap: anywhere;   /* page 360 pixels wide, cards 156 each */
}

Now the name breaks across lines inside the card, and both cards share the row equally at 156 pixels each. A third fix is common in tutorials: min-width: 0 on the card. That lowers the floor directly, and the card did shrink to 156 pixels, but the file name did not wrap. It spilled out past the card's right edge, 237 pixels of text in a 122-pixel space. So min-width: 0 is right when an item's content can cope with being squeezed, and overflow-wrap: anywhere is right when the content is text.

The same floor causes the other common case of a card that will not shrink: a wide image inside it. Lesson 14 gives images the one declaration that lets them shrink with their container.

One more trap: order and row-reverse

Flexbox can also rearrange items. order: -1 moves an item to the front of the row, and flex-direction: row-reverse flips the whole row. They look like handy shortcuts. MDN's guide warns that these change only the visual rendering: the tabbing order and the speech order still follow the order of the source code. The flexbox specification itself says authors must not use them as a substitute for correct source ordering.

Picture a navigation bar whose links have been visually reordered. A keyboard user pressing Tab moves through them in the HTML order, so the focus jumps back and forth across the bar. That runs against two WCAG 2.2 criteria at Level A: 1.3.2 Meaningful Sequence, which needs the reading order to be determinable when sequence affects meaning, and 2.4.3 Focus Order, which needs focus to move in an order that preserves meaning and operability. If you want things in a different order, change the order of the HTML.

A checklist for any flex layout that breaks

  1. Narrow the window to 360 pixels, or even 320, and look for a sideways scrollbar.
  2. Inspect each item's width in the developer tools. Unequal widths under flex: 1 mean items have hit their floors.
  3. Find what sets each floor: a long heading word, an unbreakable name or address, an image, a wide table.
  4. Choose the fix that fits: flex-wrap for too many items, overflow-wrap: anywhere for text, a shrinking rule for images.

Common misconceptions

  • "flex: 1 always makes items equal." It gives them equal shares of the space, but no item shrinks below its min-content size, so items with long words stop early.
  • "overflow-wrap: break-word fixes overflowing flex items." It does not lower the min-content size. overflow-wrap: anywhere does.
  • "flex-shrink: 10 will force it smaller." Shrink factors share out the shrinking, but the min-content floor still applies to every item.
  • "order is a safe way to rearrange a layout." It changes only what is seen. Keyboard and screen reader users still get the source order.

Where this leaves us

Remember: A flex item starts at its basis, grows by its grow share and shrinks by its shrink share, but never below its min-content size. When a row overflows on a phone, find the floor that stopped it, then wrap the row or let the text break with overflow-wrap: anywhere.

You traced a 418-pixel page to the exact widths of three words and fixed it twice, and you know why the most-searched fix did nothing. Flexbox handles one row or one column at a time. The project cards really want rows and columns at once, and the next lesson builds them with CSS grid.

Sources

  1. MDN contributors. (2026). flex. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Controlling ratios of flex items along the main axis. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). overflow-wrap. Mozilla. developer.mozilla.org
  4. MDN contributors. (2026). Ordering flex items. Mozilla. developer.mozilla.org
  5. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criteria 1.3.2 Meaningful Sequence and 2.4.3 Focus Order. W3C Recommendation, 12 December 2024. w3.org
Key terms
flex shorthand
flex sets flex-grow, flex-shrink and flex-basis together. flex: 1 means 1 1 0%; the initial value is 0 1 auto.
flex-basis
A flex item's starting size before free space is shared out. auto means the size of its content.
flex-grow
How large a share of leftover (positive free) space a flex item takes.
flex-shrink
How much a flex item gives up when the items are too big for the line (negative free space).
min-content size
The smallest an element can be if every allowed line break is used, usually the width of its longest word. Flex items do not shrink below it.
overflow-wrap: anywhere
Lets an unbreakable string break at any point and, unlike break-word, lowers the min-content size so a flex item can shrink.
Horizontal overflow
Content wider than the window, which makes the page scroll sideways; check for it at 320 to 360 pixels wide.
order
A flex and grid property that changes the visual order of items only; keyboard and screen reader order follow the HTML.

Grid: Rows and Columns at Once

  • Build a grid with grid-template-columns, the fr unit, repeat() and gap, and place items across lines and areas.
  • Compare grid and flexbox on the same content, and choose between them by whether you need one dimension or two.
  • Lay out the portfolio's projects as a grid that has one column on a phone and more on a wider screen.

Rowan's projects page will eventually hold seven projects. To see how they would look, Rowan made seven test cards and laid them out two ways on a 1,000-pixel window, with the same 16-pixel gap. The first used the wrapping flex row from the last lesson: flex-wrap: wrap and flex: 1 1 15rem on each card. The second used a grid with three equal columns. The first six cards came out identical in both, two rows of three, each card 312 pixels wide.

The seventh card was where they parted company. In the flex layout it sat alone on a third row and stretched across the whole width, 968 pixels, three times as wide as every other card. In the grid it sat in the first column of the third row, 312 pixels wide like the rest, with empty space beside it. Neither layout was broken. They were doing two different jobs, and this lesson is about the difference between those jobs.

Why the seventh card behaved differently

MDN's guide to grid and other layout methods puts the difference in one sentence: flexbox was designed for layout in one dimension, a row or a column, and grid for two dimensions, rows and columns at the same time. A wrapping flex container does not have columns at all. MDN explains that when flex items wrap, each new row is an independent flex line, and the items on it share that line's space among themselves. The seventh card was alone on its line, flex-grow told it to take the free space, so it took all of it.

A grid does have columns. You define them, and every item lands in them, so the seventh card fitted into the first column like a book on a shelf. MDN's summary is worth remembering: flexbox works from the content out, grid works from the layout in.

The comparison

QuestionFlexboxGrid
How many dimensions?One: a row or a column at a timeTwo: rows and columns together
Who decides the sizes?Mostly the items' content, then shared spaceMostly the columns and rows you define
Do items line up between rows?No: each wrapped line is independentYes: items share the same columns
An item alone on the last lineGrows to fill the line, if it may growStays one column wide
Typical jobsA navigation bar, a row of buttons, centring one thingA gallery of cards, a whole page layout

MDN suggests a simple test. Do you only need to control the layout by row or by column? Use flexbox. Do you need to control it by row and column? Use grid. Rowan's header is a single row, so it stays flexbox. The project cards should line up in columns, so they become a grid. Most real pages use both.

A grid, one line at a time

Here is the three-column grid from the test:

.gallery {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
}

display: grid makes the element a grid container, and, exactly as with flexbox, its direct children become grid items. grid-template-columns defines the columns, which grid calls tracks: MDN defines a grid track as the space between two adjacent grid lines. The fr unit represents a fraction of the available space in the grid container, so three tracks of 1fr share the space equally. repeat(3, 1fr) is shorthand for writing 1fr 1fr 1fr. You can mix units too: 15rem 1fr makes a fixed sidebar column and a flexible main column.

Notice what you did not write: any rows. You defined three columns, and the browser created as many rows as the items needed, seven items making three rows. MDN calls this the implicit grid: tracks the browser adds when content is placed outside the grid you defined, sized automatically to fit their content.

Placing items on the lines

Grid numbers the lines between and around its tracks. In a three-column grid, line 1 is the left edge, line 4 the right edge, and negative numbers count from the end, so -1 is also the right edge. An item can be told to span lines:

.banner {
  grid-column: 1 / -1;   /* from the first line to the last */
}

.featured {
  grid-column: span 2;   /* two columns wide, wherever it lands */
}

In the three-column test grid, the banner measured 968 pixels, the full width, and the featured card 640 pixels, two columns plus the gap between them.

For a whole page, grid can do something no other CSS tool does: let you draw the layout in words. Name the areas in a property called grid-template-areas, then assign each element to an area:

.page {
  display: grid;
  grid-template-columns: 1fr 15rem;
  grid-template-areas:
    "head head"
    "main side"
    "foot foot";
  gap: 16px;
}

.page header { grid-area: head; }
.page main   { grid-area: main; }
.page aside  { grid-area: side; }
.page footer { grid-area: foot; }

Each quoted string is a row and each word a cell. On the 1,000-pixel window, Chrome laid the header across the full 968 pixels, main at 712 pixels with the aside beside it at 240 pixels, which is 15rem, and the footer across the full width again. To rearrange the page, you edit the strings. One warning carries over from flexbox: moving things with grid changes only what is seen, not the order in which a keyboard or screen reader meets them, so keep the HTML in a sensible order whatever the grid does.

A grid that decides how many columns fit

A fixed three columns is wrong for a phone. At 360 pixels wide, the test grid squeezed each column to about 99 pixels. With real content in the cards, the min-content floor from Lesson 12 applies to grid columns too, and long words would push the page sideways. Grid has a better answer than a fixed number:

.projects {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: var(--space-m);
}

Read the column definition from the inside out. minmax(15rem, 1fr) means a column may be no narrower than 15rem and no wider than one share of the space. repeat(auto-fill, ...) means make as many such columns as will fit. So the grid works out its own number of columns from the space it has. Lesson 16 looks at this pattern in depth and at the argument about whether it replaces the techniques in Lesson 15.

Portfolio build 4: a header bar and a project grid

Rowan's projects page now wraps its cards in a box for the grid:

<main>
  <h1>Projects</h1>
  <div class="projects">
    <article class="card">
      <h2>Volcanoes of the world</h2>
      <p>A one-page guide to how volcanoes form, with a data table of five famous eruptions.</p>
    </article>
    <article class="card">
      <h2>A recipe card</h2>
      <p>My grandmother's flatbread recipe as an ordered list of steps, checked with the validator.</p>
    </article>
    <article class="card">
      <h2>Contrast checker</h2>
      <p>A small tool that measures the contrast between two colours. Coming in Module 9.</p>
    </article>
  </div>
</main>

The wrapper is a div, the last-resort element from Lesson 6, because it is a box for layout and means nothing more. In styles.css, the cards no longer need margins, since the grid's gap now spaces them, and their headings lose the space above them so they sit neatly at the top of each card:

/* 4. Project cards */
.projects {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: var(--space-m);
}

.card {
  padding: var(--space-m);
  border: 1px solid var(--color-border);
  background-color: var(--color-surface);
}

.card h2 {
  margin-top: 0;
}

Together with the flexbox header from Lesson 11, this is the whole of build 4. On a 1,000-pixel window, Chrome drew the header bar across the top of the 640-pixel column and the cards in two columns of 312 pixels, the third card starting a new row on the left. Inside the 40rem column there is room for two 15rem columns, not three. At 360 pixels wide the grid became a single column of 328-pixel cards, and at 320 pixels a single column of 288, with the page exactly as wide as the window both times. The W3C validator reported no errors in the HTML.

Common misconceptions

  • "Grid replaces flexbox." They solve different problems. Rowan's header stays flexbox because it is one row; the cards are grid because they need columns.
  • "You have to define every row of a grid." Define the columns, and the implicit grid adds rows as the content needs them.
  • "1fr means one equal column, whatever its contents." It is one share of the available space, but long unbreakable content can still hold a track open, just as in Lesson 12.
  • "Grid areas change the reading order." They change only the picture. Screen readers and the Tab key follow the HTML.

What you now know

Key idea: Flexbox arranges items along one line and lets their content set their sizes; grid sets up rows and columns first and places items into them. Use grid when things should line up both ways, as cards in a gallery should, and flexbox for a single row such as a navigation bar.

You can define a grid with fr, repeat() and gap, span items across lines, draw a page with grid-template-areas, and let auto-fill and minmax() choose the number of columns. Module 4 is complete: your portfolio has a header bar and a project grid that works from a phone to a laptop. Module 5 asks what a phone actually does with a web page, starting with a page that showed up on a phone at the size of a postage stamp.

Sources

  1. MDN contributors. (2026). Relationship of grid layout to other layout methods. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Basic concepts of grid layout. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). grid-template-areas and minmax(). Mozilla. developer.mozilla.org
  4. W3C. (2025, March 26). CSS Grid Layout Module Level 2. W3C Candidate Recommendation Draft. w3.org
Key terms
Grid container
An element with display: grid. Its direct children become grid items placed into its rows and columns.
Grid track
A row or column of a grid: the space between two adjacent grid lines.
fr unit
A fraction of the available space in a grid container; three 1fr columns share it equally.
repeat()
Shorthand for repeated tracks: repeat(3, 1fr) means 1fr 1fr 1fr; repeat(auto-fill, ...) makes as many as fit.
minmax()
A track size with a minimum and a maximum, such as minmax(15rem, 1fr).
Implicit grid
Rows or columns the browser adds automatically when there are more items than the tracks you defined.
grid-template-areas
A property that names a grid's areas with quoted strings, one per row, so elements can be placed by name with grid-area.
Grid line
A numbered line between tracks; 1 is the start, and negative numbers count back from the end, so 1 / -1 spans everything.

Module 5: Responsive Design for Phones

Make every page work on a phone: the viewport tag and why phones shrink pages without it, images that fit their space, media queries written mobile-first, and layouts that adapt without being told the screen size. The module ends with your portfolio working from 320 pixels wide upwards.

The Page That Shrank to a Postage Stamp

  • Explain what a phone browser does with a page that has no viewport tag, and fix it.
  • Explain why zoom must never be disabled, citing WCAG 2.2.
  • Make images and other content fit narrow screens, and test a page at 320 CSS pixels as WCAG's Reflow criterion describes.

This course made a copy of Rowan's home page with a single line deleted from the head, the one beginning meta name="viewport". It then opened the copy in Chrome pretending to be a phone 375 pixels wide. The whole page appeared, header, heading and paragraphs, shrunk into the top of the screen like a postage stamp. The text, set at 16 pixels, was about 6 pixels tall on the screen, far too small to read without pinching to zoom.

Chrome reported exactly what had happened. The page had been laid out 980 pixels wide, not 375, and then displayed at a scale of 0.383, so that all 980 pixels fitted across the screen. Sixteen times 0.383 is about 6. With the deleted line put back, the same page was laid out 375 pixels wide at a scale of exactly 1, and the text was 16 pixels again. One line made the difference between a page you can read and one you cannot. This lesson explains why phones behave like this, why that one line fixes it, and what else a page needs before it truly works on a phone.

What the phone was doing

When smartphones arrived, most websites had been designed for desktop screens around 1,000 pixels wide. Laid out at a phone's width, those pages would have broken. So phone browsers adopted a trick, which MDN describes: they render pages in a virtual window wider than the screen, often 980 pixels, and then shrink the result down to fit the screen. Old desktop pages then look like miniature desktop pages, which beats looking broken.

For a page designed to work at any width, like Rowan's, the trick is a disaster. The flexible header and the one-column grid you built in Module 4 never get the chance to adapt, because the browser has told the page that it is 980 pixels wide. The page does exactly what it would do on a laptop, and then all of it is shrunk.

One more detail explains why phone widths are numbers like 375 and not the thousands of pixels phones advertise. Those widths are in CSS pixels, the reference unit from Lesson 10. A phone screen with very dense hardware pixels uses two or three of them for each CSS pixel, so that 16-pixel text comes out at a sensible physical size.

The fix is one line in the head

<meta name="viewport" content="width=device-width, initial-scale=1">

This is the viewport meta tag. width=device-width tells the browser to make the layout as wide as the device's screen in CSS pixels, which MDN describes as the physical size of the device screen in CSS pixels. initial-scale=1 sets the starting zoom to 100 percent. MDN's current guide notes that initial-scale=1 is a common addition that is usually unnecessary, because without it the browser picks the starting zoom itself and may zoom out only when content overflows. Most templates keep it, and so does this course. Either version fixes the postage stamp.

Every page skeleton since Lesson 2 has carried this line, which is why Rowan's real pages never shrank. The danger comes when you copy a page or a template from somewhere else. Check the head of every page you add.

Never switch zoom off

Older tutorials sometimes add more to the same tag, such as maximum-scale=1 or user-scalable=no, to make a site feel more like an app by preventing pinch zoom. Do not copy them. MDN warns that disabling zoom prevents people with low vision from being able to read and understand page content. WCAG 2.2 Success Criterion 1.4.4, Resize Text, at Level AA, requires that text can be resized up to 200 percent, and MDN adds that the best practice is to allow zoom of five times. Some browsers now ignore these settings anyway: MDN notes that iOS 10 and later ignores them by default. But others do not, and the people they lock out are exactly the people who need zoom most.

The tag is not the whole job

The viewport tag stops the phone shrinking your page. It does not make your content fit. Anything with a fixed width wider than the screen will still stick out. The commonest culprit is an image. This course put an image 1,200 pixels wide into a page on a 375-pixel phone. The image was drawn at its full 1,200 pixels, and the page became 1,216 pixels wide, scrolling sideways in a way no phone user expects. One rule fixed it:

img {
  max-width: 100%;
  height: auto;
}

max-width: 100% lets an image be as wide as its own size or as wide as its container, whichever is smaller. height: auto keeps its proportions as it shrinks. With the rule in place the same image measured 343 by 229 pixels, exactly the width of the space inside the page's padding, and the page was 375 pixels wide again. Lesson 24 covers images properly, including how to stop a phone downloading a 1,200-pixel image it will only show at 343. For now, put this rule in every stylesheet you write.

The other culprits you have already met: fixed widths in pixels on boxes, rows of items that cannot wrap, and long unbreakable words, all from Lessons 9 and 12.

How narrow is narrow enough? 320 pixels

WCAG 2.2 sets a precise target in Success Criterion 1.4.10, Reflow, at Level AA. Content must be presentable without loss of information or functionality, and without scrolling in two directions, at a width equivalent to 320 CSS pixels. Text should flow down the page, with nothing pushing it sideways. The criterion exempts content that genuinely needs two dimensions, such as maps, diagrams, video, games and data tables, which may scroll.

The number 320 is not about any particular phone. WCAG's note explains that 320 CSS pixels is equivalent to a starting viewport width of 1,280 CSS pixels at 400 percent zoom. Someone with low vision who zooms a laptop browser to 400 percent gets, in effect, a 320-pixel-wide page. So a page that reflows at 320 works both for small phones and for heavily zoomed desktops.

Testing it yourself

You do not need a phone to test. Chrome's developer tools have a Toggle device toolbar button at the top of the panel. It shows the page at a width you choose, with a box where you can type 320 or 375. Chrome's documentation calls this a first-order approximation of a mobile device: it gets the size and the viewport behaviour right but not the phone's processor, touch screen or real network. Check on a real phone before you publish, but use the toolbar every time you change the layout.

For the zoom test, set the browser window to about 1,280 pixels wide and press Ctrl and the plus key, or Cmd and plus on a Mac, until the zoom reads 400 percent. Then scroll down the whole page. If you ever have to scroll sideways to read a line of text, something does not reflow.

Rowan ran both tests on the portfolio. At 320 pixels the header wrapped onto two lines, the project grid became a single column of 288-pixel cards, and the page was exactly 320 pixels wide. The work from Module 4 had paid off. The only fix needed was the image rule above, added before the first real screenshots go into the projects in Module 8.

Common misconceptions

  • "The viewport tag makes a page responsive." It stops the phone shrinking the page. Fluid layouts, wrapping and images that fit are what make the page work at that width.
  • "A phone that is 1,080 pixels across shows pages 1,080 pixels wide." Widths in the browser are in CSS pixels. Dense screens use several hardware pixels for each CSS pixel.
  • "Disabling zoom makes a site feel like an app." It makes it unusable for readers who need to enlarge it, and it breaks the spirit of WCAG's Resize Text criterion.
  • "Responsive design means building a separate mobile site." It means one site whose layout adapts to the space it has, which is what every lesson in this module builds.

The short version

Why this matters: Without the viewport tag a phone lays your page out about 980 pixels wide and shrinks it until text is unreadable; with it, the page is laid out at the phone's own width. Then everything on the page has to fit that width, down to the 320 pixels WCAG's Reflow criterion uses, and zoom must always stay on.

You saw the postage stamp, measured the 0.383 scale, and fixed it with one line. You know why user-scalable=no does harm, how to make images shrink with their container, and how to test at 320 pixels and at 400 percent zoom. The next lesson goes beyond fitting: changing the layout itself at different widths, with media queries.

Sources

  1. MDN contributors. (2026). meta name="viewport". Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Responsive web design. Learn web development. Mozilla. developer.mozilla.org
  3. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criteria 1.4.4 Resize Text and 1.4.10 Reflow. W3C Recommendation, 12 December 2024. w3.org
  4. Google. (2026). Simulate mobile devices with device mode. Chrome DevTools documentation. developer.chrome.com
Key terms
Viewport meta tag
The line <meta name="viewport" content="width=device-width, initial-scale=1"> that makes a phone lay a page out at its own width.
Virtual viewport
The wider-than-the-screen window, often 980 pixels, that phone browsers use for pages without a viewport tag before shrinking them to fit.
device-width
The width of the device's screen in CSS pixels, such as 375 on many phones.
Device pixel ratio
How many hardware pixels a screen uses for each CSS pixel; dense phone screens use two or three.
Fluid image
An image styled with max-width: 100% and height: auto, so it shrinks with its container and keeps its proportions.
Reflow
WCAG 2.2 Success Criterion 1.4.10: content works without two-way scrolling at a width of 320 CSS pixels, equal to 1,280 pixels at 400% zoom.
Device toolbar
The Chrome DevTools mode that shows a page at a chosen width, a first-order approximation of a phone.

Media Queries and Mobile-First Breakpoints

  • Write media queries in both the min-width form and the range syntax, and predict exactly where they start to apply.
  • Build styles mobile first, and explain the boundary bug that overlapping queries cause.
  • Choose breakpoints from the content in rem, and use preference queries such as prefers-reduced-motion.

Here are three rules from Rowan's stylesheet:

h1 {
  font-size: 2rem;
}

@media (min-width: 48rem) {
  h1 {
    font-size: 2.75rem;
  }
}

This course loaded a page with those rules in Chrome at a window 767 pixels wide, then at 768. At 767 the heading measured 32 pixels. At 768 it measured 44. One pixel of window made the heading 12 pixels bigger, because 48rem is 768 pixels at the default text size, and at 768 the block inside @media switched on. That block is a media query: a set of rules that apply only when a condition about the device or window is true. This lesson builds media queries step by step, the way most professional stylesheets now do, starting from the phone. Then it shows a bug that appears when the same thing is written the other way round, and what a reader's text size does to a breakpoint.

A media query, piece by piece

A media query has three parts. @media announces it. The condition in brackets, such as (min-width: 48rem), is a media feature with a value. And the block in curly braces holds ordinary CSS rules, which the browser uses while the condition is true and ignores while it is false. Rules inside a media query have no extra specificity: they take part in the cascade like any others, so a query near the end of the file can override earlier rules of equal weight, as Lesson 8 described.

min-width: 48rem means the viewport is at least 48rem wide. max-width: 48rem means at most 48rem. Newer browsers also accept a range syntax that reads like maths. MDN's guide gives both forms and notes that the comparisons are inclusive unless you use the strict signs:

@media (width >= 48rem) { ... }          /* same as min-width: 48rem */
@media (width < 48rem) { ... }           /* narrower than 48rem, not equal */
@media (30rem <= width < 48rem) { ... }  /* a band between two widths */

The width at which a layout changes is called a breakpoint. Rowan's stylesheet has one, at 48rem.

Step 1: write the phone version first

Everything outside a media query applies everywhere, including the smallest phone. So the first step is to make the page right at the narrowest width, with no media queries at all. Rowan's portfolio already is: one column, a header that wraps, cards in a single column, a 2rem heading and 1rem of padding at the sides. MDN describes this pattern: create a simple single-column layout for narrow screens, then check for wider screens and add a multiple-column layout when you know there is enough room. It is called mobile first.

Step 2: add to it as space grows

On a wide screen Rowan wants a bigger main heading and more space at the edges. Both go in one min-width query:

@media (min-width: 48rem) {
  h1 {
    font-size: 2.75rem;
  }

  body {
    padding: 0 var(--space-l);
  }
}

Chrome measured exactly what the rules predict. At 767 pixels: heading 32 pixels, side padding 16. At 768 and above: heading 44 pixels, padding 32. Each query in a mobile-first stylesheet only adds or adjusts. Nothing in the base styles has to be undone for phones, because the base styles were written for phones in the first place.

Step 3: the same idea written desktop first, and the bug it hides

Now change one input: write it the other way round. Plenty of older stylesheets start from the desktop design and use max-width queries to change things for smaller screens, often with pixel breakpoints like 768px. Mixing the two directions is where trouble starts. This course gave a paragraph two queries that looked as if they divided the screen cleanly in two:

@media (max-width: 768px) {
  p.note { color: rgb(200, 0, 0); }   /* red on small screens */
}

@media (min-width: 768px) {
  p.note { color: rgb(0, 0, 200); }   /* blue on large screens */
}

At 767 pixels the paragraph was red and at 769 it was blue, as intended. At exactly 768 pixels, both conditions were true, because max-width and min-width both include the value itself. Both blocks applied, and the later one won the cascade, so the paragraph was blue. That is harmless for a colour. It is not harmless when the two blocks set different properties, because at the boundary you get both sets at once: the narrow layout's widths and the wide layout's columns, say, mixed into something nobody designed.

The fixes are simple. Write in one direction only, mobile first with min-width, so no two queries ever need to meet. And if you do need a band of widths, use the range syntax with a strict sign at one end, such as width < 48rem, so the boundary belongs to exactly one side.

Breakpoints in rem, and a reader with big text

Rowan's breakpoint is 48rem, not 768px. That matters for the reader from Lesson 10 who set the browser's default text size to 24 pixels. The Media Queries Level 4 specification says that relative length units in media queries are based on the initial value, the browser's default text size, not on anything your CSS sets. So for that reader, 48rem is 48 times 24, which is 1,152 pixels.

This course tested it. With the default text size at 24 pixels and a 1,000-pixel window, the 48rem query did not match: the page kept its single-column phone styles, with a heading of 48 pixels, which is 2rem at 24 pixels each. At 1,200 pixels it did match. That is exactly right. A reader whose text is half as large again needs the layout designed for less space, and a rem breakpoint gives it to them without your writing anything extra. A pixel breakpoint would have switched to the wide layout at 768 regardless, cramming big text into columns built for small text. MDN's guide makes the same recommendation: define breakpoints with relative units rather than the sizes of particular devices.

Where to put a breakpoint: let the content decide

It is tempting to set breakpoints at the widths of popular phones and tablets. New devices appear every year, so those numbers date quickly. MDN's advice is to set a breakpoint at the point where the content starts to look bad, for example when a box becomes squashed with two words on each line. In practice: open the device toolbar, start narrow, and drag the width wider. The moment a line of text grows uncomfortably long, or a card looks lonely in too much space, note the width, convert it to rem by dividing by 16, and add a min-width query there. Most small sites need one or two breakpoints. Rowan's needs one.

Media queries are not only about width

Some media features describe the reader's preferences instead of the screen. Operating systems let people ask for less motion, often because animation makes them dizzy or sick. MDN lists prefers-reduced-motion among the features a page can test, along with prefers-color-scheme for dark mode and prefers-contrast:

a {
  transition: color 0.2s;
}

@media (prefers-reduced-motion: reduce) {
  a {
    transition: none;
  }
}

The first rule fades a link's colour when it changes, over 0.2 seconds. The query switches that fade off for anyone who has asked for reduced motion. WCAG 2.2 has a related criterion at its highest level, AAA: 2.3.3 Animation from Interactions, which asks that motion animation triggered by interaction can be disabled unless it is essential. Lesson 17 uses prefers-color-scheme to give the portfolio a dark theme.

Common misconceptions

  • "Breakpoints should match popular devices." Devices change every year. Put breakpoints where your content needs them, in rem.
  • "max-width: 768px and min-width: 768px divide the screen cleanly." Both include 768, so at exactly that width both apply at once.
  • "Media queries add specificity." They add a condition, not weight. Inside them, the normal cascade rules apply.
  • "Mobile first means designing a separate phone site." It means one stylesheet whose base rules suit the narrowest screen, with min-width queries adding to them.

Putting it together

The point: Write the phone layout first with no queries, then add min-width queries in rem where the content starts to look wrong. Keep to one direction so no two queries overlap, and remember that rem breakpoints move with the reader's text size, which is exactly what that reader needs.

You watched a heading jump from 32 to 44 pixels across a single pixel of window, found the boundary bug at exactly 768, and saw a rem breakpoint move to 1,152 pixels for a reader with large text. In the next lesson a group of designers asks whether you need breakpoints at all, and you will weigh their case.

Sources

  1. MDN contributors. (2026). Using media queries. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Responsive web design. Learn web development. Mozilla. developer.mozilla.org
  3. W3C. (2026, February 19). Media Queries Level 4, Section 1.3: Units. W3C Candidate Recommendation Draft. w3.org
  4. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criterion 2.3.3 Animation from Interactions. W3C Recommendation, 12 December 2024. w3.org
Key terms
Media query
An @media block whose rules apply only while a condition, such as (min-width: 48rem), is true.
Media feature
The condition tested by a media query, such as width, orientation, prefers-reduced-motion or prefers-color-scheme.
Breakpoint
A width at which a layout changes, set by a media query; best placed where the content needs it, in rem.
Mobile first
Writing base styles for the narrowest screen and adding min-width media queries as space grows.
Range syntax
The newer form of width queries, such as (width >= 48rem) or (width < 48rem), where < and > exclude the boundary.
prefers-reduced-motion
A media feature that is true when the reader has asked their system to reduce motion, so animations can be switched off.

Do You Still Need Breakpoints? Intrinsic Layouts and the Argument Over Them

  • Set out the case for explicit breakpoints and the case for intrinsic layouts, each with its own evidence.
  • Use min(), clamp() and container queries, and explain where each approach breaks.
  • Make the portfolio pass the 320-pixel reflow test at the default text size and at a large text size.

On 25 May 2010 the web design magazine A List Apart published an article by Ethan Marcotte called Responsive Web Design. Phones were starting to browse the web, and designers were building separate mobile sites to cope. Marcotte proposed one site that adapts instead, and named what it needed: fluid grids, flexible images and media queries, in his words the three technical ingredients for responsive web design. The approach spread quickly, and the breakpoint you wrote in Lesson 15 is a direct descendant of that article.

Eight years later, on a 2018 episode of The Big Web Show, the designer Jen Simmons argued that the web had moved on: with flexbox and grid, she said, you don't have to use media queries to make these things happen. She called the new approach intrinsic web design. Both positions are held by experienced, careful people, and both are in use on sites you visit every day. This lesson lays out each case with its evidence, tests where each one breaks, and then applies what survives to your portfolio.

The case for breakpoints

Marcotte's approach puts a designer's decisions at named widths. Below this width, one column; above it, two; above that, a sidebar. MDN's guide to responsive design still teaches it as the common approach: start with a single column for narrow screens and add a multiple-column layout when you know there is room.

The strongest argument for it is that some changes are not proportional. Making Rowan's heading larger on wide screens in Lesson 15 was a decision, not a calculation: at 48rem the design says the heading should become 2.75rem, a size chosen by eye. A navigation bar that turns into a menu button, a sidebar that moves below the article, a table swapped for a list on small screens: none of these happens by gradually stretching something. They happen at a point someone chose. Breakpoints also make testing concrete, because there is a known list of widths at which the design changes.

The weaknesses are the ones Lesson 15 exposed. Breakpoints copied from particular devices go out of date. Pixel breakpoints ignore a reader's text size. And two queries written in different directions can overlap at the boundary.

The case for intrinsic layouts

Simmons's argument is that the newer layout tools let the content and the space decide. A wrapping flex row moves items to new lines when they run out of room. A grid built with auto-fill and minmax() chooses its own number of columns. The authors of the book Every Layout, Heydon Pickering and Andy Bell, push the idea further. They call it algorithmic layout design: build components that harness the browser's own layout algorithms, so that media-query breakpoints become unnecessary.

The evidence is in your own portfolio. Rowan's project grid from Lesson 13 contains no media query at all, yet Chrome gave it two 312-pixel columns on a 1,000-pixel window, one 328-pixel column at 360 pixels, and one 288-pixel column at 320. Nobody chose those numbers. The grid worked them out, at every width in between as well, including widths no device has yet.

Intrinsic layouts also suit components that appear in different places. A project card might sit in a wide main column on one page and a narrow sidebar on another. A media query asks how wide the window is, which tells the card nothing about its own space. A newer tool, the container query, answers the right question. MDN describes it as applying styles to an element based on the size of its container, as an alternative to media queries, which look at the viewport:

.wrap {
  container-type: inline-size;
}

@container (min-width: 30rem) {
  .tile {
    display: flex;
    gap: 1rem;
  }
}

container-type: inline-size makes .wrap a container that its contents can measure. When this course placed two identical tiles in two containers on the same page, one 20rem wide and one 40rem wide, the tile in the narrow container stayed a plain block and the tile in the wide container became a flex row. Same window, same CSS, two layouts, each decided by its own space.

Where intrinsic layouts break: a test with large text

The intrinsic case has a weak point too, and this course found it by repeating the test from Lessons 10 and 15: the reader with a 24-pixel default text size, on a 320-pixel screen. Rowan's grid used minmax(15rem, 1fr), and for that reader 15rem is 360 pixels. The space available inside the page's padding was only 272 pixels. The grid did exactly what it was told: it made one column, 360 pixels wide, and that column stuck out past the edge of the screen. The algorithm worked, but the minimum it was given was too large.

The fix is one more function. min() takes several values and uses whichever is smallest:

.projects {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
  gap: var(--space-m);
}

Read min(15rem, 100%) as: a column's minimum is 15rem, unless the whole space is narrower than that, in which case it is the whole space. With that change the same reader got a single 272-pixel column and a page exactly 320 pixels wide. Intrinsic layouts are only as good as the limits you give them, and those limits need testing at large text sizes, not just at small widths.

Where fluid type needs care

The intrinsic camp often sizes headings fluidly, so they grow smoothly with the window instead of jumping at a breakpoint. The tool is clamp(), which takes a minimum, a preferred value and a maximum:

h1 {
  font-size: clamp(2rem, 1.5rem + 2.5vw, 3rem);
}

Chrome measured this heading at 32 pixels on a 320-pixel window, where the minimum applied, 43.2 pixels at 768, which is 24 plus 2.5 percent of 768, and 48 pixels at 1,280, where the maximum applied. MDN gives the reason the preferred value mixes rem with vw: text set using viewport units alone cannot be enlarged by zooming, because it stays tied to the size of the window. Adding a rem part keeps it zoomable. Keep the maximum modest too. Zooming in narrows the page in CSS pixels, which shrinks the vw part even as the zoom enlarges everything, so a heading allowed to grow to many times its minimum can grow less than the zoom suggests. A narrow range, such as 2rem to 3rem, keeps that effect small. Rowan's portfolio keeps the simple breakpoint from Lesson 15 instead, which is easier to check.

What would settle it

Notice that both approaches failed in the same way: by meeting a reader nobody had tested for. The pixel breakpoint failed the reader with large text. The intrinsic grid failed the same reader until min() was added. So the argument is settled less by choosing a side than by testing. Three checks catch nearly everything:

  • Reflow at 320 CSS pixels wide, as WCAG 2.2's Success Criterion 1.4.10 requires, with no sideways scrolling for text.
  • The same page with the browser's default text size raised, which is where minimums like 15rem bite.
  • A desktop window zoomed to 400 percent, which WCAG notes is equivalent to 320 pixels and which tests fluid type.

In practice most current sites use both approaches. Components such as card grids and button rows are intrinsic, and a small number of breakpoints handle the changes that need a decision. That is where Rowan's portfolio ends up.

Portfolio build 5: from 320 pixels up

Rowan's stylesheet gains a sixth section, and the grid gains min():

/* 4. Project cards (changed) */
.projects {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
  gap: var(--space-m);
}

/* 6. Responsive extras (Module 5) */
img {
  max-width: 100%;
  height: auto;
}

h1 {
  font-size: 2rem;
}

@media (min-width: 48rem) {
  h1 {
    font-size: 2.75rem;
  }

  body {
    padding: 0 var(--space-l);
  }
}

a {
  transition: color 0.2s;
}

@media (prefers-reduced-motion: reduce) {
  a {
    transition: none;
  }
}

This course tested the projects page with these rules. At 320 pixels with default settings: one 288-pixel column, a 32-pixel heading, and a page exactly 320 pixels wide. At 320 pixels with a 24-pixel default text size: one 272-pixel column, a 48-pixel heading, the navigation wrapping onto a second line, and still no sideways scrolling. At 1,000 pixels: two 312-pixel columns, a 44-pixel heading and 32 pixels of padding at the sides. The pages validated with no errors.

Common misconceptions

  • "Intrinsic design means never writing a media query." It means letting layouts adapt by themselves where they can. Changes that need a decision still need a query, or a container query.
  • "Responsive design is out of date." Fluid grids, flexible images and media queries are all still in daily use. Intrinsic techniques extend the first two.
  • "If it works at 320 pixels, it works." Rowan's grid worked at 320 pixels at the default text size and overflowed at a larger one. Test both.
  • "Fluid type in vw is the modern way to size headings." Text in vw alone stops responding to zoom. Mix it with rem and keep its range narrow, or use a breakpoint.

Looking back

The upshot: Breakpoints put a designer's decisions at chosen widths; intrinsic layouts let wrapping, auto-fill, minmax(), min() and container queries adapt to the space by themselves. Each fails when its limits meet a reader nobody tested for, so test at 320 pixels, at large text and at 400 percent zoom, and use each approach where it is strongest.

Module 5 is complete. Your portfolio reflows from 320 pixels upwards, at the default text size and at a large one, with one breakpoint for the heading and an intrinsic grid for the projects. Module 6 turns from how the site fits to how it looks: colour, contrast, type and the visual design that holds a page together.

Sources

  1. Marcotte, E. (2010, May 25). Responsive web design. A List Apart, No. 306. alistapart.com
  2. Zeldman, J. (2018, May 2). Transcript: Intrinsic web design with Jen Simmons (The Big Web Show, episode 176). zeldman.com
  3. Pickering, H., and Bell, A. (n.d.). Every Layout. every-layout.dev
  4. MDN contributors. (2026). CSS container queries, clamp() and min(). Mozilla. developer.mozilla.org
  5. MDN contributors. (2026). Responsive web design. Learn web development. Mozilla. developer.mozilla.org
Key terms
Responsive web design
Ethan Marcotte's 2010 approach of one site that adapts, built from fluid grids, flexible images and media queries.
Intrinsic web design
Jen Simmons's 2018 name for layouts that adapt to their content and space with flexbox and grid, often without media queries.
min()
A CSS function that uses the smallest of its values; min(15rem, 100%) never exceeds the space available.
clamp()
A CSS function taking a minimum, a preferred value and a maximum, used for sizes that grow between limits.
Container query
An @container rule that styles an element by the size of its container rather than the viewport.
container-type
The property that makes an element a container its contents can query, for example container-type: inline-size.

Module 6: Colour, Type and Visual Design

Choose and write colours as a designer does, measure contrast exactly as WCAG defines it, set type that is comfortable to read, and use hierarchy, alignment and space to make a page easy to scan. The module ends with your portfolio's palette, type scale and spacing, every colour pair checked.

Colour on Screens: Hex, RGB, HSL and a Palette of Your Own

  • Read and write a colour as a name, a hex code, rgb() and hsl(), and convert a hex pair to a number.
  • Build a small palette by role, using hue, saturation and lightness to make tints and contrasting colours.
  • Apply WCAG 2.2's Use of Color criterion, and add a dark theme with prefers-color-scheme.

Rowan's accent colour can be written as #8a3b12, as rgb(138 59 18), or as hsl(20.5 76.9% 30.6%). This course gave all three to Chrome, and Chrome turned each one into exactly the same thing: rgb(138, 59, 18), a dark rust brown. Then it rounded the third to hsl(20 77% 31%), which looks tidier, and Chrome produced rgb(140, 59, 18): a different colour, a shade redder. Three notations, one colour, and a reminder that the numbers are exact instructions, not approximate descriptions.

This lesson compares the ways CSS can write a colour, shows why designers prefer one of them for building palettes, and turns Rowan's handful of colours into a palette organised by job, with a dark version for readers who prefer one. The next lesson measures whether those colours can actually be read.

How a screen makes colour

Every pixel on a screen is made of three tiny lights: red, green and blue. Mixing them in different strengths makes every colour you see, which is why screen colour is called RGB. In ordinary web colour each light has 256 levels, from 0, off, to 255, full. Three lights with 256 levels each give 256 times 256 times 256 combinations, 16,777,216 colours. All 255s is white, all 0s is black, and equal amounts of all three make a grey.

The numbers mean the same thing on every screen because browsers assume a standard colour space called sRGB. WCAG's definition of relative luminance, which the next lesson uses, notes that almost all systems used to view web content assume sRGB.

Five ways to write a colour, compared

NotationRowan's accent, or an exampleHow to read itGood for
Named colourtomato, rebeccapurpleA word. MDN counts the 16 basic names plus about 150 moreQuick tests and examples
Hex code#8a3b12Three pairs of hexadecimal digits: red, green, blueCopying exact colours from design tools
rgb()rgb(138 59 18)Red, green and blue from 0 to 255Seeing the mix of light directly
hsl()hsl(20.5 76.9% 30.6%)Hue as an angle, then saturation and lightness as percentagesBuilding palettes, tints and shades
oklch()oklch(45% 0.12 45)Lightness, chroma and hue in a space matched to human visionAdvanced palettes with even steps of lightness

The named colours come with surprises: tomato is rgb(255, 99, 71), and rebeccapurple, rgb(102, 51, 153), was added in CSS Colors Level 4, MDN notes, to honour the web pioneer Eric Meyer. Names are fine for experiments, but a real palette needs colours chosen for the job, and those rarely have names.

Every notation except the names can also carry alpha, a measure of opacity, after a slash: rgb(138 59 18 / 50%) is the accent at half strength, letting what is behind show through. In hex, a fourth pair does the same job: #8a3b1280. Chrome resolved both to the same half-transparent rust.

Reading a hex code

Hexadecimal counts in sixteens instead of tens. Its digits are 0 to 9 and then a to f, which stand for 10 to 15. A pair of hex digits means the first digit times 16, plus the second:

  • 8a is 8 times 16, plus 10, which is 138: the red light.
  • 3b is 3 times 16, plus 11, which is 59: the green light.
  • 12 is 1 times 16, plus 2, which is 18: the blue light.

So #8a3b12 is rgb(138 59 18): a lot of red, a little green, almost no blue, which reads as a dark orange-brown. The largest pair, ff, is 15 times 16 plus 15, which is 255. A three-digit code is shorthand that doubles each digit, so #f60 means #ff6600.

Why designers think in HSL

Hex and rgb() describe colour the way a screen makes it. HSL describes it the way people talk about it, which makes it far easier to build with. The three values are:

  • Hue: which colour, as an angle round a colour wheel. MDN's example shows red at 0; on the same wheel green is at 120 and blue at 240.
  • Saturation: how vivid. MDN puts it as 100% completely saturated and 0% completely unsaturated, which is grey.
  • Lightness: how light. 0% is black, 100% is white and 50% is the pure colour.

Look at Rowan's palette in HSL and its logic appears. The text colour #1f2933 is hsl(210 24% 16%): a very dark, greyish blue. The link colour #1a5a96 is hsl(209 70.5% 34.5%): the same hue, much more vivid and lighter. The accent #8a3b12 sits at a hue of 20.5, on almost the opposite side of the wheel. MDN describes finding a complementary colour by adding or subtracting 180 degrees from the hue, and 210 minus 180 is 30, close to Rowan's rust. Opposite hues make each other stand out, which is why the rust heading pops against blue-grey text without clashing.

HSL also makes variations easy. To get a pale tint of the accent for a highlighted box, keep the hue and saturation and raise only the lightness: hsl(20.5 76.9% 90%), which Chrome resolved to rgb(249, 223, 210), a pale peach. A darker shade is the same move in the other direction. In hex you would have to recalculate all three pairs.

HSL has one weakness. Its lightness is mathematical, not perceptual: a yellow and a blue with the same HSL lightness look very different in brightness to the eye. That is what the newer oklch() notation fixes, and why the next lesson measures lightness properly instead of trusting the L in HSL.

A palette is a set of jobs

A working palette is small, and each colour has a role. Rowan's has six, named in styles.css by what they do rather than what they look like:

Custom propertyValueJob
--color-text#1f2933Body text
--color-background#fdfcf8The page behind everything
--color-surface#ffffffCards that sit on the background
--color-accent#8a3b12Headings and highlights
--color-link#1a5a96Links
--color-border#8c8478Decorative lines

Naming by role is what makes the next section possible. If the property were called --color-rust, giving it a pale orange value in a dark theme would make the name a lie. --color-accent can be any colour that does the accent's job.

Never colour alone

Some readers cannot tell certain colours apart. The US National Eye Institute says about 1 in 12 men have colour vision deficiency, and that the most common type makes it hard to tell red from green. So WCAG 2.2 has a Level A criterion, 1.4.1 Use of Color: colour must not be the only visual means of conveying information, indicating an action, prompting a response or distinguishing an element.

Two everyday consequences follow. Links inside paragraphs keep their underline, so they are not marked by colour alone. And when a form field has an error, a red border is not enough: the error must also be said in words, which Lesson 26 builds. Chrome's developer tools can show you your page as some readers see it: the Rendering tab has an Emulate vision deficiencies menu with several types of colour blindness, blurred vision and reduced contrast.

The same roles, a dark theme

Many people set their device to a dark appearance, and a media feature from Lesson 15 reports it: prefers-color-scheme. Because Rowan's colours are custom properties named by role, a dark theme is just new values for the same names:

@media (prefers-color-scheme: dark) {
  :root {
    --color-text: #e6e1d8;
    --color-background: #1b1f24;
    --color-accent: #f2a46b;
    --color-link: #8ec5ff;
    --color-surface: #252b32;
    --color-border: #77706a;
  }
}

Not one other rule changes. When this course loaded the projects page with Chrome set to dark mode, the page turned dark blue-grey with warm off-white text, the heading became a light orange, the links a pale blue and the cards a slightly lighter grey than the page. Notice the dark accent is not the light accent inverted. It is the same hue family, 25 against 20.5, made much lighter, because on a dark background a colour needs to be light to stand out. Every text colour in both themes was measured for contrast before it went in, which is exactly the skill the next lesson teaches.

Common misconceptions

  • "Hex is more precise than HSL." Both are exact. Rounding is what changes a colour, in any notation.
  • "A dark theme is the light theme inverted." Inverting turns dark blue text into pale yellow and photographs into negatives. Choose dark-theme colours on purpose, by role.
  • "Red for errors is clear enough." Not to the 1 in 12 men with colour vision deficiency. WCAG's Use of Color criterion needs words or another visual cue as well.
  • "The L in HSL tells you how bright a colour looks." It is a mathematical lightness. Yellow and blue at the same HSL lightness look very different, so measure contrast instead of trusting it.

Recap

In short: A screen colour is a mix of red, green and blue light, which CSS can write as a hex code, rgb() or hsl(). Designers build palettes in HSL, keeping a hue and changing lightness for tints and shades and using opposite hues for contrast. Name colours by their role, never let colour carry meaning alone, and redefine the roles for a dark theme.

You can now read #8a3b12 as 138, 59 and 18, explain why Rowan's rust and blue suit each other, make a tint in one move, and add a dark theme without touching a single rule beyond the tokens. Whether all these colours can actually be read is a question with an exact answer, and the next lesson works it out by hand.

Sources

  1. MDN contributors. (2026). hsl() and named-color. Mozilla. developer.mozilla.org
  2. W3C. (2026, October 26). CSS Color Module Level 4. W3C Candidate Recommendation Draft. w3.org
  3. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criterion 1.4.1 Use of Color and the definition of relative luminance. W3C Recommendation, 12 December 2024. w3.org
  4. National Eye Institute. (2025, November 5). Color blindness. US National Institutes of Health. nei.nih.gov
  5. MDN contributors. (2026). prefers-color-scheme. Mozilla. developer.mozilla.org
Key terms
RGB
Screen colour made by mixing red, green and blue light, each from 0 to 255 in ordinary web colour.
Hex code
A colour written as three pairs of hexadecimal digits for red, green and blue, such as #8a3b12 for 138, 59 and 18.
HSL
A colour written as hue (an angle on the colour wheel), saturation and lightness, such as hsl(20.5 76.9% 30.6%).
Hue
Which colour, as an angle on the colour wheel: red at 0, green at 120, blue at 240. Opposite hues are 180 degrees apart.
Saturation
How vivid a colour is, from 0% (grey) to 100% (fully saturated).
Lightness
In HSL, how light a colour is, from 0% (black) through 50% (the pure colour) to 100% (white).
Alpha
Opacity, written after a slash, as in rgb(138 59 18 / 50%), or as a fourth hex pair.
prefers-color-scheme
A media feature reporting whether the reader has chosen a light or dark appearance for their device.

Grey on White: Measuring Contrast the Way WCAG Does

  • State WCAG 2.2's contrast thresholds exactly: 4.5:1 for normal text, 3:1 for large text, 3:1 for interface components and graphics.
  • Calculate a contrast ratio by hand from two hex colours, using WCAG's definition of relative luminance.
  • Check every text and interface colour pair in a palette with a calculation or a tool, without rounding.

Two greys, side by side on a white page. One is #767676, the other #777777, one step lighter on each of its three channels. Next to each other they look identical. But measured by WCAG's formula, #767676 on white has a contrast ratio of 4.54 to 1, and #777777 has 4.48 to 1. For normal-sized text, WCAG 2.2 draws the line at 4.5 to 1. The first grey passes. The second fails.

A difference too small to see decides whether a page meets the accessibility guidelines. That is not pedantry. A threshold has to sit somewhere, and WCAG's explanation of this one is that 4.5 to 1 compensates for the loss in contrast sensitivity typically experienced by people with vision equivalent to about 20/40. This lesson works the calculation by hand, step by step, so that the number from any contrast tool means something to you. Then it applies it to every colour pair in Rowan's portfolio.

The rules, stated exactly

WCAG 2.2 has three contrast criteria. These are their thresholds, in the W3C's own terms:

CriterionLevelWhat it coversMinimum ratio
1.4.3 Contrast (Minimum)AAText and images of text4.5:1, or 3:1 for large-scale text
1.4.6 Contrast (Enhanced)AAAText and images of text7:1, or 4.5:1 for large-scale text
1.4.11 Non-text ContrastAAParts of interface components and graphics needed to understand them3:1 against adjacent colours

Large-scale text has a precise definition: at least 18 point, or at least 14 point if bold. WCAG's explanation of 1.4.3 gives the equivalents in CSS as approximately 24 pixels and approximately 18.5 pixels. So it is about size, not about which element the text is in: a 32-pixel h1 is large text, and a tiny h6 is not.

1.4.3 makes a few exceptions. Text that is purely decorative, part of a logo, part of an inactive control, or part of a photo with other significant content has no contrast requirement. Placeholder text in form fields, and text that appears when you hover, are not exceptions: the explanation says they need sufficient contrast too. Level AA is the level most laws and policies ask for, so 4.5:1 and 3:1 are the numbers to meet. This course meets AAA for body text wherever it can.

What the ratio measures

The contrast ratio compares how much light two colours give off, from 1:1 for two identical colours to 21:1 for black on white. WCAG defines it with a short formula:

contrast ratio = (L1 + 0.05) / (L2 + 0.05)

L1 is the relative luminance of the lighter colour and L2 that of the darker one. WCAG defines relative luminance as the relative brightness of a colour, from 0 for the darkest black to 1 for the lightest white. The 0.05 added to each stands for the light that reflects off any real screen, which is why even black on black has a ratio of 1 rather than an impossible division by zero. The work is all in finding L for each colour.

Working it out by hand: #767676 on white

WCAG's definition gives every step. Follow them for the passing grey.

Step 1: turn each channel into a number from 0 to 1. #767676 has 76 in each pair, which is 7 times 16 plus 6, or 118. Divide by 255: 118 / 255 = 0.4627.

Step 2: undo the screen's brightness curve. Screen values are not proportional to light: a value halfway up gives much less than half the light. WCAG's definition reverses that curve. For each channel value c, if c is 0.04045 or less, divide it by 12.92. Otherwise, calculate ((c + 0.055) / 1.055) raised to the power 2.4. Here 0.4627 is larger than 0.04045, so the second rule applies:

(0.4627 + 0.055) / 1.055 = 0.4907
0.4907 raised to the power 2.4 = 0.1812

A calculator with a power key does this, often written as x to the y. The answer, 0.1812, is the channel's actual share of full light, less than a fifth even though the value sat almost halfway up the scale.

Step 3: weight the channels and add them. The eye is far more sensitive to green light than to red, and least sensitive to blue. WCAG's formula weights them accordingly:

L = 0.2126 × R + 0.7152 × G + 0.0722 × B

For a grey, all three channels are equal, and the weights add up to exactly 1, so L = 0.1812.

Step 4: the ratio. White has L = 1, so it is the lighter colour:

(1 + 0.05) / (0.1812 + 0.05) = 1.05 / 0.2312 = 4.54

4.54 is at least 4.5, so #767676 passes for normal text.

Change one step: #777777 on white

Now repeat the procedure with one input changed by a single step. 77 in hex is 119. Then 119 / 255 = 0.4667. Through the curve, ((0.4667 + 0.055) / 1.055) raised to 2.4 gives 0.1845. As a grey, L = 0.1845. The ratio is 1.05 / 0.2345 = 4.48.

4.48 is below 4.5. The result flips: #777777 fails for normal text, though it passes for large text, where 3:1 is enough. And you may not round it up. WCAG's explanation says plainly that computed values should not be rounded, and gives the example that 4.499:1 does not meet the 4.5:1 threshold.

A colour, not a grey: Rowan's accent on the page

The procedure is the same for a colour, but each channel has its own value. For the accent #8a3b12 on the background #fdfcf8:

ChannelHex pairValue / 255After the curveTimes its weight
Red8a = 1380.54120.25420.0540
Green3b = 590.23140.04370.0313
Blue12 = 180.07060.00600.0004

Adding the last column gives L = 0.0857 for the accent. The near-white background works out at L = 0.9728. The ratio is (0.9728 + 0.05) / (0.0857 + 0.05) = 1.0228 / 0.1357, which is 7.54. That passes even the AAA threshold of 7:1 for normal text. Look at the table again: the red channel is more than twice the green, yet green still contributes over half as much to the luminance as red does, because of its weight. Colours with little green are darker than they look in their hex codes.

Tools, and what they are for

Nobody calculates contrast by hand for a living. Two tools do it instantly, and you now know what their numbers mean:

  • WebAIM's Contrast Checker, a free page where you enter a foreground and a background colour and it reports the ratio and which WCAG levels it passes.
  • Chrome's colour picker. In the developer tools' Styles pane, click the small colour square beside any colour. Chrome's documentation describes a Contrast ratio section that shows whether the colour meets AA and AAA, with lines marking where the thresholds fall.

Use the tools for everyday checking. Use the hand method when a tool's answer surprises you, or when you need to know how much darker a colour must be.

Rowan's palette, every pair checked

Every combination that actually appears on the site, measured with WCAG's formula:

Text or elementOnRatioNeedsResult
Text #1f2933Background #fdfcf814.37:14.5:1Passes AAA
Accent #8a3b12Background #fdfcf87.54:13:1 for large headingsPasses AAA
Link #1a5a96Background #fdfcf86.94:14.5:1Passes AA
Link #1a5a96Card #ffffff7.13:14.5:1Passes AAA
Dark theme text #e6e1d8Background #1b1f2412.72:14.5:1Passes AAA
Dark theme accent #f2a46bBackground #1b1f248.12:13:1 for large headingsPasses AAA
Dark theme link #8ec5ffCard #252b327.88:14.5:1Passes AAA
Card border #8c8478Background #fdfcf83.60:1None while decorativeWould pass 3:1

The border row matters later. A card's outline is decoration: you can tell where a card is from its content, so 1.4.11 does not apply. But the border of a text box in a form is how people find the box at all, and then 3:1 is required. When Rowan builds a form in Lesson 26, the input borders must clear that bar.

Common misconceptions

  • "Contrast is about choosing different colours." It is about luminance. Red and green of similar luminance contrast badly, however different they look in hue.
  • "4.48 rounds to 4.5, so it passes." WCAG's explanation says the computed ratio is not rounded: 4.499:1 fails.
  • "Large text means headings." It means at least 18 point, or 14 point bold: about 24 pixels, or about 18.5 pixels bold. A small heading is normal text.
  • "Placeholder text is exempt because it is temporary." WCAG's explanation includes placeholder and hover text in the requirement.

Pulling it together

So what? WCAG 2.2 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text and for the parts of controls and graphics people need to see. The ratio comes from relative luminance: divide each channel by 255, undo the screen's curve, weight green most, then divide the lighter value plus 0.05 by the darker plus 0.05. Never round up.

You calculated 4.54 for one grey and 4.48 for the next, and saw a single step flip the result. You traced Rowan's accent to 7.54, and checked every pair on the site in both themes. Colour can now be chosen with evidence. The next lesson turns to the other half of readable text: the type itself.

Sources

  1. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criteria 1.4.3, 1.4.6 and 1.4.11, and the definitions of contrast ratio, relative luminance and large scale. W3C Recommendation, 12 December 2024. w3.org
  2. W3C Web Accessibility Initiative. (2023). Understanding Success Criterion 1.4.3: Contrast (minimum). Understanding WCAG 2.2. w3.org
  3. WebAIM. (n.d.). Contrast checker. webaim.org
  4. Google. (2026). Make your website more readable (contrast in the Color Picker). Chrome DevTools documentation. developer.chrome.com
Key terms
Contrast ratio
WCAG's measure of the difference in light between two colours: (L1 + 0.05) / (L2 + 0.05), from 1:1 to 21:1.
Relative luminance
A colour's brightness from 0 (black) to 1 (white), calculated from its red, green and blue channels after undoing the screen's curve.
Large-scale text
Text at least 18 point, or 14 point bold: about 24 pixels, or about 18.5 pixels bold.
Contrast (Minimum)
WCAG 2.2 Success Criterion 1.4.3, Level AA: at least 4.5:1 for text, or 3:1 for large-scale text.
Contrast (Enhanced)
WCAG 2.2 Success Criterion 1.4.6, Level AAA: at least 7:1 for text, or 4.5:1 for large-scale text.
Non-text Contrast
WCAG 2.2 Success Criterion 1.4.11, Level AA: at least 3:1 for the visual parts of controls and graphics people need to see.
sRGB
The standard colour space that browsers and WCAG's luminance formula assume for web colours.

Type That Reads: Fonts, Sizes, Line Length and Spacing

  • Measure line length in characters and set a column width that keeps body text within a readable range.
  • Write font stacks with a generic fallback, and weigh web fonts against system fonts, including their privacy cost.
  • Build a type scale and line spacing that pass WCAG 2.2's Text Spacing test.

On a monitor 1,440 pixels wide, this course loaded a paragraph about volcanoes with no width limit at all, in 16-pixel text. Chrome laid it out in lines of 196 and 192 characters. Reading it is tiring in a way that is hard to name until you notice what your eyes are doing: at the end of every line they have to sweep back across almost the whole screen and find the start of the next line, and every so often they land on the wrong one and read a line twice.

Nothing about the text was wrong. The font was clear, the size was normal, the contrast was high. The problem was the shape of the text block, and it is one of the commonest problems on the web, because a page that looks fine on a laptop can stretch its lines without limit on a big screen. This lesson starts there, fixes it, and then works through the rest of what makes type comfortable to read: which fonts, what sizes, and how much space.

How long should a line be?

The length of a line of text is called its measure. Typographers have argued about the ideal measure for centuries, but the ranges they recommend overlap. Matthew Butterick's Practical Typography advises an average line length of 45 to 90 characters, including spaces, because longer lines make the eye travel further to find the next one. WCAG 2.2 sets its own limit at its highest level, AAA: Success Criterion 1.4.8, Visual Presentation, asks that a mechanism is available so that blocks of text are no more than 80 characters wide.

So the 196-character lines were more than twice too long. This course then measured the same paragraph in three narrower columns:

Width limitWidth in pixelsCharacters per line
None, on a 1,440-pixel window1,408192 to 196
max-width: 40rem, Rowan's column so far64083 to 88
max-width: 65ch56175 to 82
max-width: 36rem57675 to 79

Two things in that table deserve a closer look. First, 65ch did not give 65 characters. One ch is the width of the digit 0, which is wider than an average letter in most fonts, and spaces are narrow, so 65ch holds about 75 to 80 characters of ordinary text. Second, Rowan's 40rem column sat inside Butterick's range but just above WCAG's 80. Rowan narrowed the shared column to 36rem, which brought every line under 80 characters. Because rem follows the reader's text size, the column grows with large text and keeps roughly the same number of characters per line.

Choosing fonts: stacks and fallbacks

You cannot be sure a reader has any particular font installed. That is why font-family takes a list, which MDN calls a font stack: the browser tries each font in order and uses the first one available. MDN advises ending every stack with a generic family such as serif, sans-serif or monospace, so the browser always has something suitable to fall back on.

MDN also lists a handful of web-safe fonts, generally available across systems: Arial, Courier New, Georgia, Times New Roman, Trebuchet MS and Verdana, though it warns that Trebuchet MS is not widely available on mobile systems. And Lesson 7 introduced system-ui, the device's own interface font, which costs nothing to load and always looks at home on its system.

Rowan keeps system-ui for body text and adds a serif for headings, to give them a different voice:

:root {
  --font-body: system-ui, sans-serif;
  --font-heading: Georgia, "Times New Roman", serif;
}

h1, h2, h3 {
  font-family: var(--font-heading);
}

A font name with spaces goes in quotation marks. Where Georgia is installed, as it is on many computers, the headings use it; elsewhere they fall back to Times New Roman or the device's own serif. Two families, one for headings and one for text, is plenty for a portfolio. More than that quickly looks busy.

Web fonts, and what they cost

If you want a font your readers do not have, you can send it to them. The @font-face rule names a font and points to its file:

@font-face {
  font-family: "My Display Font";
  src: url("fonts/my-display-font.woff2") format("woff2");
  font-display: swap;
}

MDN recommends WOFF2 for the web: it compresses fonts better than older formats and is well supported in modern browsers. font-display: swap tells the browser to show the text in a fallback font straight away and swap in the web font when it arrives, rather than hiding the text while it downloads.

Web fonts have two costs. The first is weight: every style you load, regular, bold, italic, is another file the reader must download before the text looks right. The second is privacy, if the font comes from someone else's server. Google Fonts is the best-known free source, and its own FAQ explains what happens when a site loads fonts through its web API. Each request includes the visitor's IP address, the URL requested and browser headers, including the page the font is being shown on. Google says it does not use this to build profiles or for targeted advertising, and that the API sets no cookies. The same FAQ points out the alternative: a site can self-host the font files, and then Google receives no data about its visitors at all.

For a student portfolio, the simplest answer is often the best: system fonts and web-safe fonts need no download and involve no third party. If you do want a special font, download it under its licence and serve it from your own site.

Sizes: a type scale

MDN notes that the standard root font size across browsers is 16 pixels, and body text should not go below that. Headings then step up in size. Choosing those steps at random gives a page with no sense of order. A type scale is a small set of sizes, each a fixed ratio larger than the last. Rowan uses one built roughly on multiplying by 1.25, stored as tokens:

:root {
  --text-s: 0.875rem;   /* 14px: footer, captions */
  --text-m: 1rem;       /* 16px: body text */
  --text-l: 1.25rem;    /* 20px: h3 */
  --text-xl: 1.5rem;    /* 24px: h2 */
  --text-2xl: 2rem;     /* 32px: h1 on phones */
  --text-3xl: 2.75rem;  /* 44px: h1 from 48rem up */
}

Every heading and every piece of text now uses one of six sizes. The difference between levels is large enough to see at a glance, which is the whole job of a heading's size: to show the outline from Lesson 4 without anyone having to read it.

Space between lines

The height of each line, set by line-height, is the other half of readability. MDN recommends a line height of around 1.5 to 2 for body text, written as a unitless number, which it calls generally the best option because it multiplies each element's own font size. Rowan's body text uses 1.5. Headings, which are short and large, look better tighter, so Rowan sets them to 1.2.

WCAG 2.2 adds a requirement about readers who change spacing themselves. Some people with dyslexia or low vision use browser tools that increase the space between lines, letters and words. Success Criterion 1.4.12, Text Spacing, at Level AA, says no content or function may be lost when a reader sets line height to at least 1.5 times the font size, paragraph spacing to 2 times, letter spacing to 0.12 times and word spacing to 0.16 times. Your page does not have to use those values. It has to survive them.

This course tested Rowan's projects page by forcing exactly those values onto every element, at a width of 320 pixels. The cards simply grew taller, from 214, 178 and 178 pixels to 254, 218 and 218, the page stayed 320 pixels wide, and no text was cut off anywhere. That worked because no box in Rowan's CSS has a fixed height. A card set to height: 180px would have clipped its own text. Let content set heights, and text spacing takes care of itself.

Alignment and emphasis

Keep body text aligned to the left, which is the default for English. WCAG's AAA criterion 1.4.8 includes text that is not justified, meaning not stretched to touch both margins, because justification opens uneven gaps between words that some readers find hard to track. Centred text is fine for a short heading and hard work for a paragraph, because every line starts in a different place.

For emphasis, the tools from Lesson 4 still apply. Use strong and em for meaning, keep bold for a few words rather than whole paragraphs, and keep capital letters for short labels.

Common misconceptions

  • "Wider text columns fit more on the screen, so they are better." They fit more and are read less: lines of nearly 200 characters make readers lose their place. Keep body text between about 45 and 80 characters per line.
  • "max-width: 65ch means 65 characters." One ch is the width of the digit 0, so 65ch held 75 to 82 characters of real text.
  • "Google Fonts are free, so they cost nothing." Each visit sends the visitor's IP address and other request details to Google's servers. Self-hosting avoids that.
  • "Text spacing is fine if my page looks good." WCAG's test is whether it still works when the reader increases the spacing. Fixed heights are what usually break.

What to remember

Bottom line: Keep lines of body text between about 45 and 80 characters by limiting the column width in rem; end every font stack with a generic family; prefer system or self-hosted fonts; size text from a small type scale starting at 1rem; set body line height to about 1.5; and never fix a height that text has to fit inside.

You measured lines of 196 characters and brought them down to under 80, chose fonts that fall back gracefully, weighed what a web font costs a visitor, and checked that Rowan's pages survive the spacing WCAG says readers may impose. The next lesson pulls colour, type and space together. It starts with a page where every element is styled correctly on its own, and the page as a whole is still a mess.

Sources

  1. Butterick, M. (n.d.). Line length. Butterick's Practical Typography. practicaltypography.com
  2. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criteria 1.4.8 Visual Presentation and 1.4.12 Text Spacing. W3C Recommendation, 12 December 2024. w3.org
  3. MDN contributors. (2026). Fundamental text and font styling. Learn web development. Mozilla. developer.mozilla.org
  4. MDN contributors. (2026). @font-face. Mozilla. developer.mozilla.org
  5. Google. (n.d.). Google Fonts: Frequently asked questions, section on privacy. fonts.google.com
Key terms
Measure
The length of a line of text. Comfortable body text runs about 45 to 80 characters per line.
Font stack
A list of fonts in font-family, tried in order, ending with a generic family as the last fallback.
Generic family
A fallback such as serif, sans-serif, monospace or system-ui that the browser can always supply.
Web-safe font
A font generally installed across systems, such as Arial, Georgia, Times New Roman or Verdana.
Web font
A font file sent to the reader with @font-face, usually in WOFF2 format; it adds download weight and, if third-party, a privacy cost.
Type scale
A small set of text sizes, each a fixed ratio larger than the last, used for all text on a site.
Line height
The height of each line of text, best written as a unitless multiple such as 1.5.
Text Spacing
WCAG 2.2 Success Criterion 1.4.12, Level AA: nothing is lost when readers increase line, paragraph, letter and word spacing to set values.

Hierarchy, Alignment and Space: Redesigning a Cluttered Page

  • Diagnose a cluttered page using proximity, alignment, repetition and contrast, and fix each problem in CSS.
  • Build visual hierarchy with size and space, placing each heading closer to its own content than to what comes before.
  • Apply a palette, type scale and spacing scale consistently across the portfolio, with every colour pair checked.

Rowan's first draft of a redesigned about page broke no rules at all. The W3C validator found no errors. Every text colour passed WCAG's contrast minimum with room to spare: the purple heading measured 8.27 to 1 against white, the green subheadings 6.54, the blue note 7.13 and the body text 14.76. And yet nobody would have called it designed. In Chrome it looked like this: a small purple title in capitals in a condensed font, two red dashed boxes, a green typewriter-style subheading in each, centred text, and a bold blue line in capitals pushed against the right edge that read UPDATED EVERY WEEK.

Rowan's first instinct was that it needed more: a background image, a bolder border, another colour to liven it up. That is the most common wrong answer in visual design. This lesson traces what is actually wrong, one problem at a time, using four principles from Robin Williams's The Non-Designer's Design Book: proximity, alignment, repetition and contrast. Every fix removes something.

Measuring the mess

Before fixing anything, this course measured the draft in Chrome:

What was measuredDraft
Main heading size18 pixels
Subheading size17 pixels
Body text size16 pixels
Font families in use3: Impact, Courier New, Verdana
Text colours in use4: purple, green, blue, dark grey
Text alignments in use2: centred, and right for the note
Space between a subheading and its paragraph20 pixels, the same as everywhere else

Read the numbers before the screenshot. The three text sizes are within two pixels of each other. Almost everything differs in font, colour or alignment, so nothing stands out, because everything does.

Bug 1: no order of importance

A reader scanning a page needs to see at once what is most important, what is next, and what is detail. That ordering is called visual hierarchy. The Nielsen Norman Group, a research firm that studies how people use websites, defines it as the organisation of design elements on the page to guide the eye by relative importance, and lists the main tools: colour and contrast, scale, and grouping. On scale its advice is direct: size can be used as a marker for importance.

In the draft, the main heading was 18 pixels and the note at the bottom was 16-pixel bold capitals in a bright blue. The note shouted louder than the title. Fixing it means using Williams's fourth principle, contrast: if two things are not the same, make them clearly different. The redesign uses Rowan's type scale from Lesson 19: the h1 at 2rem, 32 pixels, the subheadings at 1.5rem, 24 pixels, body text at 16, and the note demoted to 0.875rem, 14 pixels, in ordinary weight and ordinary case. Three clearly different levels, in the same order as the HTML outline.

Bug 2: too many voices

The draft used three fonts and four text colours on a page of about forty words. Williams's principle of repetition is the cure: repeat a small number of choices throughout, so the page looks like one thing. The redesign uses two fonts, Georgia for headings and the system font for text, and two colours: the rust accent for the one main heading and the dark text colour for everything else.

The blue note had a second problem. On Rowan's site, blue already means link. A bold blue line that does nothing when you click it breaks that promise, and readers will try to click it. Colour on a site is a vocabulary: once a colour means something, keep it for that meaning only.

Bug 3: three edges where there should be one

Centred text gives every line a different starting point, and the right-aligned note added a third edge. Williams's principle of alignment says every element should line up with something else on the page. For text in English, the strongest choice is a single left edge: the eye returns to the same place at the start of every line and every block. The redesign removes the text-align rules entirely, because left is the default.

Bug 4: boxes doing the job of space

The red dashed boxes were trying to say these things belong together. That is a real need, but space says it better. The Nielsen Norman Group's article on the principle of proximity puts it this way: items close together are likely to be perceived as part of the same group. It adds the rule that matters most for headings: the text belonging to a heading is usually placed closer to that heading than the text of the section before.

In the draft, every gap was the same 20 pixels, so a subheading was exactly as close to the paragraph above it as to its own paragraph below. Nothing told the eye which way it belonged. The redesign gives each h2 a large space above and a small one below:

h2 {
  margin: 2rem 0 0.5rem;
}

p {
  margin: 0 0 1rem;
}

Measured in Chrome, each subheading now sat 8 pixels above its own paragraph and 32 pixels below the text before it, four times further from the previous section than from its own. The dashed boxes could go. The groups were now visible without them, made by empty space, which designers call whitespace whatever its colour.

The redesign

Here is all of the redesigned page's CSS. Compare its length with the list of problems: every fix made the stylesheet shorter or simpler.

body {
  font-family: system-ui, sans-serif;
  margin: 0 auto;
  max-width: 36rem;
  padding: 0 16px;
  color: #1f2933;
  background: #fdfcf8;
  line-height: 1.5;
}

h1, h2 {
  font-family: Georgia, "Times New Roman", serif;
  line-height: 1.2;
}

h1 {
  font-size: 2rem;
  color: #8a3b12;
  margin: 2rem 0 1rem;
}

h2 {
  font-size: 1.5rem;
  margin: 2rem 0 0.5rem;
}

p {
  margin: 0 0 1rem;
}

.note {
  font-size: 0.875rem;
}

In Chrome the result reads at a glance: a rust serif title at 32 pixels, two dark serif subheadings at 24, each sitting tight against its own paragraph, all aligned to one left edge, and the note as a small line of ordinary text. It uses two fonts and two colours instead of three and four. The HTML did not change at all, and both versions passed the validator and the contrast checks. Accessibility rules are a floor, not a design. A page can clear every one of them and still be hard to scan.

Portfolio build 6: palette, type and rhythm

Rowan applied the same principles across the whole portfolio. The tokens gained fonts and the type scale from Lesson 19, the shared column narrowed to 36rem, and a new section of styles.css sets the rhythm of headings and paragraphs:

/* added to :root */
  --font-heading: Georgia, "Times New Roman", serif;
  --text-s: 0.875rem;
  --text-m: 1rem;
  --text-l: 1.25rem;
  --text-xl: 1.5rem;
  --text-2xl: 2rem;
  --text-3xl: 2.75rem;

/* changed */
header, main, footer {
  max-width: 36rem;
  margin: 0 auto;
}

/* 7. Type and rhythm (Module 6) */
h1, h2, h3 {
  font-family: var(--font-heading);
  line-height: 1.2;
}

h2 {
  font-size: var(--text-xl);
  margin: var(--space-l) 0 var(--space-s);
}

h3 {
  font-size: var(--text-l);
  margin: var(--space-m) 0 var(--space-s);
}

p {
  margin: 0 0 var(--space-m);
}

footer {
  font-size: var(--text-s);
  padding: var(--space-l) 0;
}

The h1 sizes from Lesson 15 now use the tokens --text-2xl and --text-3xl, and Lesson 17's dark theme sits beside the light one. One detail is worth a second look. The project cards still start their headings flush with the top of the card, even though the new h2 rule gives every h2 a top margin. That is Lesson 8 at work: the earlier rule .card h2 scores 0-1-1 and beats the later h2 rule at 0-0-1, so cards keep margin-top: 0.

On a 1,000-pixel window in light mode, Chrome drew the projects page as a 576-pixel column with the rust Georgia heading at 44 pixels, two columns of 280-pixel cards with serif titles, and the footer in 14-pixel text. At 320 pixels it was one column with no sideways scrolling, and the validator found no errors. Every text colour pair in both themes is in the table from Lesson 18, all at 4.5:1 or above.

Common misconceptions

  • "A dull page needs more decoration." The draft had plenty. Every improvement came from removing fonts, colours, alignments and borders.
  • "If it passes WCAG, it is well designed." The draft passed every contrast check and was still hard to scan. Accessibility is the floor.
  • "Centred text looks balanced." For a short title, perhaps. For paragraphs, it gives every line a different starting point, which makes reading harder.
  • "Equal spacing everywhere looks tidy." Equal spacing hides the groups. A heading needs to be closer to its own content than to the content before.

The takeaway

The core of it: Visual design is mostly removing ambiguity. Make different things clearly different in size, repeat a few choices everywhere, line everything up on one edge, and use space to show what belongs together, with each heading close to its own content.

Module 6 is complete. Your portfolio has a palette chosen by role and checked for contrast in two themes, a type scale with fonts that fall back gracefully, lines under 80 characters, and a rhythm of space that shows the page's structure. Module 7 tests the whole site the way people who cannot see it, or cannot use a mouse, will experience it.

Sources

  1. Williams, R. (2015). The non-designer's design book (4th ed.). Peachpit Press. Chapters 2 to 5: proximity, alignment, repetition, contrast. Publisher's page: peachpit.com
  2. Gordon, K. (2021, January 17). Visual hierarchy in UX: Definition. Nielsen Norman Group. nngroup.com
  3. Harley, A. (2020, August 2). Proximity principle in visual design. Nielsen Norman Group. nngroup.com
  4. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criterion 1.4.3 Contrast (Minimum). W3C Recommendation, 12 December 2024. w3.org
Key terms
Visual hierarchy
The arrangement of a page's elements by importance, so the eye finds the most important thing first; built with size, contrast and grouping.
Proximity
The principle that items close together are seen as belonging together; headings sit closer to their own content than to what comes before.
Alignment
The principle that every element lines up with something else, ideally along one strong edge.
Repetition
The principle of reusing a few choices, such as two fonts and a handful of colours, so a page or site looks like one thing.
Contrast (design)
The principle that things which are different should look clearly different, for example in size; distinct from WCAG's contrast ratio.
Whitespace
Empty space between and around elements, whatever its colour, used to separate groups and give important things room.

Module 7: Accessibility So Everyone Can Use the Site

Experience your site the way assistive technology receives it, write alternative text that does its job, make everything work from the keyboard with focus you can see, and audit the whole portfolio against WCAG 2.2 with tools and by hand.

Using a Page Without Seeing It: Screen Readers, Alt Text and Structure

  • Read a page's accessibility tree and explain how screen reader users move through it by headings, landmarks and links.
  • Describe WCAG 2.2's structure of principles, guidelines, success criteria and levels.
  • Write alternative text using the W3C's decision tree, including empty alt text for decorative images.

Every year the organisation WebAIM runs automated accessibility tests on the home pages of the top million websites. In February 2026 it found detectable WCAG 2 failures on 95.9 percent of them, an average of 56.1 errors per page. The most common failures were low-contrast text, on 83.9 percent of home pages; images missing alternative text, on 53.1 percent; form fields without labels, on 51 percent; and empty links, on 46.3 percent. Across all those pages, 16.2 percent of all images had no alternative text at all.

Behind each of those numbers is a person who could not use part of a page. This lesson follows a page as assistive technology receives it, one structure at a time, so that those failures stop being abstract. The page is Rowan's projects page, and you have been preparing it for this since Lesson 4.

The page a screen reader is given

A screen reader does not look at the screen. It reads the accessibility tree, the version of the page the browser builds for assistive technology, which you met in Lesson 6. Each node in it has a role, such as heading or link, and usually a name: the text that will be spoken. When this course asked Chrome for the accessibility tree of Rowan's projects page, the important nodes came out in this order:

banner
  link "Rowan's portfolio"
  navigation
    list
      link "Home"
      link "Projects"  (marked as the current page)
      link "About me"
main
  heading "Projects"  (level 1)
  article
    heading "Volcanoes of the world"  (level 2)
  ...
contentinfo

Nothing about colours, fonts or layout survives into this tree. What survives is structure and words, and every bit of it was put there by HTML you wrote: the landmarks from Lesson 6, the headings from Lesson 4, the list and links from Lessons 4 and 5, and the current-page marker from aria-current.

Three ways through the page

Screen reader users rarely listen to a whole page from the top. WebAIM's 2024 survey of 1,539 screen reader users asked how they find information on a long page. Here is how each of the common methods meets Rowan's page:

  • By headings, the first choice of 71.6 percent of respondents. The screen reader jumps from heading to heading or lists them all. Rowan's page offers Projects at level 1, then one level-2 heading per project: a table of contents of the page, in order.
  • By landmarks, which 31.8 percent use often or whenever available. One command moves straight to main, past the site name and navigation that are the same on every page.
  • By links. The screen reader lists every link with its name and nothing else. Rowan's links say Home, Projects, About me and Open the volcano page, so the list makes sense on its own, which is exactly what WCAG's Link Purpose criterion in Lesson 5 asks.

Each method depends on structure that is invisible on screen. A page built from div elements with bold text instead of headings would offer none of them.

WCAG 2.2 in one paragraph

The standard behind all of this is the W3C's Web Content Accessibility Guidelines, version 2.2, a W3C Recommendation updated on 12 December 2024. It is organised under four principles: content must be perceivable, operable, understandable and robust. Under those sit 13 guidelines, and under those 86 testable success criteria, each at one of three levels. Level A, 31 criteria, is the minimum; Level AA adds 24 more; Level AAA, 31 more, is the highest. One criterion from earlier versions, 4.1.1 Parsing, is marked obsolete and removed. Most laws and policies that require accessibility ask for Level AA, and so does this course. You have already met more than a dozen criteria, from 1.4.3 Contrast to 2.5.8 Target Size, and built your site to meet them as you went.

Names: what gets spoken

For every link, button, image and form field, the screen reader speaks its accessible name. MDN defines it as the text associated with an element that gives assistive technology users a label for it. Where it comes from depends on the element:

  • A link or button takes its name from its content: the words between its tags.
  • An image takes its name from its alt attribute.
  • A form field takes its name from its label, which Lesson 26 builds.

MDN adds that it is best to use visible text as the accessible name, so that what sighted users see and what screen reader users hear match. Your links already do. Images need something extra: words written for people who cannot see them.

Alt text, decided step by step

WCAG 2.2's first success criterion, 1.1.1 Non-text Content at Level A, requires that all non-text content presented to the user has a text alternative that serves the equivalent purpose. For an image, that text alternative is its alt text. The W3C's tutorial on images offers a decision tree for writing it. Its questions come in this order:

  1. Does the image contain text? If the same text is on the page anyway, use empty alt text. If not, put the text in the alt.
  2. Is the image inside a link or button, and would its purpose be unclear without it? Then the alt describes where the link goes or what the button does, not what the picture looks like.
  3. Does the image add meaning? For a simple picture, give a brief description of that meaning. For a graph or complex image, give its information elsewhere on the page.
  4. Is it purely decorative? Then use empty alt text.

Here is how that plays out for images Rowan might add:

ImageWhat it is foralt
Screenshot of the volcano project on the projects pageShows what the project looks likeThe volcano page: a large heading, three paragraphs and a table of five eruptions.
A small arrow icon that is the only content of a link to the volcano pageIt is the linkOpen the volcano page
A wavy line between sectionsDecoration onlyEmpty: alt=""
A chart of hours spent each weekDataA short alt, with the numbers in a data table beside it

In HTML, the first two look like this:

<img src="images/volcano-page.png" width="600" height="400"
     alt="The volcano page: a large heading, three paragraphs and a table of five eruptions.">

<a href="projects/volcano.html"><img src="images/arrow.png" width="24" height="24" alt="Open the volcano page"></a>

Notice the empty alt. alt="" is not the same as leaving alt out. The W3C's tutorial on decorative images explains that empty alt text lets assistive technology ignore the image, while if the attribute is missing, some screen readers announce the image's file name instead. Imagine hearing image, I M G underscore four three nine two dot P N G, in the middle of a sentence.

What missing names look like

This course made a broken copy of Rowan's card. The screenshot had no alt attribute at all, and the link to the project contained only an arrow image with alt="". In Chrome's accessibility tree, the screenshot became an image with no name, and the link became a link with no name: one of the empty links WebAIM found on nearly half of home pages. A screen reader user would reach a link and hear only that it is a link. The W3C validator flagged the first problem with the message An img element must have an alt attribute, except under certain conditions. It could not flag the second, because alt="" is valid. Only knowing what the image was for reveals that this one should not have been empty.

Writing alt text well

WebAIM's guide to alternative text adds three habits worth keeping:

  • Do not start with image of or picture of. Screen readers already announce that it is an image, so the phrase is redundant.
  • Be succinct. Give the content and function as briefly as possible. One sentence is usually plenty.
  • Write for the context. WebAIM notes that the same image may need very different alt text depending on how it is used. A photo of a volcano in a geology article might need its type and location; the same photo as a decorative banner on a travel page might need nothing.

Common misconceptions

  • "Every image needs a description." Decorative images need alt="", so that they are skipped. Describing decoration wastes listeners' time.
  • "alt="" and no alt are the same." Empty alt says skip this; a missing alt may make a screen reader read out the file name, and the validator reports it as an error.
  • "Alt text should describe exactly what the picture shows." It should convey what the image is for. A linked icon's alt names the destination, not the icon.
  • "Accessibility is a separate step at the end." Rowan's page was usable by screen reader users because of choices made in Lessons 4, 5 and 6. Retrofitting is much harder.

Summing up

Key idea: Assistive technology receives your page as a tree of roles and names, built entirely from your HTML. Headings, landmarks and meaningful link text are how people move through it, and every image needs alt text that conveys its purpose, or an empty alt if it has none.

You have seen Rowan's projects page as a screen reader receives it, met the shape of WCAG 2.2, and learned to decide alt text question by question. Screen reader users are not the only people who do not use a mouse. The next lesson tests the portfolio with nothing but the Tab key, and finds a button that cannot be reached at all.

Sources

  1. WebAIM. (2026). The WebAIM Million: The 2026 report on the accessibility of the top 1,000,000 home pages. webaim.org
  2. W3C Web Accessibility Initiative. (2024). An alt decision tree, and Decorative images (2019). Images tutorial. w3.org
  3. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criterion 1.1.1 Non-text Content and the overall structure. W3C Recommendation, 12 December 2024. w3.org
  4. MDN contributors. (2026). Accessible name. Mozilla. developer.mozilla.org
  5. WebAIM. (n.d.). Alternative text. webaim.org
  6. WebAIM. (2024). Screen reader user survey #10 results. webaim.org
Key terms
Screen reader
Software that reads a page aloud or sends it to a braille display, working from the browser's accessibility tree.
Accessible name
The text that labels an element for assistive technology: a link's content, an image's alt, a form field's label.
Alt text
The text alternative in an img element's alt attribute, conveying what the image is for; empty for decorative images.
Decorative image
An image that adds no information, given alt="" so assistive technology skips it.
Success criterion
One of WCAG 2.2's 86 testable requirements, each at Level A, AA or AAA, such as 1.1.1 Non-text Content.
POUR
WCAG's four principles: content must be perceivable, operable, understandable and robust.
Empty link
A link with no accessible name, for example one containing only an image with empty alt text.

Tab, Tab, Enter: Keyboard Access and Visible Focus

  • Test a page with the keyboard alone and explain WCAG 2.2's keyboard and focus criteria: 2.1.1, 2.4.1, 2.4.3, 2.4.7 and 2.4.11.
  • Replace a clickable div with a native button, and say what the button provides for free.
  • Style a visible focus indicator with :focus-visible and add a working skip link to every page.

Rowan unplugged the mouse and tried to use the portfolio with the keyboard alone. The rules are simple: Tab moves to the next thing you can interact with, Shift and Tab moves back, Enter follows a link or presses a button, and Space presses a button. Rowan pressed Tab once. Nothing visible happened. Tab again, and again. Then Enter, and the about page opened, which proved that something had been selected all along. Nobody could have known which link it was.

A second problem showed up on the next attempt. Rowan had sketched a Dark theme switch in the header, to be wired up in Module 9, and styled it to look like a button. With the keyboard, it could not be reached at all: Tab went from the last navigation link straight past it. This lesson debugs both, explains the rules they break, and adds the one feature every keyboard user needs on a site with a navigation bar.

Who uses the keyboard

Many people never use a mouse: people with motor disabilities who use a keyboard or a switch device that sends keystrokes, screen reader users, who navigate almost entirely by keyboard commands, and people who simply prefer it. WCAG 2.2's Success Criterion 2.1.1, Keyboard, at Level A, requires that all functionality is operable through a keyboard interface. If something can be done with a mouse, it must be possible without one.

Bug 1: the focus that could not be seen

Whichever element will receive the next key press has keyboard focus. Browsers mark it with a focus indicator, usually an outline. This course ran Rowan's test page in Chrome and pressed Tab while recording where focus went. The first press focused the link Rowan's portfolio, and Chrome even reported that it matched :focus-visible, meaning the browser judged that focus should be shown. But the link's computed outline was none.

The cause was one line Rowan had copied from an old tutorial, because the default outline looked ugly on mouse clicks:

a:focus {
  outline: none;
}

That rule removes the only visible sign of where a keyboard user is. It fails Success Criterion 2.4.7, Focus Visible, at Level AA: any keyboard-operable interface must have a mode in which the focus indicator is visible. MDN's page on :focus-visible says it plainly: removing focus styles makes keyboard navigation inaccessible for sighted users.

The fix is not just to delete the line. The tutorial had a point: some people find the outline distracting when they click with a mouse. That is what the :focus-visible pseudo-class is for. MDN describes it as matching when an element has focus and the browser determines, by its own rules, that focus should be made evident, as it does for keyboard users. So style that state, and style it clearly:

:focus-visible {
  outline: 3px solid var(--color-link);
  outline-offset: 2px;
}

Tested again, the first Tab drew a 3-pixel solid blue outline round the focused element. The outline colour is the link blue, which measures 6.94 to 1 against the page background, comfortably above the 3:1 that Lesson 18's non-text contrast criterion asks of the visual parts of controls. In the dark theme the same rule uses the pale link blue at 9.13 to 1. The outline-offset of 2 pixels pulls the outline away from the text so it does not crowd the letters.

Bug 2: the switch that was not there

Rowan's sketched switch was a styled div:

<div class="switch">Dark theme</div>

When this course asked Chrome for its tabIndex, the browser's measure of whether an element can be reached with Tab, the div reported -1: not in the tab order. Pressing Tab after the last navigation link jumped straight past it. A div, as Lesson 6 said, has no special meaning at all, and that includes no meaning as a control. It looks like a button only to people who can see it, and it works like a button only for people with a mouse, once script makes it work at all.

The fix is to say what it is:

<button type="button" class="switch">Dark theme</button>

The same Tab test landed on the button, which reported a tabIndex of 0, in the tab order, with Chrome's own focus ring. A native button brings everything a keyboard user needs without any extra work: it can be focused, the W3C's button pattern says that both Space and Enter activate it, and it reports its role as button to assistive technology, so a screen reader announces it as a button named Dark theme. type="button" tells the browser that this button does not submit a form, which matters in Lesson 26. In Module 9 you will give it something to do.

The general rule: if something does something when you click it, it should be a button or a link. Links go somewhere; buttons do something.

Bug 3: three links before the content, on every page

With focus now visible, Rowan counted the Tab presses needed to reach the first link inside main: four, the site name and three navigation links, on every page, every time. Three or four is tolerable. A site with a twenty-item menu would demand twenty presses per page. WCAG 2.2's Success Criterion 2.4.1, Bypass Blocks, at Level A, requires a mechanism to bypass blocks of content that are repeated on multiple pages.

The landmarks from Lesson 6 already help screen reader users. For everyone using a keyboard, the standard mechanism is a skip link: a link at the very start of the page that jumps to the main content. WebAIM's guide to skip links says it should be one of the first things keyboard users reach, and that if it is hidden, it should become prominently visible when focused. It also warns that hiding it with display: none makes it inaccessible to everyone. So it is moved off screen instead, and brought back on focus:

<body>
  <a class="skip-link" href="#main">Skip to main content</a>
  <header>...</header>
  <main id="main">...</main>
</body>
.skip-link {
  position: absolute;
  top: -100px;
  left: var(--space-s);
  padding: var(--space-s) var(--space-m);
  background-color: var(--color-surface);
  color: var(--color-link);
}

.skip-link:focus {
  top: var(--space-s);
}

position: absolute takes the link out of the normal flow so that it can sit anywhere, here 100 pixels above the top of the page, out of sight. When it receives focus, it drops into view. In the test, the first Tab focused Skip to main content and brought it on screen at the top left, 179 by 37 pixels, with the blue focus outline. Enter changed the address to end in #main, the fragment link from Lesson 5, and the very next Tab focused the first link inside main. Four presses became two.

Order, obscuring and size

Three more criteria complete the keyboard picture, and Rowan's site already passes them, because of earlier choices:

  • 2.4.3 Focus Order, Level A: focus moves in an order that preserves meaning and operability. Tab follows the HTML source order, which is why Lessons 12 and 13 warned against rearranging items with order and grid placement.
  • 2.4.11 Focus Not Obscured (Minimum), Level AA and new in WCAG 2.2: when something receives focus, it must not be entirely hidden by content the author created. Sticky headers and cookie banners are the usual culprits. Rowan's header scrolls away with the page, so nothing covers focused links. If you ever make a header stick to the top of the window, tab through the whole page to check.
  • 2.5.8 Target Size (Minimum), Level AA and also new in 2.2: targets for pointer input are at least 24 by 24 CSS pixels, with exceptions such as links inside a sentence. Rowan's navigation links measured 45 by 40 in Lesson 11, and the skip link 179 by 37.

Common misconceptions

  • "Removing the focus outline makes the site look cleaner." It makes the site unusable for sighted keyboard users and fails 2.4.7. Style :focus-visible instead.
  • "A div with the right styling is as good as a button." It cannot be focused, does not respond to Enter or Space, and has no button role, unless you rebuild all three yourself. The native button has them already.
  • "Skip links are only for screen reader users." They help every keyboard user, which is why they must become visible on focus.
  • "Hide the skip link with display: none until it is needed." display: none removes it from the tab order, so it can never be needed. Move it off screen instead.

What to carry forward

Remember: Test every page with the keyboard alone. Focus must always be visible, every control must be reachable and operable, which native links and buttons give you free, and a skip link should let keyboard users past the navigation on every page.

You found an invisible focus caused by one copied line, a switch that the keyboard could not reach, and a navigation bar that cost four presses on every page, and fixed all three. The next lesson puts everything from this module together into an audit of the whole portfolio, and compares what automatic tools find with what only you can.

Sources

  1. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criteria 2.1.1 Keyboard, 2.4.1 Bypass Blocks, 2.4.3 Focus Order, 2.4.7 Focus Visible, 2.4.11 Focus Not Obscured (Minimum) and 2.5.8 Target Size (Minimum). W3C Recommendation, 12 December 2024. w3.org
  2. MDN contributors. (2026). :focus-visible. Mozilla. developer.mozilla.org
  3. W3C Web Accessibility Initiative. (n.d.). Button pattern. ARIA Authoring Practices Guide. w3.org
  4. WebAIM. (n.d.). "Skip navigation" links. webaim.org
Key terms
Keyboard focus
The element that will receive the next key press; Tab moves it forward and Shift with Tab moves it back.
Focus indicator
The visible mark, usually an outline, showing which element has keyboard focus. WCAG 2.4.7 requires it to be visible.
:focus-visible
A pseudo-class matching a focused element when the browser judges focus should be shown, as for keyboard users.
Tab order
The sequence in which Tab moves through focusable elements, which follows the HTML source order.
Native button
A button element: focusable, activated by Space and Enter, and announced as a button, with no extra code.
Skip link
A link at the start of a page that jumps keyboard users past repeated navigation to the main content.
Focus Not Obscured
WCAG 2.2 Success Criterion 2.4.11, Level AA: a focused element must not be entirely hidden by content the author created.

Auditing Your Own Site: What Tools Catch and What Only You Can

  • Explain what automated accessibility tools can and cannot detect, using evidence from a published test of ten tools.
  • Run a structured audit of a site combining an automated report with manual checks, mapped to WCAG 2.2 success criteria.
  • Record findings and fixes in an audit log, and bring the portfolio to WCAG 2.2 AA so far.

In 2017 the accessibility team at the UK's Government Digital Service built what it called the world's least accessible web page. It contained 143 accessibility failures in 19 categories: images without alt attributes, blank link text, flashing content with no warning, and dozens more. Then the team ran ten free automated testing tools over it. Together, the ten tools found 71 percent of the barriers. The single best tool found 41 percent, counting its prompts to check things by hand. One widely used tool found 17 percent. And 42 of the 143 barriers, 29 percent, were missed by every tool, among them long passages in italics, tables with empty cells, and links identified by colour alone.

The team's conclusion was not that tools are useless. It was that they cannot be relied on by themselves, and work best combined with manual checking, an expert audit and testing with users. WebAIM says the same about its own million-page study: absence of detected errors does not mean a page is accessible. This lesson builds an audit in exactly that shape, an automated report followed by a checklist that only a person can work through, and runs it on Rowan's portfolio from start to finish.

Step 1: the automated report

Chrome includes a free automated tool called Lighthouse. Google describes it as an open-source, automated tool to help improve the quality of web pages, and accessibility is one of the things it checks. To run it, open the developer tools on the page, choose the Lighthouse panel, keep the Accessibility category ticked, and click Analyze page load. A report appears with a score out of 100 and a list of failed and passed audits.

Read that score carefully. Chrome's documentation explains that the accessibility score is a weighted average of the accessibility audits, each one simply pass or fail, and that manual checks do not affect the score at all. So the number measures how many automated tests passed, weighted by how much each tends to matter. It cannot tell you whether your alt text is any good, whether your headings make sense, or whether your page works with a keyboard. A page can score 100 and still fail people. Treat the report as a list of definite problems to fix, never as a certificate.

Two other free checks belong in the same step. The W3C Nu HTML Checker from Lesson 3, because invalid HTML often causes accessibility failures and its outline option shows the heading structure. And a contrast check of every colour pair, as in Lesson 18.

Step 2: the checks only a person can make

Tools can tell whether an image has an alt attribute. Only a person can tell whether the alt text says what the image is for. Tools can find a missing label. Only a person can judge whether a heading describes its section. Here are the manual checks this course uses, each tied to the WCAG 2.2 criterion it tests and the lesson that taught it:

Manual checkWCAG 2.2 criterionLesson
Every image's alt conveys its purpose, or is empty for decoration1.1.1 Non-text Content (A)21
Headings and landmarks match the visual structure1.3.1 Info and Relationships (A)4, 6
Nothing depends on colour alone1.4.1 Use of Color (A)17
Text works at 200 percent zoom1.4.4 Resize Text (AA)14
No sideways scrolling at 320 pixels or 400 percent zoom1.4.10 Reflow (AA)14, 16
Nothing breaks under increased text spacing1.4.12 Text Spacing (AA)19
Everything works with the keyboard alone2.1.1 Keyboard (A)22
A skip link bypasses the navigation2.4.1 Bypass Blocks (A)22
Each page title describes its page2.4.2 Page Titled (A)2
Focus moves in a sensible order2.4.3 Focus Order (A)22
Link text makes sense in a list of links2.4.4 Link Purpose (In Context) (A)5
Headings describe their sections2.4.6 Headings and Labels (AA)4
Focus is always visible and never hidden2.4.7 Focus Visible (AA), 2.4.11 Focus Not Obscured (Minimum) (AA)22
Small targets are 24 by 24 pixels or well spaced2.5.8 Target Size (Minimum) (AA)11, 22
Each page declares its language3.1.1 Language of Page (A)2

That is not every WCAG criterion. Some apply only to features the site does not have yet, such as forms and video, which Module 8 adds, along with their own checks. But it covers every feature Rowan's portfolio has today.

Step 3: keep a log

An audit that lives only in your head gets repeated badly. Keep an audit log: a table with one row per finding, saying what you found, where, which criterion it fails, how you found it, and what you did. A log turns a vague feeling that the site is probably fine into evidence, and it tells you exactly what to recheck after every change.

Rowan's audit, start to finish

This course ran the audit on Rowan's three pages after the fixes from Lesson 22, using the checker, Chrome's accessibility tree, the keyboard and the measurements from earlier lessons. These are the results:

CheckResult on all three pages
HTML validity (Nu checker)No errors
Page titlesRowan's portfolio; Projects: Rowan's portfolio; About me: Rowan's portfolio. Unique and descriptive
Languagelang="en" on every page
HeadingsOne h1 per page; the about page runs h1, h2, h3, h3, h2, h2 with no skipped level
Landmarks (accessibility tree)banner, navigation, main and contentinfo on every page
Link namesEvery link has meaningful text; no empty links
ImagesNone yet; recheck after Module 8
Contrast, both themesEvery text pair 4.5:1 or above, from Lesson 18
KeyboardFirst Tab shows Skip to main content with a 3-pixel outline; order follows the page; every link reachable
Reflow and text spacingNo sideways scrolling at 320 pixels, at a 24-pixel text size or with WCAG's spacing applied
Target sizeNavigation links 45 by 40 pixels

One result needed thinking about. On the home page, Tab after the skip link landed on the first link inside main, as Lesson 22 showed. On the projects and about pages, main contained no links yet, so there was nothing inside it for Tab to land on. That is not a failure: the skip link still moves the starting point for reading and tabbing to the main content. It does mean that when Module 8 adds a link to each project card, the skip link will become much more useful on the projects page.

No automated tool would have told Rowan any of the things in the second half of that table. They came from looking, listening and pressing keys.

What an audit can and cannot claim

After this audit, Rowan can honestly say the portfolio was checked against these WCAG 2.2 Level AA criteria, by these methods, on this date, with these results. Rowan cannot honestly say the site is fully accessible to everyone. No audit can test every combination of device, assistive technology and person, which is why the GOV.UK team lists testing with users as the last step. If you are ever able to ask someone who uses a screen reader or a keyboard every day to try your site, their five minutes will teach you more than any tool.

Common misconceptions

  • "A Lighthouse score of 100 means the site is accessible." The score covers only automated pass-or-fail audits. Manual checks do not count towards it at all.
  • "Automated tools find most problems." In the GOV.UK test, ten tools together missed 29 percent of the barriers, and the best single tool found 41 percent.
  • "An audit is done once, before launch." Every change can break something. Rerun the checks in your log after each change.
  • "If I fixed everything the tool listed, I am done." The tool's list is where the audit starts. Alt text quality, heading sense and keyboard use need a person.

What you now know

What matters here: An accessibility audit has two halves: automated tools that find definite failures quickly, and manual checks of keyboard use, zoom, reflow, spacing, alt text, headings and link text that tools cannot judge. Record each finding against a WCAG criterion, fix it, and recheck after every change.

Module 7 is complete. Rowan's portfolio has passed every check that applies to its current features, with evidence for each. Module 8 adds the features that bring new checks with them: images, video and forms. It starts with a single photograph that weighed more than the rest of the site put together.

Sources

  1. Duran, M. (2017, February 24). What we found when we tested tools on the world's least-accessible webpage. Accessibility in government, Government Digital Service. accessibility.blog.gov.uk
  2. Google. (2026). Lighthouse accessibility scoring and Introduction to Lighthouse. Chrome for Developers. developer.chrome.com
  3. WebAIM. (2026). The WebAIM Million, notes on automated testing. webaim.org
  4. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2. W3C Recommendation, 12 December 2024. w3.org
Key terms
Accessibility audit
A structured check of a site against accessibility criteria, combining automated tools with manual testing.
Automated testing
Checks run by a program, such as Lighthouse, that find definite failures quickly but miss anything needing judgement.
Manual testing
Checks a person makes: keyboard use, zoom, reflow, text spacing, and whether alt text, headings and link text make sense.
Lighthouse
An open-source automated tool built into Chrome's developer tools; its accessibility score is a weighted average of pass-or-fail audits.
Audit log
A table recording each finding, where it is, the WCAG criterion it fails, how it was found and how it was fixed.
Level AA
The middle WCAG conformance level, meeting all Level A and AA success criteria; the level most policies require.

Module 8: Images, Media and Forms

Add images that are sized, compressed, described and safe to publish; video and audio with captions and controls; and accessible forms that collect only what they must. The module ends with project screenshots on the portfolio and the HTML of a contrast-checker form.

A Six-Megabyte Photo on a Phone Plan: Images Done Properly

  • Resize and compress a photo for the web, and choose an image format by what the image contains.
  • Write a complete img element with alt, width, height, srcset and loading, and explain what each attribute does.
  • Check an image's licence and strip location data before publishing, and keep other people's photos off a public site.

Apple's iPhone 15, announced in September 2023, introduced a new default of 24-megapixel photos. A 24-megapixel photo in the usual 4:3 shape is 5,712 by 4,284 pixels. The HTTP Archive's 2024 Web Almanac found that the median JPEG image on the web uses 2.0 bits of data per pixel. At that density, a 24-megapixel photo comes to about 6.1 megabytes.

Rowan has a photo like that: a model volcano built for the volcano project. On the projects page it will sit inside a card that shows it at most about 300 pixels wide. Put the original file on the page and every visitor downloads 6 megabytes to see a picture the size of a playing card, on a phone plan that may charge for every one of them. And once it arrives, the browser has to unpack all 24 million pixels at about 4 bytes each, nearly 100 megabytes of memory, just to shrink them again. This lesson fixes that photo step by step, and covers what else an image on a public site needs: a description, a licence, and nothing in it that gives away where you live.

How many pixels does the card need?

Start from where the image will be shown. In Rowan's grid, a card's image is about 250 pixels wide on a laptop and just under 300 on a phone held upright. Many phones have screens with two or three hardware pixels per CSS pixel, as Lesson 14 explained, and a photo looks sharp when it supplies roughly one image pixel per hardware pixel. So a 300-pixel slot needs 300 image pixels on an ordinary screen, 600 on a 2x screen and 900 on a 3x screen. Rowan makes two files: one 600 pixels wide, enough for ordinary and 2x screens, and one 1,200 pixels wide, which covers even 3x screens with room to spare.

The original is 5,712 pixels wide, nearly five times the 1,200 that even a dense screen can use, and that factor applies to the height too. Shrinking both sides by that much leaves about one-twentieth of the pixels. The browser would throw the rest away after downloading them.

Step 1: resize, then compress

Two different things make an image file smaller. Resizing removes pixels. Compression stores the remaining pixels more efficiently. Lossless compression, as in PNG, keeps every pixel exactly. Lossy compression, as in JPEG, WebP and AVIF, discards detail the eye is unlikely to notice, and a quality setting chooses how much.

A free tool does both inside the browser, with nothing to install: Squoosh, from Google's Chrome team, at squoosh.app. Its page says images never leave your device, because all the work is done locally; its source code notes that it does use Google Analytics to count visits and file sizes. This course opened Squoosh's own sample photograph, 3,872 by 2,592 pixels and 2.79 megabytes. Recompressed as a MozJPEG at quality 75 with no resizing, it became 862 kilobytes. With the Resize option set to 1,200 pixels wide, keeping the proportions, it became 118 kilobytes, 96 percent smaller than the original. Saved as WebP at the same size and quality, it was 106 kilobytes. At the size it is actually shown, nobody could see the difference.

Choosing a format

The Web Almanac's 2024 figures show both which formats sites use and how many bits each uses per pixel at the median:

FormatShare of images on mobile pagesMedian bits per pixelUse it for
JPEG32.4%2.0Photographs, with universal support
PNG28.4%3.8Screenshots and graphics that need exact pixels, transparency
GIF16.8%6.7Little: an old format, large for what it holds
WebP12%1.3Photographs and graphics, smaller than JPEG and PNG
SVG6.4%Not measured in pixelsLogos, icons and diagrams made of shapes, sharp at any size
AVIF1.0%1.4Photographs, with very good compression in newer browsers

SVG is different in kind from the others. It describes shapes, not pixels, so it stays sharp at any size and is often tiny. For Rowan's portfolio: the model volcano photo as WebP or JPEG; screenshots of web pages as PNG, or WebP, so that text in them stays crisp; and no GIFs.

Step 2: the img element, completely

Here is Rowan's image as it goes into the volcano card:

<img src="images/volcano-model-600.webp"
     srcset="images/volcano-model-600.webp 1x, images/volcano-model-1200.webp 2x"
     width="600" height="450"
     alt="A papier-mache volcano model, painted grey, with orange paper lava down one side."
     loading="lazy">

Each attribute has a job. src is the file and alt is the description from Lesson 21. width and height give the image's own size in pixels. They do not fix how big it is drawn, which CSS still controls with the max-width: 100% and height: auto rule from Lesson 14. Instead, MDN explains, they let the browser calculate the image's aspect ratio before the file arrives, and reserve the space it will need. Without them, the page is laid out with no room for the image, and everything below it jumps down when it finally loads, which is called a layout shift.

This course measured that directly with two images far down a page, not yet loaded. The one with width and height already occupied 768 by 576 pixels, its exact eventual size. The one without them occupied 0 by 0. The Web Almanac found that only 32 percent of images on mobile pages set both attributes.

Step 3: one file per screen density

The srcset attribute offers the browser a choice of files. In Rowan's version, each file is labelled with a pixel density descriptor: 1x for ordinary screens, 2x for screens with two hardware pixels per CSS pixel. MDN describes these as telling the browser which file to use at which pixel density, with src as the 1x choice. In the test, Chrome loaded the 600-pixel file when emulating an ordinary screen and the 1,200-pixel file when emulating a 2x screen. Nobody downloads both.

For images whose displayed width changes a great deal with the layout, such as a banner across the full width of the window, srcset can instead list widths, such as 600w and 1200w, together with a sizes attribute that tells the browser how wide the image will be drawn. MDN's page on img gives the details. For a card that never shows the image wider than about 300 pixels, 1x and 2x is enough.

Step 4: load it only when it is needed

loading="lazy" tells the browser to defer loading the image until it nears the visible part of the page. In the test, an image 3,000 pixels down the page had not loaded at all when the page opened, and had loaded once it was scrolled into view. Images nobody scrolls to are never downloaded.

The guidance from Chrome's team on web.dev adds one important limit: do not lazy-load images that are likely to be visible when the page first loads. Use lazy loading only for images outside the first screenful. On Rowan's projects page the first card's image is often visible straight away, so it leaves out loading="lazy" there and keeps it for the cards further down.

Captions with figure

When an image needs a visible caption, wrap the two together in a figure:

<figure>
  <img src="images/volcano-page-1200.png" width="1200" height="800"
       alt="The volcano page: a large heading, three paragraphs and a table of five eruptions.">
  <figcaption>The finished volcano page, viewed at laptop width.</figcaption>
</figure>

The caption is for everyone and says something about the image; the alt is for people who cannot see it and replaces the image. They should not simply repeat each other.

Whose picture is it?

Almost every image you can find online belongs to someone, and copying it into your site without permission infringes their copyright. Three kinds of image are safe to use. Images you made yourself. Public domain images, whose copyright has expired or never existed: in the United States that includes works published in 1930 or earlier, and works made by the federal government. And images released under a licence that allows reuse, most commonly Creative Commons. The Creative Commons site explains that all six of its licences require you to credit the creator; those marked NC allow only noncommercial use, ND forbids changing the image, and SA requires you to share any adapted version under the same terms. So an image licensed CC BY can be cropped and used on your portfolio with a credit line, while a CC BY-ND image can be used only unchanged. When an image's licence is unclear, do not use it.

What a photo can say about you

Phone photos carry hidden data called metadata, and it can include where the photo was taken. Apple's personal safety guide explains that location metadata comes from cellular, Wi-Fi, GPS and Bluetooth information, and that people you share a photo with may be able to learn where it was taken. A photo of a project taken in your bedroom can therefore point to your home. The same guide shows how to share without location, by turning Location off in the share sheet's Options, and how to stop the camera recording location at all. Other phones have similar settings. Use them before any photo goes near a public website.

Check what the picture shows, too. A house number, a street sign, a school badge or uniform, or the name of your school on a poster all say where to find you. And never publish a photo of another person without their permission, or, for a younger person, their parent's. Lesson 31 returns to all of this before your site goes live.

Common misconceptions

  • "CSS shrinks a big image, so file size does not matter." CSS changes how big it is drawn. The visitor still downloads every byte and the browser still decodes every pixel.
  • "width and height attributes are old-fashioned; CSS sets the size." CSS sets the drawn size. The attributes let the browser reserve the space before the image loads.
  • "If it is on a search engine, I can use it." Search engines show images that belong to other people. Check for a licence or a public domain status first.
  • "Lazy loading is always faster." For images visible straight away it delays them. Use it only below the first screenful.

The short version

Why this matters: Size images for the space they fill, with about twice the slot's width in pixels to cover high-density screens, compress them, and describe them. Give every img its alt, width and height, offer 1x and 2x files with srcset, lazy-load only what is below the first screen, and publish only images you have the right to use, with location data removed.

You took a theoretical 6-megabyte photo to about a tenth of a megabyte with no visible loss, and learned what every attribute of a finished img element is for. The next lesson moves from still pictures to moving ones: video and audio, and the captions and controls that make them usable by everyone.

Sources

  1. HTTP Archive. (2024). Media. In The 2024 Web Almanac. almanac.httparchive.org
  2. MDN contributors. (2026). The image embed element (width, height, srcset, loading). Mozilla. developer.mozilla.org
  3. Osmani, A., Djirdeh, H., Bynens, M., and Pollard, B. (2024). Browser-level image lazy loading for the web. web.dev. web.dev
  4. Apple. (2023, September 12). Apple debuts iPhone 15 and iPhone 15 Plus. Apple Newsroom. apple.com
  5. Apple. (n.d.). Manage location metadata in Photos. Personal Safety User Guide. support.apple.com
  6. Creative Commons. (n.d.). About CC licenses. creativecommons.org
Key terms
Resizing
Reducing an image's width and height in pixels so the file holds only as many pixels as its display needs.
Lossy compression
Storing an image by discarding detail the eye is unlikely to notice, as JPEG, WebP and AVIF do, at a chosen quality.
Layout shift
Content jumping as an image without reserved space loads; width and height attributes prevent it.
srcset
An img attribute offering alternative files, such as 1x and 2x versions, so the browser downloads only the one it needs.
Pixel density descriptor
A label such as 1x or 2x in srcset saying which screen density a file is meant for.
Lazy loading
loading="lazy" on an img, which defers loading until the image nears the visible part of the page.
Creative Commons licence
A licence letting others reuse a work on stated terms; all six require credit, and NC, ND and SA add limits.
Location metadata
Hidden data in a photo that can record where it was taken, which should be removed before publishing.

Video and Audio: Captions, Controls and Never Autoplaying

  • Compare hosting a video yourself with embedding one from YouTube, on size, captions, privacy and accounts.
  • Add a video with controls and a WebVTT captions track, and know why captions may not load from your own disk.
  • Apply WCAG 2.2's media criteria: captions, transcripts, audio description, and audio that never starts by itself.

The whole caption file for Rowan's nine-second video of the model volcano erupting is ten lines of plain text:

WEBVTT

00:00:00.000 --> 00:00:03.000
This is my volcano model, made from papier-mache.

00:00:03.000 --> 00:00:06.000
Baking soda is already inside the crater.

00:00:06.000 --> 00:00:09.000
[Fizzing] The vinegar goes in, and the lava foams out.

It took a few minutes to write, and it makes the video usable by anyone who cannot hear it: people who are deaf or hard of hearing, and everyone watching in a library with the sound off. This lesson compares the two ways to put a video on a page, builds the one Rowan chose, and covers the rules WCAG 2.2 sets for anything that moves or makes a sound.

Two ways to put a video on a page

You can host the video file yourself, next to your HTML, and play it with HTML's own video element. Or you can upload it to a video service such as YouTube and embed that service's player in your page. The trade-offs are clearest side by side:

QuestionYour own file, in a video elementA YouTube embed
Where the file livesIn your site's folder, published with itOn YouTube's servers
Size limitsYour host's: GitHub allows 25 MiB per file uploaded in the browser and 1 GB per siteYouTube handles size and streaming quality
CaptionsA WebVTT file you write, attached with a track elementAdded through YouTube's own caption tools
Privacy for visitorsOnly your host sees the requestGoogle receives the visit; Privacy Enhanced Mode limits how it is used
Accounts and ageNone needed beyond your hostA YouTube account to upload: YouTube's terms require age 13, and parental permission under 18
ControlsThe browser's built-in controlsYouTube's player

Reading across the rows: a short clip, like Rowan's nine seconds at 144 kilobytes, is simplest and most private as your own file. A long video, many megabytes or more, is better on a video service that can stream it at the right quality for each connection. For a student portfolio, short self-hosted clips avoid a third party, and an account, entirely.

The video element, line by line

<figure>
  <video controls width="320" height="240" preload="metadata">
    <source src="media/volcano-eruption.webm" type="video/webm">
    <track kind="captions" src="media/volcano-eruption.en.vtt" srclang="en" label="English" default>
    <p>Your browser cannot play this video. <a href="media/volcano-eruption.webm">Download the video</a> instead.</p>
  </video>
  <figcaption>The model volcano erupting, nine seconds, with captions.</figcaption>
</figure>
  • controls shows the browser's own play button, timeline, volume and full-screen controls. Without it, there is no way to play the video at all. They work from the keyboard too: in this course's test, Tab reached the video and Space started it playing.
  • width and height reserve the space, exactly as for images in Lesson 24, and the same max-width rule keeps the video inside its container.
  • preload="metadata" asks the browser to fetch only enough to know the video's length and size until someone presses play.
  • source names the file and its type. You can list several sources in different formats, and the browser uses the first it can play.
  • track attaches the caption file. kind="captions" says what it is, srclang gives its language, label is the name shown in the captions menu, and default turns it on.
  • The paragraph inside is fallback content, shown only by a browser that cannot play video at all.

When this course served the page from a local web server, Chrome drew the video with its controls, reading 0:00 / 0:08, loaded all three captions from the file, and showed the first, This is my volcano model, made from papier-mache., over the bottom of the picture.

One trap catches almost everyone. When the same page was opened straight from the disk, with a file:/// address, the captions did not appear. Chrome refused to load the track file and logged a message saying that file: URLs are treated as unique security origins. Nothing was wrong with the caption file; Chrome simply will not load a caption file into a page opened from your own disk. Test captions once the page is published, where it is served over HTTPS, as it will be after Module 10.

Writing captions

MDN documents the WebVTT format. A file must begin with the line WEBVTT. Each caption, called a cue, is a line of start and end times in hours, minutes, seconds and thousandths, joined by an arrow made of two hyphens and a greater-than sign, followed by the text, then a blank line. Three habits make captions good:

  • Caption every word that is spoken, not a summary.
  • Describe sounds that matter in square brackets, such as [Fizzing] or [Music stops], because they are part of what a hearing viewer gets.
  • Keep each cue short enough to read in the time it is on screen; two lines is a sensible maximum.

Captions are not the same as subtitles, which translate the dialogue for people who can hear but do not understand the language. kind="subtitles" exists for those.

What WCAG 2.2 requires for media

Four Level A criteria and one AA criterion cover prerecorded audio and video:

  • 1.2.1 Audio-only and Video-only (Prerecorded), A: a recording with only sound, such as a podcast, needs a text alternative, a transcript; a silent video needs a transcript or an audio track.
  • 1.2.2 Captions (Prerecorded), A: captions for all prerecorded audio in video.
  • 1.2.3 Audio Description or Media Alternative (Prerecorded), A, and 1.2.5 Audio Description (Prerecorded), AA: people who cannot see the video must get its visual information too, through audio description.

That last pair sounds daunting, but the W3C's explanation of 1.2.5 says that where all the video's information is already provided in the existing audio, no additional audio description is necessary. So narrate what you show. If Rowan's voice says that the vinegar goes in and the lava foams out, a listener misses nothing. A silent clip of the same eruption would need a description.

For audio alone, the audio element works like video, with controls, and the transcript goes on the page beside it:

<audio controls src="media/interview.mp3">
  <a href="media/interview.mp3">Download the recording</a>
</audio>

Never autoplay with sound

WCAG 2.2's Success Criterion 1.4.2, Audio Control, at Level A, applies to the whole page: if any audio plays automatically for more than 3 seconds, there must be a way to pause or stop it, or to control its volume separately from the system volume. Sound that starts by itself drowns out a screen reader, which is speech too. MDN's guide to autoplay adds that browsers generally block media with sound from playing automatically until the user has interacted with the page, while muted video may autoplay. So the autoplay attribute rarely works with sound anyway, and should not be tried.

Moving content has its own rule. Success Criterion 2.2.2, Pause, Stop, Hide, Level A, requires a way to pause, stop or hide anything that moves, blinks or scrolls automatically for more than five seconds alongside other content. And 2.3.1, Three Flashes or Below Threshold, Level A, forbids anything that flashes more than three times in any one second, which can trigger seizures. A portfolio video with controls and no autoplay meets all three.

Embedding a YouTube video properly

If you do embed from YouTube, two details matter. First, the domain. Google's help page explains that changing the embed address to youtube-nocookie.com switches on Privacy Enhanced Mode, which stops views of the embedded video being used to personalise the viewer's later YouTube experience, and limits ads to non-personalised ones. Second, the title attribute on the iframe, the element that holds the embedded player. The W3C's technique H64 uses an iframe's title to describe its contents, so assistive technology can name the frame. Here is an embed of a real W3C video:

<iframe width="560" height="315"
        src="https://www.youtube-nocookie.com/embed/20SHvU2PKsM"
        title="Introduction to Web Accessibility and W3C Standards, a video from the W3C"
        allowfullscreen loading="lazy"></iframe>

The W3C validator accepted it, along with Rowan's video page, with no errors. Even in Privacy Enhanced Mode the page still loads the player from Google, so the self-hosted video remains the most private choice.

Common misconceptions

  • "Automatic captions are good enough." Automatically generated captions can get names and technical words wrong. Check and correct them, or write your own.
  • "Captions and subtitles are the same." Subtitles translate speech for people who can hear. Captions give speech and important sounds to people who cannot.
  • "My captions file is broken, because it does not show." From a file:/// page, Chrome blocks the track file. Test captions on a served page.
  • "A background video with music sets the mood." Audio that starts by itself fails WCAG's Audio Control criterion unless it can be stopped, and browsers usually block it anyway.

Putting it together

In short: Host short clips yourself in a video element with controls, width and height, and a captions track; embed long ones with a titled iframe in Privacy Enhanced Mode. Caption all speech and important sounds, give audio a transcript, narrate what you show, and never let sound start by itself.

You wrote a caption file, attached it, saw it work over a server and fail from a file:/// page, and matched every line of the video element to its job. Media is something visitors watch. The last lesson of this module is about something visitors fill in: forms, and the hard question of what a student's site should ever ask for.

Sources

  1. MDN contributors. (2026). The video embed element and The embed text track element. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Web Video Text Tracks Format (WebVTT). Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). Autoplay guide for media and Web Audio APIs. Mozilla. developer.mozilla.org
  4. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criteria 1.2.1 to 1.2.5, 1.4.2, 2.2.2 and 2.3.1, and Understanding 1.2.5. W3C Recommendation, 12 December 2024. w3.org
  5. Google. (n.d.). Turn on privacy-enhanced mode. YouTube Help. support.google.com
  6. YouTube. (2023, December 15). Terms of service, section on age requirements. youtube.com
Key terms
video element
HTML's built-in video player; with controls, it shows play, timeline, volume and full-screen controls that work with the keyboard.
track element
Attaches a timed text file, such as captions, to a video, with kind, src, srclang, label and optionally default.
WebVTT
The plain-text caption format: the line WEBVTT, then cues of start --> end times followed by text.
Captions
Text of the speech and important sounds in a video, for people who cannot hear it; different from translated subtitles.
Transcript
A full text version of audio or video, placed on the page, required for audio-only recordings by WCAG 1.2.1.
Audio description
Narration of important visual information; not needed when the existing audio already conveys everything shown.
Privacy Enhanced Mode
YouTube's embed mode at youtube-nocookie.com, in which views do not personalise the viewer's YouTube experience.
Audio Control
WCAG 2.2 Success Criterion 1.4.2: sound that plays automatically for over 3 seconds must be stoppable or have its own volume control.

Forms: Labels, Inputs and the Data You Should Not Collect

  • Build a form with labelled inputs, a fieldset and legend, a suitable input type, an output and a button.
  • Explain where form data goes with GET and POST, and use built-in validation with required, type and autocomplete.
  • Decide what a public student portfolio should never ask for, and add the contrast checker form and project images to the portfolio.

In WebAIM's February 2026 study of the top million home pages, 51 percent had form fields with no label. A field without a label is a box that a screen reader user meets as an unnamed edit field, a box that cannot be filled in sensibly by anyone who cannot see what is printed next to it. Labelling one takes a single attribute. This lesson builds a form properly, step by step, using the one Rowan's portfolio needs: the contrast checker promised on the projects page since Lesson 13. Then a second form shows what changes when a form asks a visitor for personal information, and why a student site should not.

Step 1: the form and its controls

Here is the whole of the checker's form, as it goes into a new page, checker.html:

<form id="checker">
  <fieldset>
    <legend>Choose two colours</legend>
    <p>
      <label for="text-colour">Text colour</label>
      <input type="color" id="text-colour" name="text" value="#1f2933">
    </p>
    <p>
      <label for="background-colour">Background colour</label>
      <input type="color" id="background-colour" name="background" value="#fdfcf8">
    </p>
  </fieldset>
  <p>Contrast ratio: <output id="ratio" for="text-colour background-colour">not checked yet</output></p>
  <p><button type="submit">Check contrast</button></p>
</form>

The form element holds every control whose values belong together. Each input is a control; its type decides what kind. type="color" shows a colour swatch that opens the device's colour picker, and its value is always a hex code like the ones in Lesson 17. The name attribute is the key under which the value is sent; the id is for the label and for CSS.

In Chrome the page showed a bordered box headed Choose two colours, holding two labelled colour swatches, one near-black and one near-white, then the line Contrast ratio: not checked yet, then a button. The W3C checker found no errors.

Step 2: a label for every control

A label is connected to its control when the label's for attribute matches the control's id: for="text-colour" and id="text-colour". MDN's glossary entry on accessible names, from Lesson 21, says form fields take their name from their label. When this course read Chrome's accessibility tree, the two swatches were named Text colour and Background colour. Without the labels they would have had no names at all.

A connected label also does something for everyone: clicking the label text activates its control, which gives people with shaky hands a much bigger target. WCAG 2.2 covers labels in several places, most directly 3.3.2 Labels or Instructions, Level A: labels or instructions are provided when content requires user input.

Step 3: group related controls

A fieldset groups controls that answer one question, and its legend is the question. In the accessibility tree the fieldset became a group named Choose two colours, so a screen reader announces the question along with each control inside it. Groups matter most for radio buttons, where each option's label, say Weekly, makes no sense without the question it answers.

Step 4: an output for the answer

The output element holds the result of a calculation. Its for attribute lists the ids of the controls that feed it. MDN notes that many browsers treat output as a live region, so assistive technology announces new results put inside it without the user having to move focus away from the controls. In Chrome's tree it appeared with the role status. That is exactly what the checker needs: change a colour, hear the new ratio.

Step 5: where the data goes

Press Check contrast now and something odd happens. When this course clicked the button, the page reloaded and its address gained a question mark and a query from Lesson 1:

?text=%231f2933&background=%23fdfcf8

That is what a form does on its own. Each control's name and value are joined with an equals sign, pairs are joined with an ampersand, and characters such as the hash sign are encoded, here as %23. Then they are sent to the address in the form's action attribute, using the method in its method attribute. MDN's guide explains both defaults: with no action, the data goes to the page the form is on, and with no method, it uses GET.

The two methods differ in where the data travels. GET appends it to the address, as above. POST puts it in the body of the HTTP request, out of the address bar. MDN is blunt about the first: never use GET for a password or any other sensitive data, or it appears in the address bar, and so in browser history and server logs.

And something has to be at that address to receive it: a program on a server that reads the data and does something with it. GitHub Pages, where your site is going, runs no such programs. Its documentation says it does not support server-side languages, and that Pages sites should not be used for sensitive transactions such as sending passwords or credit card numbers. So the checker will never send its colours anywhere. In Module 9, a few lines of JavaScript will catch the button press in the browser, work out the ratio and show it in the output, and the page will not reload at all.

A second form, where one input changes everything

Now change one input: ask for something personal. Here is a typical sign-up form for a club newsletter, shown only to teach validation. It does not belong on your portfolio:

<form action="/subscribe" method="post">
  <p>
    <label for="email">Email address</label>
    <input type="email" id="email" name="email" autocomplete="email" required>
  </p>
  <fieldset>
    <legend>How often?</legend>
    <p><input type="radio" id="weekly" name="frequency" value="weekly" checked> <label for="weekly">Weekly</label></p>
    <p><input type="radio" id="monthly" name="frequency" value="monthly"> <label for="monthly">Monthly</label></p>
  </fieldset>
  <p><button type="submit">Sign up</button></p>
</form>

Three attributes on the email field do a great deal of work without any code:

  • type="email" makes the browser check the value looks like an email address. With rowan@ typed in, Chrome reported the field as invalid with the message Please enter a part following '@'. 'rowan@' is incomplete.
  • required makes an empty field invalid. Left empty, Chrome's message was Please fill out this field. Remove required and the empty field became valid.
  • autocomplete="email" tells the browser this field wants the user's own email address, so it can offer to fill it in. WCAG 2.2's 1.3.5 Identify Input Purpose, Level AA, asks for exactly this on fields that collect information about the user.

When this course clicked Sign up with rowan@ in the field, the form was not sent. Chrome stayed on the page, moved focus to the email field and showed its message. That is the browser identifying the error and describing it in text, which is what WCAG's 3.3.1 Error Identification, Level A, requires. The two radio buttons share the name frequency, which is what makes them a set where choosing one unchooses the other.

A placeholder is not a label

A popular shortcut is to drop the label and put the prompt inside the field as placeholder="Your email". Chrome did fall back on the placeholder to name the field in its accessibility tree. The W3C's forms tutorial still says plainly that placeholder text is not a replacement for labels: it disappears as soon as someone starts typing, it is usually shown in lower-contrast text, and it is not broadly supported across assistive technologies. Use a real label; add a placeholder only for an example of the format, if at all.

The data you should not collect

The sign-up form is the kind of thing that turns a portfolio into a liability. Every field that collects a visitor's details creates data someone must store, protect and eventually delete, and a site run by a student has no safe way to do any of that. On GitHub Pages there is nowhere for the data to go. Services that receive form data on a static site's behalf exist, but they are another company holding your visitors' details under its own terms, which usually include its own minimum age.

The rule for this course is simple: your portfolio's forms work entirely in the visitor's browser and collect nothing. The contrast checker fits that rule. It asks for two colours, sends them nowhere, and stores nothing. Lesson 31 covers how visitors can get in touch with you without your publishing an email address, a phone number or a form.

One visual rule finishes the job. The border of a text box or colour swatch is how people find it, so WCAG's 1.4.11 Non-text Contrast from Lesson 18 applies: at least 3:1 against the background. Rowan's form gives the swatches a 1-pixel border in the text colour, far above that, and the button a 2-pixel border in the link blue, which measured 7.13 to 1 against the white of the button.

Portfolio build 8: pictures, links and a form

Module 8's build has two parts. First, the project cards get the images from Lesson 24 and links to the projects themselves:

<article class="card">
  <h2>Volcanoes of the world</h2>
  <img src="images/volcano-page-600.png"
       srcset="images/volcano-page-600.png 1x, images/volcano-page-1200.png 2x"
       width="600" height="400"
       alt="The top of the volcano page: a large heading, three short paragraphs and the start of a table of eruptions.">
  <p>A one-page guide to how volcanoes form, with a data table of five famous eruptions.</p>
  <p><a href="projects/volcano.html">Open the volcano page</a></p>
</article>

Rowan made the two screenshots by capturing the volcano page itself at 600 by 400 pixels and again at twice the density: 24 kilobytes and 82 kilobytes as PNG files. In the card on a 1,000-pixel window the image was drawn 246 by 164 pixels, and on a 320-pixel phone 254 by 169, with the page exactly as wide as the window. The contrast checker card gets a link too: Try the contrast checker.

Second, checker.html is a new page with the same header, skip link and footer as the others, and the form from Step 1 in its main. Its styles join styles.css as a ninth section:

/* 9. Forms (Module 8) */
fieldset {
  border: 1px solid var(--color-border);
  padding: var(--space-m);
}

label {
  display: block;
  font-weight: bold;
}

input[type="color"] {
  width: 4rem;
  height: 2.75rem;
  border: 1px solid var(--color-text);
}

button {
  font: inherit;
  padding: var(--space-s) var(--space-m);
  border: 2px solid var(--color-link);
  background-color: var(--color-surface);
  color: var(--color-link);
}

font: inherit matters more than it looks: buttons do not inherit the page's font by default. In Chrome, a button without it used 13.3-pixel Arial, whatever the rest of the page was set in. Each swatch measured 64 by 44 pixels and the button 140 by 44, comfortably above the 24-pixel target size. Every page validated. The checker does not check anything yet: that is Module 9.

Common misconceptions

  • "A placeholder can stand in for a label." It vanishes when typing starts and is often low contrast. The W3C says it is not a replacement for a label.
  • "A form sends its data to the site's owner." It sends data to whatever address action names, and something there must receive it. GitHub Pages runs no code that could.
  • "GET and POST are equally private." GET puts the data in the address bar, history and logs. Never use it for anything sensitive.
  • "A contact form makes a portfolio look professional." On a student site it collects personal data with nowhere safe to keep it. A form that works in the browser and collects nothing is the safe choice.

Looking back

The upshot: Every control needs a label connected by for and id, related controls need a fieldset and legend, and results belong in an output. Forms send name and value pairs to their action by GET or POST, so a static site cannot receive them, and a student portfolio should not collect personal data at all.

Module 8 is complete. Your project cards carry real screenshots, sized and described, with links to the projects, and the contrast checker has a finished, accessible form waiting for something to make it work. Module 9 is that something: your first JavaScript, starting with a button that does nothing at all.

Sources

  1. WebAIM. (2026). The WebAIM Million: The 2026 report on the accessibility of the top 1,000,000 home pages. webaim.org
  2. MDN contributors. (2026). Sending form data and The output element. Mozilla. developer.mozilla.org
  3. W3C Web Accessibility Initiative. (2024). Forms tutorial: Labeling controls, Grouping controls, and Form instructions. w3.org
  4. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, Success Criteria 1.3.5, 1.4.11, 3.3.1 and 3.3.2. W3C Recommendation, 12 December 2024. w3.org
  5. GitHub. (2026). GitHub Pages limits and Creating a GitHub Pages site. GitHub Docs. docs.github.com
Key terms
form element
Holds controls whose values are submitted together, to the address in its action attribute by the method in its method attribute.
label element
The visible name of a control, connected to it when the label's for attribute matches the control's id.
fieldset and legend
A group of related controls and the question that names it, announced with each control inside.
output element
Holds the result of a calculation; many browsers treat it as a live region so new results are announced.
GET and POST
Form methods: GET appends name=value pairs to the address; POST sends them in the request body.
Built-in validation
Checks the browser makes from attributes such as required and type="email", blocking submission and describing the error.
autocomplete attribute
Names the purpose of a field, such as email, so browsers can fill it in; required by WCAG 1.3.5 for fields about the user.
Placeholder
Example text shown inside an empty field. It disappears when typing starts and is not a replacement for a label.

Module 9: A First Taste of JavaScript

Make the page respond: select elements, listen for events and change the page; read the Console when a script fails; and turn the contrast checker into a working tool. The module ends with a theme button and a contrast checker that both work, from mouse and keyboard.

The Button That Did Nothing: Your First JavaScript

  • Attach a script with defer, find an element with querySelector and respond to clicks with addEventListener.
  • Change a page from JavaScript by toggling a class and an attribute, and explain what each line of a short script does.
  • Build a toggle button that works from mouse and keyboard and reports its state with aria-pressed.

Rowan finally put the Dark theme button into the header of every page. It was a real button element, so it could be reached with Tab and announced as a button, just as Lesson 22 promised. It was styled to match the site. Rowan clicked it. Nothing happened. Pressed Enter. Nothing. Pressed Space. Still nothing.

Nothing was broken. A button in HTML is a promise that something can be done; it does not say what. HTML says what things are, CSS says how they look, and the third language of the web, JavaScript, says what happens when people use them. This lesson writes Rowan's first script, five lines that make the button switch the site's theme, and explains each line as it arrives.

Step 1: give the page a script

JavaScript lives in its own file, just as CSS does. Rowan created script.js in the portfolio folder and attached it in the head of every page, under the stylesheet link:

<script src="script.js" defer></script>

Unlike link, the script element is not void: it needs its end tag even when empty. The defer attribute matters. MDN describes it as telling the browser that the script is meant to run after the document has been parsed. Without it, MDN explains, a script is fetched and run immediately, before the browser carries on reading the page, so a script in the head would run before the button below it even exists. The next lesson shows exactly what that mistake looks like.

The button itself gains an id and one more attribute:

<button type="button" id="theme-toggle" aria-pressed="false">Dark theme</button>

Step 2: find the button

Here is the whole of script.js:

// script.js: Rowan's portfolio
const themeButton = document.querySelector('#theme-toggle');

themeButton.addEventListener('click', function () {
  const isDark = document.documentElement.classList.toggle('dark');
  themeButton.setAttribute('aria-pressed', String(isDark));
  console.log('Dark theme is now ' + isDark);
});

The first line, starting with two slashes, is a comment: JavaScript ignores everything after // to the end of the line. The second line does two things.

document.querySelector('#theme-toggle') asks the page for an element. document is JavaScript's name for the page, the same tree of elements you have seen as the DOM since Lesson 3. querySelector takes a CSS selector, exactly like the ones from Lesson 8, here an id selector, and MDN says it returns the first element in the document that matches, or null if there is none. Remember that null; it is the subject of Lesson 28.

const themeButton = ... stores the result in a variable: a name for a value, so the script can refer to it later. const means the name will always refer to this same button. The name is Rowan's choice; JavaScript cares only that it is spelled the same way every time, including capital letters.

Step 3: listen for a click

The third statement attaches an event listener. An event is something that happens on the page: a click, a key press, a change to a form field. addEventListener takes two things in its brackets: the name of the event, 'click', and a function, a block of instructions in curly braces that the browser keeps and runs every time that event happens on that element. Nothing inside the function runs when the page loads. It runs only when someone clicks.

Notice the punctuation. Text in JavaScript goes in quotation marks, single or double; this course uses single. Each instruction ends with a semicolon. And the function's closing brace is followed by the closing bracket of addEventListener and a final semicolon: });. Losing track of those is the most common typing slip in JavaScript, and Phoenix Code helps by highlighting matching brackets.

Step 4: switch the theme with a class

Inside the function, the first line does the actual work. document.documentElement is the html element, the root of the page, the same element :root matches in CSS. Its classList is the list of its classes, and toggle('dark') adds the class dark if it is missing and removes it if it is there. MDN adds the detail this script relies on: toggle returns true if the class is present afterwards and false if not. So isDark is true right after switching the dark theme on, and false after switching it off.

The class only matters because the stylesheet uses it. Rowan added one rule, which gives the same roles the dark values from Lesson 17:

:root.dark {
  --color-text: #e6e1d8;
  --color-background: #1b1f24;
  --color-accent: #f2a46b;
  --color-link: #8ec5ff;
  --color-surface: #252b32;
  --color-border: #77706a;
}

:root.dark scores 0-2-0 in Lesson 8's terms, a pseudo-class and a class, so it overrides the plain :root tokens, which score 0-1-0. Every colour on the site comes from those tokens, so one class on one element repaints every page. JavaScript did not touch a single colour itself.

Step 5: say what state it is in

A sighted user sees the theme change. A screen reader user needs to be told. The W3C's button pattern describes a toggle button: a two-state button whose aria-pressed attribute is true when it is on and false when it is off, which screen readers announce. That is the second line of the function: setAttribute changes an attribute, and String(isDark) turns the true or false into the text "true" or "false" that attributes hold. The pattern adds a rule that is easy to get wrong: the button's label must not change with its state. It stays Dark theme; aria-pressed says whether it is on.

Watching it work

The last line of the function, console.log, writes a message into the developer tools' Console, the panel that shows a script's messages and errors. In Chrome, Ctrl+Shift+J on Windows or Cmd+Option+J on a Mac opens it directly. Visitors never see it; it is for you.

This course clicked the button in Chrome with the device set to a light appearance. The html element gained the class dark, aria-pressed changed to true, the page background changed from rgb(253, 252, 248) to rgb(27, 31, 36), and the Console showed Dark theme is now true. Then the keyboard test from Lesson 22: Tab twice reached the button, Space switched the dark theme on, and Enter switched it off again, with aria-pressed following each time. The listener was written for click, yet the keys worked, because a native button fires a click event when Space or Enter activates it. A div would have needed extra code for each key.

What it does not do yet

One more test exposed a gap. With Chrome set to a dark appearance, the page already started dark, thanks to Lesson 17's prefers-color-scheme rule. Clicking the button added the class and set aria-pressed to true, and nothing on screen changed at all, because the page was already dark. For those readers the button cannot switch to a light theme, and it claims to have done something it has not. The next lesson fixes that with a decision in the code, after first breaking the script in the two ways nearly every beginner does.

Common misconceptions

  • "A button in HTML does something by itself." Outside a form, a button does nothing until a script listens for its click.
  • "A script in the head runs after the page loads." Without defer it runs immediately, before the elements below it exist.
  • "The keyboard needs its own listener." A native button turns Space and Enter into click events, so one click listener serves mouse and keyboard.
  • "A toggle button should change its label to show its state." The W3C's pattern says the label stays the same and aria-pressed carries the state.

Recap

The point: JavaScript finds elements with querySelector, waits for events with addEventListener, and changes the page, here by toggling one class that CSS turns into a whole theme. Attach scripts with defer, use native buttons so the keyboard works for free, and report a toggle's state with aria-pressed.

You wrote five lines that made a dead button switch a whole site's colours and announce its state, and you watched the result in the Console and with the keyboard. You also found a case where it fails silently. The next lesson starts by making the script fail loudly, then uses the Console to find out why.

Sources

  1. MDN contributors. (2026). The script element (the defer attribute). Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Document: querySelector() method and EventTarget: addEventListener() method. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). DOMTokenList: toggle() method. Mozilla. developer.mozilla.org
  4. W3C Web Accessibility Initiative. (n.d.). Button pattern, toggle buttons. ARIA Authoring Practices Guide. w3.org
  5. Google. (2026). Console overview. Chrome DevTools documentation. developer.chrome.com
Key terms
JavaScript
The web's programming language, which makes pages respond to what people do.
defer
A script attribute that runs the script after the whole page has been parsed, so the elements it needs exist.
querySelector
Finds the first element matching a CSS selector, such as '#theme-toggle', or returns null if none matches.
Variable
A name for a value; const creates one that always refers to the same value, such as the button element.
Event listener
A function the browser runs every time a named event, such as 'click', happens on an element.
Function
A block of instructions in curly braces that runs when it is called, for example when an event happens.
classList.toggle
Adds a class if it is missing and removes it if present, returning true if the class is there afterwards.
aria-pressed
An attribute that makes a button a toggle and tells assistive technology whether it is on (true) or off (false).
Console
The developer tools panel that shows messages from console.log and any errors a script causes.

Cannot Read Properties of Null: Debugging a Script in the Console

  • Read a JavaScript error message in the Console, including its type, its message and the file and line it names.
  • Trace a null value back to a script that ran too early or a mistyped selector, and use console.log to inspect values.
  • Fix a logic bug that produces no error, using if and else, a function that returns a value, and matchMedia.

Rowan tidied up the head of the home page and, while moving lines around, retyped the script element without its defer attribute. The theme button stopped working. No change of colour, no message on the page, nothing. Then Rowan opened the Console, and there it was, in red:

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')
    at script.js:4:13

That one line contains nearly everything needed to fix the problem, once you know how to read it. This lesson reads it word by word, traces it to its cause, then meets a second bug that produces the same message for a completely different reason, and finally a third bug that produces no message at all, which is the hardest kind.

Reading the error, word by word

When this course recreated Rowan's mistake, Chrome printed exactly that message and pointed at line 4, column 13 of script.js. Each part means something:

  • Uncaught: nothing in the script was set up to handle this problem, so the script stopped at that line. Everything after it never ran.
  • TypeError: the kind of error. A value was the wrong type for what the code tried to do with it. MDN's error reference explains that this kind of error often involves null or undefined values.
  • Cannot read properties of null: the code tried to use something as if it were an object, an element, but the value was null, which in JavaScript means no object at all.
  • (reading 'addEventListener'): what the code was trying to get from it.
  • script.js:4:13: the file, the line and the column, which is the 13th character along that line. Click the file name in the Console and Chrome opens the file at that line.

Line 4 of Rowan's script is themeButton.addEventListener('click', ...). So the message says: themeButton was null when line 4 tried to read addEventListener from it.

Tracing null back to where it came from

An error appears where the problem is noticed, which is often not where it was caused. The next step is always to ask: where did that value come from? themeButton was set on line 2:

const themeButton = document.querySelector('#theme-toggle');

Lesson 27 quoted MDN: querySelector returns the first matching element, or null if there is no match. So at the moment line 2 ran, the page had no element with the id theme-toggle. There are two usual reasons.

Cause 1: the script ran too early. MDN explains that a script without defer is fetched and run immediately, before the browser carries on reading the page. Rowan's script element sits in the head, and the button is in the body, further down. Without defer, line 2 ran while the browser had only read as far as the head, so there was no button yet. Putting defer back fixed it at once.

Cause 2: the selector does not match. To check the other cause, this course kept defer and introduced a one-letter typing slip instead: '#theme-togle', with one g. Chrome printed exactly the same error, at exactly the same line. Same message, different cause. That is why the Console's message tells you where to look, not what is wrong.

You can test either cause directly in the Console, which runs any JavaScript you type into it. Type document.querySelector('#theme-togle') and press Enter: the Console answers null. Type the correct id and it answers with the button element, which highlights on the page when you hover over it. Asking the page a question like this takes seconds and usually settles which cause you have.

Inside a script, the same trick is console.log. A line such as console.log(themeButton); placed just after line 2 prints the variable's value when the script runs: null in both broken versions, the button element in the working one.

The bug with no error

With defer restored and the typo fixed, the script ran without a single red line. Then came the test from the end of Lesson 27: a device set to a dark appearance. The page started dark. Clicking the button logged Dark theme is now true, aria-pressed became true, and nothing on screen changed.

This is a logic bug. The code ran exactly as written: it added the class dark to a page that was already dark. What it did was not what Rowan meant, and no browser can detect that, because only Rowan knows what was meant. The console.log line is the clue. It reported true, the page was dark before and after, so the script's idea of the theme and the reader's view of the page had come apart.

The fix needs the script to make a decision: if the page is dark now, make it light; otherwise make it dark. To know whether the page is dark, the script must check three things: the dark class, a light class, and, if neither has been set, the device's own setting. MDN describes window.matchMedia as returning an object that tells you whether the document matches a media query, through its matches property, which is true when it does. That is Lesson 15's media query, asked from JavaScript.

Teaching the script to decide

Here is Rowan's fixed script.js:

// script.js: Rowan's portfolio
const themeButton = document.querySelector('#theme-toggle');
const root = document.documentElement;
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

function pageIsDark() {
  if (root.classList.contains('dark')) {
    return true;
  }
  if (root.classList.contains('light')) {
    return false;
  }
  return systemDark;
}

themeButton.setAttribute('aria-pressed', String(pageIsDark()));

themeButton.addEventListener('click', function () {
  if (pageIsDark()) {
    root.classList.remove('dark');
    root.classList.add('light');
  } else {
    root.classList.remove('light');
    root.classList.add('dark');
  }
  themeButton.setAttribute('aria-pressed', String(pageIsDark()));
});

Four new ideas appear, each doing one job:

  • A named function: function pageIsDark() { ... } defines a function with a name, so it can be used in several places. return hands a value back to whoever called it and ends the function at once.
  • if: if (condition) { ... } runs the code in its braces only when the condition is true. The first if in pageIsDark returns true if the dark class is present. If not, the second checks for light. If neither, the last line returns the device's setting.
  • else: in the click listener, else holds the code to run when the if's condition is false. Exactly one of the two blocks runs on each click.
  • contains, add and remove: three more classList methods, which check for a class, add one, or take one away.

The script also sets aria-pressed once when the page loads, so the button reports the right state from the start: pressed on a dark device, not pressed on a light one. And the stylesheet gains a rule for the new class, giving the light values, so that a reader who chooses light gets light even when the device is set to dark:

:root.light {
  --color-text: #1f2933;
  --color-background: #fdfcf8;
  --color-accent: #8a3b12;
  --color-link: #1a5a96;
  --color-surface: #ffffff;
  --color-border: #8c8478;
}

:root.light scores 0-2-0, so it overrides the dark values that the media query puts on :root, which score 0-1-0.

Testing every path

A decision creates more than one path through the code, and each needs a test. This course ran all of them in Chrome:

Device settingOn loadAfter click 1After click 2
LightLight page, aria-pressed falseDark, class dark, pressed trueLight, class light, pressed false
DarkDark page, aria-pressed trueLight, class light, pressed falseDark, class dark, pressed true

Every click now changes what the reader sees, on either kind of device, and aria-pressed always tells the truth about it.

A method for any broken script

  1. Open the Console before anything else. A red line there is a gift.
  2. Read the error's last part first: the type, the message and the file and line.
  3. Go to that line and find the value the error names. Then ask where that value came from, and follow it back.
  4. Check your guess by asking the page in the Console, or with console.log in the script.
  5. If there is no error but the page does the wrong thing, log what the script believes and compare it with what you see.
  6. Fix one thing at a time, and retest every path, not just the one that was broken.

Common misconceptions

  • "The line in the error is the line with the mistake." It is where the problem was noticed. Rowan's mistakes were in the head and on line 2; the error pointed at line 4.
  • "One error message means one cause." A script that ran too early and a mistyped selector gave identical messages.
  • "No errors in the Console means the script works." The dark-device bug produced no error at all. Only testing every path found it.
  • "null is the same as an empty element." null means there is no element at all, which is why nothing can be read from it.

Pulling it together

So what? An error message tells you where a script noticed a problem; your job is to trace the bad value back to where it came from. Cannot read properties of null almost always means querySelector found nothing, because the script ran before the element existed or the selector was wrong. And when there is no error at all, log what the script believes, and give it the decision it lacks.

You read an error word by word, found two causes behind one message, and fixed a silent logic bug with matchMedia, a named function, and if and else, then tested every path. The last lesson of this module uses all of it to make the contrast checker from Lesson 26 actually check contrast.

Sources

  1. MDN contributors. (2026). What went wrong? Troubleshooting JavaScript. Learn web development. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). TypeError: "x" is (not) "y". JavaScript error reference. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). Window: matchMedia() method. Mozilla. developer.mozilla.org
  4. MDN contributors. (2026). Making decisions in your code: conditionals. Learn web development. Mozilla. developer.mozilla.org
  5. Google. (2026). Console overview. Chrome DevTools documentation. developer.chrome.com
Key terms
Error message
The Console's report of a failure: a type such as TypeError, a description, and the file and line where it was noticed.
TypeError
An error caused by using a value of the wrong type, such as treating null as an element.
null
JavaScript's value for no object at all; querySelector returns it when nothing matches.
console.log
Prints a value or message to the Console so you can see what a script believes while it runs.
Logic bug
A mistake where the code runs without errors but does something other than what was meant.
if and else
A decision: the if block runs when its condition is true, and the else block runs when it is false.
return
Ends a function and hands a value back to the code that called it.
matchMedia
A JavaScript method that tests a media query; its matches property is true when the page matches.

Making the Contrast Checker Answer

  • Stop a form's default submission with the submit event and preventDefault, and show a result in an output element.
  • Turn a step-by-step formula into small functions using parseInt, slice, Math.max and Math.min, testing each in the Console.
  • Choose between results with if, else if and else, and display a ratio without ever rounding it up.

Rowan's contrast checker looks finished. It has two labelled colour swatches in a fieldset, an output that says not checked yet, and a Check contrast button. Press the button, though, and the page reloads with ?text=%231f2933&background=%23fdfcf8 on the end of its address, just as Lesson 26 predicted. The form sends its data to the page it is on, and nothing there reads it.

Lesson 18 worked out contrast by hand in four steps. This lesson turns those steps into JavaScript, a few lines at a time, and tests each piece in the Console before building on it. That order of work is the most useful habit in programming: write a small piece, test it, then write the next. At the end comes Module 9's build, which finishes the portfolio's scripts.

Step 1: a script for one page

The theme script belongs on every page. The checker's script belongs only on checker.html, so it gets a file of its own, checker.js, attached under script.js in that page's head:

<link rel="stylesheet" href="styles.css">
<script src="script.js" defer></script>
<script src="checker.js" defer></script>

MDN states that scripts with defer run in the order in which they appear in the document, after the page has been read. The first lines of checker.js find the four elements it needs, using the ids from Lesson 26:

// checker.js: the contrast checker on checker.html
const form = document.querySelector('#checker');
const textInput = document.querySelector('#text-colour');
const backgroundInput = document.querySelector('#background-colour');
const output = document.querySelector('#ratio');

Test it straight away. Type output in the Console and press Enter. Chrome showed the output element, still saying not checked yet. Had it said null, Lesson 28's method would come first.

Step 2: stop the reload

When a form is sent, the browser fires a submit event. MDN points out that it fires on the form element itself, not on the button inside it. It also fires however the form is sent: by a click on a submit button, by implicit submission such as pressing Enter in a text field, or when a script calls the form's requestSubmit method. So the listener goes on the form:

form.addEventListener('submit', function (event) {
  event.preventDefault();
  console.log('Checking ' + textInput.value + ' on ' + backgroundInput.value);
});

This time the function has something in its brackets. When an event happens, the browser hands the listener an event object describing it, and naming it event lets the code use it. Its preventDefault method, in MDN's words, tells the browser that the event is being explicitly handled, so its default action should not be taken. A link's default action is to follow the link. A form's is to be sent. Cancel that, and the page stays put.

When this course clicked Check contrast, the Console logged Checking #1f2933 on #fdfcf8. The address stayed plain checker.html, and the page did not reload. The log also shows what the script has to work with. A colour input's value is always a hash and six hexadecimal digits in lower case: setting #AABBCC read back as #aabbcc, and even setting the word red read back as #ff0000. MDN's page on the colour input calls this seven-character lower-case hexadecimal notation.

Step 3: one channel, Lesson 18's first two steps

Lesson 18 turned each two-digit channel into a share of full light. In JavaScript, that becomes a named function with a parameter: a name in the function's brackets for whatever value is passed in when the function is called.

// One channel, such as '76', as its share of full light (Lesson 18, steps 1 and 2)
function channelLight(pair) {
  const c = parseInt(pair, 16) / 255;
  if (c <= 0.04045) {
    return c / 12.92;
  }
  return ((c + 0.055) / 1.055) ** 2.4;
}

Three pieces are new:

  • parseInt(pair, 16) turns text into a whole number. MDN explains that its second argument is the radix, the base of the number system. 16 means hexadecimal, so parseInt('76', 16) is 118, which is 7 times 16 plus 6. parseInt('ff', 16) is 255.
  • <= means less than or equal to. If the channel is that dark, the first return hands back c / 12.92 and the function ends there.
  • ** raises a number to a power. MDN says it is equivalent to Math.pow, so the last line is Lesson 18's ((c + 0.055) / 1.055) raised to the power 2.4.

Test: channelLight('76') returned 0.18116424424986022, the 0.1812 that Lesson 18 worked out by hand for #767676. channelLight('8a') returned 0.25415..., the red channel of Rowan's accent, which Lesson 18's table rounded to 0.2542.

Step 4: a whole colour, Lesson 18's third step

// A colour such as '#767676' as relative luminance, from 0 to 1 (step 3)
function luminance(hex) {
  const r = channelLight(hex.slice(1, 3));
  const g = channelLight(hex.slice(3, 5));
  const b = channelLight(hex.slice(5, 7));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

slice copies a piece out of a string. MDN describes its two numbers as the index of the first character to include and the index of the first character to leave out. Counting starts at 0. In '#8a3b12' the hash is character 0, so slice(1, 3) gives '8a', slice(3, 5) gives '3b' and slice(5, 7) gives '12'. A single asterisk means multiply, and the three weights are WCAG's, from Lesson 18.

Notice that luminance calls channelLight three times. Small functions that call each other keep a program readable: each has one job, and each can be tested alone. Test: luminance('#ffffff') returned 1, luminance('#000000') returned 0, and luminance('#8a3b12') returned 0.0857..., Lesson 18's figure for the accent.

Step 5: the ratio, Lesson 18's fourth step

// The contrast ratio of two colours, lighter one on top (step 4)
function contrastRatio(hexA, hexB) {
  const lighter = Math.max(luminance(hexA), luminance(hexB));
  const darker = Math.min(luminance(hexA), luminance(hexB));
  return (lighter + 0.05) / (darker + 0.05);
}

WCAG's formula needs the lighter colour's luminance on top. Math.max gives the larger of the numbers it is given and Math.min the smaller, so the function works whichever order the colours arrive in. Test: contrastRatio('#767676', '#ffffff') returned 4.542224959605253, and swapping the two colours gave exactly the same. The ends of the scale came out right too: black on white gave 21, and white on white gave 1.

Step 6: show the answer

function showResult() {
  const ratio = contrastRatio(textInput.value, backgroundInput.value);
  let verdict;
  if (ratio >= 4.5) {
    verdict = 'enough for any text (WCAG AA)';
  } else if (ratio >= 3) {
    verdict = 'enough for large text only (WCAG AA)';
  } else {
    verdict = 'not enough for text (WCAG AA)';
  }
  const shown = Math.floor(ratio * 100) / 100;
  output.textContent = shown + ' to 1, ' + verdict;
}

In the submit listener, the console.log line is then replaced by a call to the new function: showResult();. Four details carry the meaning:

  • let: MDN describes let as declaring variables that can be reassigned, and notes that many style guides prefer const whenever a variable is not. verdict starts with no value and gets one later, in whichever branch runs, so it needs let.
  • else if: a second condition, checked only when the first was false. The order matters. Because 4.5 is tested first, the 3 branch is reached only by ratios below 4.5.
  • >=: greater than or equal to, which is WCAG's "at least". A ratio of exactly 4.5 passes.
  • Math.floor: rounds down to a whole number. Multiplying by 100, rounding down and dividing by 100 keeps two decimal places and simply cuts off the rest.

Why not round normally? JavaScript's toFixed(2) rounds, and in the Console (4.499).toFixed(2) gave 4.50, a failing ratio dressed up as a pass. Math.floor gave 4.49. Lesson 18 quoted WCAG's advice that computed values should not be rounded, so the checker never shows a number higher than the true one. That is why it shows 4.47 for #777777 on white, where Lesson 18 rounded the same 4.478 to 4.48. Either way, the verdict is decided on the full, uncut number.

Finally, setting textContent replaces the text inside an element. Lesson 26 found that Chrome gives the output element the role status, and MDN notes that many browsers treat it as a live region. So screen readers can announce the new result without anyone moving focus.

Step 7: answer when a colour changes

Pressing a button after every choice is one step too many. MDN describes two events on a colour input: input fires every time the colour changes, and change fires when the user dismisses the colour picker. change suits the checker better. It gives one new result per choice, rather than a new one for every small movement while someone drags across the picker. Two lines at the end of checker.js do it:

textInput.addEventListener('change', showResult);
backgroundInput.addEventListener('change', showResult);

Look closely: showResult has no brackets here. The listener needs the function itself, to keep and run later. Written as showResult(), with brackets, the function would run once, immediately, and addEventListener would receive whatever it returned, which is nothing.

Testing against known answers

A calculator is only as good as its tests, and the best tests are answers you already know. This course set each pair below, then pressed Check contrast or fired the change event that the picker sends when it closes:

TextBackgroundThe checker showed
#1f2933#fdfcf814.37 to 1, enough for any text (WCAG AA)
#e6e1d8#1b1f2412.71 to 1, enough for any text (WCAG AA)
#767676#ffffff4.54 to 1, enough for any text (WCAG AA)
#777777#ffffff4.47 to 1, enough for large text only (WCAG AA)
#949494#ffffff3.03 to 1, enough for large text only (WCAG AA)
#ffffff#ffffff1 to 1, not enough for text (WCAG AA)
#000000#ffffff21 to 1, enough for any text (WCAG AA)

The first two rows are Rowan's own text colours. Lesson 18's table gave them as 14.37 and 12.72; the checker cuts the second to 12.71. With the keyboard alone, Tab reached the two swatches and then the button, and both Space and Enter on the button showed a result. The address never gained a question mark, and the page made no request to any server. The colours never left the browser.

Three quiet mistakes and one loud one

Lesson 28 showed that the worst bugs are the quiet ones. This course broke the checker in three common ways to see what each looks like:

  • Leaving out the 16. Without a radix, parseInt('76') read the digits as an ordinary decimal number, 76. parseInt('8a') stopped at the letter and gave 8. parseInt('ff') gave NaN, JavaScript's value for Not a Number. Arithmetic with NaN gives NaN, and every comparison with it is false, so the verdict fell through to the last branch: NaN to 1, not enough for text (WCAG AA). No error appeared.
  • Brackets after showResult. With showResult() in the change listeners, a ratio appeared the moment the page loaded, and choosing new colours did nothing until the button was pressed. No error appeared either.
  • Reusing a name. Scripts on the same page share their top-level names. When checker.js also declared const root, a name script.js already uses, Chrome reported Uncaught SyntaxError: Identifier 'root' has already been declared. None of checker.js ran, so the button sent the form and reloaded the page again.

The loud one: attaching checker.js to the home page produced Lesson 28's TypeError, Cannot read properties of null (reading 'addEventListener'), because there is no form with the id checker on that page. The theme button there kept working. An error stops only the script file it happens in.

Portfolio build 9: the portfolio's scripts

Module 9's build finishes the site's JavaScript. Check each piece against your own files:

  1. Every page has the theme button at the end of its header, after the nav, as in Lesson 27.
  2. Every page links script.js, Lesson 28's fixed version, with defer. The volcano page sits in the projects folder, so its path is ../script.js: Lesson 5's two dots, meaning go up one folder, just as its stylesheet link needs ../styles.css.
  3. styles.css gains a tenth section, headed /* 10. Theme switch (Module 9) */, holding the :root.dark rule from Lesson 27 and the :root.light rule from Lesson 28.
  4. checker.html, and only checker.html, links checker.js after script.js.
  5. No console.log lines are left in either file. They were for you while testing; visitors never need them.

Here is the whole of checker.js, in order:

// checker.js: the contrast checker on checker.html
const form = document.querySelector('#checker');
const textInput = document.querySelector('#text-colour');
const backgroundInput = document.querySelector('#background-colour');
const output = document.querySelector('#ratio');

// One channel, such as '76', as its share of full light (Lesson 18, steps 1 and 2)
function channelLight(pair) {
  const c = parseInt(pair, 16) / 255;
  if (c <= 0.04045) {
    return c / 12.92;
  }
  return ((c + 0.055) / 1.055) ** 2.4;
}

// A colour such as '#767676' as relative luminance, from 0 to 1 (step 3)
function luminance(hex) {
  const r = channelLight(hex.slice(1, 3));
  const g = channelLight(hex.slice(3, 5));
  const b = channelLight(hex.slice(5, 7));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

// The contrast ratio of two colours, lighter one on top (step 4)
function contrastRatio(hexA, hexB) {
  const lighter = Math.max(luminance(hexA), luminance(hexB));
  const darker = Math.min(luminance(hexA), luminance(hexB));
  return (lighter + 0.05) / (darker + 0.05);
}

function showResult() {
  const ratio = contrastRatio(textInput.value, backgroundInput.value);
  let verdict;
  if (ratio >= 4.5) {
    verdict = 'enough for any text (WCAG AA)';
  } else if (ratio >= 3) {
    verdict = 'enough for large text only (WCAG AA)';
  } else {
    verdict = 'not enough for text (WCAG AA)';
  }
  const shown = Math.floor(ratio * 100) / 100;
  output.textContent = shown + ' to 1, ' + verdict;
}

form.addEventListener('submit', function (event) {
  event.preventDefault();
  showResult();
});

textInput.addEventListener('change', showResult);
backgroundInput.addEventListener('change', showResult);

This course checked the finished pages. Every page passed the W3C validator with no errors. On checker.html, Tab went from the skip link to the site name, the three navigation links, Dark theme, the two swatches and Check contrast. At 320 pixels wide the header wrapped onto three lines, site name, navigation and then the theme button, and the page stayed exactly 320 pixels wide. The theme button measured 118 by 44 pixels, well over the 24-pixel minimum target size. On a device set to dark, the checker page started dark with the button pressed, and one click turned it light.

Common misconceptions

  • "parseInt knows the text is hexadecimal." Without the 16 it reads ordinary decimal digits, stops at the first letter, or gives NaN.
  • "Rounding a ratio for display is harmless." toFixed turned 4.499 into 4.50. Cut the number off; never round it up.
  • "showResult and showResult() mean the same thing." Without brackets you hand over the function. With them you run it now and hand over its result.
  • "Each script file has its own names." Scripts on one page share their top-level names, so two files that both declare const root clash.

What to remember

Worth holding on to: Catch the form's submit event and call preventDefault to keep the work in the browser. Build a calculation as small functions, one step each, and test every function in the Console against answers you already know before joining them up. Show results without rounding up, and pass functions to listeners without brackets.

Module 9 is complete. In three lessons you went from a button that did nothing to a theme switch that works on any device and a checker that measures contrast the way WCAG does. All of it runs in the visitor's browser and collects nothing. Module 10 puts the site on the web, at an address anyone can visit.

Sources

  1. MDN contributors. (2026). HTMLFormElement: submit event. Mozilla. developer.mozilla.org
  2. MDN contributors. (2026). Event: preventDefault() method. Mozilla. developer.mozilla.org
  3. MDN contributors. (2026). parseInt() and let. JavaScript reference. Mozilla. developer.mozilla.org
  4. MDN contributors. (2026). <input type="color">. Mozilla. developer.mozilla.org
  5. W3C. (2024). Web Content Accessibility Guidelines (WCAG) 2.2, definitions of contrast ratio and relative luminance. W3C Recommendation, 12 December 2024. w3.org
Key terms
submit event
The event a form fires, on the form element itself, whenever it is sent, whether by a submit button or implicitly.
preventDefault
A method of the event object that stops the browser's default action, such as sending a form and reloading the page.
Parameter
A name in a function's brackets for the value passed in when the function is called, such as pair in channelLight(pair).
parseInt
Turns text into a whole number; with 16 as its second argument it reads hexadecimal, so parseInt('ff', 16) is 255.
slice
Copies part of a string from a start index up to, but not including, an end index; counting starts at 0.
let
Declares a variable that can be given a new value later; const is preferred when the value never changes.
else if
A second condition, checked only when the condition before it was false.
textContent
The text inside an element; setting it replaces that text, which is how the checker shows its result.
NaN
Not a Number: the value JavaScript gives when a calculation receives something that is not a number.

Module 10: Publishing Your Portfolio

Put the portfolio on the web: publish a harmless test page on GitHub Pages and follow its address, then weigh up what a public site should carry, review yours for privacy and publish it. The module ends with your portfolio live at a free address.

From a Folder to an Address: Publishing on GitHub Pages

  • Publish files to a GitHub Pages user site from a public repository by uploading them in the browser.
  • Follow a visit to a github.io address through DNS, response headers and GitHub's two 404 pages, and fix the usual publishing faults.
  • State GitHub's age rules, what Pages makes public and logs, and its limits, and involve a parent, guardian or teacher where needed.

For nine modules Rowan's portfolio has lived in a folder. It validates, it passes its own accessibility audit, and it works on a phone. Nobody else can see it. Publishing means copying those files to a computer that answers requests from anyone, at an address anyone can type. This lesson follows one small upload from Rowan's folder to a live address, step by step.

Rowan did not start with the portfolio itself. The first upload was a test page that says nothing about anyone:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Coming soon</title>
  </head>
  <body>
    <main>
      <h1>Coming soon</h1>
      <p>A web design portfolio is on its way.</p>
    </main>
  </body>
</html>

It passed the W3C validator, and in Chrome it showed a large bold heading and one line of black text on white. Practising with a page like this means a mistake in the process costs nothing. Lesson 31 reviews the real site for privacy before it goes anywhere near the internet.

An account, and the age rules that come with it

The host this course uses is GitHub Pages, a free service from GitHub, a website where people store and share code. GitHub's documentation describes Pages as a static site hosting service that takes HTML, CSS and JavaScript files and publishes a website. Static suits Rowan's site exactly: nothing runs on the server, and every file is sent just as it is.

Pages needs a GitHub account, and accounts come with rules. GitHub's Terms of Service, effective 27 April 2026, say you must be 13 or older. They add that if you live outside the United States, your country's minimum age may be older, and you are responsible for following your country's laws. They also require a valid email address and allow only one free account per person.

  • If you are under 13, GitHub does not allow you an account. Keep the site on your own computer, where it works perfectly well, and talk to a parent, guardian or teacher about what to do next.
  • If you are 13 to 17, set the account up with a parent, guardian or teacher. Terms of service are a contract, so read them together.
  • Whatever your age, remember that your username becomes part of your site's address. Lesson 31 argues out whether it should be your real name. If you are unsure, read its first half before you sign up.

A repository named after the site

On GitHub, a project lives in a repository: a folder stored by GitHub that keeps every file and every earlier version of it. Rowan's repository has to have one exact name. GitHub's guide to creating a Pages site says a user site's repository must be named <username>.github.io, with your username in place of <username>, and any capital letters in it made lower case.

The guide's steps are short. Rowan chose New repository from the plus menu at the top right, typed the name, chose the visibility, turned on Add README and pressed Create repository. A README is a short text file that describes a repository. The visibility was not really a choice: the guide says that on GitHub Free the repository must be public. GitHub's page about repositories puts it plainly: public repositories are accessible to everyone on the internet. That means every file in the repository and every earlier version, not only the pages your site links to.

The upload

Files reach the repository through the browser. Following GitHub's guide to adding a file, Rowan opened the Add file menu above the list of files, chose Upload files, and dragged the test page in. The guide sets two limits for this route: 25 MiB per file, and up to 100 files at a time. A MiB, or mebibyte, is just over a million bytes. Rowan's whole portfolio is ten files, about 120 kilobytes in all, and the largest, the 82-kilobyte screenshot from Lesson 26, is far below the limit.

One rule decides where files must go. The guide says GitHub Pages looks for an index.html, index.md or README.md file as the entry file for the site, and that the entry file must be at the top level of the publishing folder. So upload what is inside your site folder, not the folder itself. Drag in a folder called portfolio, and your home page becomes portfolio/index.html, one level too deep, and your address will not open it.

Next, the guide asks for a commit message. A commit is a saved set of changes with a message, an author and a time, and in a public repository anyone can read the list of them. Because a commit records its author, Rowan had already changed one setting before this first upload: in the account's Settings, then Emails, Rowan selected Keep my email addresses private. GitHub's documentation says this keeps your email address private when you work with files in the browser; with it on, such commits carry a GitHub no-reply address instead of yours. Rowan typed Add a test page as the message. The guide then asks whether to commit to the current branch or a new one. A branch is a separate line of changes. The guide recommends a new branch and a pull request, which is a request to merge those changes and lets a team review work before it joins the main version. For a one-person site that step adds nothing, so Rowan committed directly to main, the default branch, which is also the one the site will publish from. Then Rowan confirmed the commit.

Switching Pages on

GitHub's guide to configuring a publishing source gives the next steps. Under the repository name, Rowan chose Settings, then Pages in the sidebar's Code, planning, and automation section. Under Build and deployment, Rowan set Source to Deploy from a branch, picked main in the branch menu and the root folder in the folder menu, and pressed Save.

Then GitHub does the work. Its guide explains that it builds and deploys the site with a GitHub Actions workflow, a set of jobs GitHub runs automatically, and that it can take up to 10 minutes for changes to publish. If something goes wrong, the record of that run shows the error; the docs point to the repository's workflow runs, which are under its Actions tab. Once the site was live, Settings, then Pages showed a Visit site button.

Following the address

Rowan's site now lives at <username>.github.io, reached over HTTPS like any modern site. A visit repeats the journey from Lesson 1, so this course followed one to a github.io site that anyone can visit: octocat.github.io, the site of GitHub's own demonstration account.

First, the name. nslookup answered with eight addresses: four IPv4 addresses, 185.199.108.153 to 185.199.111.153, and four IPv6 addresses. GitHub's own pages.github.com gave exactly the same eight. Many Pages sites share those addresses, which is why the Host line from Lesson 1 matters: it tells GitHub's servers which site's files to send.

Then the reply. These are five of the lines in the response this course received on 26 October 2026:

HTTP/1.1 200 OK
Server: GitHub.com
Content-Type: text/html; charset=utf-8
Last-Modified: Sun, 27 Apr 2014 10:07:39 GMT
Cache-Control: max-age=600

Lesson 1 taught you to read most of this label. The new line is Cache-Control. MDN explains that max-age=N means the response stays fresh until N seconds after it was generated, and that caches can store it and reuse it while it is fresh. 600 seconds is ten minutes. So after you publish a change, a browser that visited recently may go on showing its saved copy of a page or stylesheet for a while. If an update does not appear, use the reload that Chrome's help page describes as ignoring cached content: Ctrl+Shift+R or Shift+F5 on Windows and Linux, Cmd+Shift+R on a Mac.

When the address shows a 404

GitHub Pages has two different 404 pages, and the difference is a clue. This course requested a github.io name that has no site and got a 404 page saying There isn't a GitHub Pages site here. That means nothing is published at that name: the repository is named wrongly, Pages has not been switched on, or the first deployment has not finished.

Then it requested octocat.github.io/INDEX.html, in capital letters. That site exists and its home page is index.html, so the answer was a 404 page headed File not found. It said the site at this address does not contain the requested file, and suggested checking that the file name's case matches the address. The same address in lower case answered 200. This is Lesson 5's About.html problem again: Windows and macOS usually treat INDEX.html and index.html as the same name, and GitHub's servers do not. The page also reminds owners that a site's root address needs an index.html file.

Changing the site later

Updating works like the first upload. Upload a new version of a file, with the same name, to the same place. The new commit replaces the file in the current version of the site, and Pages deploys it again within the same 10 minutes. Every earlier version stays in the repository's history, where anyone can read it. Lesson 31 explains why that matters before the real portfolio goes up.

What GitHub keeps, and what Pages is not for

  • Visitors are logged. GitHub's documentation says that when a Pages site is visited, the visitor's IP address is logged and stored for security purposes, whether or not the visitor is signed in to GitHub.
  • Everything is public. The Pages guide warns that Pages sites are publicly available on the internet, even when the repository behind them is private on a plan that allows it. On GitHub Free, the repository is public too.
  • Limits. GitHub's limits page says a published site may be no larger than 1 GB, and sets soft limits of 100 GB of bandwidth a month and 10 builds an hour. A deployment that takes longer than 10 minutes times out.
  • Not for business or secrets. The same page says Pages is not allowed to be used as a free host for an online business, and should not be used for sensitive transactions like sending passwords or credit card numbers. Rowan's checker, which sends nothing anywhere, is exactly the kind of thing Pages is for.

Common misconceptions

  • "A private repository would keep my site private." GitHub says Pages sites are public even when the repository is private, and on GitHub Free the repository must be public anyway.
  • "Upload the site folder." Upload what is inside it, so that index.html sits at the top level.
  • "If the change has not appeared, the upload failed." Deploying can take up to 10 minutes, and a browser may keep a saved copy for up to ten more. Check the Actions tab, then reload while ignoring the cache.
  • "File names work the same online as on my computer." On GitHub's servers, INDEX.html and index.html are different files.

The takeaway

Bottom line: A GitHub Pages user site is a public repository named <username>.github.io, with index.html at its top level, published from the branch you choose under Settings, then Pages. Everything in it is public, every version is kept, and GitHub logs visitors' IP addresses. Accounts are for people 13 and over, older where local law says so, and anyone under 18 should set one up with a parent, guardian or teacher.

You followed a test page from a folder to a live address, then followed the address back through DNS, response headers and two kinds of 404. Lesson 31 asks what belongs on a public site at all, reviews Rowan's portfolio, and publishes it.

Sources

  1. GitHub. (2026). Creating a GitHub Pages site and Configuring a publishing source for your GitHub Pages site. GitHub Docs. docs.github.com
  2. GitHub. (2026). Adding a file to a repository, About repositories and Setting your commit email address. GitHub Docs. docs.github.com
  3. GitHub. (2026). What is GitHub Pages? and GitHub Pages limits. GitHub Docs. docs.github.com
  4. GitHub. (2026). GitHub Terms of Service, effective 27 April 2026. GitHub Docs. docs.github.com
  5. MDN contributors. (2026). Cache-Control header. Mozilla. developer.mozilla.org
  6. Google. (2026). Chrome keyboard shortcuts. Google Chrome Help. support.google.com
Key terms
GitHub Pages
GitHub's free static hosting, which publishes HTML, CSS and JavaScript files from a repository.
Repository
A project's folder stored on GitHub, keeping every file and every earlier version of it.
Public repository
A repository anyone on the internet can browse, history included; GitHub Free requires one for Pages.
Commit
A saved set of changes with a message, an author and a time; each upload in the browser makes one.
Branch
A separate line of commits in a repository; main is the default, and Pages publishes from the branch you choose.
Entry file
The file a site shows at its root address, such as index.html, which must sit at the top level of the publishing folder.
Cache
A saved copy of a response that a browser or server can reuse; Cache-Control: max-age says for how many seconds.
User site
A GitHub Pages site published from a repository named <username>.github.io, at that address.

What Goes on a Public Portfolio, and What Never Does

  • Weigh the case for publishing under your real name against the case for a chosen name, using evidence about footprints, permanence and admissions.
  • List the personal details that never belong on a public site, and explain why deleting them later does not undo publishing them.
  • Carry out a privacy review of a portfolio and its GitHub account, then publish the site and check it live.

Rowan's home page says Hi, I'm Rowan, and that first name is the only name anywhere on the site. The username, and so the address, is still to be settled. Should a portfolio carry its maker's real, full name? Sensible people give opposite answers, and both sides have evidence. This lesson sets out each case, settles on the rule this course recommends, and then finishes the portfolio with a privacy review and publishes it.

The case for your real name

A portfolio exists to show what you can do, and work only counts for you if people can tell it is yours. The UK Safer Internet Centre's advice for young people puts it directly: from applying to universities to getting a job, it is important to have a positive digital footprint. Your digital footprint is everything about you that can be found online. The same advice suggests building a good one on purpose, for example by starting a blog or making a video that teaches a skill. A portfolio of accessible, validated pages is exactly that kind of thing.

Some people will look you up. Kaplan, a company that prepares students for admissions tests, surveyed admissions officers at 205 American colleges between August and December 2023. Of them, 67 percent said checking applicants' social media was fair game, and 28 percent said they had actually done it. A good portfolio under your real name is something such a search could find. Under a name nobody links to you, it can only help with people you have told about it.

The case for a chosen name

The other side starts from what publishing actually does. Lesson 30 showed that a Pages site, and on GitHub Free its repository, is public to everyone on the internet, and that every version of every file stays in the history. GitHub's page on removing sensitive data shows how hard taking something back is. It says such data can be removed from a repository's history only if you can carefully coordinate with everyone who has cloned it and are willing to manage the side effects. Even then, it may still be reachable in clones, which are full copies of the repository on other people's computers, and in forks, which are copies under other GitHub accounts.

Copies can also be made by strangers. The Internet Archive's help page describes its Save Page Now tool in a sentence: put an address into the form, press the button, and the page is saved, with a permanent address of its own. The archive says it does not keep the IP address of whoever asks, so the saving is anonymous.

A full name also joins things up. On its own, a name may be shared by many people. Next to a town, a school badge in a photo, a sports club or a weekly routine, it can lead a stranger to one particular teenager. Kaplan's survey carries a warning too: its executive director of college admissions programs said that when admissions officers do visit applicants' social media, they are much likelier to find something that hurts an applicant's chances than something that helps. And a username is hard to change cleanly. GitHub's documentation says that after a change, links to your old profile page return a 404 error, and your old username becomes available for anyone else to claim.

GitHub leaves room for either choice. Its guide to personalizing your profile says a profile name is required, but that it does not need to correlate with your real-world identity, and that all other profile information, such as a location, is optional.

Weighing it up

Look at what each side actually wants. The real-name side wants the work to count for you with the people who matter: a teacher, an admissions officer, an employer. The chosen-name side wants strangers not to be able to connect the site to a particular person in a particular place. Those aims clash only if the connection has to be made in public, and it does not. When you send your address to a teacher, attach it to an application or show it to a club, the person receiving it already knows who you are. They get the benefit the first side wants, and the public gets nothing the second side fears.

There is also an asymmetry. Adding your full name later takes one upload. Taking it off later never fully works, because of the history, the clones and the archive copies above. When one choice can be undone and the other cannot, the one that can be undone is the safer place to start.

What never goes on, whatever name you choose

Some details stay off a public site whichever side you take, because together with almost anything else they point to where you can be found:

  • Your home address, or anything that pins it down: a street name, a house number, the view from a window.
  • Your school's name, and photos that show a uniform, a badge or the building.
  • Your phone number and personal email address, and links to accounts that show them.
  • Your date of birth and your routine: timetables, regular clubs, holiday dates, anything that says where you will be and when.
  • Photos of other people, unless they have agreed, and for anyone under 18 a parent or guardian too.
  • Location data hidden inside photos, which Lesson 24 showed how to remove.
  • Anything you would not want a stranger to read: private notes, drafts, passwords, other people's messages.

Lesson 26 promised an answer to a practical question: how do visitors get in touch, with no email address, phone number or form on the site? They do not need to. A student portfolio is shown to people you choose, and they already have a way to reach you, through the teacher, the application or the club that brought them there. Rowan's footer stays exactly as it is.

Portfolio build 10: review, then publish

Rowan settled the argument the way this course recommends for anyone under 18: a username built from a nickname and the word design, with no surname or birth year in it, and a first name on the pages. Then came the review, in this order:

  1. The account. Keep my email addresses private was already selected, from Lesson 30. The required profile name was set to the same chosen name, and every optional profile field, location included, was left empty.
  2. The words. Rowan read every page aloud, footer included, listening for names, places, ages, dates and routines. The recipe card mentions a grandmother's flatbread, with no name or place, so it stayed.
  3. The pictures. The two screenshots show only Rowan's own volcano page. The original phone photo of the model volcano was still sitting in the images folder: 6 megabytes, straight from the camera, location data included. No page links to it, but every file in a public repository can be browsed by anyone, so it stayed on Rowan's computer.
  4. The folder. Only files the site uses went up. Next to the README from Lesson 30, the repository ended up holding exactly these:
index.html
projects.html
about.html
checker.html
styles.css
script.js
checker.js
images/volcano-page-600.png
images/volcano-page-1200.png
projects/volcano.html

Before the upload, this course ran one more check on its copy of Rowan's site. It compared every one of the 42 addresses in the five pages, for links, images, the stylesheet and the scripts, with the real file names, capital letters included, the way GitHub's servers will. None was broken, and all five pages passed the W3C validator.

Then Rowan published, exactly as in Lesson 30. The contents of the portfolio folder went into the upload page of the same repository, so the new index.html replaced the test page, and the commit went to main with the message Publish portfolio. When the deployment finished, Rowan checked the live site, not the copy on the computer:

  • every page reached from the navigation, then the volcano page and its ../ links back, because the live server is the one that minds about capital letters;
  • each page's address put through the W3C validator, which can check a page by its address as well as by pasted code;
  • the whole site with the keyboard alone, skip link first;
  • the site on a real phone, and again with the device set to dark;
  • the contrast checker against the known answers from Lesson 29.

Common misconceptions

  • "Deleting a file takes it off the internet." The repository's history keeps it, and clones, forks and archive copies may keep it too.
  • "A file no page links to is hidden." Everything in a public repository can be browsed, linked or not.
  • "A first name and a town are harmless together." Details that are harmless alone can, together, point to one person in one place.
  • "A portfolio needs a contact form or an email address." The people you send it to already know how to reach you.

Where this leaves us

The core of it: Publish under a chosen name and connect the work to yourself privately, by sending the address to the people you choose. Add your full name later if you decide to, because adding is easy and removing never fully works. Whatever name you use, never publish your address, school, phone number, email, date of birth, routine, or photos of other people without their permission, and review every file before it goes up, since a public repository shows everything and forgets nothing.

That is the whole course. Your portfolio began in Module 1 as one text file and is now a public website: valid HTML that means something, a stylesheet with a palette and a type scale, layouts that work from 320 pixels up, colours checked against WCAG 2.2's 4.5:1 and 3:1, pages you can use with a keyboard and a screen reader, images that load quickly, and two scripts that do real work. Keep it growing. Every project you add from now on goes through the same four steps: build it, check it, review it, publish it.

Sources

  1. Cawthorne, B. (2017, November 14). Online reputation: taking care of your digital footprint, advice for young people. UK Safer Internet Centre. saferinternet.org.uk
  2. Kaplan. (2024, January 30). Kaplan survey: College admissions officers think it's OK to visit applicants' social media but most don't. Press release. kaplan.com
  3. Internet Archive. (n.d.). Save pages in the Wayback Machine. Internet Archive Help Center. help.archive.org
  4. GitHub. (2026). Removing sensitive data from a repository and Username changes. GitHub Docs. docs.github.com
  5. GitHub. (2026). Personalize your profile. GitHub Docs. docs.github.com
Key terms
Digital footprint
Everything about you that can be found online, from pages you publish to what other people post about you.
Chosen name
A name you publish under that is not your full legal name, such as a nickname joined to a word.
Clone
A full copy of a repository, history included, kept on someone else's computer.
Fork
A copy of a repository made under another GitHub account.
Save Page Now
The Internet Archive's tool that lets anyone save a copy of a public page with a permanent address.
No-reply address
The address GitHub puts on commits made in the browser when Keep my email addresses private is selected.
Privacy review
A check of every page, picture, file and account setting for personal details before anything is published.

Open the interactive version with quizzes and progress →