Text, links, images, tables
In short: The content of an HTML page is made of headings h1–h6, paragraphs, lists, links with href, images with alt, and data tables with caption, th and scope.
Problem
"Milk" leads nowhere
A customer writes that on the bread page the link "Milk" leads to "No such page". On the home page the same menu works. The page is at https://shop.example/bread/white/dobrudzha.
Console
Click "Milk". The sandbox is a single page and does not open links, but the console says what is in href: milk. The other links start with /, and this one does not. Without / the browser does not go to /milk. It builds the address from the address of the page you are on, and gets a different one.
The idea
Full and relative addresses
A link is <a> with the attribute href: where it leads. The address in href (a URL) is either full or relative.
A full address has everything: the scheme (https://), the host (shop.example) and the path (/bread/white/dobrudzha). The path is made of folders separated by /, and the last part is the page.
A relative address is incomplete, and the browser completes it from the address of the page the link is on. The start decides how:
milk(nothing in front): from the folder of the page.../milk: one folder up./milk: from the root of the site, whatever the page.#price: a place in the same page, the element withid="price".
That is why the menu, which is on every page, uses addresses from the root: /milk leads to the same place from everywhere. You write a full address only for another site.
See it
Watch the address being built
Five links on the same page. Watch which parts of the address at the top the browser keeps and what it adds.
<!-- the address of the page: https://shop.example/bread/white/dobrudzha --><a href="milk">Milk</a><a href="../milk">Milk</a><a href="/milk">Milk</a><a href="#price">The price</a><a href="https://recipes.example/bread">Recipes</a>Step by step, in words
- The page lives at https://shop.example/bread/white/dobrudzha. The browser splits the address into parts: the scheme, the host and the path. The path is made of folders, and the last part is the page itself.
- href="milk" has nothing in front. The browser keeps the folder of the page, /bread/white/, and replaces only the last part. It gets /bread/white/milk: a page that does not exist.
- ../ means one folder up. From /bread/white/ the browser goes up to /bread/ and adds milk. It is still not /milk.
- A slash in front means from the root of the site. The browser keeps only the scheme and the host. This address works from every page of the shop.
- # is a place in the same page. The browser keeps the whole address and adds #price. The page is not loaded again: it scrolls to the element with id="price".
- An address with a scheme is full. The browser takes nothing from the page. This is how you link to another site.
- The start of href decides: nothing or ../ counts from the folder, / from the root, # stays on the page, https:// is a full address.
The browser does not ask the server whether the address exists. It builds the address by the rules and goes there. That is why a mistake in href does not show in the code, but only when someone clicks the link. In the browser you see the built address when you hold the mouse over a link: bottom left of the window.
Try it
Fix the links
Fix the links: "Milk" and "Cart" must work from every page of the shop (an address from the root: /milk and /cart). "To the price" must jump to the paragraph with the price. "Recipes with this bread" goes to another site: make it open in a new tab without that site getting access to our page.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dobrudzha bread · The shop</title>
</head>
<body>
<!-- The address of the page: https://shop.example/bread/white/dobrudzha -->
<nav>
<a href="/bread">Bread</a>
<a href="/milk">Milk</a>
<a href="/cart">Cart</a>
</nav>
<main>
<h1>Dobrudzha bread</h1>
<p><a href="#price">To the price</a></p>
<p>White bread, 650 g, baked this morning.</p>
<p id="price">Price: €1.80</p>
<p><a href="https://recipes.example/bread" target="_blank" rel="noopener">Recipes with this bread</a></p>
</main>
</body>
</html>
href="/milk" and href="/cart" start from the root and do not depend on the page. Here ../milk leads to /bread/milk: one folder up from /bread/white/ is /bread/.
#price looks for an element with id="price", so the id goes on the paragraph with the price. The hash is only in the link, not in the id.
target="_blank" opens the link in a new tab. Then the new page can reach ours through window.opener and replace it while the customer is reading recipes. rel="noopener" stops that. New browsers do it by themselves for target="_blank", and rel says it explicitly and protects in older ones too.
Try it
Fill in
The link has to jump to the price on the same page. What goes in href?
<a href="">To the price</a>
…
<p id="price">Price: €1.80</p>#price: a hash and the id of the element. The browser does not load the page again, it scrolls to the element.
The idea
Text: outline, paragraphs, lists
The headings <h1> … <h6> are not six font sizes. They are the outline of the page, like the table of contents of a book. <h1> is the title of the page, <h2> are its sections, <h3> are parts of a section. Levels are not skipped: after <h2> comes <h3>, not <h5>. A screen reader shows this outline as a list, and a person jumps from section to section. The size is changed with CSS, without touching the outline.
The rest:
<p>is a paragraph.<br>is a new line inside a paragraph (an address, a poem), not a way to make lists.<ul>is a list without order (ingredients),<ol>has an order (steps). Each item is<li>.<dl>is a list of "name and value" pairs:<dt>is the name (Weight),<dd>is the value (650 g).<strong>is important ("Contains gluten."), and<em>is stress that changes the meaning ("in a cloth bag, not in plastic"). If you only want bold looks without meaning, that is CSS.
Console
Try it
Make an outline
A colleague picked the headings by size: the bread name is an <h4> and the offer is an <h1>. Make the outline: the product name is the title of the page, "Ingredients" and "How to keep it" are the sections. The offer is not a heading: make it important text in a paragraph. The ingredients are a list.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dobrudzha bread · The shop</title>
</head>
<body>
<main>
<h1>Dobrudzha bread</h1>
<p><strong>Offer: −10% until Sunday</strong></p>
<p>White bread, 650 g, baked this morning.</p>
<h2>Ingredients</h2>
<ul>
<li>wheat flour</li>
<li>water</li>
<li>yeast</li>
<li>salt</li>
</ul>
<h2>How to keep it</h2>
<p>In a cloth bag, <em>not</em> in plastic.</p>
</main>
</body>
</html>
The name is <h1>, the sections are <h2>, and the offer becomes <p><strong>…</strong></p>: it is important, but it does not start a section. The ingredients are a <ul> with one <li> each, without order, because their order does not matter. The check looks for the headings by level and counts the list items: dots and <br> are not a list, however much they look like one.
The idea
Images
<img src="img/bread.svg" alt="A round white loaf with a crisp crust" width="240" height="240">srcis the address of the image. It is built likehref.altis the text instead of the image. A screen reader reads it, a search engine indexes it, and if the image does not load, the browser shows it in its place. Write what the image shows and why it is there.- An image that is only decoration, or repeats the text next to it, gets
alt="". Then the screen reader skips it. Noaltat all is a mistake: the reader does not know it is decoration. - An image that is the only content of a link gives the link its name. The
altis where the link leads: "The shop", not "logo". widthandheightare the size in pixels. While the image is on its way, the browser does not know how big it is and gives it 0 pixels of height. When it arrives, the text below jumps down. Withwidthandheightthe browser keeps the place in advance, even when CSS makes it smaller.
Try it
Add alt and sizes
The logo is the only thing in the link to the home page: the link must be called "The shop". The photo of the bread shows something: describe it in alt. The gift icon repeats the text next to it. The photo is 240 × 240 pixels: tell the browser.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dobrudzha bread · The shop</title>
</head>
<body>
<header>
<a href="/"><img src="img/logo.svg" alt="The shop" width="40" height="40"></a>
</header>
<main>
<h1>Dobrudzha bread</h1>
<img src="img/bread.svg" alt="A round white loaf with a crisp crust" width="240" height="240">
<p>Price: €1.80</p>
<p><img src="img/gift.svg" alt="" width="24" height="24"> A gift with orders over €30</p>
</main>
</body>
</html>
The logo has alt="The shop": that is the name of the link. The photo has an alt that describes the bread, and width="240" height="240". The icon has alt="": the text next to it already says "gift". The check counts the images by role: an image with alt="" has no "image" role, and an image without alt has one, so an empty alt is not the same as a missing one.
A screen reader says only "link" for one link. Which line has the mistake?
On line 5 the link to the cart contains only an image with alt="". So the link has no name. The image is not decoration: it is the only thing that says where the link leads. It needs alt="Cart".
The idea
Data tables
A table is for data in rows and columns: prices, orders, a timetable. A screen reader reads it as a table: "row 3, column Price, €7.90". That is why it is only for data, and not for laying out a page in columns. CSS does that.
<table> <caption>Prices today</caption> <thead> <tr><th scope="col">Product</th><th scope="col">Price</th></tr> </thead> <tbody> <tr><th scope="row">White cheese</th><td>€7.90</td></tr> </tbody></table><caption>is the name of the table. It is inside the table, not a heading above it.<tr>is a row.<td>is a data cell, and<th>is a header cell.scope="col"says the header is for the column below it, andscope="row"for the row to its right.<thead>holds the header rows,<tbody>the data,<tfoot>the sum at the bottom.
Try it
The price list
The price list is made of <td> cells and bold text. Make it a data table: the name "Prices today" belongs to the table, the header row is in <thead>, and the products are in <tbody>. The column headers and the product names are header cells; use scope to say what each one is for.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Prices today · The shop</title>
</head>
<body>
<table>
<caption>Prices today</caption>
<thead>
<tr><th scope="col">Product</th><th scope="col">Price</th><th scope="col">In stock</th></tr>
</thead>
<tbody>
<tr><th scope="row">Dobrudzha bread</th><td>€1.80</td><td>24</td></tr>
<tr><th scope="row">Fresh milk 3%</th><td>€2.40</td><td>35</td></tr>
<tr><th scope="row">White cheese</th><td>€7.90</td><td>18</td></tr>
<tr><th scope="row">Apples, 1 kg</th><td>€2.10</td><td>80</td></tr>
</tbody>
</table>
</body>
</html>
<caption>Prices today</caption> gives the table its name: a screen reader says it when it reaches the table. The header row is in <thead> with three <th scope="col">, and each product name is a <th scope="row">. Now the cell "€7.90" has two headers: "White cheese" and "Price".
In a simple table like this one Chromium guesses the headers even without scope, from where they are. scope says it explicitly and does not depend on guessing: in a table with merged cells the guess can be wrong. The check looks for scope, so a <th> without it does not pass.
More precisely, if you are curious
More about addresses. ./milk is the same as milk. ?page=2 keeps the path and changes only the query: /bread/white/dobrudzha?page=2. An address that starts with // keeps only the scheme: //cdn.example/x.js is https://cdn.example/x.js. ../ above the root goes nowhere higher: ../../../milk from our page is /milk. In JavaScript this building is new URL('../milk', 'https://shop.example/bread/white/dobrudzha').
target="_blank" and window.opener. Chromium already gives a new tab from target="_blank" window.opener === null, the same as with rel="noopener", and rel="opener" brings the link back. We write rel="noopener" so that it is clear in browsers that do not do it by themselves.
Images for different screens. srcset gives several sizes of one image, and the browser picks one for the screen. loading="lazy" loads the image when it gets close to the screen. Both belong to the CSS module and to speed.
Merged cells. colspan="3" merges three cells in a row, and rowspan="2" two cells in a column. Then scope matters: without it the browser guesses which cells the header is for.
<b>, <i> and <br>. They exist and have a meaning: <b> is text that stands out without being more important (a keyword), <i> is a different voice (a term, the title of a book). For important text there is <strong>, for stress there is <em>.
Exercise
Order 1042
Build the page of order 1042 from scratch. A menu with a link "My orders" to /orders. A heading "Order 1042", and under it "Status: shipped". Then a table "The products in the order" with the columns Product, Quantity, Price, Sum: Dobrudzha bread 2 × €1.80 = €3.60, fresh milk 3% 1 × €2.40 = €2.40, white cheese 1 × €7.90 = €7.90. In front of each name there is a 32 × 32 image (img/bread.svg, img/milk.svg, img/cheese.svg). At the bottom is the row "Total" with €13.90 in the last column.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order 1042 · The shop</title>
</head>
<body>
<nav>
<a href="/orders">My orders</a>
</nav>
<main>
<h1>Order 1042</h1>
<p>Status: <strong>shipped</strong></p>
<table>
<caption>The products in the order</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Quantity</th>
<th scope="col">Price</th>
<th scope="col">Sum</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><img src="img/bread.svg" alt="" width="32" height="32"> Dobrudzha bread</th>
<td>2</td>
<td>€1.80</td>
<td>€3.60</td>
</tr>
<tr>
<th scope="row"><img src="img/milk.svg" alt="" width="32" height="32"> Fresh milk 3%</th>
<td>1</td>
<td>€2.40</td>
<td>€2.40</td>
</tr>
<tr>
<th scope="row"><img src="img/cheese.svg" alt="" width="32" height="32"> White cheese</th>
<td>1</td>
<td>€7.90</td>
<td>€7.90</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="3">Total</th>
<td>€13.90</td>
</tr>
</tfoot>
</table>
</main>
</body>
</html>
The product name is a <th scope="row">, and the image in it has alt="": the name is next to it, so the image is decoration. With alt="Dobrudzha bread" the row header becomes "Dobrudzha bread Dobrudzha bread". "Total" is in <tfoot>: it is the sum of the table, not one more product. The "Total" cell is <th scope="row" colspan="3"> and spans the three columns on the left.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The HTML document
- Continues in: Forms · Accessibility · Responsive design · Events
- Tasks on this topic: HTML: the structure of a page