Building it
Build it in the order the browser cares about: structure, then tokens, then layout, then components. Doing it in that order means you are never undoing work, and each stage is testable before the next.
This lesson follows the actual build of the reference site, including the three bugs it had — because those are more useful than the parts that worked first time.
Stage 1: the structure of one page, with no CSS at all
Write index.html and open it with no stylesheet linked. It should already read as
a sensible document.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aarambh Yoga — Hatha and vinyasa classes in Kothrud, Pune</title>
<meta name="description" content="A small yoga studio in Kothrud, Pune…">
<link rel="icon" href="images/favicon.svg" type="image/svg+xml">
</head>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header>
<a href="index.html">Aarambh Yoga</a>
<nav aria-label="Main">
<ul>
<li><a href="index.html" aria-current="page">Home</a></li>
<li><a href="classes.html">Classes</a></li>
</ul>
</nav>
</header>
<main id="main" tabindex="-1">
<h1>Yoga that starts where you are</h1>
…
</main>
<footer>…</footer>
</body>
</html>
Everything structural is already there: the skip link first, the landmarks, the labelled
nav as a list, aria-current on the current page, tabindex="-1" on <main> so the
skip link actually moves focus.
Adding those later means restructuring. Adding them now costs nothing.
Read the unstyled page. If it does not make sense, no CSS will fix it.
Stage 2: tokens and the reset
@layer reset, tokens, base, layout, components, utilities;
One line, first, and after it the order of everything else stops mattering — module 7's point.
Then the reset from module 2, the tokens from the brief, and the base element styles. Reload: the page should now look plain but deliberate. Readable line length, sensible heading sizes, visible focus rings.
Stop here and check contrast, before any component exists. It is five minutes now and a repaint later.
Stage 3: the layout primitives
Three of them carry the whole site:
.container {
width: min(100% - (2 * var(--gutter)), 72rem);
margin-inline: auto;
}
.stack > * + * { margin-block-start: var(--space-3); }
.grid-auto {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
gap: var(--space-4);
}
.container is module 4's min() pattern — one declaration instead of a width, a
max-width and a padding, and the gutter survives at 320px.
.stack is the owl from module 7. Spacing belongs to the container.
.grid-auto is module 3's one-liner. Note minmax(min(16rem, 100%), 1fr) — the inner
min() is what stops it overflowing below 16rem, which plain minmax(16rem, 1fr) does
on a 320px screen.
Stage 4: the components
One at a time, each finished before the next. Header, hero, card, table, form, footer.
The hero, without positioning
Two grid items in one cell, from module 3:
.hero { display: grid; }
.hero > * { grid-area: 1 / 1; }
The image, the scrim and the text all occupy the same cell and stack in DOM order. No
position: absolute, no z-index, nothing to anchor. That is the trick worth taking
from this build.
And the image is an <img>, not a background-image, so it can have srcset,
fetchpriority and dimensions — module 4's argument.
The card, adapting to its slot
.grid-auto > * { container-type: inline-size; }
.card { display: flex; flex-direction: column; }
.card__body { flex: 1; }
.card__meta { margin-block-start: auto; }
@container (width >= 26rem) {
.card { flex-direction: row; }
.card__media { flex: 0 0 11rem; }
}
The card goes side-by-side when its own slot is wide enough — not when the window is. So the same markup works in the three-across grid and would work in a sidebar, with no variant class. Module 6's point, doing real work.
flex: 1 on the body plus margin-block-start: auto on the meta line is the pair from
module 3: the body has to absorb the spare height before auto has anything to eat.
The timetable
The hardest piece of content, decided in the brief:
<div class="table-wrap">
<table class="timetable">
<caption>Weekly class timetable, from 1 October 2026</caption>
<thead>
<tr><th scope="col">Time</th><th scope="col">Mon</th>…</tr>
</thead>
<tbody>
<tr><th scope="row">6:30</th><td>Hatha</td>…</tr>
</tbody>
</table>
</div>
.table-wrap { overflow-x: auto; }
.timetable { min-width: 34rem; font-variant-numeric: tabular-nums; }
.timetable td:empty::after { content: "—"; }
scope on both axes, so a screen reader announces "Wednesday, 8:00, Pranayama" rather
than "Pranayama". tabular-nums so the times line up. And td:empty::after puts a dash
in the gaps — the empty cells are real information and an empty cell reads as nothing.
At 320px the table is 734px wide and scrolls inside its 288px wrapper. The page does not scroll. That is the whole technique.
The form
Module 1's markup with module 6's styling. The parts that matter:
<label for="phone">Phone number</label>
<p class="field__hint" id="phone-hint">Ten digits, starting 6 to 9. We reply on WhatsApp.</p>
<input type="tel" id="phone" name="phone"
inputmode="numeric" autocomplete="tel"
pattern="[6-9][0-9]{9}" maxlength="10" required
aria-describedby="phone-hint"
title="A ten-digit Indian mobile number, starting 6 to 9">
type="tel" and not type="number", for the three reasons module 1 verified:
maxlength is ignored on a number field, 1e3 is accepted, and a stray scroll changes
the value.
.field :is(input, select, textarea) {
font-size: max(1rem, 16px); /* iOS zooms the page below 16px */
}
.field :is(input, select, textarea):user-invalid { border-color: var(--colour-error); }
.field:has(:user-invalid) .field__error { display: flex; }
:user-invalid, so nothing is red before the visitor has typed. And :has() showing the
error message — a form that validates and explains itself with no JavaScript at all.
Stage 5: four pages without four copies of the header
A static site has no includes, and four hand-maintained copies of a header drift apart within a week. Two honest options:
Copy carefully and accept the discipline. Fine for four pages, if you are the only person touching it.
Generate them. The reference site has a small build-pages.py that holds the header,
the nav and the footer once, and writes the four files. You edit the Python, never the
four HTML files.
That is not a build step for the visitor — the output is still plain static HTML. It is a build step for you, and it is the point at which most people start using a static site generator.
Whichever you choose, the nav needs aria-current="page" on the right item per page, and
getting that wrong on one page is exactly the drift being described.
The three bugs this build actually had
More useful than the parts that worked.
1. A component class silently lost to a descendant selector
The hero's eyebrow — the small "KOTHRUD, PUNE" line — rendered at body size instead of small. The cause:
.hero p { font-size: var(--step-1); } /* 0,1,1 */
.eyebrow { font-size: var(--step--1); } /* 0,1,0 — loses */
.hero p is one class and one type, so it beats the plain class. Nothing was struck
through in an obvious place because both rules applied; only the font-size was
overridden.
The fix is not to make .eyebrow more specific. It is to stop the descendant
selector reaching in:
.hero p:not(.eyebrow) { font-size: var(--step-1); }
Module 7's warning about descendant chains, met in practice at two levels deep.
2. Text over a photograph, measured rather than eyeballed
The hero text looked fine. Measured against the composited image and gradient, pixel by pixel, the eyebrow was 3.28:1 — passing only because bold 20px counts as large text, and it would have failed the moment the fluid type shrank it on a phone.
The scrim's weak end went from 0.35 to 0.55:
.hero__scrim {
background: linear-gradient(to top, rgb(8 20 16 / 0.86), rgb(8 20 16 / 0.55));
}
After which the worst pixel under any hero text is 7.09:1 — passing the strict body threshold, not relying on an exemption. Module 5 said to check the lightest part of the photo, not the middle. This is why.
3. A dark-mode border that failed the boundary requirement
Light mode used #767676 — 4.54:1 on white, deliberately. Dark mode used #4b5658,
chosen by eye, which measures 2.36:1 against #14181a and fails the 3:1 that a
component boundary needs.
Raised to #667376: 3.64:1. Module 4's warning that the two modes are not
symmetrical, arriving as a real defect in a site built by somebody who had just written
that warning.
All three were found by measuring. None was visible.
One inline style, which should not have existed
The eyebrow originally carried style="color:#cfe5dc" because it needed to be lighter on
the photograph. Module 2's rule says no inline styles: they cannot be reused, cannot hold
a media query, and only !important can override them.
.eyebrow--on-image { color: #ffffff; }
A modifier class, which is also what made the contrast fixable in one place.
Check your work
The build order. Structure, tokens, layout primitives, components — so you never undo work.
What to check before any CSS. That the unstyled page reads as a document.
Why @layer goes first. After it, file order stops mattering.
Why minmax(min(16rem, 100%), 1fr). Plain minmax(16rem, 1fr) overflows below 16rem.
How to build a hero with no positioning. Two grid items in grid-area: 1 / 1.
Why the hero image is an <img>. A background cannot have srcset, fetchpriority
or dimensions.
Why the card uses a container query. It adapts to its slot, so one component works anywhere.
Why flex: 1 and margin-block-start: auto together. The body must absorb the height
before auto has anything to eat.
What td:empty::after fixes. An empty cell reads as nothing; the gap is information.
Why type="tel" not type="number". maxlength is ignored, 1e3 is accepted, and a
scroll changes the value.
The two honest options for a shared header. Careful copying, or generating the pages.
The three bugs, and what they have in common. All found by measuring; none visible.
Practice
- Write your
index.htmlwith no stylesheet and read it. Fix the structure first. - Add
@layer, your reset and your tokens. Check every contrast ratio before going on. - Build the three layout primitives and nothing else. Check 320px.
- Build one component fully — including its focus, hover and dark-mode states — before starting the next.
- Build your hero with two grid items in one cell. Then try it with
position: absoluteand compare the CSS. - Give your card slots
container-type: inline-sizeand write one container query. Then put the same card in a narrow column and confirm it adapts with no new class. - Build the table with
scopeon both axes and listen to a cell with a screen reader. Removescopeand listen again. - Put the table on a 320px viewport. Confirm the page does not scroll but the table does.
- Build the form. Submit it empty, then with a bad phone number, and read every message.
- Add
aria-current="page"to all four pages and check each one. - Deliberately reach into a component with a descendant selector and watch a class lose.
Fix it with
:not(). - Put white text on your hero and measure it against the lightest pixel of your image, not the middle.
- Check your dark-mode border contrast separately from your light-mode one.
- Find any inline
styleattribute in your HTML and replace it with a modifier class.
Official documentation
- MDN — CSS layout cookbook — Recipes for the header, card and page patterns in this build.
- MDN — Container queries — For the card that adapts to its slot.
- W3C WAI — Tables tutorial —
scopeandcaption, with the screen-reader output shown. - W3C WAI — Forms tutorial — Labels, hints and error messages.
- The finished site's stylesheet — Every decision above, in one commented file.
Next: real content and real images.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship