Web development basics · Lesson 2 of 11 · about 30 minutes

HTML and CSS: structure and style

Two versions of the same bakery page, built to look alike. One is div soup: generic boxes styled to look like headings, links and a button. The other uses HTML that says what each part is, and CSS that bends to fit the screen. A browser shows them almost the same on a laptop. A screen reader, a keyboard and a phone don't: one page has no headings, one reachable control and sideways scrolling; the other passes every check. Then how CSS decides what wins, and the seven rules that make the bakery's page work at any width.

By the end you'll know

  • What semantic HTML is, and what it gives you for free
  • How to see a page the way a screen reader, a keyboard and a phone do
  • What an HTML checker catches, and what it can't
  • How CSS decides which rule wins
  • How to build a layout that fits any screen with flexbox, grid and one media query

Before you start

  • You need: Lesson 1, which explains the DOM and how the browser builds a page. Node.js, for the checker and the inspection script (optional; your browser's developer tools show the same things).
  • The example: Juniper & Rye is the made-up bakery from Lesson 1. Both pages and every line of output below are real.
  • Maps to: the web accessibility guidelines (WCAG 2.2) for contrast, names and keyboard use; Lesson 10 goes further.

The short version

  • HTML says what things are; CSS says how they look. Keep those jobs separate and you get a lot for free.
  • Div soup looks fine and fails quietly. The bakery's div-soup page gave a screen reader no headings, no landmarks and no button; the keyboard reached one thing out of five.
  • Semantic HTML fixed all of it with no extra code: headings, a navigation landmark, links, a labelled field and a real button.
  • Checkers catch some problems, not all. An HTML checker found 3 errors in the div soup, and missed the missing label, the fake button and the pale text.
  • CSS picks a winner by importance, then specificity, then order. Most "my style doesn't work" bugs are specificity.
  • Fluid layouts fit any screen. The div soup scrolled sideways by 510 pixels on a phone; the semantic page's CSS has no fixed widths and one media query.

Two pages that look alike

Lesson 1's bakery page was a sketch. This lesson writes it properly, twice.

The first version is div soup: every part of the page is a <div> or <span>, a generic box, styled to look like what it should be. It's how a lot of real pages end up, one quick fix at a time:

HTML
<body>
  <div class="top">
    <div class="big">Juniper &amp; Rye</div>
    <div class="links"><span onclick="location='#bakes'">Bakes</span> <span onclick="location='#hours'">Hours</span></div>
  </div>
  <div class="hero">
    <img src="loaf.svg" width="600">
    <div class="tag">Sourdough, rye and pastries, baked every morning on Alder Street.</div>
  </div>
  <div id="bakes" class="title">Today's bakes</div>
  <div class="menu">
    <div><b>Country sourdough</b> $8.50</div>
    ...
  </div>
  <div class="title">Get the weekly bake list</div>
  <input type="text" placeholder="Email">
  <div class="button" onclick="alert('Thanks!')">Sign up</div>
  <div class="bottom">12 Alder Street</div>
</body>

The "links" are spans that change the page's address when clicked. The headings are divs with a big font. The button is a div that reacts to a click. The image has no description. Its CSS sets body { width: 900px; } and a pale text colour. On a laptop, it looks like a website.

The second version says what each part is:

HTML
<body>
  <header class="site-header">
    <p class="logo">Juniper &amp; Rye</p>
    <nav aria-label="Main">
      <ul>
        <li><a href="#bakes">Bakes</a></li>
        <li><a href="#hours">Hours</a></li>
        <li><a href="#signup">Bake list</a></li>
      </ul>
    </nav>
  </header>
  <main>
    <section class="hero">
      <h1>Sourdough, rye and pastries, baked every morning</h1>
      <img src="loaf.svg" alt="A round sourdough loaf, scored across the top" width="320" height="200">
    </section>
    <section id="bakes" aria-labelledby="bakes-title">
      <h2 id="bakes-title">Today's bakes</h2>
      <ul class="menu">
        <li><span class="item">Country sourdough</span> <span class="price">$8.50</span></li>
        ...
      </ul>
    </section>
    <section id="signup" aria-labelledby="signup-title">
      <h2 id="signup-title">Get the weekly bake list</h2>
      <form action="/signup" method="post">
        <label for="email">Email address</label>
        <input id="email" name="email" type="email" autocomplete="email" required>
        <button type="submit">Sign up</button>
      </form>
    </section>
  </main>
  <footer>
    <address>12 Alder Street</address>
  </footer>
</body>

What each choice does:

ElementWhat it saysWhat you get for free
<header>, <main>, <footer>, <nav>The page's regionsLandmarks a screen reader can jump between
<h1> to <h6>Headings, by levelAn outline; the most common way screen reader users move around a page
<a href>A link to somewhereKeyboard focus, Enter to follow, open in a new tab, right-click to copy
<button>Something that does an actionKeyboard focus, Enter and Space to press, form submission
<label for>The name of a form fieldA spoken name; clicking the label focuses the field
type="email", autocomplete, requiredWhat the field holdsAn email keyboard on phones, autofill, built-in validation
alt on <img>What the image showsA description for people who can't see it, and when it fails to load
lang="en"The page's languageCorrect pronunciation by screen readers; correct translation

None of this needs JavaScript. Every behaviour in that last column is built into the browser, and you only get it by using the right element. A <div> styled as a button gets none of it, and putting it back by hand (focus, keyboard handling, a role for screen readers) takes more code than the <button> would have.

Check 1: an HTML checker

html-validate (opens in a new tab) checks HTML against the standard and a set of recommended rules. Its configuration file turns one rule off:

JSON
{
  "extends": ["html-validate:recommended"],
  "rules": { "doctype-style": "off" }
}

doctype-style wants <!DOCTYPE html> in capitals. Lowercase is valid HTML, so it's a style preference, and on its first run it was the only complaint about the semantic page. Configure a checker's rules; don't just obey them.

(Your machine), in the folder with both pages:

BASH
npx html-validate soup.html index.html | sed "s|$PWD/||"

npx runs html-validate without installing it globally. It prints full file paths, so sed strips the current folder ($PWD) from them to keep the output short.

TEXT
soup.html
   2:2   error  <html> is missing required "lang" attribute  element-required-attributes
   5:18  error  Raw "&" must be encoded as "&amp;"           no-raw-characters
  14:6   error  <img> is missing required "alt" attribute    wcag/h37

✖ 3 problems (3 errors, 0 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.

More information:
  https://html-validate.org/rules/element-required-attributes.html
  https://html-validate.org/rules/no-raw-characters.html
  https://html-validate.org/rules/wcag/h37.html

Three errors in the div soup, none in the semantic page. Each error is real: no language, a raw & in the title (it should be &amp;), and an image with no alt.

Now look at what the checker didn't find: the email field with no label, the spans pretending to be links, the div pretending to be a button, and the pale text. All of them are valid HTML. A checker can tell you whether markup follows the rules; it can't tell you whether a <div> with a click handler was meant to be a button. For that, you look at the page the way people use it.

Check 2: what a screen reader, a keyboard and a phone get

A short Playwright script loads each page and asks three questions. Here is its core; the full script is in the practice folder:

JS
const browser = await chromium.launch();
for (const file of process.argv.slice(2)) {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto(base + file);

  // 1. The accessibility tree: what a screen reader is given
  console.log(await page.locator('body').ariaSnapshot());

  // 2. Press Tab up to 8 times and record what gets focus
  for (let i = 0; i < 8; i++) {
    await page.keyboard.press('Tab');
    // ...record document.activeElement
  }

  // 3. Body text contrast, then the page's width on a phone and a laptop
  const c = await page.evaluate(() => { const s = getComputedStyle(document.body); return [s.color, s.backgroundColor]; });
  for (const w of [390, 1280]) {
    await page.setViewportSize({ width: w, height: 800 });
    const sw = await page.evaluate(() => document.documentElement.scrollWidth);
  }
}
  • ariaSnapshot() prints the accessibility tree: the version of the page the browser hands to screen readers and other assistive technology. It's built from the DOM, using each element's role (heading, link, button) and name (its label or text).
  • keyboard.press('Tab') moves focus the way someone using a keyboard would. document.activeElement is whatever has focus.
  • getComputedStyle gives the colours the browser actually used, and the script works out their contrast ratio with the formula from the accessibility guidelines.
  • scrollWidth is how wide the page really is. If it's wider than the screen, the page scrolls sideways.

(Your machine), with Lesson 1's server running:

BASH
node inspect.js soup.html index.html

The div soup

TEXT
==== soup.html
-- what a screen reader gets (accessibility tree)
   - text: Juniper & Rye Bakes Hours
   - img
   - text: Sourdough, rye and pastries, baked every morning on Alder Street. Today's bakes Country sourdough $8.50 Dark rye $9.00 Cardamom bun $4.25 Hours Tuesday to Sunday, 7am to 2pm Get the weekly bake list
   - textbox "Email"
   - text: Sign up 12 Alder Street
-- what the Tab key reaches
   input ""
-- body text contrast: 2.14 : 1 (WCAG AA needs 4.5 for normal text)
-- at 390px wide: page is 900px wide, scrolls sideways by 510px
-- at 1280px wide: page is 1280px wide, fits

To a screen reader, this page is one long run of text, an unnamed image, a text box and more text. There are no headings to jump between, no navigation, no links and no button: "Sign up" is just words. The text box's name, "Email", comes from its placeholder, which disappears as soon as someone starts typing.

The keyboard reached one thing: the text box. The two "links" and the "button" can't be reached or pressed without a mouse.

The body text's contrast is 2.14 to 1. The accessibility guidelines' AA level, the usual legal and contractual standard, asks for at least 4.5 to 1 for normal text. Pale text is hard for many people to read, and for everyone in sunlight.

On a 390-pixel phone, the page is 900 pixels wide and scrolls sideways by 510 pixels, because its CSS fixes the width.

The semantic page

TEXT
==== index.html
-- what a screen reader gets (accessibility tree)
   - banner:
     - paragraph: Juniper & Rye
     - navigation "Main":
       - list:
         - listitem:
           - link "Bakes":
             - /url: "#bakes"
         - listitem:
           - link "Hours":
             - /url: "#hours"
         - listitem:
           - link "Bake list":
             - /url: "#signup"
   - main:
     - heading "Sourdough, rye and pastries, baked every morning" [level=1]
     - img "A round sourdough loaf, scored across the top"
     - region "Today's bakes":
       - heading "Today's bakes" [level=2]
       - list:
         - listitem: Country sourdough $8.50
         - listitem: Dark rye $9.00
         - listitem: Cardamom bun $4.25
     - region "Hours":
       - heading "Hours" [level=2]
       - paragraph: Tuesday to Sunday, 7am to 2pm. Closed Mondays.
     - region "Get the weekly bake list":
       - heading "Get the weekly bake list" [level=2]
       - text: Email address
       - textbox "Email address"
       - button "Sign up"
   - contentinfo: 12 Alder Street
-- what the Tab key reaches
   a "Bakes", a "Hours", a "Bake list", input "email", button "Sign up"
-- body text contrast: 14.75 : 1 (WCAG AA needs 4.5 for normal text)
-- at 390px wide: page is 390px wide, fits
-- at 1280px wide: page is 1280px wide, fits

Everything the div soup lacked is there, and none of it took extra code:

  • Landmarks: banner (the header), navigation "Main", main, three named regions and contentinfo (the footer). A screen reader user can jump straight to any of them.
  • Headings with levels: one h1 for the page, h2s for each section.
  • Real links and a real button, all five reachable with Tab, in order.
  • A named field: the label "Email address" stays, unlike a placeholder.
  • Contrast of 14.75 to 1, and a page that fits at both widths.

One honest caveat about the phone result: the script narrows a desktop browser to 390 pixels. A real phone loading a page with no <meta name="viewport"> tag, like the div soup, does something different: it pretends to be about 980 pixels wide and shrinks the whole page to fit, so everything is tiny instead of cut off. Neither is usable. The viewport tag in the semantic page's <head> tells phones to use their real width.

Both pages at phone width. Select the image to enlarge it.

How CSS decides what wins

Every element can match many CSS rules, and they often disagree. The browser settles it the same way every time, in this order:

  1. Importance: a declaration marked !important beats one that isn't. Avoid it; it's how style sheets become impossible to change.
  2. Specificity: the more specific selector wins. An ID (#menu) beats a class (.price), which beats an element (li). #menu li beats .price because it has an ID in it.
  3. Order: if all else is equal, the rule that comes later wins.

Some properties are also inherited: set color or font-family on body, and everything inside gets it unless something says otherwise. That's why the bakery's colours are set once.

Most "my CSS isn't working" problems are specificity. The fix is almost never !important: it's keeping selectors simple (mostly single classes), so nothing is much more specific than anything else. In developer tools, the Styles pane shows every rule that matched an element, with the losers crossed out.

The bakery's CSS, rule by rule

The semantic page's whole style sheet is seven short sections. Each one is a habit worth keeping:

CSS
/* 1. Colours and type in one place */
:root {
  --ink: #2b2118;
  --crust: #7a4a1e;
  --flour: #fbf7f0;
  --space: 1rem;
}

Custom properties (CSS variables) hold the bakery's colours and spacing. var(--crust) uses them. Change a colour here and it changes everywhere. rem is a size relative to the browser's base font size, so spacing grows if someone enlarges their text.

CSS
/* 2. The page: fluid, never wider than is comfortable to read */
body {
  margin: 0;
  font-family: Georgia, serif;
  line-height: 1.5;
  color: var(--ink);
  background: var(--flour);
}
header, main, footer { max-width: 60rem; margin: 0 auto; padding: 0 var(--space); }

max-width instead of width: the content is as wide as the screen, up to a comfortable reading width, then centred with margin: 0 auto. This one change is the difference between the two phones above.

CSS
/* 3. Header: logo and nav side by side, wrapping when there's no room */
.site-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space); }
.logo { font-size: 1.75rem; color: var(--crust); margin: var(--space) 0; }
nav ul { display: flex; gap: var(--space); list-style: none; margin: 0; padding: 0; }
a { color: var(--crust); }

Flexbox (display: flex) lays things out in a row. justify-content: space-between pushes the logo left and the links right; flex-wrap: wrap moves the links below the logo when there isn't room; gap spaces them without margins.

CSS
/* 4. Images never overflow their box */
img { max-width: 100%; height: auto; }

/* 5. Menu: name on the left, price on the right */
.menu { list-style: none; padding: 0; }
.menu li { display: flex; justify-content: space-between; border-bottom: 1px dotted var(--crust); padding: 0.4rem 0; }
.price { font-variant-numeric: tabular-nums; }

max-width: 100% stops an image from being wider than its container, and height: auto keeps its shape. The menu uses flexbox again to put prices on the right; tabular-nums makes every digit the same width, so the prices line up.

CSS
/* 6. Form: label above field; field and button in a row on wider screens */
form { display: grid; gap: 0.5rem; max-width: 28rem; }
input, button { font: inherit; padding: 0.5rem; }
button { background: var(--crust); color: #fff; border: 0; cursor: pointer; }
:focus-visible { outline: 3px solid var(--crust); outline-offset: 2px; }

Grid (display: grid) lays things out in rows and columns; with no columns set, it stacks them with a gap. font: inherit stops form controls using the browser's smaller default font. :focus-visible draws a clear outline around whatever the keyboard has focused. Never remove focus outlines without replacing them: they're how keyboard users see where they are.

CSS
/* 7. Wider screens: the hero gets two columns */
@media (min-width: 48rem) {
  .hero { display: grid; grid-template-columns: 3fr 2fr; gap: 2rem; align-items: center; }
  form { grid-template-columns: 1fr auto; }
  form label { grid-column: 1 / -1; }
}

A media query applies rules only when its condition is true: here, screens at least 48rem (768 pixels at the default size) wide. This is mobile-first: the plain rules above are for phones, and this block adds a two-column layout for larger screens. 3fr 2fr splits the space three parts to two; grid-column: 1 / -1 makes the label span both columns.

That's the whole layout: no fixed widths, one media query, and it fits from a small phone to a wide monitor.

In real systems

  • Reach for the native element first. <button>, <a>, <label>, <details>, <dialog> and <select> come with keyboard support and screen reader roles. Rebuilding them from divs is a common source of accessibility bugs.
  • Run a checker in CI, the way the CI/CD track runs tests. html-validate and axe (Lesson 10) catch regressions before anyone sees them.
  • Design systems and frameworks don't guarantee any of this. A component library can still render a <div> with a click handler. Check the output, not the component's name.
  • Test with the keyboard. Unplug the mouse for five minutes. It finds more problems, faster, than most tools.
  • Accessibility is often a legal requirement. In the US, the Department of Justice's 2024 rule sets WCAG 2.1 AA for state and local government websites, and the EU's European Accessibility Act has applied to many private services since June 2025.

Spot the problem

Work out what's wrong before you read the explanation.

"It looks exactly like the design." A team rebuilds a shop's checkout page. Their new "Place order" button is a <div class="btn"> with a click handler, styled to match the design perfectly. The page passes the HTML checker, looks pixel-perfect in every browser the team tries, and ships. Within a week, support hears from customers who couldn't finish checking out: some use a keyboard, some a screen reader, and some had pressed Enter in the last field expecting the order to go through. What's wrong? A div that looks like a button isn't one. It can't be reached with Tab, it doesn't respond to Enter or Space, a screen reader announces it as plain text, and it doesn't submit the form when someone presses Enter in a field. The checker passed it because it's valid HTML. Three fixes: use <button type="submit"> inside the form, and style it to match; test with the keyboard and the accessibility tree, not just by looking; and add a check for this to CI, since it's exactly what accessibility tools like axe (Lesson 10) flag.

Keep this

HabitWhy
lang, charset and the viewport tag on every pageLanguage, characters and phone sizing
Landmarks and headings in orderHow screen reader users move around
<a> to go somewhere, <button> to do somethingKeyboard and screen reader support for free
A <label> for every fieldPlaceholders disappear; labels don't
alt on every image (empty for decoration)A description when it can't be seen
Contrast at least 4.5 to 1Readable for low vision, and in sunlight
max-width, not widthFits any screen
Simple selectors, no !importantStyles you can still change next year
Mobile-first, with media queries for larger screensThe small screen is the hard case
Keep focus outlinesKeyboard users need to see where they are

Where you are now

You can write HTML that says what each part of a page is, see a page as a screen reader, a keyboard and a phone see it, read a checker's results for what they do and don't prove, predict which CSS rule wins, and lay out a page that fits any screen. The next lesson adds behaviour: JavaScript that responds to people, fetches data, and changes the page without breaking what this lesson built.

Check yourself

  1. What does semantic HTML mean?

  2. Why couldn't the keyboard reach the div soup's 'Sign up'?

  3. What did the HTML checker miss in the div soup?

  4. Why is a placeholder not a label?

  5. Which wins: .price { color: green } or #menu li { color: red }?

  6. What one change most made the semantic page fit on a phone?

Key terms

  • Semantic HTML: elements chosen for what content is (a heading, a link, a button), not how it looks.
  • Div soup: a page built from generic divs and spans, styled to look like other elements.
  • Accessibility tree: the version of the page a browser gives to screen readers, built from roles and names.
  • Landmark: a region of the page (banner, navigation, main, contentinfo) that assistive technology can jump to.
  • Role and name: what an element is (button, link) and what it's called (its label or text).
  • Contrast ratio: how different two colours' brightness is; WCAG AA asks for 4.5 to 1 for normal text.
  • Specificity: how precisely a CSS selector targets an element; more specific wins.
  • Flexbox and grid: CSS layout systems for rows, and for rows and columns.
  • Media query: CSS that applies only when a condition, like screen width, is true.
  • Mobile-first: writing styles for small screens first, then adding to them for larger ones.

Go deeper