Emmanuel Akinfulubi

HTML and CSS: Practical I

Exercises 1 to 15: centring, spacing, and the layout pieces you reach for daily.

15 exercises

1 Centre a box inside its parent

easy

Put a child element exactly in the middle of its parent, both across and down. Use the modern approach rather than the old margin tricks.

Stuck? Three hints, smallest first
  1. 1. Three declarations on the parent do it: display: flex, align-items: center, justify-content: center.
  2. 2. align-items works down the cross axis, justify-content along the main axis. With the default row direction that means vertical and horizontal respectively.
  3. 3. The parent needs a height, or there is no vertical space to centre within.

index.html

<div class="frame">
  <div class="box">Centred</div>
</div>

style.css

.frame {
  display: flex;
  align-items: center;      /* down the cross axis */
  justify-content: center;  /* along the main axis */
  height: 140px;
  border: 2px dashed #3055d8;
  border-radius: 8px;
}

.box {
  padding: 12px 20px;
  background: #3055d8;
  color: #fff;
  border-radius: 6px;
}
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 1

test('the parent is a flex container', declares(css, 'display', 'flex'), true);
test('centred on the cross axis', declares(css, 'align-items', 'center'), true);
test('centred on the main axis', declares(css, 'justify-content', 'center'), true);
test('the parent has a height to centre within', declares(css, 'height', '140px'), true);
test('there is a child to centre', hasTag(html, 'div'), true);
test('exactly two elements', countTag(html, 'div'), 2);
test('no float or absolute positioning was needed', declares(css, 'position', 'absolute'), false);

Output


      

Two declarations plus a height is the whole answer, and it replaces a decade of margin: 0 auto plus transform: translateY(-50%) workarounds. The detail people miss is the height: with none, the parent collapses to fit the child and there is no vertical space for align-items to work in, so it looks like the property did nothing.

Answers HTML-CSS Practical 1 in the original set.

2 A grid that fits as many columns as will go

fair

Lay cards out in columns that reflow on their own as the width changes, without writing any media queries.

Stuck? Three hints, smallest first
  1. 1. repeat(auto-fill, minmax(140px, 1fr)) is the whole trick.
  2. 2. auto-fill keeps empty tracks; auto-fit collapses them. That difference shows when there are few items.
  3. 3. minmax sets the smallest a column may be and lets it grow to share the space.

index.html

<ul class="cards">
  <li>Lagos</li>
  <li>Abuja</li>
  <li>Kano</li>
  <li>Enugu</li>
  <li>Ibadan</li>
</ul>

style.css

