Emmanuel Akinfulubi

HTML and CSS, 1–32

Structure and styling together: text, links, images, lists, tables and forms.

Exercises 1 to 32 of 34

1 Headings and paragraphs, in order

easy

Mark up a short page with a title, two sections and prose. Use heading levels to describe the outline, not to pick a font size, and let CSS do the spacing.

Stuck? Three hints, smallest first
  1. 1. One <h1> per page. Everything below it steps down one level at a time.
  2. 2. Never skip a level to get a smaller heading. Change font-size in CSS instead.
  3. 3. <br> is not a spacing tool. Paragraph margins are.

index.html

<h1>Jibowu Park</h1>
<p>A working bus terminal on the Lagos mainland, and the busiest of the four.</p>

<h2>Getting there</h2>
<p>Six routes stop outside the gate. The walk from Yaba takes about twenty minutes.</p>

<h3>By bus</h3>
<p>Any vehicle marked for Oyingbo passes the entrance.</p>

<h2>Opening hours</h2>
<p>Ticketing opens at 05:00 and the last departure boards at 21:30.</p>

style.css

body {
  max-width: 62ch;          /* a readable measure, in characters */
  color: #17171a;
}

h1 { font-size: 1.6rem; margin: 0 0 0.4rem; }
h2 { font-size: 1.15rem; margin: 1.4rem 0 0.3rem; }
h3 { font-size: 1rem; margin: 1rem 0 0.3rem; }

