HTML describes what something on a page is, not how it looks: This is the main heading, this is the navigation, this is a table of contacts. How it looks is up to CSS later. This separation isn’t an end in itself. A screen reader reads the headings of a page aloud to a blind person like a table of contents and, on request, jumps straight to the navigation. A search engine recognizes from the markup what a page is about. The keyboard reaches links and buttons on its own. All of this works only if the HTML says the meaning correctly.
In this lesson you build the first two pages of the Kladde’s prototype: the home page and the contact list. The prototype is a handful of static pages on which you practice HTML, forms, CSS, SCSS and accessibility in this module. From module 2.5 on, it becomes the real interface in Blazor.
The skeleton of a document
Every page begins with the same skeleton:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contacts – Kladde</title>
</head>
<body>
<h1>Contacts</h1>
<p>The list will go here soon.</p>
</body>
</html>
Line by line:
<!doctype html>tells the browser: modern HTML. Without this line it switches to a compatibility mode for very old pages and calculates some sizes differently.<html lang="en">names the language. A screen reader chooses the pronunciation by it; withoutlangit may read English text with a German voice. Hyphenation and the browser’s translation offers depend on it too.<head>holds what doesn’t appear on the page: information about the page.<meta charset="utf-8">sets the encoding. It has to be within the first 1024 bytes, so right at the top.<meta name="viewport" …>tells the phone: The page is as wide as the device. Without this line a phone pretends to be a wide screen and shows the page shrunk to a tiny size.<title>appears in the browser’s tab, in bookmarks and as the headline in search results. Screen readers read it first when a page opens. Every page gets a title of its own, the most important part first: “Contacts – Kladde”, not “Kladde – Contacts”.<body>holds everything that appears on the page.
Elements, attributes and special characters
An element usually consists of a start tag, content and an end tag: <p>A paragraph.</p>. Some elements have no content and no end
tag, for example <img>, <meta>, <input> and <br>. Elements nest like boxes, and what is opened inside is closed inside:
<p><strong>important</strong></p>, not <p><strong>important</p></strong>.
Attributes stand in the start tag as name="value": <a href="contacts.html">. An attribute like id must be unique on the whole
page, because other elements refer to it.
The characters < and & have a meaning in HTML. If you want to show them as text, write them as an entity: < for < and
& for &. “Feld & Partner” becomes Feld & Partner in the source. Thanks to UTF-8 you simply write accented letters as they
are.
The browser collapses spaces and line breaks in the source into a single space. So you may indent as you like; you never create visible spacing with spaces or empty paragraphs, but later with CSS.
A word on error tolerance: Browsers display even broken HTML. They guess what was meant and quietly repair it. That is friendly but treacherous, because different tools guess differently, and a screen reader may get something other than what your eye sees. That’s why you check every page with a validator instead of relying on “looks fine to me”.
Headings in order
The headings h1 to h6 form the page’s table of contents. Many people with a screen reader first jump from heading to heading to get
an overview. Three rules:
- Exactly one
h1: the title of the main content, on the contact list that is “Contacts”. - Don’t skip a level: After an
h1comes anh2, not anh3. You may only go deeper step by step, and back up to any higher level at any time. - The level follows the outline, not the font size. If an
h2should look smaller, CSS takes care of that. Conversely, a bold paragraph isn’t a heading just because it’s big.
This is what a good outline looks like:
h1 Kladde
h2 What the Kladde can do
h2 Office hours
Landmarks: the regions of a page
Almost every page has the same regions: a head at the top with logo and navigation, then the content, a foot at the bottom. HTML has elements of its own for them, the landmarks. A screen reader lists them and jumps into one with a single keystroke:
| Element | Meaning | How often |
|---|---|---|
header |
head of the page: logo, name, often the navigation | usually once |
nav |
a group of links for navigation | several possible, then told apart by an aria-label each |
main |
the main content, what people come to the page for | exactly once |
aside |
supplementary material beside the main content | as needed |
footer |
foot of the page: notes, contact, legal links | usually once |
In addition there are two elements that structure the main content: section for a part with a heading of its own and article for
something that could stand on its own, such as a guide or an entry in a list of news.
In the past, people built pages out of nothing but div elements with classes like head or menu. A div means nothing, though; it’s
just a box for CSS. For a screen reader, a page made of div elements is one long text without regions. Use div only when there is no
element with a fitting meaning.
aria-label="Main navigation" gives the navigation a name that the screen reader reads aloud. The aria- at the start shows that the
attribute comes from ARIA, an addition to HTML for accessibility. You’ll learn the basic rule for ARIA in lesson 2.1.7: first the
fitting HTML element, ARIA only where HTML isn’t enough. HTML alone can’t express a name for one of several navigations, which is why
aria-label is right here.
Text, lists and links
pis a paragraph.ulis a list without an order,ola numbered one, and in both each item stands in anli. The navigation, too, is a list of links; a screen reader then says “list, three items”, and you know where you are.dlis a list of terms (dt) and descriptions (dd), good for pairs like “Friday: 9:00 to 13:00”.strongmeans “important”,emmeans “emphasized”. Both usually look bold or italic, but they mean something.bandiare only looks; use them rarely.
A link (a with href) leads somewhere else. Its text must say where, even when you read it without the sentence around it. Many
people with a screen reader have all the links of a page given to them as a list. There it then says “here” ten times, and nobody knows
which one leads where.
<!-- bad -->
<p>You can find older appointments <a href="archive.html">here</a>.</p>
<!-- good -->
<p><a href="archive.html">Older appointments in the archive</a></p>
A link with mailto: opens the mail program: <a href="mailto:mara.feld@example.org">mara.feld@example.org</a>.
Links and buttons are different things, even if they can look the same: A link (a) leads to another address, a button
(button) triggers an action, such as “Save”. Both can be reached with the keyboard by default; a div with a click event can’t.
aria-current="page" on the link of the current page tells the screen reader “current page”. With CSS you can later also use it to
highlight the link visibly.
Images and alternative text
<img src="logo.svg" alt="Kladde, home page" width="48" height="48">
srcis the address of the image file.altis the alternative text: It replaces the image for everyone who can’t see it, and appears when the image doesn’t load.widthandheightgive the size in pixels. The browser then keeps the space free before the image has loaded, and the text doesn’t jump. More on this in lesson 2.1.8.
What a good alternative text says depends on what the image is there for on the page:
- An image with content (a photo of the office, a chart): Describe what people need to know, not every detail. “Chart: bookings per week, twice as many in March as in January” instead of “Chart”.
- An image as the only content of a link: The alternative text is the link text, so it says where the link goes. For the logo in the header: “Kladde, home page”.
- An image just for decoration (a decorative border, an icon next to a text that already says everything):
alt="", empty. The screen reader then skips it. A missingalt, on the other hand, is an error: Many screen readers then read out the file name.
Don’t begin an alternative text with “Image of”; the screen reader already says by itself that it’s an image.
Tables for data
A table is for data with rows and columns, such as a contact list. You don’t use it to lay out a page; CSS does that (lesson 2.1.4).
<table>
<caption>The office’s contacts</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Company</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Mara Feld</th>
<td>Feld & Partner</td>
</tr>
</tbody>
</table>
captionis the table’s label. Screen readers name it before they enter the table.this a header cell.scope="col"means “applies to the column below”,scope="row"“applies to the row beside it”. In a cell, a screen reader then reads “Company, Feld & Partner” and knows whom it belongs to.theadandtbodyseparate the head from the data.
A header row made of td with <b> inside looks the same to the eye, but to a screen reader it’s an ordinary data row.
Checking with the W3C validator
The W3C, which maintains the web’s standards, runs a free validator: validator.w3.org/nu. There, choose “file upload” under “Check by”, upload your file and click “Check”. The validator reports three kinds of findings:
- Error: The HTML violates the standard. Every error has to go.
- Warning: probably a mistake, such as a missing language.
- Info: a note. Read it; it’s rarely a problem.
Each finding names the line. For the broken page from exercise 3, for example, the validator reports:
Error: An “img” element must have an “alt” attribute, except under certain conditions. (line 9)
Error: Duplicate ID “notice”. (line 24)
Info: The first occurrence of ID “notice” was here. (line 23)
Warning: Consider adding a “lang” attribute to the “html” start tag to declare the language of this document. (line 2)
Error: The heading “h3” (with computed level 3) follows the heading “h1” (with computed level 1), skipping 1 heading level. (line 15)
Read the findings from top to bottom, like the compiler’s error messages in K0: A forgotten end tag can trigger a series of follow-on errors further down.
The validator checks whether the HTML is valid, not whether it makes sense. A page made of nothing but div without a main, a
table without header cells or a link “here” is valid HTML. For that you need your eyes, and for some of the most common cases a small
program of your own.
Your own checker: outline.cs
The following program reads an HTML file the way a browser does, shows the language, title, landmarks and headings, and reports typical problems. It uses the AngleSharp library (MIT license), which reads HTML by the same rules as a browser. The bUnit library, with which you’ll test components in module 2.7, is built on it as well.
Create outline.cs in your practice folder k2-practice:
// outline.cs: shows the outline of an HTML file and reports typical problems
#:package AngleSharp@1.10.0
using AngleSharp;
using AngleSharp.Dom;
if (args.Length != 1 || !File.Exists(args[0]))
{
Console.WriteLine("Usage: dotnet run outline.cs -- <file.html>");
return 2;
}
var context = BrowsingContext.New(Configuration.Default);
var document = await context.OpenAsync(request => request.Content(File.ReadAllText(args[0])));
var problems = new List<string>();
string? lang = document.DocumentElement.GetAttribute("lang");
Console.WriteLine($"Language: {lang ?? "(missing)"}");
Console.WriteLine($"Title: {(string.IsNullOrWhiteSpace(document.Title) ? "(missing)" : document.Title)}");
if (string.IsNullOrWhiteSpace(lang)) problems.Add("<html> has no lang.");
if (string.IsNullOrWhiteSpace(document.Title)) problems.Add("There is no <title>.");
Console.WriteLine("Landmarks:");
foreach (var landmark in document.QuerySelectorAll("header, nav, main, aside, footer"))
{
string name = landmark.GetAttribute("aria-label") is { } label ? $" \"{label}\"" : "";
Console.WriteLine($" {landmark.LocalName}{name}");
}
int mains = document.QuerySelectorAll("main").Length;
if (mains != 1) problems.Add($"There are {mains} <main> elements, exactly one is expected.");
Console.WriteLine("Headings:");
int previous = 0;
foreach (var heading in document.QuerySelectorAll("h1, h2, h3, h4, h5, h6"))
{
int level = heading.LocalName[1] - '0';
Console.WriteLine($" {new string(' ', (level - 1) * 2)}h{level} {heading.TextContent.Trim()}");
if (previous > 0 && level > previous + 1) problems.Add($"h{previous} is followed by h{level} ({heading.TextContent.Trim()}).");
previous = level;
}
int h1 = document.QuerySelectorAll("h1").Length;
if (h1 != 1) problems.Add($"There are {h1} <h1> elements, exactly one is expected.");
foreach (var image in document.QuerySelectorAll("img:not([alt])"))
{
problems.Add($"Image without alt: {image.GetAttribute("src")}");
}
foreach (var link in document.QuerySelectorAll("a[href]"))
{
string text = (link.TextContent + string.Join(" ", link.QuerySelectorAll("img[alt]").Select(i => i.GetAttribute("alt")))).Trim();
if (text.Length == 0) problems.Add($"Link without text: {link.GetAttribute("href")}");
else if (text is "here" or "more" or "click here") problems.Add($"Link with meaningless text \"{text}\": {link.GetAttribute("href")}");
}
foreach (var table in document.QuerySelectorAll("table"))
{
if (table.QuerySelector("caption") is null) problems.Add("Table without <caption>.");
if (table.QuerySelector("th") is null) problems.Add("Table without <th>.");
}
Console.WriteLine(problems.Count == 0 ? "No problems found." : $"{problems.Count} problems:");
foreach (string problem in problems)
{
Console.WriteLine($" - {problem}");
}
return problems.Count == 0 ? 0 : 1;
The line #:package AngleSharp@1.10.0 fetches the library from nuget.org on the first start, with a fixed version so that the program runs
exactly the same next year. The selectors in QuerySelectorAll ("h1, h2", "img:not([alt])") are the same ones you’ll use in lesson
2.1.4 to select elements for CSS.
The program ends with exit code 0 if it finds nothing, with 1 on problems and with 2 on a wrong call. That way a script or, later, a
pipeline can use it as a check. It doesn’t replace the validator, it complements it: The validator checks the rules of the standard,
outline.cs a few rules of meaning.
Exercise 1: The Kladde’s home page
In the Kladde’s repository, create a branch prototype-pages and in it the folder prototype (English like all names in the code; the
page names are English too, because they become addresses that users read). Create logo.svg there with this content:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<rect x="6" y="4" width="36" height="40" rx="3" fill="#1f5f5b"/>
<rect x="12" y="4" width="3" height="40" fill="#f4efe6"/>
<path d="M20 14h16M20 22h16M20 30h10" stroke="#f4efe6" stroke-width="3" stroke-linecap="round"/>
</svg>
Then build prototype/index.html, the home page:
- the complete skeleton with
lang="en", character set, viewport and the title “Kladde”; - a header with the logo as a link to the home page and a navigation “Main navigation” with three links: Home (
index.html), Contacts (contacts.html) and Book an appointment (appointment.html, the page follows in lesson 2.1.3); the link of the current page carriesaria-current="page"; - a main content with the heading “Kladde”, a sentence saying what the Kladde is, and two sections: “What the Kladde can do” with a list of three items and “Office hours” with the times Monday to Thursday 9:00 to 17:00 and Friday 9:00 to 13:00, plus a link to the appointment booking with meaningful text;
- a footer with the note that all people and companies are made up.
Open the file in the browser with a double click. Then check it twice: The validator reports nothing, and
dotnet run outline.cs -- ../kladde/prototype/index.html
ends with “No problems found.” and exit code 0. Adjust the path if your folders are laid out differently.
Solution 1
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kladde</title>
</head>
<body>
<header>
<a href="index.html"><img src="logo.svg" alt="Kladde, home page" width="48" height="48"></a>
<nav aria-label="Main navigation">
<ul>
<li><a href="index.html" aria-current="page">Home</a></li>
<li><a href="contacts.html">Contacts</a></li>
<li><a href="appointment.html">Book an appointment</a></li>
</ul>
</nav>
</header>
<main>
<h1>Kladde</h1>
<p>The Kladde is the workbook of a small IT office: contacts, appointments and public tenders in one place.</p>
<section>
<h2>What the Kladde can do</h2>
<ul>
<li>Keep contacts with company and email address</li>
<li>Plan appointments in the office</li>
<li>Book an appointment online without calling</li>
</ul>
</section>
<section>
<h2>Office hours</h2>
<dl>
<dt>Monday to Thursday</dt>
<dd>9:00 to 17:00</dd>
<dt>Friday</dt>
<dd>9:00 to 13:00</dd>
</dl>
<p><a href="appointment.html">Book an appointment online</a></p>
</section>
</main>
<footer>
<p>Kladde, a practice project. All people and companies are made up.</p>
</footer>
</body>
</html>
Language: en
Title: Kladde
Landmarks:
header
nav "Main navigation"
main
footer
Headings:
h1 Kladde
h2 What the Kladde can do
h2 Office hours
No problems found.
The validator reports not a single finding for this file. Your texts may be different; compare the structure. Two places are often solved differently:
- The office hours as a table aren’t wrong either, but for pairs of a term and a value
dlis the more fitting element. - If the text “Kladde” also stands in the link in the header next to the logo, the image gets
alt="", because the text then says it all. Otherwise a screen reader would read “Kladde, home page Kladde”.
Exercise 2: The contact list
Build prototype/contacts.html with the same header and footer as the home page. aria-current="page" is now on the “Contacts” link.
The title is “Contacts – Kladde”, the heading “Contacts”. Below it come a sentence saying how many contacts there are and a table with
these four made-up contacts, sorted by last name:
| Name | Company | |
|---|---|---|
| Bernd Example | Example Corp. | bernd@example.org |
| Mara Feld | Feld & Partner | mara.feld@example.org |
| Ilse Kranich | Kranich Construction | ilse.kranich@example.net |
| Ada Sample | Sample Inc. | ada@example.com |
The table has a caption, header cells for the columns, the name as the header cell of each row, and the email addresses as mailto
links. As in exercise 1, check with the validator and outline.cs, and click back and forth between the two pages in the browser.
Solution 2
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contacts – Kladde</title>
</head>
<body>
<header>
<a href="index.html"><img src="logo.svg" alt="Kladde, home page" width="48" height="48"></a>
<nav aria-label="Main navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="contacts.html" aria-current="page">Contacts</a></li>
<li><a href="appointment.html">Book an appointment</a></li>
</ul>
</nav>
</header>
<main>
<h1>Contacts</h1>
<p>Four contacts, sorted by name.</p>
<table>
<caption>The office’s contacts</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Company</th>
<th scope="col">Email</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Bernd Example</th>
<td>Example Corp.</td>
<td><a href="mailto:bernd@example.org">bernd@example.org</a></td>
</tr>
<tr>
<th scope="row">Mara Feld</th>
<td>Feld & Partner</td>
<td><a href="mailto:mara.feld@example.org">mara.feld@example.org</a></td>
</tr>
<tr>
<th scope="row">Ilse Kranich</th>
<td>Kranich Construction</td>
<td><a href="mailto:ilse.kranich@example.net">ilse.kranich@example.net</a></td>
</tr>
<tr>
<th scope="row">Ada Sample</th>
<td>Sample Inc.</td>
<td><a href="mailto:ada@example.com">ada@example.com</a></td>
</tr>
</tbody>
</table>
</main>
<footer>
<p>Kladde, a practice project. All people and companies are made up.</p>
</footer>
</body>
</html>
Language: en
Title: Contacts – Kladde
Landmarks:
header
nav "Main navigation"
main
footer
Headings:
h1 Contacts
No problems found.
Note Feld & Partner: The validator lets a bare & before a space through, but with & you’re always on the safe side. The page
has only one heading, and that’s fine: The table carries its own label.
Header and footer now stand identically in two files. If the navigation changes, you have to change both files and must not forget either. This is exactly the problem that Blazor’s layouts and components solve in module 2.5. Until then, you keep the copies the same by hand.
Exercise 3: Nine defects
Save this page as broken.html in your practice folder. It contains nine defects. Find them all: first with the validator, then with
outline.cs, the rest with your eyes and what you’ve learned in this lesson. For each defect, write down why it’s a problem and for
whom. Then rebuild the page so that the validator and outline.cs report nothing more.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Appointments</title>
</head>
<body>
<div class="top">
<img src="logo.svg">
<div class="menu">
<a href="index.html">Home</a> | <a href="appointments.html">Appointments</a>
</div>
</div>
<h1>Appointments</h1>
<h3>This week</h3>
<table>
<tr><td><b>Day</b></td><td><b>Time</b></td><td><b>With</b></td></tr>
<tr><td>Tuesday</td><td>10:00</td><td>Mara Feld</td></tr>
<tr><td>Thursday</td><td>14:30</td><td>Ilse Kranich</td></tr>
</table>
<h1>Older appointments</h1>
<p>You can find older appointments <a href="archive.html">here</a>.</p>
<p id="notice">All appointments last one hour.</p>
<p id="notice">Please cancel one day ahead.</p>
</body>
</html>
Solution 3
The validator finds four defects, outline.cs eight findings, which partly overlap with those of the validator:
Language: (missing)
Title: Appointments
Landmarks:
Headings:
h1 Appointments
h3 This week
h1 Older appointments
8 problems:
- <html> has no lang.
- There are 0 <main> elements, exactly one is expected.
- h1 is followed by h3 (This week).
- There are 2 <h1> elements, exactly one is expected.
- Image without alt: logo.svg
- Link with meaningless text "here": archive.html
- Table without <caption>.
- Table without <th>.
The nine defects:
- No
lang: The screen reader guesses the language and may read with the wrong voice. - No viewport: On the phone the page appears tiny. Neither of the two tools reports this; you only see it on a phone or in the device emulation (lesson 2.1.5).
divinstead of landmarks: For assistive technology, the head and menu are just text; there is noheader, nonavand nomain. Anyone who wants to jump to the content can’t.- Logo without
alt: Many screen readers then read “logo dot s v g”. h3afterh1: The levelh2is missing; in the table of contents it looks as if a section were missing.- Two
h1: “Older appointments” is a section of the page, so anh2. - Table without header cells and label: The first row is merely bold, not marked up as a header. A screen reader can’t assign “10:00” to a column.
- Link text “here”: In the list of all links it says nothing.
- Duplicate
id“notice”: Anidmust be unique. If a field later refers to it witharia-describedby(lesson 2.1.3), it’s unclear which note is meant.
The improved page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Appointments – Kladde</title>
</head>
<body>
<header>
<a href="index.html"><img src="logo.svg" alt="Kladde, home page" width="48" height="48"></a>
<nav aria-label="Main navigation">
<ul>
<li><a href="index.html">Home</a></li>
<li><a href="appointments.html" aria-current="page">Appointments</a></li>
</ul>
</nav>
</header>
<main>
<h1>Appointments</h1>
<section>
<h2>This week</h2>
<table>
<caption>This week’s appointments</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Time</th>
<th scope="col">With</th>
</tr>
</thead>
<tbody>
<tr>
<td>Tuesday</td>
<td>10:00</td>
<td>Mara Feld</td>
</tr>
<tr>
<td>Thursday</td>
<td>14:30</td>
<td>Ilse Kranich</td>
</tr>
</tbody>
</table>
<p id="notice-duration">All appointments last one hour.</p>
<p id="notice-cancellation">Please cancel one day ahead.</p>
</section>
<section>
<h2>Older appointments</h2>
<p><a href="archive.html">Older appointments in the archive</a></p>
</section>
</main>
</body>
</html>
The validator reports nothing more for it, and outline.cs ends with “No problems found.” and exit code 0. The two notes would also be
right without an id; if nobody needs them, the attributes can go altogether.
The Kladde: the first pull request on the web
The two pages and the logo now belong in the Kladde, the way you agreed in module 2.10:
- Check that on the branch
prototype-pagesonlyprototype/index.html,prototype/contacts.htmlandprototype/logo.svgare new (git status), and commit them with a message like “Add the prototype's start page and contact list”. - Push the branch and open a pull request. In the description, write how you checked: validator without findings,
outline.cswithout problems, both pages clicked through in the browser. - Go through the definition of done from
docs/definition-of-done.md. The keyboard test comes in detail in lesson 2.1.7; even now you should be able to reach all the links of the pages in order with the Tab key.
The prototype is deliberately just HTML. It still looks like 1995, and that’s as it should be: first the meaning, then the looks.
What you can do now
- You build an HTML document with
lang, character set, viewport and a title that puts the most important part first. - You structure a page with exactly one
h1, headings without gaps and the landmarksheader,nav,mainandfooter. - You write links that are understandable on their own, and alternative texts that fit the purpose of the image.
- You mark up data as a table with
captionandth. - You check pages with the W3C validator and with a program of your own whose exit code counts.
Read more
- Build web pages with HTML and CSS for beginners: the Microsoft Learn learning path on HTML and CSS, with exercises of its own.
- Get started with web development using Visual Studio Code: write HTML and CSS in VS Code and view them in the browser.
- HTML elements reference at MDN: every element with its meaning, attributes and accessibility notes.
- Nu HTML Checker: the W3C validator.