Emmanuel Akinfulubi

HTML and CSS: Practical II

Exercises 16 to 30: flexbox and grid, overflow, and truncation.

15 exercises

16 Centred two ways

easy

Centre a box in its parent on both axes with properties on the parent. Then do it again with a single property on the child, and know when each is the better choice.

Stuck? Three hints, smallest first
  1. 1. On the parent: display: flex plus place-content: center.
  2. 2. On the child: margin: auto. Inside a flex container, auto margins absorb the free space on both axes.
  3. 3. The parent still needs a height either way, or there is no vertical space to share out.

index.html

<div class="frame parent-centred">
  <div class="box">Centred by the parent</div>
</div>

<div class="frame child-centred">
  <div class="box auto">Centred by margin: auto</div>
</div>

style.css

.frame {
  display: flex;
  height: 110px;
  margin-bottom: 10px;
  border: 2px dashed #3055d8;
  border-radius: 8px;
}

/* One shorthand for both axes: place-content is align-content plus
   justify-content. */
.parent-centred {
  place-content: center;
}

/* Nothing on the parent but display: flex. An auto margin in a flex container
   eats all the free space in that direction, so all four centre the child. */
.box.auto {
  margin: auto;
}

.box {
  padding: 10px 16px;
  background: #3055d8;
  color: #ffffff;
  border-radius: 6px;
  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 16

test('both frames are flex containers', /\.frame\s*\{[^}]*display:\s*flex/.test(css), true);
test('the first centres from the parent', /place-content:\s*center/.test(css), true);
test('the second centres from the child', /\.box\.auto\s*\{[^}]*margin:\s*auto/.test(css), true);
test('the frames have a height, or there is nothing to centre within', /height:\s*110px/.test(css), true);
test('no absolute positioning was needed', /position:\s*absolute/.test(css), false);
test('and no transform translate trick', /translate/.test(css), false);
test('two frames, one child each', countTag(html, 'div'), 4);

Output


      

place-content: center is align-content and justify-content in one, so it covers both axes in a single declaration. The margin: auto version is worth knowing for a different reason: in a flex container an auto margin absorbs all the free space on that side, which is why it centres when applied on all four sides and why it pushes an item to the far end when applied on one. That second behaviour is what the navbar exercise later depends on. Either way the parent needs a height — without one it shrinks to fit the child and nothing has room to move.

Answers CSS Practical 16 in the original set.

17 Direction, wrapping and gap

easy

Lay items in a row that wraps onto more lines when it runs out of room, then stack the same items in a column. Space them with gap rather than margins.

Stuck? Three hints, smallest first
  1. 1. flex-wrap: wrap is opt-in. The default is nowrap, which shrinks items instead.
  2. 2. flex-direction: column turns the main axis vertical, and wrap then needs a height to wrap against.
  3. 3. gap applies between items only, so there is no leftover margin at the edges to undo.

index.html

<ul class="row">
  <li>Abuja</li>
  <li>Kano</li>
  <li>Enugu</li>
  <li>Ibadan</li>
  <li>Benin</li>
  <li>Warri</li>
  <li>Jos</li>
</ul>

<ul class="col">
  <li>First</li>
  <li>Second</li>
  <li>Third</li>
</ul>

style.css

.row, .col {
  list-style: none;
  margin: 0 0 12px;
  padding: 0;
  display: flex;
  gap: 8px;                  /* between items only, never at the edges */
}

.row {
  flex-wrap: wrap;           /* opt-in: the default is nowrap */
}

.col {
  flex-direction: column;
  align-items: flex-start;   /* otherwise each item stretches full width */
}

.row li, .col li {
  padding: 7px 12px;
  background: #eef1fd;
  border: 1px solid #3055d8;
  border-radius: 999px;
  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 17

test('both lists are flex containers', (css.match(/display:\s*flex/g) || []).length, 1);
test('the row wraps', /flex-wrap:\s*wrap/.test(css), true);
test('the column runs down the page', /flex-direction:\s*column/.test(css), true);
test('spacing is gap, not margins on the items', /gap:\s*8px/.test(css), true);
test('and the items carry no margin of their own', /li\s*\{[^}]*margin/.test(css), false);
test('the column items do not stretch to full width', /align-items:\s*flex-start/.test(css), true);
test('seven items in the row, three in the column', countTag(html, 'li'), 10);

Output


      

Two defaults catch people out. flex-wrap is nowrap unless you say otherwise, so an overfull row squeezes its items down to nothing rather than moving them to a second line. And align-items defaults to stretch, so items in a column go full width unless you say flex-start — which is usually read as “my width is being ignored”. gap is strictly better than margins here: it only ever applies between items, so there is no trailing margin on the last one to strip off with :last-child.

Answers CSS Practical 17 in the original set.

18 What flex: 1 actually means

hard

Use the three flex properties separately, then write out what the common shorthands expand to. The difference between a basis of 0 and auto is the whole exercise.

Stuck? Three hints, smallest first
  1. 1. flex is flex-grow flex-shrink flex-basis, and flex: 1 means 1 1 0%.
  2. 2. flex: auto is 1 1 auto: it grows, but starts from its content width, so items with more text end up wider.
  3. 3. flex: 1 starts every item from zero, which is why the columns come out equal.

index.html

<div class="bar">
  <div class="one">flex: 1</div>
  <div class="one">flex: 1, but much more text inside it</div>
</div>

<div class="bar">
  <div class="auto">flex: auto</div>
  <div class="auto">flex: auto, but much more text inside it</div>
</div>

<div class="bar">
  <div class="fixed">grow 0, basis 90px</div>
  <div class="grows">grow 2, basis 0</div>
</div>

style.css

.bar {
  display: flex;
  gap: 8px;
  margin-bottom: 10px;
}

.bar > * {
  padding: 8px 10px;
  background: #eef1fd;
  border: 1px solid #3055d8;
  border-radius: 6px;
  font-size: 0.8rem;
}

/* Basis 0: content width is ignored, so both items come out equal. */
.one { flex: 1 1 0%; }

/* Basis auto: each starts from its content, then shares the surplus, so the
   wordier one stays wider. */
.auto { flex: 1 1 auto; }

.fixed { flex: 0 0 90px; }     /* never grows, never shrinks */
.grows { flex: 2 1 0%; }       /* takes twice the share of a grow: 1 sibling */
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 18

// Expand the shorthand the way the specification does.
const expand = (value) => {
  const v = value.trim();
  if (v === 'none') return ['0', '0', 'auto'];
  if (v === 'auto') return ['1', '1', 'auto'];
  if (v === 'initial') return ['0', '1', 'auto'];
  if (/^\d+$/.test(v)) return [v, '1', '0%'];
  return v.split(/\s+/);
};

test('flex: 1 expands to 1 1 0%', expand('1'), ['1', '1', '0%']);
test('flex: auto expands to 1 1 auto', expand('auto'), ['1', '1', 'auto']);
test('flex: none expands to 0 0 auto', expand('none'), ['0', '0', 'auto']);
test('the default, flex: initial, is 0 1 auto', expand('initial'), ['0', '1', 'auto']);
test('so 1 and auto differ only in the basis', expand('1')[2] === expand('auto')[2], false);
test('the stylesheet writes all three parts out in full', (css.match(/flex:\s*\d[^;]*;/g) || []).length, 4);
test('one item is fixed at 90px and cannot grow', /flex:\s*0 0 90px/.test(css), true);
test('another takes double the share', /flex:\s*2 1 0%/.test(css), true);

Output


      

flex: 1 and flex: auto look interchangeable and are not: both grow, both shrink, but the first starts every item from a basis of zero and the second starts from its content width. So flex: 1 gives you equal columns and flex: auto gives you columns proportional to how much text is in them. That single difference explains most “why is one column wider” reports. The other thing worth remembering is that flex-basis beats width when both are set, so a width on a flex item with a basis is simply ignored.

Answers CSS Practical 18 in the original set.

19 The two axes, and how they swap

fair

Align items along the main axis and across the cross axis, then flip the direction to column and predict which property now controls which direction.

Stuck? Three hints, smallest first
  1. 1. justify-content works along the main axis, align-items across the cross axis. Neither is tied to a screen direction.
  2. 2. With row the main axis is horizontal. With column it is vertical, and the two properties swap what they appear to do.
  3. 3. That is why remembering them as “horizontal” and “vertical” breaks the first time you change direction.

index.html

<div class="frame as-row">
  <div class="box">A</div>
  <div class="box">B</div>
</div>

<div class="frame as-col">
  <div class="box">A</div>
  <div class="box">B</div>
</div>

style.css

.frame {
  display: flex;
  height: 110px;
  margin-bottom: 10px;
  border: 2px dashed #3055d8;
  border-radius: 8px;
  justify-content: space-between;   /* along the main axis */
  align-items: center;              /* across the cross axis */
}

/* Main axis horizontal: space-between spreads left to right, centre is vertical. */
.as-row { flex-direction: row; }

/* Main axis vertical: the very same two declarations now spread top to bottom
   and centre horizontally. */
.as-col { flex-direction: column; }

.box {
  padding: 8px 14px;
  background: #3055d8;
  color: #ffffff;
  border-radius: 6px;
  font-size: 0.85rem;
}
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 19

// Which screen direction each property controls, given a direction.
const axes = (dir) =>
  dir.startsWith('row')
    ? { justifyContent: 'horizontal', alignItems: 'vertical' }
    : { justifyContent: 'vertical', alignItems: 'horizontal' };

test('in a row, justify-content spreads horizontally', axes('row').justifyContent, 'horizontal');
test('and align-items centres vertically', axes('row').alignItems, 'vertical');
test('in a column the pair swap over', axes('column'), { justifyContent: 'vertical', alignItems: 'horizontal' });
test('so neither property is tied to a screen direction', axes('row').justifyContent === axes('column').justifyContent, false);
test('one shared rule declares both alignments', /justify-content:\s*space-between/.test(css) && /align-items:\s*center/.test(css), true);
test('only the direction differs between the two frames', /\.as-row\s*\{\s*flex-direction:\s*row/.test(css), true);
test('and the second is a column', /\.as-col\s*\{\s*flex-direction:\s*column/.test(css), true);
test('two boxes in each frame', countTag(html, 'div'), 6);

Output


      

Both frames share one alignment rule and differ only in flex-direction, which is what makes the point visible: the same two declarations produce a horizontal spread in the first and a vertical one in the second. Learn them as main and cross rather than horizontal and vertical, or every switch to column feels like the properties broke. The same holds for row-reverse, where the main axis still runs horizontally but starts from the other end.

Answers CSS Practical 19 in the original set.

20 A navbar with one item pushed to the end

easy

Put a brand and three links on the left of a bar and a sign-in link hard against the right, without wrapping anything in an extra container.

Stuck? Three hints, smallest first
  1. 1. margin-left: auto on the item you want pushed. The auto margin eats all the free space.
  2. 2. This beats space-between when the split is not simply first and last.
  3. 3. Use a real <nav> with a list, and keep a visible focus style on the links.

index.html

<nav class="bar" aria-label="Main">
  <a class="brand" href="#home">Jibowu Park</a>
  <ul class="links">
    <li><a href="#routes">Routes</a></li>
    <li><a href="#fares">Fares</a></li>
    <li><a href="#help">Help</a></li>
    <li class="end"><a href="#signin">Sign in</a></li>
  </ul>
</nav>

style.css

.bar {
  display: flex;
  align-items: center;
  gap: 18px;
  padding: 10px 14px;
  background: #17171a;
  border-radius: 8px;
}

.links {
  display: flex;
  align-items: center;
  gap: 14px;
  flex: 1;              /* so the list fills the bar and has space to give away */
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Everything before this stays put; this one is pushed to the far end. */
.end {
  margin-left: auto;
}

.bar a {
  color: #ffffff;
  text-decoration: none;
  font-size: 0.88rem;
}

.brand { font-weight: 700; }

.bar a:hover { text-decoration: underline; }

.bar a:focus-visible {
  outline: 2px solid #ffd75e;
  outline-offset: 3px;
}
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

test('the bar is a flex row', /\.bar\s*\{[^}]*display:\s*flex/.test(css), true);
test('the last item is pushed by an auto margin', /\.end\s*\{[^}]*margin-left:\s*auto/.test(css), true);
test('space-between was not needed', /justify-content/.test(css), false);
test('the list itself takes the spare width', /\.links\s*\{[^}]*flex:\s*1/.test(css), true);
test('a real nav landmark, and it is named', /<nav class="bar" aria-label="Main">/.test(html), true);
test('the links are a list', hasTag(html, 'ul') && countTag(html, 'li') === 4, true);
test('five links including the brand', countTag(html, 'a'), 5);
test('focus is visible against the dark bar', /:focus-visible/.test(css) && /#ffd75e/.test(css), true);

Output


      

margin-left: auto is the tool for “push this one and everything after it to the end”, and unlike space-between it does not care where the item sits in the list — you can move the split by moving one declaration. Two details in the markup: aria-label on the <nav> matters once a page has more than one navigation landmark, otherwise a screen reader announces two identical “navigation” regions; and the yellow focus ring is chosen because the default dark ring would be invisible on a near-black bar.

Answers CSS Practical 20 in the original set.

21 Columns of equal height

fair

Put three cards of different content lengths side by side so all three are the same height, and pin each card’s action to the bottom regardless.

Stuck? Three hints, smallest first
  1. 1. Flex items stretch to the tallest by default, so equal height is what you already have unless you break it.
  2. 2. Make each card a column flex container, and give the middle part flex: 1 so it absorbs the slack.
  3. 3. margin-top: auto on the action does the same job from the other end.

index.html

<div class="cards">
  <article class="card">
    <h3>Abuja</h3>
    <p>Twelve hours, one stop.</p>
    <a class="cta" href="#a">Book</a>
  </article>
  <article class="card">
    <h3>Kano</h3>
    <p>Eighteen hours with two rest stops, arriving early the next morning at the
      central terminal.</p>
    <a class="cta" href="#k">Book</a>
  </article>
  <article class="card">
    <h3>Enugu</h3>
    <p>Nine hours, direct.</p>
    <a class="cta" href="#e">Book</a>
  </article>
</div>

style.css

.cards {
  display: flex;
  align-items: stretch;    /* the default, stated so it is deliberate */
  gap: 10px;
}

.card {
  display: flex;
  flex-direction: column;  /* so the parts inside can be pushed apart */
  flex: 1 1 0%;
  box-sizing: border-box;
  padding: 12px;
  border: 1px solid #c9cad1;
  border-radius: 8px;
  background: #f7f7f8;
  font-size: 0.82rem;
}

.card h3 { margin: 0 0 6px; font-size: 0.95rem; }

/* Absorbs whatever height is spare, which pushes the action down. */
.card p { margin: 0 0 10px; flex: 1; }

.cta {
  margin-top: auto;        /* belt and braces: sticks to the bottom edge */
  align-self: flex-start;
  padding: 6px 12px;
  border-radius: 6px;
  background: #3055d8;
  color: #ffffff;
  text-decoration: none;
}

.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 21

test('the row stretches its children', /align-items:\s*stretch/.test(css), true);
test('each card is itself a column', /\.card\s*\{[^}]*flex-direction:\s*column/.test(css), true);
test('the cards share the width equally, from a zero basis', /flex:\s*1 1 0%/.test(css), true);
test('the body text absorbs the spare height', /\.card p\s*\{[^}]*flex:\s*1/.test(css), true);
test('and the action is pinned to the bottom', /margin-top:\s*auto/.test(css), true);
test('no fixed height was needed to make them match', /[^-]height:\s*\d/.test(css), false);
test('three cards, three actions', countTag(html, 'article') === 3 && countTag(html, 'a') === 3, true);
test('the action keeps a visible focus ring', /\.cta:focus-visible/.test(css), true);

Output


      

Equal-height columns used to be a genuine puzzle and are now the default: align-items: stretch is what a flex container already does, so the work is not making the cards match but making their insides line up. That needs each card to be a column flex container, at which point flex: 1 on the text or margin-top: auto on the action pushes the button to the bottom of every card. Note the sixth check: no fixed height anywhere, so a longer description just makes all three taller.

Answers CSS Practical 21 in the original set.

22 A card grid with no breakpoints

fair

Lay cards out in as many columns as the space allows, with no media queries. Then say precisely how auto-fit differs from auto-fill.

Stuck? Three hints, smallest first
  1. 1. repeat(auto-fit, minmax(140px, 1fr)) is the whole declaration.
  2. 2. auto-fill keeps the empty tracks it created; auto-fit collapses them so the items stretch.
  3. 3. The difference is only visible when there are fewer items than tracks.

index.html

<ul class="grid">
  <li>Abuja</li>
  <li>Kano</li>
  <li>Enugu</li>
  <li>Ibadan</li>
  <li>Benin</li>
</ul>

style.css

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

.grid li {
  padding: 14px 12px;
  background: #eef1fd;
  border: 1px solid #3055d8;
  border-radius: 8px;
  text-align: center;
  font-size: 0.85rem;
}
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

// How many tracks fit, given a container width, a track minimum and a gap.
const fits = (container, min, gap) =>
  Math.max(1, Math.floor((container + gap) / (min + gap)));

test('at 480px wide, with a 140px minimum and a 10px gap, three fit', fits(480, 140, 10), 3);
test('at 300px, two fit', fits(300, 140, 10), 2);
test('at 120px it never drops below one', fits(120, 140, 10), 1);
test('the declaration uses auto-fit', /repeat\(\s*auto-fit/.test(css), true);
test('with a floor and a share of the surplus', /minmax\(\s*140px\s*,\s*1fr\s*\)/.test(css), true);
test('no media query anywhere', /@media/.test(css), false);
test('five cards, in a real list', countTag(html, 'li') === 5 && hasTag(html, 'ul'), true);
test('spaced with gap rather than margins', /gap:\s*10px/.test(css), true);

Output


      

The arithmetic in the first three checks is what the browser does for you, and it is worth seeing written down: the container width plus one gap, divided by a track plus one gap. That is why no breakpoint is needed — the layout responds to the width it actually has. The auto-fit versus auto-fill difference only shows with fewer items than tracks: auto-fill leaves the empty columns in place, so two cards stay narrow at the left, while auto-fit collapses them so the two stretch across. Neither is wrong; they answer different questions.

Answers CSS Practical 22 in the original set.

23 Naming the areas of a grid

fair

Lay out a small page with grid-template-areas, so the stylesheet contains a picture of the layout and the markup order stays sensible.

Stuck? Three hints, smallest first
  1. 1. Each string in grid-template-areas is one row, and each name in it is one cell.
  2. 2. A name repeated across adjacent cells makes that item span them.
  3. 3. A . means an empty cell. Every row must have the same number of cells or the whole declaration is invalid.

index.html

<div class="page">
  <header class="ph">Header</header>
  <nav class="pn" aria-label="Sections">Nav</nav>
  <main class="pm">Main</main>
  <footer class="pf">Footer</footer>
</div>

style.css

.page {
  display: grid;
  grid-template-areas:
    'head head'
    'nav  main'
    'foot foot';
  grid-template-columns: 90px 1fr;
  grid-template-rows: auto 1fr auto;
  gap: 8px;
  min-height: 170px;
}

.ph { grid-area: head; }
.pn { grid-area: nav; }
.pm { grid-area: main; }
.pf { grid-area: foot; }

.page > * {
  padding: 10px;
  border-radius: 6px;
  background: #eef1fd;
  border: 1px solid #3055d8;
  font-size: 0.82rem;
}
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 23

// Every row of the template must declare the same number of cells, or the
// browser throws the whole declaration away.
const rows = [...css.matchAll(/'([a-z. ]+)'/g)].map((m) => m[1].trim().split(/\s+/));
const widths = [...new Set(rows.map((r) => r.length))];

test('three rows in the template', rows.length, 3);
test('each declares two cells', widths, [2]);
test('the header name is repeated, so it spans both columns', rows[0], ['head', 'head']);
test('and so is the footer', rows[2], ['foot', 'foot']);
test('the middle row splits into nav and main', rows[1], ['nav', 'main']);
test('every area name is claimed by an element', ['head', 'nav', 'main', 'foot'].every((a) => new RegExp('grid-area:\\s*' + a + ';').test(css)), true);
test('the columns are sized as a sidebar plus the rest', /grid-template-columns:\s*90px 1fr/.test(css), true);
test('semantic landmarks, not four divs', ['header', 'nav', 'main', 'footer'].every((t) => hasTag(html, t)), true);

Output


      

The check that matters is the second: a grid-template-areas whose rows have different cell counts is invalid and the browser discards the whole declaration, so the layout collapses to a single column with no error anywhere. Counting the cells per row is the first thing to do when that happens. The payoff for getting it right is that the stylesheet reads as a diagram, and reordering the layout does not mean reordering the markup — which matters because the markup order is the order a screen reader and the tab key follow.

Answers CSS Practical 23 in the original set.

24 The holy grail, in grid

hard

Build the classic five-part layout — header, left nav, main, right aside, footer — with a fixed sidebar, a fluid centre, and the footer always last. Mobile first: one column by default.

Stuck? Three hints, smallest first
  1. 1. Default to a single column, then add the three-column template inside a min-width query.
  2. 2. Line and area names both work. Areas keep the intent readable at a glance.
  3. 3. The centre column is 1fr; the two sides are fixed, so only the middle absorbs the change in width.

index.html

<div class="grail">
  <header class="g-head">Header</header>
  <nav class="g-nav" aria-label="Sections">Nav</nav>
  <main class="g-main">Main content, the only part that changes width.</main>
  <aside class="g-side">Aside</aside>
  <footer class="g-foot">Footer</footer>
</div>

style.css

/* One column is the default, so a narrow screen needs no query at all. */
.grail {
  display: grid;
  grid-template-areas:
    'head'
    'nav'
    'main'
    'side'
    'foot';
  gap: 8px;
  min-height: 200px;
}

@media (min-width: 34em) {
  .grail {
    grid-template-areas:
      'head head head'
      'nav  main side'
      'foot foot foot';
    grid-template-columns: 80px 1fr 80px;
    grid-template-rows: auto 1fr auto;
  }
}

.g-head { grid-area: head; }
.g-nav  { grid-area: nav; }
.g-main { grid-area: main; }
.g-side { grid-area: side; }
.g-foot { grid-area: foot; }

.grail > * {
  padding: 10px;
  border-radius: 6px;
  border: 1px solid #3055d8;
  background: #eef1fd;
  font-size: 0.8rem;
}

.g-main { background: #ffffff; }
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 24

const wide = (/@media[^{]*\{([\s\S]*?)\n  \}/.exec(css) || [, ''])[1];
const wideRows = [...wide.matchAll(/'([a-z. ]+)'/g)].map((m) => m[1].trim().split(/\s+/));

test('the default is a single column', /\.grail\s*\{[\s\S]*?'head'[\s\S]*?'foot'/.test(css), true);
test('the three-column version lives in a min-width query', /@media \(min-width: 34em\)/.test(css), true);
test('which is mobile first, not max-width', /max-width/.test(css), false);
test('the wide template has three rows of three cells', wideRows.map((r) => r.length), [3, 3, 3]);
test('the middle row is nav, main, aside', wideRows[1], ['nav', 'main', 'side']);
test('only the centre column is fluid', /grid-template-columns:\s*80px 1fr 80px/.test(css), true);
test('all five areas are claimed', ['head', 'nav', 'main', 'side', 'foot'].every((a) => new RegExp('grid-area:\\s*' + a + ';').test(css)), true);
test('the footer is last in the markup as well as the layout', html.lastIndexOf('g-foot') > html.lastIndexOf('g-side'), true);

Output


      

This layout took a decade of float and table hacks and is now nine lines. Two things are deliberate. It is mobile first: one column is the default and the query adds the sidebars, which fails better than the reverse if the query never matches. And the markup order matches the reading order — grid can place areas anywhere, so it is entirely possible to build a layout that looks right and tabs in nonsense order. The last check is a cheap guard against exactly that.

Answers CSS Practical 24 in the original set.

25 Spanning rows and columns

fair

Make one tile twice as wide and another twice as tall inside a four-column grid, using line numbers and the span keyword.

Stuck? Three hints, smallest first
  1. 1. grid-column: span 2 is shorthand for “take two tracks from wherever you land”.
  2. 2. grid-row: 1 / 3 names the start and end lines, and lines are counted from 1, not 0.
  3. 3. A grid with four columns has five column lines. Off-by-one here is the usual bug.

index.html

<ul class="mosaic">
  <li class="wide">wide, span 2</li>
  <li class="tall">tall, rows 1 to 3</li>
  <li>three</li>
  <li>four</li>
  <li>five</li>
  <li>six</li>
</ul>

style.css

.mosaic {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-auto-rows: 48px;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Two tracks wide, starting wherever the auto-placer puts it. */
.wide { grid-column: span 2; }

/* Explicit lines. Four columns means five lines, so 1 / 3 covers two rows. */
.tall { grid-row: 1 / 3; }

.mosaic li {
  display: grid;
  place-items: center;
  background: #eef1fd;
  border: 1px solid #3055d8;
  border-radius: 6px;
  font-size: 0.78rem;
  text-align: center;
}

.wide { background: #d9dcea; }
.tall { background: #cfd6f3; }
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

// A grid of N columns has N + 1 lines. Getting that wrong is the classic bug.
const columns = Number(/repeat\((\d+), 1fr\)/.exec(css)[1]);
const lines = columns + 1;
const rowSpan = (/grid-row:\s*(\d+) \/ (\d+)/.exec(css) || []).slice(1).map(Number);

test('four columns', columns, 4);
test('which means five column lines', lines, 5);
test('the tall tile runs from line 1 to line 3', rowSpan, [1, 3]);
test('so it covers two rows', rowSpan[1] - rowSpan[0], 2);
test('the wide tile uses the span keyword instead', /grid-column:\s*span 2/.test(css), true);
test('rows created on the fly have a set height', /grid-auto-rows:\s*48px/.test(css), true);
test('six tiles', countTag(html, 'li'), 6);
test('no absolute positioning was used to overlap them', /position:\s*absolute/.test(css), false);

Output


      

Line numbers and span answer different questions. span 2 says “two tracks from wherever I land”, so it survives reordering; 1 / 3 says “these exact lines”, which is what you want when the position is the point. The arithmetic in the second check is the thing to internalise: four columns have five lines, so 1 / 3 spans two tracks and not three. grid-auto-rows is the companion detail — rows the auto-placer creates have no height rule of their own otherwise, so they collapse to their content.

Answers CSS Practical 25 in the original set.

26 place-items and its relatives

easy

Centre every cell’s content in a grid with one declaration, centre the whole grid inside its box with another, and override a single cell. Write out what each shorthand expands to.

Stuck? Three hints, smallest first
  1. 1. place-items is align-items then justify-items; one value means both.
  2. 2. place-content aligns the tracks inside the container; place-items aligns the content inside each cell.
  3. 3. place-self is the per-item override.

index.html

<div class="board">
  <div>one</div>
  <div>two</div>
  <div class="odd">bottom right</div>
  <div>four</div>
</div>

style.css

.board {
  display: grid;
  grid-template-columns: repeat(2, 70px);
  grid-auto-rows: 60px;
  gap: 8px;
  height: 160px;

  place-items: center;      /* align-items: center; justify-items: center */
  place-content: center;    /* align-content: center; justify-content: center */

  border: 2px dashed #3055d8;
  border-radius: 8px;
}

/* One cell breaks ranks. Block axis first, inline axis second. */
.odd {
  place-self: end end;
}

.board > div {
  padding: 4px;
  background: #eef1fd;
  border-radius: 6px;
  font-size: 0.75rem;
  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 26

// One value applies to both axes; two are block axis then inline axis.
const pair = (value) => {
  const parts = value.trim().split(/\s+/);
  return parts.length === 1 ? [parts[0], parts[0]] : parts;
};

test('place-items: center means centre on both axes', pair('center'), ['center', 'center']);
test('two values are block axis first, then inline', pair('end end'), ['end', 'end']);
test('and they may differ', pair('start end'), ['start', 'end']);
test('the grid centres the content of every cell', /place-items:\s*center/.test(css), true);
test('and centres the tracks inside itself', /place-content:\s*center/.test(css), true);
test('one cell overrides both with place-self', /place-self:\s*end end/.test(css), true);
test('the container has a height, so place-content has room to work', /height:\s*160px/.test(css), true);
test('four cells', countTag(html, 'div'), 5);

Output


      

The pair to keep straight is place-items against place-content: the first aligns each item within its own cell, the second aligns the whole block of tracks within the container. They look interchangeable in a grid that fills its box, and they diverge the moment the tracks are smaller than the container — which is why this one has an explicit height. Both take one value for both axes or two, block axis first, and place-self is the escape hatch for a single item.

Answers CSS Practical 26 in the original set.

27 scroll, auto, hidden, and the clipped focus ring

hard

Show the three overflow behaviours side by side. Then deal with the accessibility trap: a focus ring drawn outside an element is cut off by an ancestor that clips.

Stuck? Three hints, smallest first
  1. 1. scroll always shows the scrollbar or reserves its gutter; auto only when there is something to scroll.
  2. 2. hidden clips with no way to reach what was cut off, so it must never hide focusable content.
  3. 3. Where clipping is unavoidable, draw the ring inside with a negative outline-offset.

index.html

<p class="cap">overflow-y: scroll &mdash; gutter always reserved</p>
<div class="pane always">
  <p>06:30 Abuja</p><p>08:00 Kano</p><p>11:15 Enugu</p><p>14:45 Ibadan</p>
</div>

<p class="cap">overflow-y: auto &mdash; scrolls, and reachable by keyboard</p>
<div class="pane demand" tabindex="0" role="region" aria-label="Departures">
  <p><a href="#a">06:30 Abuja</a></p>
  <p><a href="#k">08:00 Kano</a></p>
  <p><a href="#e">11:15 Enugu</a></p>
  <p><a href="#i">14:45 Ibadan</a></p>
</div>

<p class="cap">overflow: hidden &mdash; clipped, so nothing focusable goes in here</p>
<div class="pane clip">
  <p>Decorative text only. The rest is cut off and cannot be reached.</p>
  <p>Second line, clipped away.</p>
</div>

style.css

.cap { margin: 10px 0 4px; font-size: 0.75rem; color: #5c5c66; }

.pane {
  height: 60px;
  padding: 0 10px;
  border: 1px solid #c9cad1;
  border-radius: 6px;
  font-size: 0.8rem;
}

.pane p { margin: 6px 0; }

.always { overflow-y: scroll; }

.demand { overflow-y: auto; }

/* Clips with no scrollbar, so anything past the edge is unreachable. */
.clip { overflow: hidden; }

/* The scrollable pane is focusable, so it needs its own ring. */
.demand:focus-visible {
  outline: 2px solid #3055d8;
  outline-offset: 2px;
}

/* Drawn INSIDE the link, so a clipping ancestor cannot cut it off. */
.demand a:focus-visible {
  outline: 2px solid #3055d8;
  outline-offset: -2px;
}

.demand a { color: #17171a; }
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

// The clipping pane is last, so everything from its class attribute onwards is
// the part that gets cut off.
const clipped = html.slice(html.indexOf('pane clip'));

test('all three behaviours are shown', ['overflow-y:\\s*scroll', 'overflow-y:\\s*auto', 'overflow:\\s*hidden'].every((p) => new RegExp(p).test(css)), true);
test('the clipping pane holds nothing focusable', /<a\b|<button\b|tabindex/.test(clipped), false);
test('the scrolling pane is reachable by keyboard', /class="pane demand" tabindex="0"/.test(html), true);
test('and named, since it is now a focusable region', /aria-label="Departures"/.test(html), true);
test('it has its own focus ring', /\.demand:focus-visible/.test(css), true);
test('the links inside draw their ring inwards', /outline-offset:\s*-2px/.test(css), true);
test('no outline is simply removed anywhere', /outline:\s*none/.test(css), false);
test('every pane has a height, or nothing would overflow', /\.pane\s*\{[^}]*height:\s*60px/.test(css), true);

Output


      

Three separate traps. overflow: hidden makes content unreachable, not merely invisible — there is no scrollbar and no keyboard route — so putting a link inside one is a way of hiding a control from some users and not others. A container that scrolls needs tabindex="0" to be scrollable by keyboard at all, and once it is focusable it needs a name and a focus ring of its own. And a focus ring is painted outside the element by default, so an ancestor that clips will shave it off; a negative outline-offset draws it inside instead. None of that is measurable here, so the preview is where you check it — but the source conditions are, and those are what is asserted.

Answers CSS Practical 27 in the original set.

28 Truncate one line, and clamp several

fair

Cut a single line with an ellipsis, then clamp a paragraph to three lines. Count the declarations each one genuinely needs.

Stuck? Three hints, smallest first
  1. 1. One line needs three together: white-space: nowrap, overflow: hidden, text-overflow: ellipsis.
  2. 2. Multi-line needs four: display: -webkit-box, -webkit-box-orient: vertical, -webkit-line-clamp, overflow: hidden.
  3. 3. The prefixed properties are not legacy. They are the only interoperable way to clamp lines today.

index.html

<p class="one-line">One line only: this sentence is far too long for the box it has been put in.</p>

<p class="clamped">Three lines, then an ellipsis. The coach leaves Jibowu at 06:30, stops once at
  Lokoja for twenty minutes, and reaches the central terminal in Abuja by early
  evening barring roadworks on the Abaji stretch, which are frequent enough to be
  worth allowing for.</p>

style.css

.one-line {
  max-width: 260px;
  white-space: nowrap;       /* without this it wraps and never truncates */
  overflow: hidden;          /* without this it spills past the border */
  text-overflow: ellipsis;   /* without this it is cut mid-letter */
  border: 1px solid #c9cad1;
  border-radius: 6px;
  padding: 8px;
  font-size: 0.85rem;
}

.clamped {
  max-width: 260px;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;     /* the line count */
  overflow: hidden;          /* still required, even here */
  border: 1px solid #c9cad1;
  border-radius: 6px;
  padding: 8px;
  font-size: 0.85rem;
  line-height: 1.5;
}
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

const single = ['white-space:\\s*nowrap', 'overflow:\\s*hidden', 'text-overflow:\\s*ellipsis'];
const multi = ['display:\\s*-webkit-box', '-webkit-box-orient:\\s*vertical', '-webkit-line-clamp', 'overflow:\\s*hidden'];
const present = (list) => list.filter((p) => new RegExp(p).test(css)).length;

test('the single-line recipe needs three declarations', single.length, 3);
test('and all three are here', present(single), 3);
test('the clamp needs four', multi.length, 4);
test('and all four are here', present(multi), 4);
test('overflow: hidden appears in both, because both depend on it', (css.match(/overflow:\s*hidden/g) || []).length, 2);
test('both have a width to overflow', (css.match(/max-width:\s*260px/g) || []).length, 2);
test('the clamp is set to three lines', /-webkit-line-clamp:\s*3/.test(css), true);
test('and line-height is set, so three lines is a predictable height', /line-height:\s*1\.5/.test(css), true);

Output


      

Every declaration in both recipes is load-bearing, and each omission fails differently. Drop nowrap and the text wraps, so no ellipsis ever appears; drop overflow: hidden and it spills past the border instead of being cut; drop text-overflow and it is chopped mid-letter. The clamp is the same story with four parts, and the one people forget is overflow: hidden — without it the clamp does nothing at all. The -webkit- prefixes look like a smell and are not: no unprefixed equivalent has shipped, and every browser supports these.

Answers CSS Practical 28 in the original set.

29 A table header that stays put

fair

Make the header row of a scrolling table stick to the top. Then fix the border that disappears when you do, which is the part nobody warns you about.

Stuck? Three hints, smallest first
  1. 1. Put position: sticky on the <th> cells, not on <thead>.
  2. 2. A sticky cell needs an opaque background, or the rows scroll through it.
  3. 3. With border-collapse: collapse the borders belong to the table, not the cell, so they do not travel. Use separate and an inset box-shadow.

index.html

<div class="scrollwrap" tabindex="0" role="region" aria-label="Departures">
  <table class="sched">
    <caption>All departures today</caption>
    <thead>
      <tr>
        <th scope="col">Time</th>
        <th scope="col">Route</th>
        <th scope="col">Bay</th>
      </tr>
    </thead>
    <tbody>
      <tr><th scope="row">06:30</th><td>Abuja</td><td>B</td></tr>
      <tr><th scope="row">08:00</th><td>Kano</td><td>C</td></tr>
      <tr><th scope="row">11:15</th><td>Enugu</td><td>D</td></tr>
      <tr><th scope="row">14:45</th><td>Ibadan</td><td>A</td></tr>
      <tr><th scope="row">17:00</th><td>Benin</td><td>E</td></tr>
      <tr><th scope="row">19:30</th><td>Warri</td><td>F</td></tr>
    </tbody>
  </table>
</div>

style.css

.scrollwrap {
  max-height: 150px;
  overflow: auto;
  border: 1px solid #c9cad1;
  border-radius: 8px;
}

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

/* separate, not collapse: with collapsed borders the lines belong to the table
   and stay behind while the sticky cell travels. */
.sched {
  width: 100%;
  border-collapse: separate;
  border-spacing: 0;
  font-size: 0.82rem;
}

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

.sched thead th {
  position: sticky;
  top: 0;
  background: #17171a;      /* opaque, or the rows show through */
  color: #ffffff;
  text-align: left;
  padding: 8px 10px;
  /* The bottom rule, drawn as a shadow so it travels with the cell. */
  box-shadow: inset 0 -1px 0 #5c5c66;
}

.sched tbody th,
.sched tbody td {
  padding: 7px 10px;
  border-bottom: 1px solid #e4e5e9;
  text-align: left;
  font-weight: 400;
}
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 29

test('the header cells are sticky, not the thead', /thead th\s*\{[^}]*position:\s*sticky/.test(css), true);
test('with a threshold to stick at', /top:\s*0/.test(css), true);
test('and an opaque background, or rows show through', /thead th\s*\{[\s\S]*?background:\s*#17171a/.test(css), true);
test('borders are separate, so the sticky cell keeps its rule', /border-collapse:\s*separate/.test(css), true);
test('collapse is avoided for exactly that reason', /border-collapse:\s*collapse/.test(css), false);
test('the header rule is drawn as an inset shadow instead', /box-shadow:\s*inset 0 -1px 0/.test(css), true);
test('the wrapper is what scrolls, and by keyboard too', /overflow:\s*auto/.test(css) && /tabindex="0"/.test(html), true);
test('every header still declares its scope', (html.match(/scope="(col|row)"/g) || []).length, 9);

Output


      

position: sticky on <thead> is ignored in several browsers, so it goes on the <th> cells. Then two things go wrong that look like separate bugs and are not. The header is transparent by default, so rows scroll through it — hence an opaque background. And with border-collapse: collapse the borders are owned by the table rather than the cell, so they stay behind while the cell travels, leaving the header with no bottom rule. Switching to separate with border-spacing: 0 and drawing the line as an inset shadow fixes it without changing the look.

Answers CSS Practical 29 in the original set.

30 A rail that snaps

hard

Build a horizontal rail of cards that comes to rest neatly on a card rather than half way between two. Keep it usable by keyboard and know why mandatory is a risk.

Stuck? Three hints, smallest first
  1. 1. scroll-snap-type: x mandatory goes on the scrolling container.
  2. 2. scroll-snap-align: start goes on each child, and says which edge lines up.
  3. 3. mandatory always snaps, which can make content unreachable if an item is larger than the scrollport. proximity only snaps when you are already close.

index.html

<div class="rail" tabindex="0" role="region" aria-label="Featured routes">
  <article class="slide"><h3>Abuja</h3><p>12 hours, one stop</p></article>
  <article class="slide"><h3>Kano</h3><p>18 hours, two stops</p></article>
  <article class="slide"><h3>Enugu</h3><p>9 hours, direct</p></article>
  <article class="slide"><h3>Ibadan</h3><p>2 hours, direct</p></article>
</div>

style.css

.rail {
  display: flex;
  gap: 10px;
  padding: 4px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  /* So a snapped card is not flush against the container edge. */
  scroll-padding-left: 4px;
  /* Keeps a swipe from scrolling the page behind once the rail runs out. */
  overscroll-behavior-x: contain;
}

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

.slide {
  flex: 0 0 60%;             /* no shrinking, so the rail really does overflow */
  scroll-snap-align: start;
  box-sizing: border-box;
  padding: 12px;
  border: 1px solid #3055d8;
  border-radius: 8px;
  background: #eef1fd;
  font-size: 0.8rem;
}

.slide h3 { margin: 0 0 4px; font-size: 0.9rem; }
.slide p { margin: 0; color: #5c5c66; }

/* A reader who has asked for less motion gets no smooth scrolling. */
@media (prefers-reduced-motion: no-preference) {
  .rail { scroll-behavior: smooth; }
}
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

test('snapping is set on the scroll container', /scroll-snap-type:\s*x mandatory/.test(css), true);
test('and an alignment edge on each child', /scroll-snap-align:\s*start/.test(css), true);
test('the container actually scrolls', /overflow-x:\s*auto/.test(css), true);
test('the cards refuse to shrink, so there is something to scroll', /flex:\s*0 0 60%/.test(css), true);
test('a scroll gutter keeps the snapped card off the edge', /scroll-padding-left/.test(css), true);
test('the rail is focusable and named', /tabindex="0"/.test(html) && /aria-label="Featured routes"/.test(html), true);
test('with a focus ring of its own', /\.rail:focus-visible/.test(css), true);
test('smooth scrolling is opt-in for people who tolerate motion', /prefers-reduced-motion: no-preference/.test(css), true);

Output


      

Snapping needs both halves: a type on the container and an alignment on the children. Miss the second and nothing happens, which is the usual first attempt. mandatory is the sharper edge — the scroller must always come to rest on a snap point, so if a card is wider than the scrollport there are positions the reader simply cannot reach; proximity avoids that by only snapping when you stop nearby. Two accessibility details ride along: a scroll container needs tabindex="0" to be scrollable from the keyboard, and scroll-behavior: smooth is animation, so it is gated behind prefers-reduced-motion: no-preference rather than switched off inside a reduce query.

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