p {
  margin: 0 0 0.8rem;       /* the gap between paragraphs, not a <br> */
  line-height: 1.6;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 1

// Pull the heading levels out in document order, then look for a jump.
const levels = [...html.matchAll(/<h([1-6])\b/g)].map((m) => Number(m[1]));
const skips = levels.filter((l, i) => i > 0 && l - levels[i - 1] > 1);

test('the outline runs 1, 2, 3, 2', levels, [1, 2, 3, 2]);
test('no level is skipped', skips, []);
test('exactly one h1', countTag(html, 'h1'), 1);
test('four paragraphs carry the prose', countTag(html, 'p'), 4);
test('no br tags standing in for spacing', hasTag(html, 'br'), false);
test('the spacing is margin on the paragraph', /p\s*\{[^}]*margin/.test(css), true);
test('the measure is limited in ch, which tracks the font', /max-width\s*:\s*62ch/.test(css), true);
test('heading sizes are set in CSS, not chosen by level', /h2\s*\{[^}]*font-size/.test(css), true);

Output


      

Heading level is structure, not size. A screen reader user can list the headings and jump between them, so skipping from h2 to h4 to get smaller text puts a hole in the outline they navigate by. Set the size in CSS and the two concerns stay separate. The 62ch measure is the other habit worth taking: ch is the width of a “0” in the current font, so the line length stays readable when the font changes.

Answers HTML CSS 1 in the original set.

2 Emphasis that means something

easy

Mark up stressed words and important words with the elements that carry that meaning. Then use <i> for the one job it still has, and leave <b> alone.

Stuck? Three hints, smallest first
  1. 1. <em> is stress emphasis; <strong> is importance. Both are announced.
  2. 2. <i> is for a different voice or mood: a taxonomic name, a foreign phrase, a ship. It carries no emphasis.
  3. 3. If you only want bold or italic pixels, that is a CSS job, not an element.

index.html

<p>The last bus leaves at <strong>21:30</strong>, not 22:00.</p>
<p>Bring the ticket <em>and</em> the receipt; one alone is not enough.</p>
<p>The park is named after the <i lang="yo">jibowu</i> tree that stood at the gate.</p>

style.css

strong {
  font-weight: 700;         /* already the default; stated so it is deliberate */
  color: #a3341f;
}

/* em stays italic. Restyling it to bold would make it look like strong while
   still being announced as emphasis, so the two would disagree. */
em {
  font-style: italic;
}

i[lang] {
  font-style: italic;
  color: #5c5c66;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 2

test('stress emphasis uses em', hasTag(html, 'em'), true);
test('importance uses strong', hasTag(html, 'strong'), true);
test('the different-voice case uses i', hasTag(html, 'i'), true);
test('and names its language, which is the point of it', /<i lang="yo">/.test(html), true);
test('b is not used to mean important', /<b\b/.test(html), false);
test('em is not restyled to look like strong', /em\s*\{[^}]*font-weight/.test(css), false);
test('strong is styled, and stays bold', /strong\s*\{[^}]*font-weight\s*:\s*700/.test(css), true);

Output


      

<em> and <strong> are announced — a screen reader can change pitch or stress for them — while <b> and <i> are not. That is the whole distinction, and it is why the sixth check refuses to give em a font-weight: a bold em looks like importance but is announced as emphasis, so the appearance and the meaning drift apart. <i> is not deprecated; it just means “another voice” now, which is why it pairs with lang here.

Answers HTML CSS 2 in the original set.

3 Three kinds of link

fair

Write a link that jumps within the page, a link that opens a new tab safely, and a link that starts an email. Say out loud, in the link text, which one leaves the page.

Stuck? Three hints, smallest first
  1. 1. An in-page link is href="#id" pointing at an element with that id.
  2. 2. target="_blank" needs rel="noopener", or the new page gets a handle on yours.
  3. 3. A new tab with no warning is disorienting. Put it in the link text, not only in an icon.

index.html

<p><a href="#fares">Jump to the fares table</a></p>

<p><a href="https://example.com/timetable.pdf" target="_blank" rel="noopener noreferrer">
  Full timetable (PDF, opens in a new tab)</a></p>

<p><a href="mailto:tickets@example.com?subject=Refund">Email the ticket office</a></p>

<h2 id="fares">Fares</h2>
<p>Lagos to Abuja is &#8358;18,000 one way.</p>

style.css

a {
  color: #3055d8;
  text-decoration: underline;   /* keep it; the underline is the affordance */
  text-underline-offset: 2px;
}

a:hover { color: #2543b4; }

a:focus-visible {
  outline: 2px solid #17171a;
  outline-offset: 2px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 3

test('an in-page link points at a fragment', /href="#fares"/.test(html), true);
test('and something on the page carries that id', /id="fares"/.test(html), true);
test('the new tab is opened deliberately', /target="_blank"/.test(html), true);
test('and rel="noopener" comes with it', /rel="noopener/.test(html), true);
test('the link text warns that it leaves the page', /opens in a new tab/i.test(html), true);
test('an email link is a real href, not a handler', /href="mailto:/.test(html), true);
test('the underline is kept, not removed', /text-decoration\s*:\s*none/.test(css), false);
test('keyboard focus is visible', /:focus-visible/.test(css), true);

Output


      

rel="noopener" is the one to remember: without it the opened page can reach back through window.opener and navigate the tab it came from. Modern browsers imply it for target="_blank", but stating it is still the honest habit because you cannot know which browser you got. The warning in the link text matters for the same reason a new tab is jarring on a screen reader: focus moves to a document that was never announced.

Answers HTML CSS 3 in the original set.

4 An image that does not shift the page

easy

Place two images: one that carries information and one that is pure decoration. Reserve their space so nothing jumps as they load, and let them shrink on a narrow screen.

Stuck? Three hints, smallest first
  1. 1. width and height attributes let the browser reserve the box before the bytes arrive.
  2. 2. A decorative image takes alt="" — empty, never missing. Missing means the reader hears the filename.
  3. 3. max-width: 100% with height: auto lets it shrink and keeps the ratio.

index.html

<img
  class="shot"
  width="240" height="150"
  alt="Six bays under a corrugated roof, each with a bus reversed into it"
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='150'%3E%3Crect width='240' height='150' fill='%233055d8'/%3E%3Crect y='96' width='240' height='54' fill='%230b3a8f'/%3E%3C/svg%3E">

<img
  class="rule"
  width="240" height="8"
  alt=""
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='8'%3E%3Crect width='240' height='8' fill='%23ffd75e'/%3E%3C/svg%3E">

style.css

img {
  display: block;      /* removes the few pixels of gap an inline image leaves */
  max-width: 100%;     /* never wider than the column */
  height: auto;        /* so the aspect ratio survives the shrink */
  border-radius: 6px;
}

.rule {
  margin-top: 10px;
  border-radius: 2px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 4

test('two images', countTag(html, 'img'), 2);
test('the informative one has real alternative text', /alt="[^"]{10,}"/.test(html), true);
test('the decorative one has an EMPTY alt, not a missing one', /alt=""/.test(html), true);
test('both declare their intrinsic size', (html.match(/width="\d+"/g) || []).length, 2);
test('and their height too, so the box is reserved', (html.match(/height="\d+"/g) || []).length, 2);
test('both sources are inline, so nothing is fetched', (html.match(/src="data:image/g) || []).length, 2);
test('they shrink with the column', /max-width\s*:\s*100%/.test(css), true);
test('height auto keeps the ratio while shrinking', /height\s*:\s*auto/.test(css), true);

Output


      

The width and height attributes look redundant next to CSS, and they are not: the browser divides them to get an aspect ratio and reserves that box before a single byte of image arrives, which is what stops the text below jumping down. Leave them off and you get layout shift on every slow connection. The empty alt="" is the other half — it tells a screen reader “skip this, it says nothing”, whereas no alt at all makes it read the file name out.

Answers HTML CSS 4 in the original set.

5 A figure with a caption

easy

Give an image a visible caption that stays attached to it. The caption and the alternative text should say different things.

Stuck? Three hints, smallest first
  1. 1. <figure> wraps the image; <figcaption> goes inside it, first or last.
  2. 2. The caption is for everyone. The alt is for someone who cannot see the image.
  3. 3. Repeating the caption in the alt means a screen reader hears the same sentence twice.

index.html

<figure class="fig">
  <img
    width="240" height="140"
    alt="A queue of about thirty people along a painted kerb"
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='140'%3E%3Crect width='240' height='140' fill='%23e9ecf6'/%3E%3Crect x='16' y='108' width='208' height='10' fill='%233055d8'/%3E%3C/svg%3E">
  <figcaption>Boarding for the 06:30 to Abuja. Photographed in March.</figcaption>
</figure>

style.css

.fig {
  max-width: 240px;
  margin: 0;
  padding: 8px;
  border: 1px solid #c9cad1;
  border-radius: 8px;
  background: #f7f7f8;
}

.fig img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 4px;
}

.fig figcaption {
  margin-top: 8px;
  color: #5c5c66;
  font-size: 0.85rem;
  line-height: 1.45;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 5

// Compare the two strings rather than trusting that they differ.
const alt = (/alt="([^"]*)"/.exec(html) || [, ''])[1].trim();
const caption = (/<figcaption>([\s\S]*?)<\/figcaption>/.exec(html) || [, ''])[1].trim();

test('a figure wraps the image', hasTag(html, 'figure'), true);
test('and carries a caption', hasTag(html, 'figcaption'), true);
test('the caption is inside the figure', /<figure[\s\S]*<figcaption[\s\S]*<\/figure>/.test(html), true);
test('the alt text is not a copy of the caption', same(alt, caption), false);
test('both actually say something', alt.length > 10 && caption.length > 10, true);
test('the figure margin is reset, since browsers indent it', /\.fig\s*\{[^}]*margin\s*:\s*0/.test(css), true);
test('the caption is styled as secondary text', /figcaption\s*\{[^}]*color\s*:\s*#5c5c66/.test(css), true);
test('the image still declares its intrinsic size', hasAttr(html, 'width') && hasAttr(html, 'height'), true);

Output


      

The fourth check is the teaching. A caption describes what the picture means in the flow of the page; alt describes what is in it. Paste one into the other and a screen reader reads the same sentence twice, once as the image and once as the caption. Note also that <figure> arrives with a browser default margin of about 40px on each side, so resetting it is nearly always step one.

Answers HTML CSS 5 in the original set.

6 Offer a different image at a different width

fair

Use <picture> to swap in a wider crop on a large screen, while keeping one image that always works.

Stuck? Three hints, smallest first
  1. 1. <source media="..."> lines up before the <img>; the first match wins.
  2. 2. The <img> is not optional. It is the fallback and the thing that carries alt.
  3. 3. Sources are checked in order, so put the narrowest condition where you mean it.

index.html

<picture>
  <!-- Only on a wide viewport. The preview frame is narrower than this, so the
       fallback below is what you see rendered. -->
  <source media="(min-width: 60em)" srcset="park-wide.svg">
  <img
    class="hero"
    width="240" height="120"
    alt="The terminal frontage, seen from the road"
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='120'%3E%3Crect width='240' height='120' fill='%230b3a8f'/%3E%3Crect x='24' y='72' width='192' height='24' fill='%23ffd75e'/%3E%3C/svg%3E">
</picture>

style.css

picture {
  display: block;
  max-width: 240px;
}

.hero {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 8px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 6

test('a picture element is used', hasTag(html, 'picture'), true);
test('with one conditional source', countTag(html, 'source'), 1);
test('the condition is a media query', /media="\(min-width: 60em\)"/.test(html), true);
test('there is exactly one img, which is the fallback', countTag(html, 'img'), 1);
test('the alt lives on the img, not on the source', /<img[\s\S]*alt="/.test(html) && /<source[^>]*alt=/.test(html) === false, true);
test('the source comes before the img, because order decides', html.indexOf('<source') < html.indexOf('<img'), true);
test('the img still reserves its box', /width="240"/.test(html) && /height="120"/.test(html), true);
test('and its own src is inline, so the fallback needs no network', /src="data:image/.test(html), true);

Output


      

<picture> is a wrapper around one <img>, not a replacement for it: the <img> is what renders, what carries alt, and what a browser with no <picture> support falls back to. Sources are evaluated top to bottom and the first matching one wins, so a broad condition placed first shadows everything after it. The wide-screen source here names a file this course does not ship, which is why the note above the markup says the preview always shows the fallback.

Answers HTML CSS 6 in the original set.

7 Ordered where order matters

easy

Write one list where the sequence is part of the meaning and one where it is not. Then start the numbered one at a value other than 1.

Stuck? Three hints, smallest first
  1. 1. <ol> for steps and rankings, <ul> for a set of things.
  2. 2. start sets the first number; type changes the numeral style.
  3. 3. Reordering a <ul> changes nothing. Reordering an <ol> changes the meaning — that is the test for which to use.

index.html

<h2>Buying a ticket</h2>
<ol start="2">
  <li>Join the queue at the window marked for your route.</li>
  <li>Pay and take the printed slip.</li>
  <li>Show the slip at the bay.</li>
</ol>

<h2>On board</h2>
<ul>
  <li>Air conditioning</li>
  <li>Reclining seats</li>
  <li>One free bag</li>
</ul>

style.css

ol, ul {
  margin: 0 0 1rem;
  padding-left: 1.4rem;
}

li {
  margin-bottom: 0.3rem;
  line-height: 1.5;
}

ol li::marker {
  color: #3055d8;
  font-weight: 600;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 7

test('the steps are an ordered list', hasTag(html, 'ol'), true);
test('the features are an unordered list', hasTag(html, 'ul'), true);
test('the numbering starts at 2', /<ol start="2">/.test(html), true);
test('six items in total', countTag(html, 'li'), 6);
test('the markers are styled through ::marker', /::marker/.test(css), true);
test('only the ordered list gets that treatment', /ol li::marker/.test(css), true);
test('the indent comes from padding, not a margin hack', /padding-left\s*:\s*1\.4rem/.test(css), true);
test('each list is introduced by a heading', countTag(html, 'h2'), 2);

Output


      

The question to ask is whether shuffling the items changes what the page says. Steps two, three and four cannot be swapped; air conditioning and reclining seats can. Assistive technology announces the count either way, but only <ol> announces position, so choosing the wrong one loses information that was in your head and never made it into the markup. start exists precisely so a list continued after a break keeps its real numbers instead of restarting at one.

Answers HTML CSS 7 in the original set.

8 A nested list, nested correctly

easy

Put a sub-list under one item of a list. The nesting has to be inside the item it belongs to, which is where most people get it wrong.

Stuck? Three hints, smallest first
  1. 1. The inner <ul> goes inside the <li>, before its closing tag.
  2. 2. A list may only contain <li> children, so a <ul> after </li> is invalid.
  3. 3. Change the marker at the second level so the hierarchy reads at a glance.

index.html

<ul class="routes">
  <li>Northbound
    <ul>
      <li>Abuja</li>
      <li>Kano</li>
    </ul>
  </li>
  <li>Eastbound
    <ul>
      <li>Enugu</li>
    </ul>
  </li>
  <li>Local shuttle</li>
</ul>

style.css

.routes {
  margin: 0;
  padding-left: 1.3rem;
  list-style-type: disc;
}

/* Second level: a different marker, so depth is visible without indentation
   alone carrying the message. */
.routes ul {
  margin: 0.3rem 0 0.5rem;
  padding-left: 1.2rem;
  list-style-type: circle;
}

.routes > li {
  margin-bottom: 0.35rem;
  font-weight: 600;
}

.routes ul li {
  font-weight: 400;
  color: #5c5c66;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 8

// A nested list must sit inside an li. The classic error is closing the li
// first, which puts a ul where only li is allowed.
const nestedInsideItem = /<li>[^<]*\s*<ul>/.test(html);
const nestedAfterItem = /<\/li>\s*<ul>/.test(html);

test('the sub-list is inside its item', nestedInsideItem, true);
test('and never a sibling of it', nestedAfterItem, false);
test('two levels of list', countTag(html, 'ul'), 3);
test('six items across both levels', countTag(html, 'li'), 6);
test('the second level uses a different marker', /list-style-type\s*:\s*circle/.test(css), true);
test('the first level keeps the disc', /list-style-type\s*:\s*disc/.test(css), true);
test('the child selector styles only top-level items', /\.routes\s*>\s*li/.test(css), true);

Output


      

<ul> and <ol> may only contain <li>, so a sub-list placed after </li> is invalid and browsers recover from it inconsistently — usually by rendering an extra level of indentation that no element accounts for. The second check is there to catch exactly that. The > in .routes > li is doing real work too: without it, the bold rule would apply to the nested items as well, since they are also descendants.

Answers HTML CSS 8 in the original set.

9 A description list for pairs

fair

Lay out term-and-definition pairs, including one term with two definitions, and style them as two columns without using a table.

Stuck? Three hints, smallest first
  1. 1. <dl> holds <dt> terms and <dd> descriptions; a term may have several descriptions.
  2. 2. The pairs are siblings, not nested, so a two-column grid on the <dl> lines them up.
  3. 3. <dd> has a browser default indent. Reset margin before you lay it out.

index.html

<dl class="spec">
  <dt>Departures</dt>
  <dd>05:00 to 21:30, every day</dd>

  <dt>Bays</dt>
  <dd>Six, lettered A to F</dd>

  <dt>Payment</dt>
  <dd>Cash at the window</dd>
  <dd>Transfer, with the slip shown at the bay</dd>
</dl>

style.css

.spec {
  display: grid;
  grid-template-columns: max-content 1fr;   /* term column hugs its widest term */
  gap: 6px 16px;
  margin: 0;
  max-width: 380px;
}

.spec dt {
  font-weight: 600;
  color: #17171a;
}

.spec dd {
  margin: 0;               /* browsers indent dd by 40px; reset it */
  color: #5c5c66;
}

/* The second description of a term sits under the first, in the value column. */
.spec dd + dd {
  grid-column-start: 2;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 9

test('a description list is used', hasTag(html, 'dl'), true);
test('three terms', countTag(html, 'dt'), 3);
test('four descriptions, because one term has two', countTag(html, 'dd'), 4);
test('the dd default indent is reset', /dd\s*\{[^}]*margin\s*:\s*0/.test(css), true);
test('the pairs are laid out as two columns', /grid-template-columns\s*:\s*max-content\s+1fr/.test(css), true);
test('the term column sizes to its content', /max-content/.test(css), true);
test('a repeated description is pushed to the value column', /dd \+ dd/.test(css), true);
test('no table was used for a non-tabular pairing', hasTag(html, 'table'), false);

Output


      

A <dl> is a list of groups, not of pairs, which is why one <dt> can legitimately own several <dd>. That is also why laying it out needs care: the elements are flat siblings, so dd + dd is how you tell a second description to start in the value column rather than the term column. max-content on the first track is the neat part — the column becomes exactly as wide as the longest term and no wider.

Answers HTML CSS 9 in the original set.

10 Replace the markers, keep the list

fair

Give a list your own markers — a tick for one list, a formatted number for another — without losing the fact that it is a list.

Stuck? Three hints, smallest first
  1. 1. ::marker accepts content, color and font properties, and keeps list semantics.
  2. 2. list-style-type takes a string in modern CSS: list-style-type: "—".
  3. 3. list-style: none can drop the list role in Safari, so pair it with role="list".

index.html

<ul class="ticks" role="list">
  <li>Ticket printed</li>
  <li>Bag tagged</li>
  <li>Seat assigned</li>
</ul>

<ol class="steps">
  <li>Queue</li>
  <li>Pay</li>
  <li>Board</li>
</ol>

style.css

/* list-style: none is what drops the role in Safari, which is why the markup
   carries role="list" as well. */
.ticks {
  list-style: none;
  margin: 0 0 1rem;
  padding: 0;
}

.ticks li::before {
  content: '\2713\00a0';   /* tick, then a non-breaking space */
  color: #17734b;
  font-weight: 700;
}

.steps {
  margin: 0;
  padding-left: 2.2rem;
}

/* ::marker styles the number the browser already generates, so the counter and
   the list semantics stay the browser's job. */
.steps li::marker {
  content: 'Step ' counter(list-item) '. ';
  color: #3055d8;
  font-weight: 600;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 10

test('the tick list drops the default marker', /list-style\s*:\s*none/.test(css), true);
test('and gets role="list" back, which Safari needs', /role="list"/.test(html), true);
test('the tick is drawn with a pseudo-element', /::before/.test(css), true);
test('the numbered list keeps its real counter', /counter\(list-item\)/.test(css), true);
test('styled through ::marker rather than replaced', /::marker/.test(css), true);
test('an ol is used where the order counts', hasTag(html, 'ol'), true);
test('and a ul where it does not', hasTag(html, 'ul'), true);
test('six items in total', countTag(html, 'li'), 6);

Output


      

Two routes, and they are not equivalent. ::marker restyles the marker the browser already generates, so the numbering, the list role and the item count all stay intact — reach for it first. list-style: none plus ::before gives you more freedom but throws the marker away, and in Safari with VoiceOver it also throws away the list role, so “list, three items” stops being announced. role="list" puts that back. counter(list-item) is the browser's own counter, not one you have to maintain.

Answers HTML CSS 10 in the original set.

11 A table with a head, a body and a foot

fair

Build a data table with a caption, a header row, a body and a totals row in the right section. Tell assistive technology which cells are headers and what they head.

Stuck? Three hints, smallest first
  1. 1. <caption> is the table’s name and goes first, inside the table.
  2. 2. <tfoot> is for a summary of the body, such as a total.
  3. 3. scope="col" on a column header, scope="row" on a row header.

index.html

<table class="fares">
  <caption>Fares from Jibowu, one way</caption>
  <thead>
    <tr>
      <th scope="col">Route</th>
      <th scope="col">Seats</th>
      <th scope="col">Fare</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Abuja</th>
      <td>14</td>
      <td>&#8358;18,000</td>
    </tr>
    <tr>
      <th scope="row">Kano</th>
      <td>30</td>
      <td>&#8358;22,000</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row">Total</th>
      <td>44</td>
      <td>&#8358;40,000</td>
    </tr>
  </tfoot>
</table>

style.css

.fares {
  border-collapse: collapse;
  font-size: 0.9rem;
}

.fares caption {
  caption-side: top;
  text-align: left;
  padding-bottom: 8px;
  color: #5c5c66;
  font-weight: 600;
}

.fares th,
.fares td {
  padding: 7px 12px;
  border-bottom: 1px solid #c9cad1;
  text-align: left;
}

.fares thead th {
  background: #e9ecf6;
}

.fares tfoot th,
.fares tfoot td {
  border-top: 2px solid #17171a;
  border-bottom: 0;
  font-weight: 700;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 11

test('the table is named by a caption', hasTag(html, 'caption'), true);
test('all three sections are present', ['thead', 'tbody', 'tfoot'].every((t) => hasTag(html, t)), true);
test('three column headers', (html.match(/scope="col"/g) || []).length, 3);
test('and a row header on every body and foot row', (html.match(/scope="row"/g) || []).length, 3);
test('the totals row lives in tfoot, not tbody', /<tfoot>[\s\S]*Total/.test(html), true);
test('borders are collapsed to one per edge', /border-collapse\s*:\s*collapse/.test(css), true);
test('the header row is distinguishable', /thead th\s*\{[^}]*background/.test(css), true);
test('the caption is placed above the table', /caption-side\s*:\s*top/.test(css), true);

Output


      

scope is what turns a grid of cells into data a screen reader can read out in context: landing on ₦22,000 it can announce “Kano, Fare, ₦22,000” instead of a bare number. <tfoot> is not decoration either — it marks the row as a summary of the body rather than another data row, which matters when the table is long enough to scroll. And <caption> is the accessible name of the table, so a reader listing tables on the page gets something better than “table”.

Answers HTML CSS 11 in the original set.

12 Cells that span

hard

Merge cells across columns and down rows, and keep the grid rectangular. Prove the arithmetic rather than trusting your eye.

Stuck? Three hints, smallest first
  1. 1. colspan widens a cell; rowspan makes it reach down into later rows.
  2. 2. A row under a rowspan declares one fewer cell, because the cell above is occupying that slot.
  3. 3. Add the declared spans per row. Every row must reach the same total once the spans from above are counted.

index.html

<table class="bays">
  <caption>Bay assignments</caption>
  <thead>
    <tr>
      <th scope="col">Bay</th>
      <th scope="col">Morning</th>
      <th scope="col">Afternoon</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">A</th>
      <td colspan="2">Closed for resurfacing</td>
    </tr>
    <tr>
      <th scope="row">B</th>
      <td rowspan="2">Abuja</td>
      <td>Kano</td>
    </tr>
    <tr>
      <th scope="row">C</th>
      <td>Enugu</td>
    </tr>
  </tbody>
</table>

style.css

.bays {
  border-collapse: collapse;
  font-size: 0.9rem;
}

.bays caption {
  text-align: left;
  padding-bottom: 8px;
  color: #5c5c66;
  font-weight: 600;
}

.bays th,
.bays td {
  padding: 7px 12px;
  border: 1px solid #c9cad1;
  text-align: left;
}

.bays thead th { background: #e9ecf6; }

/* A merged cell reads better centred over the columns it covers. */
.bays td[colspan] { text-align: center; color: #5c5c66; }
.bays td[rowspan] { vertical-align: middle; background: #f7f7f8; }
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 12

// Add up the columns each row DECLARES. The row under the rowspan declares one
// fewer, because the cell above is still occupying that slot.
const rows = html.match(/<tr>[\s\S]*?<\/tr>/g) || [];
const declaredWidth = (row) =>
  (row.match(/<t[hd][^>]*>/g) || []).reduce((n, cell) => {
    const span = /colspan="(\d+)"/.exec(cell);
    return n + (span ? Number(span[1]) : 1);
  }, 0);
const widths = rows.map(declaredWidth);

test('four rows', rows.length, 4);
test('they declare 3, 3, 3 and 2 columns', widths, [3, 3, 3, 2]);
test('the short row plus the rowspan above it makes 3', widths[3] + 1, 3);
test('one cell spans two columns', /colspan="2"/.test(html), true);
test('one cell spans two rows', /rowspan="2"/.test(html), true);
test('spanned cells are styled by attribute, not an extra class', /td\[colspan\]/.test(css), true);
test('every row still has a row header', (html.match(/scope="row"/g) || []).length, 3);

Output


      

The second and third checks are the whole exercise. Rows under a rowspan genuinely declare fewer cells, so counting tags gives 2 for the last row and nothing is wrong — the missing slot is filled from above. Get this off by one and the browser silently grows a phantom column, which is the usual cause of a table with a ragged right edge. Styling with td[colspan] rather than a class is a small win: the attribute is already the truth, so there is nothing extra to keep in sync.

Answers HTML CSS 12 in the original set.

13 Collapsed borders and zebra stripes

fair

Turn the doubled borders of a default table into single lines, then stripe alternate body rows. Keep the stripe out of the header.

Stuck? Three hints, smallest first
  1. 1. border-collapse: collapse merges adjacent cell borders into one.
  2. 2. tbody tr:nth-child(even) selects every other row, counting within the body.
  3. 3. border-spacing only does anything while borders are separate, so it is the wrong tool here.

index.html

<table class="grid">
  <caption>Departures</caption>
  <thead>
    <tr>
      <th scope="col">Time</th>
      <th scope="col">Route</th>
      <th scope="col">Bay</th>
    </tr>
  </thead>
  <tbody>
    <tr><td>06:30</td><td>Abuja</td><td>B</td></tr>
    <tr><td>08:00</td><td>Kano</td><td>C</td></tr>
    <tr><td>11:15</td><td>Enugu</td><td>D</td></tr>
    <tr><td>14:45</td><td>Ibadan</td><td>A</td></tr>
  </tbody>
</table>

style.css

.grid {
  border-collapse: collapse;   /* one border per edge, not two side by side */
  width: 100%;
  max-width: 340px;
  font-size: 0.9rem;
}

.grid caption {
  text-align: left;
  padding-bottom: 8px;
  color: #5c5c66;
  font-weight: 600;
}

.grid th,
.grid td {
  padding: 7px 10px;
  border-bottom: 1px solid #c9cad1;
  text-align: left;
}

.grid thead th {
  background: #17171a;
  color: #ffffff;
}

/* Counting inside tbody, so the header is never striped. */
.grid tbody tr:nth-child(even) {
  background: #f2f3f5;
}

.grid tbody tr:hover {
  background: #e9ecf6;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 13

test('borders are collapsed', /border-collapse\s*:\s*collapse/.test(css), true);
test('alternate rows are striped', /nth-child\(even\)/.test(css), true);
test('the stripe is scoped to tbody, so the header is spared', /tbody tr:nth-child\(even\)/.test(css), true);
test('border-spacing is not used, since it needs separate borders', /border-spacing/.test(css), false);
test('the header has its own treatment', /thead th\s*\{[^}]*background\s*:\s*#17171a/.test(css), true);
test('four body rows, so two get a stripe', (html.match(/<tr><td/g) || []).length, 4);
test('white on near-black in the header, which clears AA comfortably', /color\s*:\s*#ffffff/.test(css), true);
test('a row highlight on hover as well', /tbody tr:hover/.test(css), true);

Output


      

Two traps. border-spacing is the property people reach for to tighten a table up, and it does nothing at all once border-collapse: collapse is set — the two models are mutually exclusive, which is why the fourth check refuses it. And tr:nth-child(even) without tbody in front counts the header row as child one, so the stripes land on the wrong rows and the header picks one up. Scoping the selector to tbody fixes both at once.

Answers HTML CSS 13 in the original set.

14 A form where every field has a label

easy

Build a three-field form. Every field gets a real label, the right input type, and the browser’s own required check.

Stuck? Three hints, smallest first
  1. 1. <label for="x"> must match an id="x" on the field. Nothing else joins them.
  2. 2. The type changes the keyboard on a phone and the validation you get free: email, tel, date.
  3. 3. required is an attribute with no value, and the browser enforces it before submit.

index.html

<form class="book" action="#" method="post">
  <p class="row">
    <label for="name">Full name</label>
    <input id="name" name="name" type="text" autocomplete="name" required>
  </p>

  <p class="row">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required>
  </p>

  <p class="row">
    <label for="travel">Travel date</label>
    <input id="travel" name="travel" type="date" required>
  </p>

  <button type="submit">Reserve</button>
</form>

style.css

.book { max-width: 300px; }

.row {
  display: grid;
  gap: 4px;
  margin: 0 0 12px;
}

.row label {
  font-size: 0.85rem;
  font-weight: 600;
}

.row input {
  padding: 8px 10px;
  border: 1px solid #8e8e98;
  border-radius: 6px;
  font: inherit;            /* inputs do not inherit typography by default */
}

.row input:focus-visible {
  outline: 2px solid #3055d8;
  outline-offset: 1px;
}

button {
  padding: 9px 16px;
  border: 0;
  border-radius: 6px;
  background: #3055d8;
  color: #ffffff;
  font: inherit;
  cursor: pointer;
}

button:focus-visible {
  outline: 2px solid #17171a;
  outline-offset: 2px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 14

// Every label must point at an id that exists, and every field must be pointed at.
const labelled = [...html.matchAll(/<label for="([^"]+)"/g)].map((m) => m[1]);
const ids = [...html.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);

test('three labels', labelled.length, 3);
test('each one points at a field that exists', labelled.every((f) => ids.includes(f)), true);
test('and every field has a label pointing at it', ids.every((i) => labelled.includes(i)), true);
test('the types are chosen, not all text', ['type="email"', 'type="date"'].every((t) => html.includes(t)), true);
test('all three are required', (html.match(/\brequired\b/g) || []).length, 3);
test('autocomplete hints are given where they exist', (html.match(/autocomplete="/g) || []).length, 2);
test('the controls inherit the page font', /font\s*:\s*inherit/.test(css), true);
test('focus is visible and the outline is never removed', /:focus-visible/.test(css) && /outline\s*:\s*none/.test(css) === false, true);

Output


      

for and id are the only thing joining a label to its field. Get them wrong and the label still looks right, but clicking it no longer focuses the input and a screen reader announces the field as unlabelled — a failure that is invisible on screen, which is why the check compares the two lists rather than counting tags. type earns its keep on a phone, where email and date summon a different keyboard, and autocomplete is the difference between a form a returning user fills in one tap and one they retype.

Answers HTML CSS 14 in the original set.

15 A radio group that behaves like a group

fair

Offer three mutually exclusive choices. Group them so assistive technology announces the question as well as the options, and make exactly one of them selected to begin with.

Stuck? Three hints, smallest first
  1. 1. Radios are exclusive only if they share the same name. Different names means three independent toggles.
  2. 2. <fieldset> with a <legend> is what names the group.
  3. 3. Each radio still needs its own id and its own <label for>.

index.html

<fieldset class="seats">
  <legend>Seat class</legend>

  <p class="opt">
    <input id="cls-std" name="seatclass" type="radio" value="standard" checked>
    <label for="cls-std">Standard</label>
  </p>

  <p class="opt">
    <input id="cls-exec" name="seatclass" type="radio" value="executive">
    <label for="cls-exec">Executive</label>
  </p>

  <p class="opt">
    <input id="cls-sleep" name="seatclass" type="radio" value="sleeper">
    <label for="cls-sleep">Sleeper</label>
  </p>
</fieldset>

style.css

.seats {
  max-width: 280px;
  margin: 0;
  padding: 10px 14px 14px;
  border: 1px solid #c9cad1;
  border-radius: 8px;
}

.seats legend {
  padding: 0 6px;
  font-weight: 600;
}

.opt {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 8px;
}

.opt input {
  accent-color: #3055d8;   /* tints the native control, no rebuild needed */
  width: 16px;
  height: 16px;
}

.opt input:focus-visible {
  outline: 2px solid #17171a;
  outline-offset: 2px;
}

.opt label { cursor: pointer; }
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 15

const names = [...html.matchAll(/name="([^"]+)"/g)].map((m) => m[1]);
const distinct = [...new Set(names)];

test('three radios', (html.match(/type="radio"/g) || []).length, 3);
test('they all share one name, which is what makes them exclusive', distinct, ['seatclass']);
test('each has its own id', new Set([...html.matchAll(/id="([^"]+)"/g)].map((m) => m[1])).size, 3);
test('and its own label', (html.match(/<label for="/g) || []).length, 3);
test('the group is named by a legend inside a fieldset', hasTag(html, 'fieldset') && hasTag(html, 'legend'), true);
test('exactly one is checked to start with', (html.match(/\bchecked\b/g) || []).length, 1);
test('the native control is tinted rather than rebuilt', /accent-color/.test(css), true);
test('focus is visible on the control itself', /input:focus-visible/.test(css), true);

Output


      

The shared name is the mechanism, not a convention: change one of them and you get three checkboxes wearing round costumes, all independently selectable. <legend> is the other half — without it a screen reader reads “Standard, radio button, one of three” and never says what the question was. accent-color is worth knowing because the usual alternative is hiding the input and drawing a fake one, which throws away keyboard behaviour, the focus ring and Windows high contrast mode in one go.

Answers HTML CSS 15 in the original set.

16 Checkbox, select and textarea

fair

Add the three remaining control shapes: an independent yes/no, a list of one-from-many, and a multi-line note. Label all three, and give the select a sensible default.

Stuck? Three hints, smallest first
  1. 1. A checkbox is independent, so its label goes after it and it does not need a group.
  2. 2. <select> holds <option>; mark the default with selected.
  3. 3. <textarea> has no value attribute. Its content is the text between the tags.

index.html

<p class="row">
  <label for="pickup">Pick-up point</label>
  <select id="pickup" name="pickup">
    <option value="jibowu" selected>Jibowu</option>
    <option value="ojota">Ojota</option>
    <option value="berger">Berger</option>
  </select>
</p>

<p class="check">
  <input id="bag" name="bag" type="checkbox" value="yes">
  <label for="bag">I am carrying a bag over 20kg</label>
</p>

<p class="row">
  <label for="notes">Anything we should know</label>
  <textarea id="notes" name="notes" rows="3" maxlength="200"></textarea>
</p>

style.css

.row {
  display: grid;
  gap: 4px;
  margin: 0 0 12px;
  max-width: 300px;
}

.row label,
.check label { font-size: 0.9rem; }

.row label { font-weight: 600; }

select,
textarea {
  padding: 8px 10px;
  border: 1px solid #8e8e98;
  border-radius: 6px;
  font: inherit;
}

textarea {
  resize: vertical;    /* let it grow downwards, not sideways out of the column */
}

.check {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin: 0 0 12px;
  max-width: 300px;
}

.check input { accent-color: #3055d8; margin-top: 3px; }

select:focus-visible,
textarea:focus-visible,
.check input:focus-visible {
  outline: 2px solid #3055d8;
  outline-offset: 1px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 16

test('a select with three options', countTag(html, 'select') === 1 && countTag(html, 'option') === 3, true);
test('one option is the default', (html.match(/\bselected\b/g) || []).length, 1);
test('the checkbox is a single independent control', (html.match(/type="checkbox"/g) || []).length, 1);
test('the textarea is empty by content, not by a value attribute', /<textarea[^>]*>\s*<\/textarea>/.test(html), true);
test('and it declares rows and a length cap', /rows="3"/.test(html) && /maxlength="200"/.test(html), true);
test('all three controls are labelled', (html.match(/<label for="/g) || []).length, 3);
test('resize is limited to vertical, so it cannot break the column', /resize\s*:\s*vertical/.test(css), true);
test('every control has a visible focus state', (css.match(/:focus-visible/g) || []).length >= 1, true);

Output


      

<textarea> is the odd one out: value="..." is silently ignored, and whitespace between the tags becomes part of the content, so <textarea> </textarea> starts with a newline in it. Write the tags closed up. resize: vertical is the other quiet fix — the default lets a reader drag the box wider than its column and wreck the layout, and there is no reason they would need to.

Answers HTML CSS 16 in the original set.

17 Placeholder is not a label

fair

Take a field that only had placeholder text and give it a real label, a hint that survives typing, and validation the browser performs itself.

Stuck? Three hints, smallest first
  1. 1. A placeholder disappears the moment someone types, so it cannot be the label.
  2. 2. Put the format hint in its own element and join it with aria-describedby.
  3. 3. pattern, minlength and inputmode get you validation and the right keyboard with no code.

index.html

<p class="field">
  <label for="plate">Vehicle plate</label>
  <input
    id="plate" name="plate" type="text"
    inputmode="latin" autocomplete="off"
    pattern="[A-Z]{3}-[0-9]{3}-[A-Z]{2}" minlength="10" maxlength="10"
    required aria-describedby="plate-hint">
  <span class="hint" id="plate-hint">Three letters, three digits, two letters. For example LAG-441-XA.</span>
</p>

style.css

.field {
  display: grid;
  gap: 4px;
  max-width: 320px;
  margin: 0;
}

.field label { font-weight: 600; font-size: 0.9rem; }

.field input {
  padding: 8px 10px;
  border: 1px solid #8e8e98;
  border-radius: 6px;
  font: inherit;
  text-transform: uppercase;
}

.field input:focus-visible {
  outline: 2px solid #3055d8;
  outline-offset: 1px;
}

/* Only after the field has been touched, so an untouched form is not all red. */
.field input:user-invalid {
  border-color: #a3341f;
}

.hint {
  color: #5c5c66;
  font-size: 0.8rem;
  line-height: 1.4;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 17

test('there is a real label', /<label for="plate"/.test(html), true);
test('and no placeholder pretending to be one', /placeholder=/.test(html), false);
test('the format hint is a persistent element', /id="plate-hint"/.test(html), true);
test('joined to the field so it is announced', /aria-describedby="plate-hint"/.test(html), true);
test('the browser validates the shape itself', /pattern="\[A-Z\]\{3\}-\[0-9\]\{3\}-\[A-Z\]\{2\}"/.test(html), true);
test('a length is enforced both ways', /minlength="10"/.test(html) && /maxlength="10"/.test(html), true);
test('the invalid style waits until the field has been used', /:user-invalid/.test(css), true);
test('and :invalid alone is not used, which would redden it on load', /[^-]:invalid/.test(css), false);

Output


      

Placeholder text fails three ways at once: it vanishes as soon as there is a value, it is often too low-contrast to read, and several screen readers do not announce it at all. A label plus a hint joined with aria-describedby costs one more element and survives all three. The CSS trap is the last check: :invalid matches an empty required field immediately, so a fresh form loads covered in red. :user-invalid waits until the user has actually interacted, which is what you meant.

Answers HTML CSS 17 in the original set.

18 What width actually means

fair

Two boxes, same declared width, same padding and border, different box-sizing. Work out the rendered width of each from the numbers in the stylesheet.

Stuck? Three hints, smallest first
  1. 1. The default is content-box: width sizes the content only, and padding and border are added on.
  2. 2. border-box makes width the total, so padding and border eat into it.
  3. 3. Add it up: 200 content, 16 padding each side, 2 border each side.

index.html

<div class="box content">width: 200px, content-box</div>
<div class="box border">width: 200px, border-box</div>

style.css

.box {
  width: 200px;
  padding: 16px;
  border: 2px solid #3055d8;
  margin-bottom: 10px;
  background: #eef1fd;
  color: #17171a;
  font-size: 0.85rem;
}

.content {
  box-sizing: content-box;   /* the default: 200 + 32 + 4 = 236px on screen */
}

.border {
  box-sizing: border-box;    /* 200px on screen, 164px of it for content */
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 18

// Read the numbers out of the stylesheet, then do the arithmetic the browser does.
const declared = Number(/\swidth:\s*(\d+)px/.exec(css)[1]);
const pad = Number(/padding:\s*(\d+)px/.exec(css)[1]);
const border = Number(/border:\s*(\d+)px/.exec(css)[1]);

const contentBoxTotal = declared + pad * 2 + border * 2;
const borderBoxTotal = declared;
const borderBoxContent = declared - pad * 2 - border * 2;

test('the stylesheet declares 200px', declared, 200);
test('content-box renders 236px wide', contentBoxTotal, 236);
test('border-box renders exactly the 200px asked for', borderBoxTotal, 200);
test('and leaves 164px for the content', borderBoxContent, 164);
test('the two differ by the padding and border', contentBoxTotal - borderBoxTotal, pad * 2 + border * 2);
test('both models appear in the stylesheet', /content-box/.test(css) && /border-box/.test(css), true);
test('this is arithmetic on the source, not a measured box', typeof contentBoxTotal, 'number');

Output


      

Nothing here measures anything — there is no browser in the build, so the numbers come from adding up what the stylesheet says, which is exactly what the browser does. That is the point worth internalising: under the default content-box, width: 200px is a promise about the content only, so a box you asked to be 200 wide arrives at 236 and your two-column layout overflows. Most projects set box-sizing: border-box on everything once, at the top, and never think about it again.

Answers HTML CSS 18 in the original set.

19 Margins that collapse

hard

Two stacked blocks, one with a bottom margin and one with a top margin. Work out the gap between them, then stop a child’s margin from escaping its parent.

Stuck? Three hints, smallest first
  1. 1. Adjacent vertical margins collapse: the gap is the larger of the two, not the sum.
  2. 2. A child’s top margin escapes through a parent that has no padding, border or new block context.
  3. 3. display: flow-root creates that context on purpose, without the side effects of overflow: hidden.

index.html

<div class="a">margin-bottom: 24px</div>
<div class="b">margin-top: 16px</div>

<div class="parent">
  <div class="child">My 30px top margin stays inside</div>
</div>

style.css

.a {
  margin-bottom: 24px;
  padding: 8px;
  background: #e9ecf6;
}

.b {
  margin-top: 16px;    /* the gap above is 24px, not 40px */
  padding: 8px;
  background: #eef1fd;
}

/* flow-root establishes a new block formatting context, so the child's margin
   is contained instead of poking out through the top of the parent. */
.parent {
  display: flow-root;
  background: #f2f3f5;
  border-radius: 6px;
}

.child {
  margin-top: 30px;
  padding: 8px;
  background: #ffd75e;
  color: #17171a;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 19

const bottom = Number(/margin-bottom:\s*(\d+)px/.exec(css)[1]);
const top = Number(/margin-top:\s*(\d+)px/.exec(css)[1]);
const collapsed = Math.max(bottom, top);

test('the declared margins are 24 and 16', [bottom, top], [24, 16]);
test('the gap is the larger of the two', collapsed, 24);
test('it is NOT the sum', collapsed === bottom + top, false);
test('the parent creates a block context to contain its child', /display\s*:\s*flow-root/.test(css), true);
test('overflow: hidden was not used for it, which also clips', /overflow\s*:\s*hidden/.test(css), false);
test('nor a 1px transparent border, the other old workaround', /border[^;]*transparent/.test(css), false);
test('four blocks in the markup', countTag(html, 'div'), 4);

Output


      

Collapsing catches everyone twice. First between siblings, where two margins produce one gap of the larger size, so tuning both is wasted effort. Then between parent and first child, where the child’s top margin passes straight through a parent that has no padding, no border and no block formatting context — the parent appears to move instead of the child. display: flow-root is the modern fix and does nothing else; overflow: hidden also works but clips content and kills sticky positioning inside, which is a lot of collateral damage for a spacing bug.

Answers HTML CSS 19 in the original set.

20 Which rule wins

hard

Four rules all target the same paragraph. Score their specificity, say which one wins, and do it without reaching for !important.

Stuck? Three hints, smallest first
  1. 1. Specificity is three numbers: ids, then classes and attributes and pseudo-classes, then element types.
  2. 2. Compare left to right. One id beats any number of classes; one class beats any number of types.
  3. 3. On a tie, the rule written later wins. That is the cascade, not specificity.

index.html

<div id="intro">
  <p class="lede">Which colour am I?</p>
  <p>And which colour am I?</p>
</div>

style.css

p          { color: #5c5c66; }   /* 0,0,1 */
.lede      { color: #17734b; }   /* 0,1,0 */
p.lede     { color: #a3341f; }   /* 0,1,1 */
#intro p   { color: #3055d8; }   /* 1,0,1 — this one wins for both paragraphs */

p {
  margin: 0 0 8px;
  font-size: 0.95rem;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 20

// Score a simple selector the way the cascade does: ids, then classes, then types.
const spec = (sel) => {
  const ids = (sel.match(/#[\w-]+/g) || []).length;
  const classes = (sel.match(/\.[\w-]+/g) || []).length;
  const types = (sel.replace(/[#.][\w-]+/g, ' ').match(/[a-z]+/g) || []).length;
  return [ids, classes, types];
};

// Highest score wins; ties go to whichever was written last.
const winner = ['p', '.lede', 'p.lede', '#intro p']
  .map((sel) => ({ sel, s: spec(sel) }))
  .reduce((best, r) => (r.s.join('') >= best.s.join('') ? r : best));

test('a type selector scores 0,0,1', spec('p'), [0, 0, 1]);
test('a class scores 0,1,0 and outranks it', spec('.lede'), [0, 1, 0]);
test('class plus type scores 0,1,1', spec('p.lede'), [0, 1, 1]);
test('an id plus a type scores 1,0,1', spec('#intro p'), [1, 0, 1]);
test('so the id rule wins for both paragraphs', winner.sel, '#intro p');
test('and it is the blue one', /#intro p\s*\{\s*color:\s*#3055d8/.test(css), true);
test('no !important was needed anywhere', /!important/.test(css), false);
test('all four rules are present, so the cascade has something to resolve', (css.match(/color:/g) || []).length, 4);

Output


      

Specificity is compared column by column, not added up: 1,0,1 beats 0,1,1 because the first column decides it, which is why an id you added for JavaScript can quietly outrank every class in your stylesheet. Note the #intro p rule wins for both paragraphs, including the one with .lede — the class never gets a look in. !important would settle it too, and it is a debt: the only way to beat it later is another !important, and then you are ranking those against each other instead.

Answers HTML CSS 20 in the original set.

21 What is inherited and what is not

fair

Set typography once, high up, and let it flow down. Then deal with the one family of elements that refuses to take part.

Stuck? Three hints, smallest first
  1. 1. Text properties inherit: color, font-family, font-size, line-height, text-align.
  2. 2. Box properties do not: border, padding, margin, background, display.
  3. 3. Form controls are the exception. They take their font from the operating system until you say font: inherit.

index.html

<div class="card">
  <p>This paragraph was never given a colour or a font. It inherited both.</p>
  <p><em>So did this emphasis, through two levels.</em></p>
  <input type="text" value="And this control, only because it was told to">
</div>

style.css

.card {
  /* Set once here. Everything textual below picks it up. */
  color: #17171a;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 0.95rem;
  line-height: 1.6;

  /* None of these reach the children. */
  padding: 14px;
  border: 1px solid #c9cad1;
  border-radius: 8px;
  background: #f7f7f8;
}

.card p { margin: 0 0 10px; }

/* Controls do not inherit typography. This is the fix, and it is not optional
   if you want the form to look like the page. */
.card input {
  font: inherit;
  color: inherit;
  padding: 6px 8px;
  border: 1px solid #8e8e98;
  border-radius: 6px;
  background: #ffffff;
  width: 100%;
  box-sizing: border-box;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 21

// The children are never given a colour or a family of their own.
const paraRule = (/\.card p\s*\{([^}]*)\}/.exec(css) || [, ''])[1];

test('the paragraph rule sets no colour of its own', /color/.test(paraRule), false);
test('nor a font family', /font-family/.test(paraRule), false);
test('both are set once, on the ancestor', /\.card\s*\{[\s\S]*?color:\s*#17171a[\s\S]*?font-family:/.test(css), true);
test('the control is told to inherit, because it will not on its own', /font\s*:\s*inherit/.test(css), true);
test('and to inherit the colour too', /color\s*:\s*inherit/.test(css), true);
test('box properties are declared where they are wanted, not inherited', /\.card\s*\{[\s\S]*?padding:/.test(css), true);
test('the control needs its own border, since border does not inherit', /\.card input\s*\{[\s\S]*?border:/.test(css), true);
test('the em inherits through two levels without a rule of its own', /em\s*\{/.test(css), false);

Output


      

Inheritance is why a stylesheet can be short: set the typography on one ancestor and every descendant that renders text picks it up, including ones you add later. Box properties deliberately do not inherit, or every nested div would draw its own border. The trap is form controls: <input>, <select>, <textarea> and <button> start from the operating system font, not the page, so a form looks foreign until you write font: inherit. That is why the shorthand appears in nearly every reset ever published.

Answers HTML CSS 21 in the original set.

22 Four ways to write a colour

easy

Write the same blue as a hex value and as rgb, pick a related shade with hsl, and add a translucent overlay with an alpha channel. Convert the hex to its channels in the checks.

Stuck? Three hints, smallest first
  1. 1. Hex pairs are red, green and blue in base 16: #3055d8 is 48, 85, 216.
  2. 2. hsl takes a hue in degrees, then saturation and lightness as percentages. Changing lightness alone gives a matching shade.
  3. 3. Modern syntax puts alpha after a slash: rgb(23 23 26 / 55%). The comma form still works.

index.html

<div class="swatch hex">#3055d8</div>
<div class="swatch rgb">rgb(48 85 216)</div>
<div class="swatch hsl">hsl(226 68% 40%)</div>
<div class="swatch veil">alpha over the blue</div>

style.css

.swatch {
  padding: 10px 12px;
  margin-bottom: 6px;
  border-radius: 6px;
  color: #ffffff;
  font-size: 0.85rem;
}

.hex { background: #3055d8; }

.rgb { background: rgb(48 85 216); }        /* the same colour, other notation */

.hsl { background: hsl(226 68% 40%); }     /* same hue, darker */

/* A translucent layer over an opaque one. Alpha applies to this colour only,
   unlike opacity, which fades the element and everything inside it. */
.veil {
  background: rgb(48 85 216);
  box-shadow: inset 0 0 0 999px rgb(23 23 26 / 55%);
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 22

// Convert the hex the stylesheet uses into channels, and compare with the rgb().
const hex = /\.hex\s*\{\s*background:\s*#([0-9a-f]{6})/i.exec(css)[1];
const channels = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16));
const stated = (/rgb\((\d+) (\d+) (\d+)\)/.exec(css) || []).slice(1).map(Number);

test('the hex is #3055d8', hex.toLowerCase(), '3055d8');
test('which is 48, 85, 216', channels, [48, 85, 216]);
test('and the rgb() says the same thing', stated, channels);
test('hsl is used for a related shade', /hsl\(226 68% 40%\)/.test(css), true);
test('alpha is written with a slash', /\/\s*55%/.test(css), true);
test('opacity is NOT used, which would fade the text as well', /[^-]opacity\s*:/.test(css), false);
test('white on all four backgrounds, each of which clears AA', /color:\s*#ffffff/.test(css), true);
test('four swatches', countTag(html, 'div'), 4);

Output


      

Hex and rgb say the same thing in different bases, which the second and third checks prove by converting one into the other. hsl earns its place when you need a set of related colours: hold the hue, move the lightness, and you get a consistent scale instead of four hand-picked hex values that nearly match. The real distinction to hold on to is alpha versus opacity: alpha makes one colour translucent, opacity fades the whole element including its text, which is almost never what a designer meant.

Answers HTML CSS 22 in the original set.

23 em, rem and the rest

fair

Size the same thing in px, em, rem, % and ch, and work out what each resolves to when the root is 16px and the parent is 20px.

Stuck? Three hints, smallest first
  1. 1. rem is always relative to the root font size. em is relative to the element’s own parent.
  2. 2. Nest two em sizes and they multiply. That is the compounding people get caught by.
  3. 3. ch is the width of a “0” in the current font, which makes it the honest unit for line length.

index.html

<div class="parent">
  parent, font-size 20px
  <p class="in-em">1.5em &rarr; 30px, because em looks at the parent</p>
  <p class="in-rem">1.5rem &rarr; 24px, because rem looks at the root</p>
  <p class="in-ch">max-width 30ch, a measure that tracks the font</p>
</div>

style.css

:root { font-size: 16px; }

.parent {
  font-size: 20px;
  padding: 12px;
  border: 1px solid #c9cad1;
  border-radius: 8px;
  background: #f7f7f8;
}

.in-em  { font-size: 1.5em; }    /* 1.5 x 20 = 30px */
.in-rem { font-size: 1.5rem; }   /* 1.5 x 16 = 24px */
.in-ch  { max-width: 30ch; }

.parent p { margin: 0 0 8px; }
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 23

const root = Number(/:root\s*\{\s*font-size:\s*(\d+)px/.exec(css)[1]);
const parent = Number(/\.parent\s*\{\s*font-size:\s*(\d+)px/.exec(css)[1]);
const emFactor = Number(/\.in-em\s*\{\s*font-size:\s*([\d.]+)em/.exec(css)[1]);
const remFactor = Number(/\.in-rem\s*\{\s*font-size:\s*([\d.]+)rem/.exec(css)[1]);

test('the root is 16px and the parent 20px', [root, parent], [16, 20]);
test('1.5em resolves against the parent, giving 30px', emFactor * parent, 30);
test('1.5rem resolves against the root, giving 24px', remFactor * root, 24);
test('so the same factor produces different sizes', emFactor * parent === remFactor * root, false);
test('a nested 1.5em would compound to 45px', emFactor * emFactor * parent, 45);
test('the measure is set in ch, not px', /max-width:\s*30ch/.test(css), true);
test('the root size is stated rather than assumed', /:root/.test(css), true);

Output


      

The fifth check is the one that bites in real code: em compounds. Two nested elements at 1.5em land at 45px from a 20px parent, so a component that looked right on its own grows every time it is nested one level deeper. rem sidesteps that entirely by always measuring from the root, which is why it is the default choice for type and spacing, with em kept for things that genuinely should scale with their own text — padding inside a button, for instance. Never set the root size in px in production, though: it overrides the reader’s browser font setting.

Answers HTML CSS 23 in the original set.

24 A font stack that always lands somewhere

easy

Write two font stacks, one for prose and one for code, each ending in a generic family so there is always something to fall back to. Use no downloaded fonts.

Stuck? Three hints, smallest first
  1. 1. The browser walks the list left to right and stops at the first family it has.
  2. 2. The last entry must be a generic family: serif, sans-serif or monospace.
  3. 3. A family name with a space in it needs quotes: ’Times New Roman’.

index.html

<p class="prose">Set in whichever of these the reader already has.</p>
<p class="ui">Interface text, using the system font.</p>
<pre class="code">border-collapse: collapse;</pre>

style.css

.prose {
  font-family: Georgia, 'Times New Roman', Times, serif;
  font-size: 1rem;
}

/* system-ui is the operating system's own interface font: no download, and it
   already looks native on every platform. */
.ui {
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}

.code {
  font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
  font-size: 0.85rem;
  padding: 8px 10px;
  border-radius: 6px;
  background: #f2f3f5;
  color: #17171a;
  overflow-x: auto;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 24

// Every stack has to end in a generic family, or a reader with none of the
// named fonts falls back to the browser default and your metrics change.
const generic = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui'];
const stacks = [...css.matchAll(/font-family:\s*([^;]+);/g)]
  .map((m) => m[1].split(',').map((f) => f.trim()));
const lastOf = stacks.map((s) => s[s.length - 1]);

test('three stacks are declared', stacks.length, 3);
test('each ends in a generic family', lastOf.every((f) => generic.includes(f)), true);
test('the fallbacks are serif, sans-serif and monospace', lastOf, ['serif', 'sans-serif', 'monospace']);
test('multi-word names are quoted', /'Times New Roman'/.test(css), true);
test('every stack offers more than one option', stacks.every((s) => s.length >= 3), true);
test('no font is downloaded', /@font-face/.test(css), false);
test('and none is imported', /@import/.test(css), false);

Output


      

The stack is a preference list, not a request: the browser takes the first family it can find and never asks for the rest, so the last entry being generic is what guarantees an answer. Naming system-ui first is the cheapest way to look native on every platform, and it costs no download at all — which matters more than it sounds, because a web font is a blocking request that can leave text invisible while it loads. This course ships no fonts for exactly that reason.

Answers HTML CSS 24 in the original set.

25 line-height without a unit, and two text tweaks

fair

Set line-height so it stays proportional when a descendant changes size. Then set small-caps-style headings with text-transform and open them up with letter-spacing.

Stuck? Three hints, smallest first
  1. 1. A unitless line-height is a multiplier and is re-computed per element. A px value is inherited as a fixed length.
  2. 2. The child at 24px inherits the factor 1.5 and gets 36px. With line-height: 24px it would still get 24px, and the lines would touch.
  3. 3. letter-spacing on uppercase text stops it looking cramped. Never letter-space lowercase body copy.

index.html

<div class="wrap">
  <p>Body copy at 16px with a line-height factor of 1.5, so 24px of leading.</p>
  <p class="big">A pull quote at 24px. The same factor gives it 36px, so it never
    collides with itself.</p>
  <h3 class="kicker">Departures board</h3>
</div>

style.css

.wrap {
  font-size: 16px;
  line-height: 1.5;      /* a factor, not a length: recomputed per element */
  max-width: 46ch;
}

.big {
  font-size: 24px;       /* inherits the 1.5, so 36px of leading */
}

/* Uppercase set solid looks cramped, so open it up a little. Applied to the
   transform, not to the source text, so the markup stays readable. */
.kicker {
  margin: 14px 0 0;
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: #5c5c66;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 25

const factor = Number(/line-height:\s*([\d.]+)\s*;/.exec(css)[1]);
const base = Number(/\.wrap\s*\{\s*font-size:\s*(\d+)px/.exec(css)[1]);
const big = Number(/\.big\s*\{\s*font-size:\s*(\d+)px/.exec(css)[1]);

test('the line-height is a bare number', factor, 1.5);
test('body copy gets 24px of leading', factor * base, 24);
test('the pull quote gets 36px, from the same factor', factor * big, 36);
test('a fixed 24px would have left the quote cramped', factor * big === base * factor, false);
test('no unit is attached to line-height', /line-height:\s*[\d.]+(px|em|rem)/.test(css), false);
test('the kicker is uppercased in CSS, not in the markup', /text-transform:\s*uppercase/.test(css), true);
test('and the markup still reads normally', /Departures board/.test(html), true);
test('letter-spacing is in em, so it scales with the size', /letter-spacing:\s*[\d.]+em/.test(css), true);

Output


      

A unitless line-height is inherited as a factor and recomputed by each element from its own font size; a px value is inherited as a fixed length, so a 24px pull quote inside a line-height: 24px parent ends up with its lines touching. That is the whole reason resets use a bare number. On the typographic side, text-transform keeps the source text readable and lets a screen reader announce normal words — typing the heading in capitals would put shouting in the markup — and letter-spacing in em keeps the tracking proportional if the size changes.

Answers HTML CSS 25 in the original set.

26 Backgrounds and gradients

easy

Give three panels a flat colour, a two-stop linear gradient and a repeating stripe. Keep the text on all three readable, and draw the stripes with CSS rather than an image file.

Stuck? Three hints, smallest first
  1. 1. linear-gradient() is a value for background-image, so it stacks like one.
  2. 2. The angle comes first: 135deg runs from the top left corner downwards.
  3. 3. repeating-linear-gradient() with hard stops gives you stripes with no image and no request.

index.html

<div class="panel flat">A flat colour</div>
<div class="panel fade">A two-stop gradient</div>
<div class="panel stripe">Repeating stripes</div>

style.css

.panel {
  padding: 16px;
  margin-bottom: 8px;
  border-radius: 8px;
  color: #ffffff;
  font-size: 0.9rem;
}

.flat { background-color: #0b3a8f; }

.fade {
  background-image: linear-gradient(135deg, #0b3a8f 0%, #3055d8 100%);
}

/* Hard stops, so each colour ends exactly where the next begins. The text sits
   on the darker of the two, which is the one that has to clear contrast. */
.stripe {
  background-image: repeating-linear-gradient(
    45deg,
    #0b3a8f 0 12px,
    #17307a 12px 24px
  );
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 26

test('a flat background colour', /background-color:\s*#0b3a8f/.test(css), true);
test('a linear gradient with an angle', /linear-gradient\(\s*135deg/.test(css), true);
test('and two explicit stops', /#0b3a8f 0%[\s\S]*#3055d8 100%/.test(css), true);
test('the stripes repeat', /repeating-linear-gradient/.test(css), true);
test('with hard stops rather than a fade', /#0b3a8f 0 12px/.test(css), true);
test('gradients are background-image values, not colours', (css.match(/background-image:/g) || []).length, 2);
test('no image file is referenced at all', /url\(/.test(css), false);
test('white text on dark panels throughout', /color:\s*#ffffff/.test(css), true);

Output


      

A gradient is an image, not a colour, which explains two things people trip over: it belongs in background-image, and you can stack several with commas the way you would stack images. Hard stops — giving one colour an end position and the next the same start position — turn a gradient into flat bands, which is how you draw stripes, checks and rules with no file and no request. The contrast note is not decoration: text over a gradient has to clear AA against the lightest part it covers, so it is worth picking the range deliberately rather than eyeballing the middle.

Answers HTML CSS 26 in the original set.

27 A card with a radius and a shadow

easy

Assemble a small card: rounded corners, a two-layer shadow, and inner spacing that does not fight the border. Keep the heading order sane.

Stuck? Three hints, smallest first
  1. 1. border-radius takes one value for all corners, or four starting at the top left.
  2. 2. Two shadows read better than one: a tight dark layer for the edge, a wide soft one for the lift.
  3. 3. box-shadow is drawn outside the border box, so it needs no space reserved for it.

index.html

<article class="card">
  <h3 class="card__title">Lagos to Abuja</h3>
  <p class="card__meta">Departs 06:30 &middot; Bay B</p>
  <p class="card__price">&#8358;18,000</p>
  <a class="card__cta" href="#book">Reserve a seat</a>
</article>

style.css

.card {
  box-sizing: border-box;
  max-width: 240px;
  padding: 16px;
  border: 1px solid #e4e5e9;
  border-radius: 12px;
  background: #ffffff;
  /* Two layers: a tight one for the edge, a soft one for the lift. */
  box-shadow: 0 1px 2px rgb(23 23 26 / 8%), 0 10px 24px rgb(23 23 26 / 10%);
}

.card__title { margin: 0 0 4px; font-size: 1rem; }

.card__meta { margin: 0 0 10px; color: #5c5c66; font-size: 0.85rem; }

.card__price { margin: 0 0 12px; font-size: 1.25rem; font-weight: 700; }

.card__cta {
  display: inline-block;
  padding: 8px 14px;
  border-radius: 8px;
  background: #3055d8;
  color: #ffffff;
  text-decoration: none;
  font-size: 0.9rem;
}

.card__cta:hover { background: #2543b4; }

.card__cta:focus-visible {
  outline: 2px solid #17171a;
  outline-offset: 2px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 27

// Count the shadow layers by splitting on the commas that separate them.
const shadow = (/box-shadow:\s*([^;]+);/.exec(css) || [, ''])[1];
const layers = shadow.split(/,(?![^(]*\))/).length;

test('the corners are rounded', /border-radius:\s*12px/.test(css), true);
test('the shadow has two layers', layers, 2);
test('both are drawn with an alpha channel', (shadow.match(/rgb\(/g) || []).length, 2);
test('the card is border-box, so padding stays inside the width', /box-sizing:\s*border-box/.test(css), true);
test('the call to action is a link, since it navigates', /<a class="card__cta"/.test(html), true);
test('it has a hover state', /\.card__cta:hover/.test(css), true);
test('and a visible focus state, with the outline replaced not removed', /:focus-visible/.test(css) && /outline:\s*none/.test(css) === false, true);
test('the heading is an h3, following the h2 sections above it', hasTag(html, 'h3'), true);

Output


      

box-shadow is painted outside the border box and takes part in no layout at all, so it never pushes a neighbour or changes a width — which is why it is safe to animate and why two cards side by side can overlap shadows without moving. Two layers is the trick worth copying: a 1–2px shadow at low alpha defines the edge, and a wide one at low alpha suggests height. One big soft shadow on its own reads as blur. Since the action here navigates rather than acts, it is an <a>, not a <button>.

Answers HTML CSS 27 in the original set.

28 block, inline and inline-block

fair

Give three identical spans the same width and padding, and change only display. Watch which of them takes any notice.

Stuck? Three hints, smallest first
  1. 1. An inline box ignores width and height completely.
  2. 2. inline-block sits in the line like text but accepts a box: width, height and vertical padding all apply.
  3. 3. block starts on a new line and fills the available width unless you constrain it.

index.html

<p>Before them.
  <span class="chip a">inline</span>
  <span class="chip b">inline-block</span>
  <span class="chip c">block</span>
  After them.</p>

style.css

.chip {
  width: 130px;              /* the same request for all three */
  padding: 10px;
  background: #eef1fd;
  border: 1px solid #3055d8;
  border-radius: 6px;
  font-size: 0.85rem;
}

.a { display: inline; }        /* width and vertical padding do not apply */

.b { display: inline-block; }  /* stays in the line, but takes the box */

.c { display: block; }         /* breaks the line, takes the box */
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 28

// Which rule asks for a width, and which display value will honour it.
const ruleFor = (cls) => (new RegExp('\\.' + cls + '\\s*\\{([^}]*)\\}').exec(css) || [, ''])[1];

test('all three chips share one rule that asks for a width', /\.chip\s*\{[^}]*width:\s*130px/.test(css), true);
test('the first is inline', /display:\s*inline;/.test(ruleFor('a')), true);
test('the second is inline-block', /display:\s*inline-block/.test(ruleFor('b')), true);
test('the third is block', /display:\s*block/.test(ruleFor('c')), true);
test('none of them overrides the width, so only display differs', ['a', 'b', 'c'].some((c) => /width/.test(ruleFor(c))), false);
test('three spans, which are inline elements to begin with', countTag(html, 'span'), 3);
test('nothing is floated to fake the difference', /float:/.test(css), false);
test('and nothing is absolutely positioned either', /position:\s*absolute/.test(css), false);

Output


      

The assertions can only prove that one rule asks for a width and that the three display values differ — whether the width is honoured is a rendering question, so the preview is the evidence there. What you should see: the first chip ignores the 130px entirely and its top and bottom padding spills over the lines above and below without pushing them apart, because an inline box does not reserve vertical space. inline-block is the middle ground people actually want, and block takes the whole line.

Answers HTML CSS 28 in the original set.

29 Positioning, and what absolute is relative to

fair

Pin a badge to the top-right corner of a card. The badge should be positioned against the card, not against the page.

Stuck? Three hints, smallest first
  1. 1. position: absolute resolves against the nearest ancestor that is itself positioned.
  2. 2. So the card needs position: relative, even though it does not move.
  3. 3. With no positioned ancestor, the badge escapes to the page and lands in the corner of the viewport.

index.html

<div class="card">
  <span class="badge">3 left</span>
  <h2 class="card__title">Kano, 08:00</h2>
  <p class="card__body">Executive coach, one stop at Kaduna.</p>
</div>

style.css

.card {
  position: relative;    /* the anchor: without this the badge escapes */
  box-sizing: border-box;
  max-width: 260px;
  padding: 14px;
  border: 1px solid #c9cad1;
  border-radius: 10px;
  background: #ffffff;
}

.badge {
  position: absolute;
  top: -10px;
  right: 10px;
  padding: 3px 9px;
  border-radius: 999px;
  background: #a3341f;
  color: #ffffff;
  font-size: 0.75rem;
}

.card__title { margin: 0 0 4px; font-size: 1rem; }
.card__body { margin: 0; color: #5c5c66; font-size: 0.85rem; }
Rendered result. Shown, not machine-checked: there is no browser in the build.
7 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 29

const cardRule = (/\.card\s*\{([^}]*)\}/.exec(css) || [, ''])[1];
const badgeRule = (/\.badge\s*\{([^}]*)\}/.exec(css) || [, ''])[1];

test('the badge is taken out of the flow', /position:\s*absolute/.test(badgeRule), true);
test('and offset from two edges', /top:/.test(badgeRule) && /right:/.test(badgeRule), true);
test('the card is the anchor it resolves against', /position:\s*relative/.test(cardRule), true);
test('the card itself is not offset, so it does not move', /\btop:|\bleft:/.test(cardRule), false);
test('the badge sits inside the card in the markup', /<div class="card">[\s\S]*<span class="badge">/.test(html), true);
test('nothing relies on a negative margin instead', /margin[^;]*-\d/.test(css), false);
test('the card is border-box, so the padding stays inside 260px', /box-sizing:\s*border-box/.test(cardRule), true);

Output


      

position: relative with no offsets looks like a no-op and is the entire mechanism: it makes the card a containing block, which is what absolute measures from. Leave it off and the badge walks up the tree looking for a positioned ancestor, finds none, and resolves against the initial containing block — so it lands near the top right of the page instead. That is the single most common absolute-positioning bug, and the fix is one declaration on the parent you forgot.

Answers HTML CSS 29 in the original set.

30 Sticky headings, and z-index that does nothing

hard

Make a heading stick to the top of a scrolling panel. Then stack two overlapping boxes, and include one that asks for a z-index and is ignored.

Stuck? Three hints, smallest first
  1. 1. position: sticky needs a threshold — top: 0 — or it never sticks.
  2. 2. It sticks within its own scrolling ancestor, so that ancestor needs to be the thing that scrolls.
  3. 3. z-index applies only to a positioned element or a flex or grid child. On a plain static block it is ignored.

index.html

<div class="scroller">
  <h2 class="stuck">Departures</h2>
  <p>06:30 Abuja</p>
  <p>08:00 Kano</p>
  <p>11:15 Enugu</p>
  <p>14:45 Ibadan</p>
  <p>17:00 Benin</p>
  <p>19:30 Warri</p>
</div>

<div class="stack">
  <div class="layer under">z-index: 1</div>
  <div class="layer over">z-index: 2</div>
  <div class="layer inert">z-index: 5, and ignored</div>
</div>

style.css

.scroller {
  height: 130px;
  overflow-y: auto;        /* this is the scrolling ancestor sticky works within */
  border: 1px solid #c9cad1;
  border-radius: 8px;
  padding: 0 10px 10px;
}

.stuck {
  position: sticky;
  top: 0;                  /* the threshold; without it, nothing sticks */
  margin: 0 -10px 8px;
  padding: 8px 10px;
  background: #17171a;
  color: #ffffff;
  font-size: 0.9rem;
}

.scroller p { margin: 0 0 6px; font-size: 0.85rem; }

.stack {
  position: relative;
  height: 90px;
  margin-top: 12px;
}

.layer {
  padding: 8px 10px;
  border-radius: 6px;
  font-size: 0.8rem;
  color: #ffffff;
}

.under {
  position: absolute;
  top: 0; left: 0;
  z-index: 1;
  width: 150px;
  background: #0b3a8f;
}

.over {
  position: absolute;
  top: 22px; left: 40px;
  z-index: 2;
  width: 150px;
  background: #a3341f;
}

/* No position, so the z-index below is thrown away. Left in on purpose. */
.inert {
  z-index: 5;
  width: 200px;
  margin-top: 56px;
  background: #17734b;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 30

const inertRule = (/\.inert\s*\{([^}]*)\}/.exec(css) || [, ''])[1];

test('the heading is sticky', /position:\s*sticky/.test(css), true);
test('and has a threshold to stick at', /top:\s*0/.test(css), true);
test('its ancestor is the thing that scrolls', /overflow-y:\s*auto/.test(css), true);
test('the ancestor has a height, or there is nothing to scroll', /height:\s*130px/.test(css), true);
test('the two stacked boxes are positioned, so z-index applies', (css.match(/position:\s*absolute/g) || []).length, 2);
test('the third asks for a z-index of 5', /z-index:\s*5/.test(inertRule), true);
test('but declares no position, so it is ignored', /position:/.test(inertRule), false);
test('overflow: hidden is avoided, since it would break sticky', /overflow[^;]*hidden/.test(css), false);

Output


      

Three ways to make sticky silently do nothing: forget the threshold, put it in a container that does not scroll, or give an ancestor overflow: hidden, which creates a scroll container with no scrollbar and pins the element to a box it can never leave. The last two checks are the other lesson: z-index: 5 on a static element is discarded, so the box painted last wins regardless. z-index only has meaning for a positioned element or a flex or grid child, which is why so many “my z-index is not working” bugs are really missing position: relative.

Answers HTML CSS 30 in the original set.

31 Pseudo-classes that pick and react

fair

Highlight every third row of a list starting from the first, style the last item differently, and give the links inside a hover and a keyboard focus state that are not the same rule.

Stuck? Three hints, smallest first
  1. 1. :nth-child(3n+1) matches items 1, 4, 7 — the offset is the starting item.
  2. 2. :focus-visible fires for keyboard focus but not for a mouse click, which is why it replaced :focus for rings.
  3. 3. :last-child is cleaner than adding a .last class you have to maintain.

index.html

<ul class="board">
  <li><a href="#r1">06:30 Abuja</a></li>
  <li><a href="#r2">08:00 Kano</a></li>
  <li><a href="#r3">11:15 Enugu</a></li>
  <li><a href="#r4">14:45 Ibadan</a></li>
  <li><a href="#r5">17:00 Benin</a></li>
  <li><a href="#r6">19:30 Warri</a></li>
  <li><a href="#r7">21:30 Jos</a></li>
</ul>

style.css

.board {
  list-style: none;
  margin: 0;
  padding: 0;
  max-width: 240px;
  border: 1px solid #c9cad1;
  border-radius: 8px;
  overflow: hidden;
}

.board li { border-bottom: 1px solid #e4e5e9; }

/* Items 1, 4 and 7. The +1 is where the run starts, not how many are skipped. */
.board li:nth-child(3n + 1) { background: #eef1fd; }

.board li:last-child { border-bottom: 0; font-weight: 600; }

.board a {
  display: block;
  padding: 8px 12px;
  color: #17171a;
  text-decoration: none;
  font-size: 0.88rem;
}

.board a:hover { background: #3055d8; color: #ffffff; }

/* Not the same rule: a keyboard user gets a ring, a mouse user does not. */
.board a:focus-visible {
  outline: 2px solid #3055d8;
  outline-offset: -2px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 31

// Work out which items 3n + 1 selects, for the seven that are there.
const count = countTag(html, 'li');
const picked = Array.from({ length: count }, (_, i) => i + 1).filter((i) => i % 3 === 1);

test('seven items', count, 7);
test('3n + 1 picks the first, fourth and seventh', picked, [1, 4, 7]);
test('that selector is the one in the stylesheet', /nth-child\(3n \+ 1\)/.test(css), true);
test('the last item is styled without a helper class', /:last-child/.test(css), true);
test('and no such class exists in the markup', /class="last"/.test(html), false);
test('hover and keyboard focus are separate rules', /a:hover/.test(css) && /a:focus-visible/.test(css), true);
test('the outline is offset inwards so the rounded corner does not clip it', /outline-offset:\s*-2px/.test(css), true);
test('the whole row is the target, not just the text', /\.board a\s*\{[^}]*display:\s*block/.test(css), true);

Output


      

3n + 1 reads as “every third, starting at the first”, and the second check spells out which items that is — the offset trips people up because 3n alone starts at 3. Two habits in the rest: :last-child instead of a class means nothing to update when a row is added, and display: block on the link makes the entire row clickable rather than the six characters of text. Keeping :hover and :focus-visible as separate rules is deliberate: a mouse user does not want a ring, a keyboard user cannot work without one.

Answers HTML CSS 31 in the original set.

32 Generated content, before and after

fair

Add a decorative quotation mark before a pull quote and a “required” marker after a label, using pseudo-elements. Keep both out of the accessibility tree.

Stuck? Three hints, smallest first
  1. 1. No content, no pseudo-element. Even an empty string counts: content: "".
  2. 2. They attach inside the element, at the start and the end of its content.
  3. 3. They do not work on replaced elements: <img>, <input>, <br> and friends have no content box to insert into.

index.html

<blockquote class="pull">
  <p>The 06:30 leaves at 06:30. That is the whole reputation.</p>
</blockquote>

<p class="field">
  <label class="req" for="phone">Phone</label>
  <input id="phone" name="phone" type="tel" required>
</p>

style.css

.pull {
  position: relative;
  margin: 0 0 14px;
  padding: 4px 0 4px 34px;
  max-width: 300px;
  font-size: 0.95rem;
}

/* Decorative, and announced to nobody: alt text on generated content is what
   aria-hidden on the parent would be, so keep it purely visual. */
.pull::before {
  content: '\201C';        /* a left double quotation mark */
  position: absolute;
  left: 0;
  top: -4px;
  font-size: 2.4rem;
  line-height: 1;
  color: #3055d8;
}

.pull p { margin: 0; }

.field { display: grid; gap: 4px; max-width: 240px; }

.req { font-weight: 600; font-size: 0.9rem; }

/* The marker is a hint, not the requirement. The input carries the required
   attribute, so the real state is in the markup and this is only the cue. */
.req::after {
  content: ' (required)';
  color: #a3341f;
  font-weight: 400;
}

.field input {
  padding: 8px 10px;
  border: 1px solid #8e8e98;
  border-radius: 6px;
  font: inherit;
}

.field input:focus-visible {
  outline: 2px solid #3055d8;
  outline-offset: 1px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
8 checks that prove this answer

Assertions, run in the browser. The markup and styles above are handed to them as html and css, so press Run and each check prints ok. The build fails if a single one fails.

Checks for exercise 32

test('a ::before draws the quotation mark', /\.pull::before/.test(css), true);
test('and an ::after adds the required marker', /\.req::after/.test(css), true);
test('both declare content, without which they do not exist', (css.match(/content:/g) || []).length, 2);
test('the quote mark is a character escape, not typed inline', /content:\s*'\\201C'/.test(css), true);
test('the pseudo-element is attached to the label, not the input', /\.req::after/.test(css) && /input::after/.test(css) === false, true);
test('the real requirement lives on the field itself', /\brequired\b/.test(html), true);
test('the label is still joined to the input by for and id', /<label class="req" for="phone"/.test(html) && /id="phone"/.test(html), true);
test('a semantic blockquote holds the quote', hasTag(html, 'blockquote'), true);

Output


      

Two things worth carrying away. content is not optional — a ::before without it generates nothing at all, which is the most common reason a rule appears to be ignored. And a pseudo-element needs a content box to sit inside, so input::after generates nothing whatever you write: the marker has to go on the label, which is why the fifth check pins that down. Text you put in content is read out by some screen readers and not others, so it should never be the only place a fact lives: here the required attribute is the truth and the marker is only the cue.

Answers HTML CSS 32 in the original set.

The exercise list follows w3resource's HTML and CSS exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.