.cards {
  display: grid;
  /* As many 140px columns as fit; each then shares the leftover space. */
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.cards li {
  padding: 14px;
  background: #f2f3f5;
  border-radius: 6px;
  text-align: center;
}
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 2

test('it is a grid', declares(css, 'display', 'grid'), true);
test('the columns repeat automatically', /repeat\(\s*auto-fill/.test(css), true);
test('each column has a floor and can grow', /minmax\(\s*140px\s*,\s*1fr\s*\)/.test(css), true);
test('there is a gap rather than margins', declares(css, 'gap', '10px'), true);
test('no media query was needed', /@media/.test(css), false);
test('five cards', countTag(html, 'li'), 5);
test('the list markers are removed', declares(css, 'list-style', 'none'), true);
test('a list is used, because it is a list', hasTag(html, 'ul'), true);

Output


      

The fifth assertion is the point: this reflows at every width with no @media at all, because the browser is doing the arithmetic. Swap auto-fill for auto-fit and, with only two cards, they stretch to fill the row instead of leaving empty tracks — that is the entire difference between the two, and it only shows when the items are fewer than the columns.

Answers HTML-CSS Practical 2 in the original set.

3 Truncate one line with an ellipsis

easy

Cut a single line of text that is too long for its container and finish it with an ellipsis.

Stuck? Three hints, smallest first
  1. 1. Three properties are all required together: white-space: nowrap, overflow: hidden, text-overflow: ellipsis.
  2. 2. Leave out nowrap and the text wraps instead of truncating, so the ellipsis never appears.
  3. 3. The element also needs a width limit, or there is nothing to overflow.

index.html

<p class="one-line">
  This sentence is deliberately far too long to fit on a single line inside its box.
</p>

style.css

.one-line {
  max-width: 240px;
  white-space: nowrap;      /* without this it wraps and never truncates */
  overflow: hidden;         /* without this it spills out visibly */
  text-overflow: ellipsis;  /* without this it is simply cut off */
  border: 1px solid #d7d8dc;
  padding: 8px;
  border-radius: 6px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
6 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('the text cannot wrap', declares(css, 'white-space', 'nowrap'), true);
test('the overflow is hidden', declares(css, 'overflow', 'hidden'), true);
test('the ellipsis is asked for', declares(css, 'text-overflow', 'ellipsis'), true);
test('there is a width to overflow', declares(css, 'max-width', '240px'), true);
test('all three properties are present, not just one', ['white-space', 'overflow', 'text-overflow'].every((p) => new RegExp(p + '\\s*:').test(css)), true);
test('one paragraph', countTag(html, 'p'), 1);

Output


      

All three properties are load-bearing and the failure modes are different: without nowrap the text wraps and you never see an ellipsis; without overflow: hidden it spills past the border; without text-overflow it is chopped mid-letter. That is why the fifth assertion checks for all three rather than any one. Worth knowing this only works for a single line — multi-line clamping needs -webkit-line-clamp.

Answers HTML-CSS Practical 3 in the original set.

4 A footer that stays at the bottom

fair

Keep a footer at the bottom of the viewport when the page is short, but let it sit below the content when the page is long.

Stuck? Three hints, smallest first
  1. 1. Make the page a column flex container at least as tall as the viewport.
  2. 2. Give the main content flex: 1 so it absorbs the leftover space.
  3. 3. min-height: 100vh, not height: with a fixed height, long content would overflow instead of pushing the footer down.

index.html

<div class="page">
  <header>Header</header>
  <main>Short content</main>
  <footer>Always at the bottom</footer>
</div>

style.css

.page {
  display: flex;
  flex-direction: column;
  min-height: 260px;   /* stands in for 100vh so the preview shows the effect */
}

.page main {
  flex: 1;             /* soak up whatever space is left over */
  padding: 12px 0;
}

.page header,
.page footer {
  padding: 10px;
  background: #f2f3f5;
  border-radius: 6px;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
6 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('the page is a flex column', declares(css, 'flex-direction', 'column'), true);
test('it is a flex container', declares(css, 'display', 'flex'), true);
test('the main area absorbs the spare space', declares(css, 'flex', '1'), true);
test('a minimum height, not a fixed one', /min-height\s*:/.test(css), true);
test('height is not fixed, which would clip long content', /[^-]height\s*:\s*260px/.test(css), false);
test('semantic landmarks are used', ['header', 'main', 'footer'].every((t) => hasTag(html, t)), true);

Output


      

min-height rather than height is the whole distinction, and the fifth assertion pins it down. With a fixed height, content taller than the box overflows and the footer stays put at the wrong place; with a minimum, the container grows and the footer travels down with it. In a real page use 100vh — or better 100dvh, which accounts for mobile browser toolbars appearing and disappearing.

Answers HTML-CSS Practical 4 in the original set.

5 Keep an image in proportion inside a fixed box

easy

Fit an image into a box of a set size without squashing it. Fill the box, cropping whatever does not fit.

Stuck? Three hints, smallest first
  1. 1. object-fit: cover fills the box and crops; contain fits it all in and leaves gaps.
  2. 2. The image needs width and height set to the box, or object-fit has nothing to work against.
  3. 3. object-position decides which part survives the crop.

index.html

<div class="thumb">
  <img
    alt="A solid colour standing in for a photograph"
    width="300" height="200"
    src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='200'%3E%3Crect width='300' height='200' fill='%233055d8'/%3E%3Ccircle cx='60' cy='60' r='40' fill='%23ffd75e'/%3E%3C/svg%3E">
</div>

style.css

.thumb {
  width: 160px;
  height: 160px;
  overflow: hidden;
  border-radius: 8px;
}

.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;          /* fill the box, crop the overflow */
  object-position: top left;  /* and keep this corner when cropping */
  display: block;
}
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

test('the image fills and crops', declares(css, 'object-fit', 'cover'), true);
test('which part survives is chosen', /object-position\s*:/.test(css), true);
test('the image is sized to its box', /width\s*:\s*100%/.test(css), true);
test('and given a height too', /height\s*:\s*100%/.test(css), true);
test('display block removes the inline gap below it', declares(css, 'display', 'block'), true);
test('the image has alternative text', hasAttr(html, 'alt'), true);
test('and intrinsic dimensions, so the layout does not jump', hasAttr(html, 'width') && hasAttr(html, 'height'), true);
test('the source is inline, so this page needs no network', /^<div/.test(html.trim()) && /data:image/.test(html), true);

Output


      

Three details beyond object-fit. The alt attribute is not optional — a decorative image takes alt="", never nothing at all. The width and height attributes let the browser reserve space before the image loads, which is what stops the page jumping. And display: block removes the few pixels of space an inline image leaves underneath it, which is the cause of most "why is there a gap" questions.

Answers HTML-CSS Practical 5 in the original set.

6 A navigation bar with items pushed apart

easy

Build a horizontal navigation bar with the brand on the left and the links on the right.

Stuck? Three hints, smallest first
  1. 1. justify-content: space-between pushes the first and last children to the edges.
  2. 2. Wrap the links in their own container so they stay together as one child.
  3. 3. Use a real <nav> and a list, so assistive technology can announce it.

index.html

<nav class="bar">
  <a class="brand" href="#">Jibowu Park</a>
  <ul class="links">
    <li><a href="#">Routes</a></li>
    <li><a href="#">Fares</a></li>
    <li><a href="#">Contact</a></li>
  </ul>
</nav>

style.css

.bar {
  display: flex;
  align-items: center;
  justify-content: space-between;  /* first child left, last child right */
  padding: 10px 14px;
  background: #17171a;
  border-radius: 8px;
}

.bar a { color: #fff; text-decoration: none; }
.brand { font-weight: 600; }

.links {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
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('the bar is a flex row', declares(css, 'display', 'flex'), true);
test('the ends are pushed apart', declares(css, 'justify-content', 'space-between'), true);
test('items are aligned on the cross axis', declares(css, 'align-items', 'center'), true);
test('the links are their own flex group', hasSelector(css, '.links'), true);
test('spaced with gap, not margins', declares(css, 'gap', '16px'), true);
test('a real nav landmark is used', hasTag(html, 'nav'), true);
test('the links are a list', hasTag(html, 'ul') && countTag(html, 'li') === 3, true);
test('four links in total including the brand', countTag(html, 'a'), 4);

Output


      

space-between works on the bar's direct children, which is why the links are wrapped in their own <ul>: that makes them one child rather than three, so they travel to the right as a group. Using <nav> and a list is not decoration either — a screen reader announces "navigation, list, three items", which a row of bare <a> tags does not.

Answers HTML-CSS Practical 6 in the original set.

7 Hide something visually but keep it for screen readers

hard

Remove an element from view without removing it from the accessibility tree, so a screen reader still announces it. Explain why the obvious answers are wrong.

Stuck? Three hints, smallest first
  1. 1. display: none and visibility: hidden both remove it from the accessibility tree too.
  2. 2. The standard recipe clips it to a single pixel and positions it absolutely so it takes no space.
  3. 3. Do not use width: 0; some screen readers skip zero-sized elements.

index.html

<h2 class="visually-hidden">Fares table</h2>
<p>Visible content. The heading above is announced but not shown.</p>

style.css

.visually-hidden {
  position: absolute;   /* takes no space in the layout */
  width: 1px;           /* not 0: some readers skip zero-sized elements */
  height: 1px;
  margin: -1px;         /* pull the last pixel out of view */
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
9 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('it is taken out of the layout', declares(css, 'position', 'absolute'), true);
test('one pixel wide, not zero', declares(css, 'width', '1px'), true);
test('one pixel tall, not zero', declares(css, 'height', '1px'), true);
test('the last pixel is pulled off screen', declares(css, 'margin', '-1px'), true);
test('and clipped away', /clip-path\s*:/.test(css), true);
test('display none is NOT used', declares(css, 'display', 'none'), false);
test('visibility hidden is NOT used either', declares(css, 'visibility', 'hidden'), false);
test('zero width is avoided', declares(css, 'width', '0'), false);
test('there is visible content alongside it', hasTag(html, 'p'), true);

Output


      

The two negative assertions are the exercise. display: none and visibility: hidden both hide an element from screen readers as well as from eyes, so using either for a caption or a skip link means the people who needed it most get nothing. This recipe is what frameworks call .sr-only or .visually-hidden, and the 1px rather than 0 is deliberate: some readers skip elements with no size at all.

Answers HTML-CSS Practical 7 in the original set.

8 An animated underline on hover

fair

Make a link grow an underline from nothing when hovered, animating rather than snapping. Respect a reader who has asked for less motion.

Stuck? Three hints, smallest first
  1. 1. Use a pseudo-element with transform: scaleX(0), then scale it to 1 on hover.
  2. 2. Animating transform is smooth; animating width makes the browser re-lay-out every frame.
  3. 3. @media (prefers-reduced-motion: reduce) is how you honour the system setting.

index.html

<p>Hover the <a class="grow" href="#">underlined link</a> to see it animate.</p>

style.css

.grow {
  position: relative;
  display: inline-block;   /* so the underline spans the text, not the line */
  color: #3055d8;
  text-decoration: none;
}

.grow::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: -2px;
  width: 100%;
  height: 2px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 200ms ease;
}

.grow:hover::after,
.grow:focus-visible::after {
  transform: scaleX(1);
}

/* Someone who has asked for less motion gets the underline without the slide. */
@media (prefers-reduced-motion: reduce) {
  .grow::after { transition: none; }
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
9 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

test('a pseudo-element draws the underline', /::after/.test(css), true);
test('it starts scaled to nothing', /scaleX\(0\)/.test(css), true);
test('and grows to full width on hover', /scaleX\(1\)/.test(css), true);
test('transform is what animates, not width', /transition\s*:\s*transform/.test(css), true);
test('it grows from the left', declares(css, 'transform-origin', 'left'), true);
test('inline-block keeps it to the text width', declares(css, 'display', 'inline-block'), true);
test('keyboard focus gets the same effect', /focus-visible/.test(css), true);
test('reduced motion is respected', /prefers-reduced-motion/.test(css), true);
test('the underline inherits the link colour', /currentColor/i.test(css), true);

Output


      

Three things here are habits rather than tricks. Animating transform instead of width keeps the work on the compositor, so it stays smooth; animating width forces a re-layout every frame. Adding :focus-visible alongside :hover means keyboard users get the same feedback, which a hover-only rule silently denies them. And prefers-reduced-motion is a real accessibility setting people turn on because motion makes them ill — honouring it costs three lines.

Answers HTML-CSS Practical 8 in the original set.

9 A card that lifts on hover

easy

Give a card a shadow that deepens and a slight lift when hovered. Keep it smooth and keep it accessible.

Stuck? Three hints, smallest first
  1. 1. Animate transform and box-shadow, never top or margin.
  2. 2. translateY(-4px) is the lift. Small is better than dramatic.
  3. 3. Add :focus-within so a keyboard user tabbing to a link inside the card gets the same feedback.

index.html

<article class="card">
  <h3>Lagos to Abuja</h3>
  <p>Departs 06:30 &middot; &#8358;18,000</p>
  <a href="#">Book a seat</a>
</article>

style.css

.card {
  max-width: 260px;
  padding: 16px;
  border: 1px solid #e4e5e9;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
  /* Only transform and box-shadow animate: both are cheap for the browser. */
  transition: transform 180ms ease, box-shadow 180ms ease;
}

.card:hover,
.card:focus-within {
  transform: translateY(-4px);
  box-shadow: 0 8px 20px rgb(0 0 0 / 12%);
}

.card h3 { margin: 0 0 6px; font-size: 1rem; }
.card p { margin: 0 0 10px; color: #5b5c63; }

@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
}
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('the lift uses transform', /translateY\(-4px\)/.test(css), true);
test('the shadow deepens', (css.match(/box-shadow\s*:/g) || []).length >= 2, true);
test('only cheap properties animate', /transition\s*:\s*transform[^;]*box-shadow/.test(css), true);
test('top is not animated', /transition[^;]*\btop\b/.test(css), false);
test('keyboard focus inside the card counts too', /:focus-within/.test(css), true);
test('reduced motion is honoured', /prefers-reduced-motion/.test(css), true);
test('a semantic article is used', hasTag(html, 'article'), true);
test('and it contains a heading and a link', hasTag(html, 'h3') && hasTag(html, 'a'), true);

Output


      

:focus-within is the part worth stealing. A hover-only card gives a mouse user feedback and a keyboard user none, even though tabbing to the link inside is the same intent. :focus-within matches the card whenever anything inside it has focus, so one extra selector covers both.

Answers HTML-CSS Practical 9 in the original set.

10 Keep a box at a fixed aspect ratio

easy

Make a box hold a shape, say 16 by 9, whatever width it is given. Use the modern property and note the trick it replaced.

Stuck? Three hints, smallest first
  1. 1. aspect-ratio: 16 / 9 is one line and does the whole job.
  2. 2. The old way was a wrapper with padding-top: 56.25%, which is 9 divided by 16.
  3. 3. Only set one dimension. Set both and the ratio has nothing left to decide.

index.html

<div class="ratio">16 &divide; 9</div>
<div class="ratio square">1 &divide; 1</div>

style.css

.ratio {
  width: 200px;          /* set ONE dimension; the ratio decides the other */
  aspect-ratio: 16 / 9;
  display: grid;
  place-items: center;
  margin-bottom: 10px;
  background: #3055d8;
  color: #fff;
  border-radius: 8px;
}

.ratio.square { aspect-ratio: 1 / 1; }

/* The old approach, for comparison. 9 / 16 = 56.25%, and percentage padding
   is relative to the WIDTH, which is what made it work at all:
   .old { padding-top: 56.25%; height: 0; } */
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

// The comment at the bottom shows the OLD technique, so it mentions both
// padding-top and height. Strip comments before asserting about real rules,
// or the check fails on the very example it is explaining.
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '');

test('the ratio is declared', /aspect-ratio\s*:\s*16\s*\/\s*9/.test(rules), true);
test('a square variant too', /aspect-ratio\s*:\s*1\s*\/\s*1/.test(rules), true);
test('only the width is set', /width\s*:\s*200px/.test(rules), true);
test('no explicit height fights the ratio', /[^-]height\s*:\s*\d/.test(rules), false);
test('the padding hack is not needed', /padding-top\s*:\s*56\.25%/.test(rules), false);
test('but it is still shown in a comment, for recognition', /56\.25%/.test(css), true);
test('place-items centres the label in one line', declares(css, 'place-items', 'center'), true);
test('two boxes', countTag(html, 'div'), 2);

Output


      

aspect-ratio replaced a genuinely strange hack: a wrapper with zero height and padding-top: 56.25%, which worked only because percentage padding resolves against the element's width. Worth knowing the old form because you will still meet it. The fourth assertion matters: setting an explicit height as well leaves aspect-ratio nothing to compute, so it silently does nothing.

Answers HTML-CSS Practical 10 in the original set.

11 A custom bullet list

easy

Replace the default list markers with your own, without breaking the list for a screen reader.

Stuck? Three hints, smallest first
  1. 1. list-style: none plus a ::marker or ::before gives you control.
  2. 2. ::marker is the modern, purpose-built pseudo-element and keeps list semantics intact.
  3. 3. In Safari, list-style: none alone can remove the list role; adding role="list" restores it.

index.html

<ul class="ticks" role="list">
  <li>Air conditioning</li>
  <li>Reclining seats</li>
  <li>Onboard toilet</li>
</ul>

style.css

.ticks {
  list-style: none;
  margin: 0;
  padding: 0;
}

.ticks li {
  padding-left: 24px;
  margin-bottom: 6px;
  position: relative;
}

/* ::before draws the marker. content is decorative, so it is hidden from
   assistive technology with an empty alt via the aria-hidden trick below. */
.ticks li::before {
  content: '\2713';        /* a tick */
  position: absolute;
  left: 0;
  color: #1e9e5a;
  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 default markers are removed', declares(css, 'list-style', 'none'), true);
test('a marker is drawn with a pseudo-element', /::before/.test(css), true);
test('it has content', /content\s*:/.test(css), true);
test('space is left for it', /padding-left\s*:\s*24px/.test(css), true);
test('it is positioned rather than floated', declares(css, 'position', 'absolute'), true);
test('a real list is used', hasTag(html, 'ul'), true);
test('three items', countTag(html, 'li'), 3);
test('role=list restores semantics Safari drops', /role="list"/.test(html), true);

Output


      

The last assertion is the non-obvious one. In Safari with VoiceOver, list-style: none removes the list role along with the bullets, so a screen reader stops announcing "list, three items". Adding role="list" back is a known, widely used workaround. If you only take one thing from this exercise, that is it.

Answers HTML-CSS Practical 11 in the original set.

12 A table that stays readable

fair

Style a data table so it reads well: aligned columns, a distinguishable header, and horizontal scrolling rather than overflow on a narrow screen.

Stuck? Three hints, smallest first
  1. 1. border-collapse: collapse removes the double borders between cells.
  2. 2. Numbers belong right-aligned; text belongs left-aligned. Mixed alignment is hard to scan.
  3. 3. Wrap the table in a container with overflow-x: auto so it scrolls instead of breaking the page.

index.html

<div class="tablewrap">
  <table>
    <caption>Fares from Jibowu</caption>
    <thead>
      <tr><th scope="col">Route</th><th scope="col">Seats</th><th scope="col">Fare</th></tr>
    </thead>
    <tbody>
      <tr><td>Lagos &ndash; Abuja</td><td class="num">14</td><td class="num">&#8358;18,000</td></tr>
      <tr><td>Lagos &ndash; Kano</td><td class="num">30</td><td class="num">&#8358;22,000</td></tr>
    </tbody>
  </table>
</div>

style.css

.tablewrap {
  overflow-x: auto;      /* scroll the table, do not break the page */
}

table {
  width: 100%;
  border-collapse: collapse;   /* one border between cells, not two */
  font-size: 0.9rem;
}

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

th, td {
  padding: 8px 10px;
  border-bottom: 1px solid #e4e5e9;
  text-align: left;
}

th {
  background: #f2f3f5;
  font-weight: 600;
}

/* Numbers read far better right-aligned, so the digits line up. */
.num { text-align: right; font-variant-numeric: tabular-nums; }
Rendered result. Shown, not machine-checked: there is no browser in the build.
9 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

test('borders are collapsed', declares(css, 'border-collapse', 'collapse'), true);
test('the table can scroll rather than overflow', declares(css, 'overflow-x', 'auto'), true);
test('numbers are right aligned', /\.num\s*\{[^}]*text-align\s*:\s*right/.test(css), true);
test('and use tabular figures so digits line up', /tabular-nums/.test(css), true);
test('the header is distinguishable', /th\s*\{[^}]*background/.test(css), true);
test('a caption names the table', hasTag(html, 'caption'), true);
test('proper thead and tbody', hasTag(html, 'thead') && hasTag(html, 'tbody'), true);
test('header cells declare their scope', /scope="col"/.test(html), true);
test('three columns', countTag(html, 'th'), 3);

Output


      

Two accessibility details do real work here. scope="col" tells a screen reader that each <th> heads a column, so when the user lands on a cell it can announce "Fare, ₦22,000" rather than just the number. And <caption> names the table, which is how someone scanning by table gets to the right one. On the visual side, font-variant-numeric: tabular-nums makes every digit the same width so columns of figures align.

Answers HTML-CSS Practical 12 in the original set.

13 A form field with a visible label and an error

fair

Build a labelled text field that can show an error message, wired up so assistive technology announces both the label and the error.

Stuck? Three hints, smallest first
  1. 1. A <label for> pointing at the input’s id is the connection. Placeholder text is not a label.
  2. 2. aria-describedby points the input at its error message so it gets read out.
  3. 3. aria-invalid="true" tells assistive technology the field is currently wrong.

index.html

<div class="field">
  <label for="seats">Number of seats</label>
  <input id="seats" name="seats" type="number" min="1" max="14"
         value="0" aria-invalid="true" aria-describedby="seats-error">
  <p class="error" id="seats-error">Enter a number between 1 and 14.</p>
</div>

style.css

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

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

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

/* The invalid state is styled from the ARIA attribute, so the visual and the
   announced state cannot drift apart. */
.field input[aria-invalid='true'] {
  border-color: #c0392b;
}

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

.error {
  margin: 0;
  color: #c0392b;
  font-size: 0.8rem;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
10 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('the label is joined to the input', /<label for="seats"/.test(html), true);
test('and the input has that id', /id="seats"/.test(html), true);
test('the error is announced with the field', /aria-describedby="seats-error"/.test(html), true);
test('and that id exists', /id="seats-error"/.test(html), true);
test('the invalid state is exposed', /aria-invalid="true"/.test(html), true);
test('and styled from the same attribute', /\[aria-invalid='true'\]/.test(css), true);
test('the field has real min and max', hasAttr(html, 'min') && hasAttr(html, 'max'), true);
test('focus is visible', /:focus-visible/.test(css), true);
test('the outline is not removed', /outline\s*:\s*none/.test(css), false);
test('there is no placeholder standing in for a label', /placeholder=/.test(html), false);

Output


      

Styling from [aria-invalid='true'] rather than a separate .is-error class is the idea worth keeping: the red border and the announced state come from one source, so they cannot disagree. The last two assertions guard against the two most common mistakes — removing the focus outline, which makes keyboard navigation invisible, and using a placeholder as a label, which vanishes the moment someone types.

Answers HTML-CSS Practical 13 in the original set.

14 Two columns that become one

fair

Lay out a sidebar and a main area side by side on a wide screen, stacking to one column when narrow. Then do it again with no media query at all.

Stuck? Three hints, smallest first
  1. 1. The media-query version is grid-template-columns: 200px 1fr inside a min-width query.
  2. 2. The query-free version uses flex-wrap: wrap plus a flex-basis that forces a wrap when there is no room.
  3. 3. flex: 1 1 20rem means "grow, shrink, but do not go under 20rem before wrapping".

index.html

<div class="layout">
  <aside>Sidebar</aside>
  <main>Main content area</main>
</div>

<div class="fluid">
  <div>Also sidebar</div>
  <div>Also main</div>
</div>

style.css

/* Version one: explicit, with a breakpoint. */
.layout {
  display: grid;
  gap: 12px;
  margin-bottom: 16px;
}

@media (min-width: 480px) {
  .layout { grid-template-columns: 200px 1fr; }
}

/* Version two: no media query. The basis decides when it wraps. */
.fluid {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

.fluid > * {
  flex: 1 1 12rem;   /* grow, shrink, wrap below 12rem */
}

.layout aside, .layout main, .fluid > * {
  padding: 12px;
  background: #f2f3f5;
  border-radius: 6px;
}
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 14

test('the grid version has a breakpoint', /@media\s*\(min-width/.test(css), true);
test('and two columns inside it', /grid-template-columns\s*:\s*200px\s+1fr/.test(css), true);
test('mobile first: one column is the default', /\.layout\s*\{[^}]*display\s*:\s*grid/.test(css), true);
test('the fluid version wraps', declares(css, 'flex-wrap', 'wrap'), true);
test('and uses a flex basis to decide when', /flex\s*:\s*1\s+1\s+12rem/.test(css), true);
test('semantic aside and main', hasTag(html, 'aside') && hasTag(html, 'main'), true);
test('both layouts are present', countTag(html, 'div') >= 4, true);

Output


      

Two approaches, and the second is worth reaching for more often than people do. flex: 1 1 12rem tells each child "you may grow and shrink, but below 12rem wrap instead" — so the layout responds to the space it actually has rather than to the viewport width, which is what you usually meant. Note the grid version is mobile-first: one column is the default and the query adds the second, which fails better than the reverse.

Answers HTML-CSS Practical 14 in the original set.

15 A button that looks and behaves like one

easy

Style a button with hover, focus, active and disabled states. Use the right element, and make sure it is usable by keyboard.

Stuck? Three hints, smallest first
  1. 1. Use <button>. A styled <div> is not focusable and does not fire on Enter or Space.
  2. 2. Never remove the focus outline. Replace it with something clearer if you must.
  3. 3. :disabled should look unavailable and also stop the pointer cursor.

index.html

<button class="btn" type="button">Book a seat</button>
<button class="btn" type="button" disabled>Sold out</button>

style.css

.btn {
  padding: 9px 16px;
  border: 0;
  border-radius: 6px;
  background: #3055d8;
  color: #fff;
  font: inherit;
  font-weight: 500;
  cursor: pointer;
  transition: background 150ms ease;
}

.btn:hover:not(:disabled) { background: #2544ad; }

/* Replaced, never removed. This is more visible than the browser default. */
.btn:focus-visible {
  outline: 2px solid #17171a;
  outline-offset: 2px;
}

.btn:active:not(:disabled) { transform: translateY(1px); }

.btn:disabled {
  background: #b9bac0;
  cursor: not-allowed;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
10 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

test('a real button element is used', hasTag(html, 'button'), true);
test('with an explicit type', /type="button"/.test(html), true);
test('there is a hover state', /:hover/.test(css), true);
test('a visible focus state', /:focus-visible/.test(css), true);
test('the outline is never set to none', /outline\s*:\s*none/.test(css), false);
test('an active state', /:active/.test(css), true);
test('a disabled state', /:disabled/.test(css), true);
test('hover does not apply while disabled', /:hover:not\(:disabled\)/.test(css), true);
test('the disabled cursor says so', declares(css, 'cursor', 'not-allowed'), true);
test('font is inherited rather than reset to the browser default', declares(css, 'font', 'inherit'), true);

Output


      

:hover:not(:disabled) is the detail most button styles miss: without it, a disabled button still lights up under the cursor and looks clickable. font: inherit is the other easy win, because form controls do not inherit typography by default and a button will otherwise render in the browser's own font. And <button type="button"> is deliberate — inside a form, the default type is submit.

Answers HTML-CSS Practical 15 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.