HTML Module 1 – Getting started with HTML
The document skeleton: doctype, head and body
The lines every HTML page starts with, what each one does, and what three browser engines did with a missing doctype, a late charset or no lang.
What you will learn
- Write a complete minimal document with doctype, html, head, title and body
- Explain how the doctype switches a page between quirks and standards mode
- Declare the character encoding where the parser will find it, within the first 1024 bytes
- Choose a lang value and a viewport declaration that keep the page accessible
Before you start
What you’ll learn · Before you start
What you will learn
- Write a complete minimal document with doctype, html, head, title and body
- Explain how the doctype switches a page between quirks and standards mode
- Declare the character encoding where the parser will find it, within the first 1024 bytes
- Choose a lang value and a viewport declaration that keep the page accessible
Before you start
On this page
Every HTML page starts with the same short skeleton: the doctype <!DOCTYPE html>, then an html element with a
lang attribute, a head that declares the character encoding, the viewport and the title, and a body that holds
what visitors see. Each of those lines has a job. Leave one out and the page usually still appears, which is exactly
why the mistakes survive: the browser quietly switches to an old rendering mode, guesses the encoding, or treats the
text as being in no particular language.
This lesson goes through the skeleton line by line, then shows what Chromium, Firefox and WebKit did with each kind of mistake: a missing or old doctype that changes how a table is drawn, a page whose text turned into gibberish, and a broken skeleton checked by two validators and an accessibility checker.
A minimal page, line by line
The skeleton of every page
Text description of the diagram
The diagram shows the parts every page needs, in the order they appear.
- The doctype, <!DOCTYPE html>, comes first and puts the page in standards mode.
- Then the html element, with a lang attribute that names the language of the page. It holds two elements.
- The head holds information about the page: the meta element with charset="utf-8", which comes first and must end within the first 1024 bytes of the file; the viewport meta element, which makes the page fit phone screens while still allowing zoom; and the title, which appears in the browser tab, in bookmarks and in search results.
- The body holds what visitors see.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Opening hours | Corner Café</title>
</head>
<body>
<main>
<h1>Opening hours</h1>
<p>Monday to Saturday, 8 am to 8 pm.</p>
</main>
</body>
</html> <!DOCTYPE html>comes first. It is not an element; it tells the browser to render the page the way current standards describe. It is case-insensitive, so<!doctype html>works the same.<html lang="en">is the root of the tree, andlangsays which language the page is written in.<head>holds information about the page, not content.<meta charset="utf-8">says how the bytes of the file turn into characters. It comes first in the head.<meta name="viewport" content="width=device-width, initial-scale=1">makes the page as wide as the phone’s screen instead of a shrunken desktop page.<title>names the page in the browser tab, in bookmarks and in search results. Every page needs one, and each should be different.<body>holds the content. Here it is wrapped inmain, which marks the main content of the page; the page structure lesson of the next module covers it.
Both validators, the Nu Html Checker and html-validate, reported no problems for this page. The rest of the lesson explains what goes wrong without each line.
The doctype and the three rendering modes
Long before HTML was standardised in detail, browsers drew pages in incompatible ways, and pages were written for those quirks. When browsers started to follow the CSS standards closely, they kept the old behaviour for old pages and used the doctype to tell the two apart. The HTML Standard still defines three modes, chosen while the page is parsed:
- Quirks mode imitates old browsers. A page gets it with no doctype at all, or with one of a long list of old doctypes.
- Limited-quirks mode (“almost standards”) follows the standards except for a few details, such as the height of lines that contain only an image. A few old XHTML and HTML 4.01 doctypes trigger it.
- No-quirks mode follows the standards.
<!DOCTYPE html>gives it.
The four pages in modes/ differ only in their first line and their title: no doctype, the HTML 4.01 Transitional
doctype without and with its system identifier (the address after the public one), and <!DOCTYPE html>. Each holds a table
cell with a 10 by 10 pixel image.
modes/no-doctype.html
<html>
<head>
<meta charset="utf-8">
<title>No doctype</title>
</head>
<body>
<table><tr><td><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="10" height="10" alt=""></td></tr></table>
</body>
</html> modes/html401-transitional.html
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta charset="utf-8">
<title>HTML 4.01 Transitional, no system identifier</title>
</head>
<body>
<table><tr><td><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="10" height="10" alt=""></td></tr></table>
</body>
</html> modes/html401-transitional-full.html
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="utf-8">
<title>HTML 4.01 Transitional, with system identifier</title>
</head>
<body>
<table><tr><td><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="10" height="10" alt=""></td></tr></table>
</body>
</html> modes/standards.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>HTML doctype</title>
</head>
<body>
<table><tr><td><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="10" height="10" alt=""></td></tr></table>
</body>
</html> // Prints the rendering mode (document.compatMode) and the height of a table cell around a 10 px image for the four
// pages of modes/, as three browser engines reported them (recorded by record.mjs).
import { readFileSync } from 'node:fs';
const recorded = JSON.parse(readFileSync('recorded.json', 'utf8'));
const pages = [
['no-doctype', 'no doctype'],
['html401-transitional', 'HTML 4.01 Transitional, no system id'],
['html401-transitional-full', 'HTML 4.01 Transitional, with system id'],
['standards', '<!DOCTYPE html>'],
];
const engines = recorded.engines.map((e) => e.engine);
const row = (label, cells) => console.log(`${label.padEnd(40)}${cells.map((c) => String(c).padEnd(13)).join('')}`.trimEnd());
const value = (id, engine) => recorded.checks.find((c) => c.id === id).results[engine].value;
console.log('document.compatMode');
row('', engines);
for (const [id, label] of pages) row(label, engines.map((e) => value(id, e).mode));
console.log('\nHeight of the table cell around a 10 px image, in px');
row('', engines);
for (const [id, label] of pages) row(label, engines.map((e) => Math.round(value(id, e).cellHeight * 10) / 10)); Output
document.compatMode
Chromium Firefox WebKit
no doctype BackCompat BackCompat BackCompat
HTML 4.01 Transitional, no system id BackCompat BackCompat BackCompat
HTML 4.01 Transitional, with system id CSS1Compat CSS1Compat CSS1Compat
<!DOCTYPE html> CSS1Compat CSS1Compat CSS1Compat
Height of the table cell around a 10 px image, in px
Chromium Firefox WebKit
no doctype 12 12 12
HTML 4.01 Transitional, no system id 12 12 12
HTML 4.01 Transitional, with system id 12 12 12
<!DOCTYPE html> 20 21.2 20
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node modes.mjs
Scripts can read the mode as document.compatMode: BackCompat means quirks mode, and CSS1Compat means either of
the other two. In quirks and limited-quirks mode the cell shrank to 12 pixels, the image plus a pixel of padding above
and below. In no-quirks mode the image sits on the text baseline, as any inline image does, so the cell also made room
for the space below the baseline: 20 pixels in Chromium and WebKit, 21.2 in Firefox. The Quirks Mode Standard names
this the line height calculation quirk and applies it in both of the older modes, which is why the “almost standards”
page reported CSS1Compat and still drew its table the old way.
You will not choose quirks mode on purpose. The lesson is the opposite: start every page with <!DOCTYPE html>, and
when an old page lays out strangely, check document.compatMode before anything else. One exception to know about:
a document written into an iframe’s srcdoc attribute is never put in quirks mode, whatever its doctype, so online
editors that preview your page that way cannot show the difference. To see it, open real files through a local
server, as this lesson did.
The character encoding, in the first 1024 bytes
A file is a sequence of bytes, and the browser has to know which character encoding turns them into letters.
The Encoding Standard requires authors to use UTF-8, and the HTML Standard requires the page to say so, unless the
server already did in its Content-Type header, with a declaration that ends within the first 1024 bytes of the file.
Browsers are encouraged to look for it only there, so that they can pick a decoder before they parse anything else.
The three pages in charset/ say “नमस्ते! Café crème”, a greeting in Hindi and a coffee in French. One declares UTF-8
first thing, one declares it after a long comment, and one does not declare it at all. This program measures where
each declaration ends:
// Where does the character encoding declaration end, counted in bytes from the start of the file? The HTML Standard
// requires the element that holds it to be complete within the first 1024 bytes.
import { readFileSync } from 'node:fs';
for (const file of ['charset/early.html', 'charset/late.html', 'charset/missing.html', 'broken.html', 'fixed.html']) {
const bytes = readFileSync(file);
// Reading the bytes as latin1 keeps one character per byte, so a match's position is a byte offset.
const match = /<meta\s[^>]*charset[^>]*>/i.exec(bytes.toString('latin1'));
if (!match) {
console.log(`${file.padEnd(22)}no charset declaration (${bytes.length} bytes in all)`);
continue;
}
const end = match.index + match[0].length;
console.log(`${file.padEnd(22)}${match[0]} ends at byte ${end}: ${end <= 1024 ? 'inside' : 'OUTSIDE'} the first 1024 bytes`);
} Output
charset/early.html <meta charset="utf-8"> ends at byte 68: inside the first 1024 bytes charset/late.html <meta charset="utf-8"> ends at byte 1301: OUTSIDE the first 1024 bytes charset/missing.html no charset declaration (206 bytes in all) broken.html <meta charset="utf-8"> ends at byte 1269: OUTSIDE the first 1024 bytes fixed.html <meta charset="utf-8"> ends at byte 62: inside the first 1024 bytes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node charset_position.mjs
The pages were then served as text/html with no charset in the header, the way many simple servers do it, and
loaded in the three engines:
// Prints the encoding each engine chose for the three pages of charset/, and the heading as it came out. The server
// sent them as text/html with no charset, so only the page itself could say that it is UTF-8 (recorded by record.mjs).
import { readFileSync } from 'node:fs';
const recorded = JSON.parse(readFileSync('recorded.json', 'utf8'));
// Show invisible control characters, such as U+008D, as \x8D.
const visible = (text) => text.replace(/[\u0080-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).toUpperCase()}`);
for (const id of ['early', 'late', 'missing']) {
console.log(`charset/${id}.html`);
for (const { engine } of recorded.engines) {
const { encoding, heading } = recorded.checks.find((c) => c.id === `charset-${id}`).results[engine].value;
console.log(` ${engine.padEnd(10)}${encoding.padEnd(14)}${visible(heading)}`);
}
} Output
charset/early.html Chromium UTF-8 नमस्ते! Café crème Firefox UTF-8 नमस्ते! Café crème WebKit UTF-8 नमस्ते! Café crème charset/late.html Chromium UTF-8 नमस्ते! Café crème Firefox UTF-8 नमस्ते! Café crème WebKit UTF-8 नमस्ते! Café crème charset/missing.html Chromium windows-1252 नमसà¥\x8Dते! Café crème Firefox windows-1252 नमसà¥\x8Dते! Café crème WebKit windows-1252 नमसà¥\x8Dते! Café crème
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node encodings.mjs
With the declaration at the top, every engine read the page as UTF-8. With the declaration after byte 1024, all three still found it and showed the text correctly: the standard lets a browser that meets a late declaration start over with the right decoder, at the cost of the work it already did. The Nu Html Checker reports that page as an error anyway, “found after the first 1024 bytes”, because the rule is what the standard promises, and a browser or tool that stops looking at 1024 bytes is following it. With no declaration at all, each engine fell back to the default the standard suggests for the user’s language, windows-1252 for English and most other languages, and every character outside plain English came out as gibberish. Neither validator reported that page when it checked the file on disk, so a page can pass both and still break as soon as a server sends it without a charset.
The fix is always the same: <meta charset="utf-8"> as the first element of the head, and the file saved as UTF-8.
The language, and zooming on phones
The lang attribute takes a BCP 47 language tag such as en, en-GB, fr or hi. Screen readers use it to choose
the voice and pronunciation, and the HTML Standard lets browsers use it to pick fonts, spelling dictionaries and the
layout of controls such as date pickers. WCAG 2.2 success criterion 3.1.1, Language of Page, requires it. Search is
not the reason to add it: Google says it works out the language from the visible text and does not use lang
attributes. When part of a page is in another language, mark that part with its own lang, as the fixed page below
does for its Hindi greeting.
Tags for Indian languages
Common tags are hi for Hindi, bn for Bengali, ta for Tamil, te for Telugu, mr for Marathi and en-IN for
English as written in India. A page in English with Hindi phrases keeps lang="en" on the html element and puts
lang="hi" on each Hindi phrase, so a screen reader switches voice in the right places.
The viewport declaration comes from the CSS Viewport specification. width=device-width, initial-scale=1 is all a
new page needs. Do not add user-scalable=no, and do not set maximum-scale below 2: both stop people from zooming
in, and WCAG 2.2 success criterion 1.4.4, Resize Text, expects text to be readable at twice its size. axe-core’s
meta-viewport rule reports both cases.
Tags you can leave out
The HTML Standard makes the start and end tags of html, head and body optional in most cases: as the first
lesson of this module showed, the parser creates the three elements anyway. Write them all the same. A page with its
structure spelled out is easier to read and to edit, the lang attribute needs an html start tag to sit on, and the
standard’s conditions for leaving each tag out are easy to get wrong.
Try it: repair a broken skeleton
This page has five mistakes: no doctype, a charset declaration pushed past byte 1024 by a comment, an empty title, no
lang, and a viewport that blocks zooming.
<!--
Release note 01: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 02: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 03: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 04: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 05: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 06: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 07: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 08: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 09: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 10: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 11: the menu, prices and opening hours on this page come from the shop's weekly sheet.
Release note 12: the menu, prices and opening hours on this page come from the shop's weekly sheet.
-->
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title></title>
</head>
<body>
<h1>नमस्ते! Today's special</h1>
<p>Café crème, made to order.</p>
</body>
</html> Repair it, then check your version with a validator until it reports nothing. Here is what the three tools found in the broken page and in one repaired version:
// Prints what html-validate, the Nu Html Checker and axe-core reported for broken.html and fixed.html (recorded by
// record.mjs: the validators read the files, axe-core checked each page loaded in Chromium).
import { readFileSync } from 'node:fs';
const { validators } = JSON.parse(readFileSync('recorded.json', 'utf8'));
for (const file of ['broken.html', 'fixed.html']) {
console.log(`${file}`);
for (const name of ['html-validate', 'Nu Html Checker', 'axe-core']) {
const messages = validators[name][file];
console.log(` ${name} ${validators.versions[name]}: ${messages.length ? `${messages.length} problem(s)` : 'no problems'}`);
for (const m of messages) {
const where = m.line ? `line ${m.line}`.padEnd(9) : `${m.impact}`.padEnd(9);
const kind = m.rule ? `${m.rule}: ` : m.type === 'warning' ? 'warning: ' : '';
console.log(` ${where}${kind}${m.help ?? m.message}`);
}
}
} Output
broken.html
html-validate 11.16.1: 3 problem(s)
line 1 missing-doctype: Document is missing doctype
line 15 element-required-attributes: <html> is missing required "lang" attribute
line 19 empty-title: <title> cannot be empty, must have text content
Nu Html Checker 26.10.2: 5 problem(s)
line 15 Start tag seen without seeing a doctype first. Expected “<!DOCTYPE html>”.
line 17 A “charset” attribute on a “meta” element found after the first 1024 bytes.
line 18 warning: Consider avoiding viewport values that prevent users from resizing documents.
line 19 Element “title” must not be empty.
line 15 warning: Consider adding a “lang” attribute to the “html” start tag to declare the language of this document.
axe-core 4.13.0: 5 problem(s)
serious document-title: Documents must have <title> element to aid in navigation
serious html-has-lang: <html> element must have a lang attribute
moderate landmark-one-main: Document should have one main landmark
moderate meta-viewport: Zooming and scaling must not be disabled
moderate region: All page content should be contained by landmarks
fixed.html
html-validate 11.16.1: no problems
Nu Html Checker 26.10.2: no problems
axe-core 4.13.0: no problems
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node skeleton_checks.mjs
Each tool caught a different set of the mistakes. Only the Nu Html Checker noticed the late charset declaration, only
the Nu Html Checker and axe-core flagged the viewport, the two validators reported the missing doctype, and all three
asked for a title and a lang. axe-core also
asked for a main landmark, a best-practice rule rather than an error in the HTML. The repaired page passed all
three:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Today's special | Corner Café</title>
</head>
<body>
<main>
<h1><span lang="hi">नमस्ते!</span> Today's special</h1>
<p>Café crème, made to order.</p>
</main>
</body>
</html> Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
What happens if an HTML page has no doctype?
The page still loads, but the parser puts the document in quirks mode, where the browser imitates the layout bugs of
old browsers so that very old pages keep working. Scripts can see it: document.compatMode returns BackCompat
instead of CSS1Compat. Quirks mode changes real rendering details. In a recorded test, a table cell holding a 10 px
image was 12 px tall in quirks mode and 20 px in standards mode (21.2 px in Firefox), and the Quirks Mode Standard
lists other differences, such as accepting colours without a # and lengths without units. CSS written against current
standards can therefore break in ways that are hard to trace. The fix is to start every page with <!DOCTYPE html>,
which is case-insensitive and needs nothing else.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
After a site moved to a new server, product names in Hindi show as strings such as नमसॠon some pages, though the files were not changed. What happened, how do you prove it, and what is the permanent fix?
-
Mid to senior
An old intranet page is being restyled. With the new CSS, small images inside table cells have gaps below them in one copy of the page but not in another copy that looks identical. How do you find the cause, and what do you change?
Key takeaways
- Every page starts with
<!DOCTYPE html>, thenhtmlwithlang, aheadwithmeta charset, the viewport and atitle, and abody. - No doctype, or an old one, means quirks or limited-quirks mode;
document.compatModetells quirks mode apart, but limited-quirks mode also reportsCSS1Compat. - Put
<meta charset="utf-8">first in the head, within the first 1024 bytes. With no declaration and no charset from the server, all three engines decoded a UTF-8 page as windows-1252. langis for people and assistive technology, not for search ranking; mark phrases in other languages with their ownlang.- Use
width=device-width, initial-scale=1and never block zooming. Check pages with more than one tool: each caught mistakes the others missed.
References
- HTML Standard: The DOCTYPE (WHATWG)
- HTML Standard: The "initial" insertion mode (WHATWG)
- Quirks Mode Standard (WHATWG)
- HTML Standard: Specifying the document's character encoding (WHATWG)
- HTML Standard: Determining the character encoding (WHATWG)
- Encoding Standard (WHATWG)
- HTML Standard: Optional tags (WHATWG)
- HTML Standard: The lang and xml:lang attributes (WHATWG)
- CSS Viewport Module Level 1 (W3C CSS Working Group)
- Understanding WCAG 2.2: Resize Text (W3C Web Accessibility Initiative)
- Understanding WCAG 2.2: Language of Page (W3C Web Accessibility Initiative)
- Managing multi-regional and multilingual sites (Google Search Central)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress