HTML and CSS: Practical II
Exercises 16 to 30: flexbox and grid, overflow, and truncation.
15 exercises
16 Centred two ways
easyCentre 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. On the parent:
display: flexplusplace-content: center. - 2. On the child:
margin: auto. Inside a flex container, auto margins absorb the free space on both axes. - 3. The parent still needs a height either way, or there is no vertical space to share out.
<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>
.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;
}
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.
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
easyLay 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.
flex-wrap: wrapis opt-in. The default isnowrap, which shrinks items instead. - 2.
flex-direction: columnturns the main axis vertical, andwrapthen needs a height to wrap against. - 3.
gapapplies between items only, so there is no leftover margin at the edges to undo.
<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>
.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;
}
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.
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
hardUse 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.
flexisflex-grow flex-shrink flex-basis, andflex: 1means1 1 0%. - 2.
flex: autois1 1 auto: it grows, but starts from its content width, so items with more text end up wider. - 3.
flex: 1starts every item from zero, which is why the columns come out equal.
<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>
.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 */
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.
// 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
fairAlign 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.
justify-contentworks along the main axis,align-itemsacross the cross axis. Neither is tied to a screen direction. - 2. With
rowthe main axis is horizontal. Withcolumnit is vertical, and the two properties swap what they appear to do. - 3. That is why remembering them as “horizontal” and “vertical” breaks the first time you change direction.
<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>
.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;
}
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.
// 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
easyPut 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.
margin-left: autoon the item you want pushed. The auto margin eats all the free space. - 2. This beats
space-betweenwhen the split is not simply first and last. - 3. Use a real
<nav>with a list, and keep a visible focus style on the links.
<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>
.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;
}
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.
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
fairPut 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. Flex items stretch to the tallest by default, so equal height is what you already have unless you break it.
- 2. Make each card a column flex container, and give the middle part
flex: 1so it absorbs the slack. - 3.
margin-top: autoon the action does the same job from the other end.
<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>
.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;
}
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.
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
fairLay 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.
repeat(auto-fit, minmax(140px, 1fr))is the whole declaration. - 2.
auto-fillkeeps the empty tracks it created;auto-fitcollapses them so the items stretch. - 3. The difference is only visible when there are fewer items than tracks.
<ul class="grid">
<li>Abuja</li>
<li>Kano</li>
<li>Enugu</li>
<li>Ibadan</li>
<li>Benin</li>
</ul>
.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;
}
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.
// 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
fairLay 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. Each string in
grid-template-areasis one row, and each name in it is one cell. - 2. A name repeated across adjacent cells makes that item span them.
- 3. A
.means an empty cell. Every row must have the same number of cells or the whole declaration is invalid.
<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>
.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;
}
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.
// 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
hardBuild 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. Default to a single column, then add the three-column template inside a
min-widthquery. - 2. Line and area names both work. Areas keep the intent readable at a glance.
- 3. The centre column is
1fr; the two sides are fixed, so only the middle absorbs the change in width.
<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>
/* 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; }
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.
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
fairMake 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.
grid-column: span 2is shorthand for “take two tracks from wherever you land”. - 2.
grid-row: 1 / 3names the start and end lines, and lines are counted from 1, not 0. - 3. A grid with four columns has five column lines. Off-by-one here is the usual bug.
<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>
.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; }
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.
// 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
easyCentre 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.
place-itemsisalign-itemsthenjustify-items; one value means both. - 2.
place-contentaligns the tracks inside the container;place-itemsaligns the content inside each cell. - 3.
place-selfis the per-item override.
<div class="board">
<div>one</div>
<div>two</div>
<div class="odd">bottom right</div>
<div>four</div>
</div>
.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;
}
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.
// 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
hardShow 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.
scrollalways shows the scrollbar or reserves its gutter;autoonly when there is something to scroll. - 2.
hiddenclips with no way to reach what was cut off, so it must never hide focusable content. - 3. Where clipping is unavoidable, draw the ring inside with a negative
outline-offset.
<p class="cap">overflow-y: scroll — 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 — 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 — 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>
.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; }
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.
// 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
fairCut 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. One line needs three together:
white-space: nowrap,overflow: hidden,text-overflow: ellipsis. - 2. Multi-line needs four:
display: -webkit-box,-webkit-box-orient: vertical,-webkit-line-clamp,overflow: hidden. - 3. The prefixed properties are not legacy. They are the only interoperable way to clamp lines today.
<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>
.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;
}
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.
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
fairMake 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. Put
position: stickyon the<th>cells, not on<thead>. - 2. A sticky cell needs an opaque
background, or the rows scroll through it. - 3. With
border-collapse: collapsethe borders belong to the table, not the cell, so they do not travel. Useseparateand an insetbox-shadow.
<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>
.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;
}
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.
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
hardBuild 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.
scroll-snap-type: x mandatorygoes on the scrolling container. - 2.
scroll-snap-align: startgoes on each child, and says which edge lines up. - 3.
mandatoryalways snaps, which can make content unreachable if an item is larger than the scrollport.proximityonly snaps when you are already close.
<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>
.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; }
}
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.
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.