HTML and CSS: Practical III
Exercises 31 to 45: responsive work, accessibility, and finishing touches.
15 exercises
31 Mobile first, and only upwards
fairStyle a card list for a narrow screen with no media query at all, then add two breakpoints that only ever add columns. Nothing in a query may undo something the base rules did.
Stuck? Three hints, smallest first
- 1. Write the small-screen layout as the base rules, outside every query.
- 2. Then use
min-widthqueries in ascending order, each one adding to the last. - 3. If a query has to reset something, the base rules were written for the wrong screen.
<ul class="cards">
<li>Abuja</li>
<li>Kano</li>
<li>Enugu</li>
<li>Ibadan</li>
</ul>
/* Base: one column. No query, because this is the default case. */
.cards {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.cards li {
padding: 12px;
background: #eef1fd;
border: 1px solid #3055d8;
border-radius: 8px;
font-size: 0.85rem;
text-align: center;
}
/* Then add, in ascending order. Neither query undoes anything above. */
@media (min-width: 26em) {
.cards { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 44em) {
.cards { grid-template-columns: repeat(4, 1fr); }
}
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.
// Split the stylesheet into base rules and query blocks.
const base = css.replace(/@media[^{]*\{[\s\S]*?\n\}/g, '');
const queries = [...css.matchAll(/@media \(([a-z-]+): ([\d.]+)em\)/g)]
.map((m) => ({ feature: m[1], at: Number(m[2]) }));
test('the single-column layout is a base rule', /grid-template-columns:\s*1fr/.test(base), true);
test('two breakpoints', queries.length, 2);
test('both are min-width, so the styles only ever add', queries.every((q) => q.feature === 'min-width'), true);
test('and they are written in ascending order', queries.map((q) => q.at), [26, 44]);
test('no max-width query anywhere', /max-width/.test(css), false);
test('the queries change only the column count', (css.match(/@media[^{]*\{\s*\.cards \{ grid-template-columns/g) || []).length, 2);
test('nothing in a query resets a base declaration', /@media[\s\S]*(display:\s*block|list-style)/.test(css), false);
test('four cards, in a real list', countTag(html, 'li') === 4 && hasTag(html, 'ul'), true);
Output
Mobile first is not about phones, it is about which case needs no query. The narrow layout is almost always the simpler one — a single column, everything stacked — so making it the base means the queries only ever add. That fails better too: if a query never matches, or its syntax is wrong, or the stylesheet is truncated, what the reader gets is the plain stacked version rather than a half-applied grid. The last check is the honest test of whether you really did it: any reset inside a query is a sign the base rules were written for a desktop.
Answers CSS Practical 31 in the original set.
32 min-width, max-width, and the overlap at the boundary
fairWork out which of two queries applies at a given width, and find the width where both of them match at once.
Stuck? Three hints, smallest first
- 1.
min-widthmeans “this wide or wider”.max-widthmeans “this wide or narrower”. Both are inclusive. - 2. So
max-width: 40emandmin-width: 40emboth match at exactly 40em. - 3. Pick one direction per stylesheet, or subtract a hair from the max-width bound.
<p class="banner">Resize the preview: the label below changes at 40em.</p>
<p class="wide-only">Wide layout is active.</p>
<p class="narrow-only">Narrow layout is active.</p>
.banner, .wide-only, .narrow-only {
margin: 0 0 8px;
padding: 8px 10px;
border-radius: 6px;
font-size: 0.82rem;
}
.banner { background: #f2f3f5; color: #17171a; }
/* Deliberately overlapping, to show the boundary case. */
.narrow-only { display: none; background: #fdefec; color: #a3341f; }
.wide-only { display: none; background: #eaf6ef; color: #17734b; }
@media (max-width: 40em) {
.narrow-only { display: block; }
}
@media (min-width: 40em) {
.wide-only { display: block; }
}
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.
// Both bounds are inclusive, which is the whole point of this exercise.
const applies = (feature, bound, width) =>
feature === 'min-width' ? width >= bound : width <= bound;
test('min-width: 40em applies at 48em', applies('min-width', 40, 48), true);
test('and not at 32em', applies('min-width', 40, 32), false);
test('max-width: 40em applies at 32em', applies('max-width', 40, 32), true);
test('at exactly 40em, BOTH match', [applies('max-width', 40, 40), applies('min-width', 40, 40)], [true, true]);
test('so a shared bound double-applies, which is the bug', applies('max-width', 40, 40) && applies('min-width', 40, 40), true);
test('both queries are in the stylesheet, sharing the bound', (css.match(/40em/g) || []).length, 2);
test('and the later one wins at the boundary, by the cascade', css.indexOf('min-width: 40em') > css.indexOf('max-width: 40em'), true);
test('the base state hides both labels', (css.match(/display:\s*none/g) || []).length, 2);
Output
Both features are inclusive, so a max-width: 40em and a min-width: 40em query both match at exactly 40em and you get two conflicting sets of rules resolved by source order. It shows up as a layout that is briefly wrong at one specific width, which is miserable to reproduce. Two fixes: keep to one direction per stylesheet, which is what mobile first gives you for free, or make the upper bound max-width: 39.99em. Nothing here evaluates a query — the build has no viewport — so the arithmetic is done by the helper and the preview shows the effect.
Answers CSS Practical 32 in the original set.
33 A type scale with clamp
hardSize a heading so it grows with the viewport between a floor and a ceiling, using one declaration and no breakpoints. Then work out what it resolves to at three widths.
Stuck? Three hints, smallest first
- 1.
clamp(min, preferred, max): the preferred value is used unless it falls outside the bounds. - 2. The preferred value must include a
remterm, not justvw, or the text stops responding to browser zoom. - 3.
0.9rem + 0.9vwat a 320px viewport is 14.4px plus 2.88px, which is under the floor.
<h2 class="fluid">Departures</h2>
<p class="body">Body copy stays at a fixed size, so the contrast in scale is visible.</p>
.fluid {
/* floor 18px, preferred 0.9rem + 0.9vw, ceiling 32px */
font-size: clamp(1.125rem, 0.9rem + 0.9vw, 2rem);
line-height: 1.2;
margin: 0 0 8px;
}
.body {
font-size: 0.9rem;
line-height: 1.6;
margin: 0;
max-width: 46ch;
color: #5c5c66;
}
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.
// What clamp() resolves to, given a root size and a viewport width.
const ROOT = 16;
const preferred = (vw) => 0.9 * ROOT + 0.9 * (vw / 100);
const resolve = (vw) => Math.min(Math.max(preferred(vw), 1.125 * ROOT), 2 * ROOT);
const round = (n) => Math.round(n * 100) / 100;
test('the floor is 18px and the ceiling 32px', [1.125 * ROOT, 2 * ROOT], [18, 32]);
test('at 320px the preferred value is under the floor', round(preferred(320)), 17.28);
test('so the floor is used', resolve(320), 18);
test('at 1200px it sits between the bounds', round(resolve(1200)), 25.2);
test('at 2400px it is capped at the ceiling', resolve(2400), 32);
test('one declaration, no breakpoints', /@media/.test(css), false);
test('the preferred value includes a rem term, so zoom still works', /clamp\([^)]*rem \+ [\d.]+vw/.test(css), true);
test('and it is not vw alone', /clamp\(\s*[\d.]+rem,\s*[\d.]+vw/.test(css), false);
Output
The last two checks are the accessibility part, and they are the reason this is marked hard. A preferred value of pure vw makes the text depend on the window and nothing else, so a reader who zooms to 200% gets exactly the same pixel size — the text simply refuses to grow. Including a rem term keeps the reader’s own font size in the sum. The floor matters for the same reason: without one, a narrow window can shrink a heading below body copy. The arithmetic here is the browser’s, done by hand, because there is no viewport in the build to measure.
Answers CSS Practical 33 in the original set.
34 A component that responds to its container
hardMake a card lay out side by side when it has room and stack when it does not, based on the width of its container rather than the viewport. Put two copies in differently sized slots to show it.
Stuck? Three hints, smallest first
- 1. The parent needs
container-type: inline-size, or there is nothing to query. - 2.
@container (min-width: 20em)then styles the descendants, not the container itself. - 3. Name the container with
container-namewhen a component sits inside several.
<div class="slot slot--wide">
<article class="card">
<div class="card__thumb" aria-hidden="true"></div>
<div class="card__text">
<h3>Abuja</h3>
<p>12 hours, one stop at Lokoja.</p>
</div>
</article>
</div>
<div class="slot slot--narrow">
<article class="card">
<div class="card__thumb" aria-hidden="true"></div>
<div class="card__text">
<h3>Abuja</h3>
<p>12 hours, one stop at Lokoja.</p>
</div>
</article>
</div>
.slot {
container-type: inline-size; /* without this, @container never matches */
container-name: card;
margin-bottom: 10px;
padding: 6px;
border: 1px dashed #c9cad1;
border-radius: 8px;
}
.slot--wide { max-width: 340px; }
.slot--narrow { max-width: 170px; }
/* Base: stacked. The same component, whatever it is put inside. */
.card {
display: grid;
gap: 8px;
margin: 0;
font-size: 0.8rem;
}
.card__thumb {
height: 48px;
border-radius: 6px;
background: linear-gradient(135deg, #0b3a8f, #3055d8);
}
.card h3 { margin: 0 0 2px; font-size: 0.9rem; }
.card p { margin: 0; color: #5c5c66; }
/* Side by side, but only when the CONTAINER is wide enough. */
@container card (min-width: 18em) {
.card {
grid-template-columns: 70px 1fr;
align-items: center;
}
.card__thumb { height: 60px; }
}
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 container is declared', /container-type:\s*inline-size/.test(css), true);
test('and named, so the query can target it', /container-name:\s*card/.test(css), true);
test('the query is a container query, not a media query', /@container card \(min-width: 18em\)/.test(css), true);
test('no media query is involved at all', /@media/.test(css), false);
test('the query styles descendants, not the container', /@container[^{]*\{\s*\.card \{/.test(css), true);
test('the base state is the stacked one', /\.card\s*\{[^}]*display:\s*grid/.test(css), true);
test('two copies of the same component in different slots', countTag(html, 'article'), 2);
test('the decorative thumbnail is hidden from assistive technology', /aria-hidden="true"/.test(html), true);
Output
The point of a container query is that the component stops caring where it is on the page. The same card here goes two-column in the wide slot and stacks in the narrow one, with no media query and no variant class, which is what a media query fundamentally cannot do: a sidebar at 300px inside a 1600px window is still a narrow context, and @media only sees the window. Two things catch people: container-type: inline-size is required on the parent, and the query styles the children, so styling the container from inside its own query does nothing.
Answers CSS Practical 34 in the original set.
35 Honouring reduced motion
fairAnimate something, then make it respectful: no movement for a reader who has asked their system for less. Cover transitions and animations, and keep the interface still usable.
Stuck? Three hints, smallest first
- 1.
@media (prefers-reduced-motion: reduce)matches when the system setting is on. - 2. Turn off
animationas well astransition. They are separate mechanisms. - 3. Reduce, do not remove: the state change still has to be visible, just not animated.
<button class="pulse" type="button">Hover or focus me</button>
<p class="tip">The lift and the pulse both stop when reduced motion is on. The
colour change does not, because that is the feedback.</p>
.pulse {
padding: 10px 18px;
border: 0;
border-radius: 8px;
background: #3055d8;
color: #ffffff;
font: inherit;
cursor: pointer;
transition: transform 180ms ease, background 180ms ease;
animation: breathe 2.4s ease-in-out infinite;
}
.pulse:hover,
.pulse:focus-visible {
transform: translateY(-3px);
background: #2543b4; /* the feedback that survives reduced motion */
}
.pulse:focus-visible {
outline: 2px solid #17171a;
outline-offset: 3px;
}
@keyframes breathe {
0%, 100% { box-shadow: 0 0 0 0 rgb(48 85 216 / 45%); }
50% { box-shadow: 0 0 0 6px rgb(48 85 216 / 0%); }
}
.tip { margin: 10px 0 0; font-size: 0.8rem; color: #5c5c66; max-width: 40ch; }
/* Movement off, feedback intact. */
@media (prefers-reduced-motion: reduce) {
.pulse {
transition: none;
animation: none;
transform: none;
}
}
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 reduced = (/@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}/.exec(css) || [, ''])[1];
test('there is a reduced-motion query', /prefers-reduced-motion: reduce/.test(css), true);
test('it turns transitions off', /transition:\s*none/.test(reduced), true);
test('and animations too, since they are separate', /animation:\s*none/.test(reduced), true);
test('and cancels the transform', /transform:\s*none/.test(reduced), true);
test('the colour feedback is NOT inside that query, so it survives', /background/.test(reduced), false);
test('the base state animates transform and background only', /transition:\s*transform[^;]*background/.test(css), true);
test('keyboard focus gets the same treatment as hover', /\.pulse:hover,\s*\.pulse:focus-visible/.test(css), true);
test('a real button element, with an explicit type', /<button class="pulse" type="button">/.test(html), true);
Output
The setting exists because motion makes some people genuinely unwell — vestibular disorders, migraine — so this is not a preference in the decorative sense. Two details. transition: none does not stop a @keyframes animation; they are separate mechanisms and both need switching off. And the instruction is reduce, not remove: the fifth check makes sure the colour change stays outside the query, because a button that gives no feedback at all is a different accessibility problem. Nothing here reads the system setting during the build; the assertions check the rule and your own system shows you the behaviour.
Answers CSS Practical 35 in the original set.
36 Two themes from one set of variables
hardTheme a panel with custom properties, then swap every colour for a dark scheme by redeclaring the variables only. Let the browser style form controls to match.
Stuck? Three hints, smallest first
- 1. Declare the tokens on
:root, then reference them everywhere. No component names a colour directly. - 2.
@media (prefers-color-scheme: dark)redeclares the same tokens with different values. - 3.
color-scheme: light darktells the browser to theme scrollbars and native controls too.
<div class="panel">
<h3>Fare summary</h3>
<p class="muted">Lagos to Abuja, one adult</p>
<p class="total">₦18,000</p>
<label for="promo">Promo code</label>
<input id="promo" name="promo" type="text">
</div>
:root {
color-scheme: light dark; /* native controls and scrollbars follow suit */
--paper: #ffffff;
--ink: #17171a;
--muted: #5c5c66;
--line: #c9cad1;
--brand: #3055d8;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #101013;
--ink: #ececf1;
--muted: #a1a1ad;
--line: #35353e;
--brand: #8ba4ff;
}
}
.panel {
max-width: 260px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper);
color: var(--ink);
font-size: 0.85rem;
}
.panel h3 { margin: 0 0 4px; font-size: 0.95rem; }
.muted { margin: 0 0 8px; color: var(--muted); }
.total { margin: 0 0 12px; font-size: 1.2rem; font-weight: 700; color: var(--brand); }
.panel label { display: block; margin-bottom: 4px; font-weight: 600; }
.panel input {
width: 100%;
box-sizing: border-box;
padding: 7px 9px;
border: 1px solid var(--line);
border-radius: 6px;
font: inherit;
/* No colours named here: the browser applies the scheme. */
}
.panel input:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 1px;
}
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.
// Both themes must define the same tokens, or something falls back to nothing.
const block = (re) => (re.exec(css) || [, ''])[1];
const light = block(/^:root \{([\s\S]*?)\n\}/m);
const darkQuery = block(/@media \(prefers-color-scheme: dark\)\s*\{([\s\S]*?)\n\}/);
const names = (s) => [...s.matchAll(/(--[a-z-]+):/g)].map((m) => m[1]).sort();
test('the light theme declares five tokens', names(light).length, 5);
test('the dark theme redeclares exactly the same names', names(darkQuery), names(light));
test('and no more', names(darkQuery).length, names(light).length);
test('color-scheme is declared, so native controls follow', /color-scheme:\s*light dark/.test(css), true);
test('components reference tokens rather than naming colours', (css.match(/var\(--/g) || []).length, 7);
test('the dark override touches only :root', /^\s*:root \{/.test(darkQuery), true);
test('no component rule is repeated inside it', /\.panel/.test(darkQuery), false);
test('the input is labelled', /<label for="promo"/.test(html) && /id="promo"/.test(html), true);
Output
The discipline is that no component names a colour: every rule references a token, so the dark theme is nine lines that redeclare variables and nothing else. The seventh check enforces exactly that — a .panel rule inside the dark query would mean the theme had started to fork. Two easy wins alongside. color-scheme: light dark is what makes the browser theme scrollbars, the caret and unstyled controls, which is why the input needs no colours of its own. And a token missing from one theme does not error, it just inherits the wrong value, so comparing the two lists is worth doing.
Answers CSS Practical 36 in the original set.
37 Media that keeps its shape
easyGive a fluid-width image a fixed shape so the layout never reflows as it loads, and fill the box without distorting the picture.
Stuck? Three hints, smallest first
- 1.
aspect-ratiopluswidth: 100%means only one dimension is set and the ratio decides the other. - 2.
object-fit: coverfills the box and crops;containfits it all in and leaves gaps. - 3. Setting an explicit
heightas well leavesaspect-rationothing to compute.
<figure class="media">
<img
width="320" height="180"
alt="A coach pulling out of bay B at first light"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='180'%3E%3Crect width='320' height='180' fill='%230b3a8f'/%3E%3Ccircle cx='72' cy='60' r='30' fill='%23ffd75e'/%3E%3Crect y='140' width='320' height='40' fill='%2317171a'/%3E%3C/svg%3E">
<figcaption>16 by 9, whatever width the column is.</figcaption>
</figure>
.media {
max-width: 280px;
margin: 0;
}
.media img {
display: block;
width: 100%; /* one dimension only */
aspect-ratio: 16 / 9;
object-fit: cover; /* fill the box, crop the surplus */
object-position: center;
border-radius: 8px;
background: #f2f3f5;
}
.media figcaption {
margin-top: 6px;
color: #5c5c66;
font-size: 0.8rem;
}
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 ratio = (/aspect-ratio:\s*(\d+)\s*\/\s*(\d+)/.exec(css) || []).slice(1).map(Number);
const heightAt = (width) => Math.round((width * ratio[1]) / ratio[0]);
test('the ratio is 16 by 9', ratio, [16, 9]);
test('so at 280px wide the box is 158px tall', heightAt(280), 158);
test('and at 320px it is 180px', heightAt(320), 180);
test('only the width is set in CSS', /width:\s*100%/.test(css), true);
test('no explicit height competes with the ratio', /[^-]height:\s*\d/.test(css), false);
test('the image fills the box and crops', /object-fit:\s*cover/.test(css), true);
test('the intrinsic size is still on the element, for the pre-load reservation', /width="320" height="180"/.test(html), true);
test('and there is real alternative text', /alt="[^"]{15,}"/.test(html), true);
Output
Set one dimension and let aspect-ratio derive the other; set both and the ratio has nothing left to decide, which is why the fifth check refuses an explicit height. object-fit is the companion: without it a fluid image forced into a fixed ratio is stretched, and cover crops instead. The width and height attributes are still worth having even with a ratio in CSS — they are what reserves the box before the stylesheet or the image has arrived.
Answers CSS Practical 37 in the original set.
38 Let the browser choose the file size
hardOffer the same photograph at three widths and describe how much of the layout it will occupy, so the browser can pick the smallest file that still looks sharp.
Stuck? Three hints, smallest first
- 1.
srcsetlists candidates with awdescriptor: the real pixel width of each file. - 2.
sizesdescribes the rendered width in CSS terms, and the browser needs it before layout, which is why it cannot work this out itself. - 3. With no
sizes, the browser assumes100vwand usually downloads too much.
<img
class="photo"
srcset="bus-480.jpg 480w, bus-960.jpg 960w, bus-1440.jpg 1440w"
sizes="(min-width: 44em) 640px, 100vw"
src="bus-960.jpg"
width="960" height="540"
alt="A coach at bay B with its luggage doors open">
/* No photographs ship with this course, so the file names above resolve to
nothing and the preview shows the styled box with its alt text. The markup is
the subject; the box is here so there is something to look at. */
.photo {
display: block;
width: 100%;
max-width: 280px;
aspect-ratio: 16 / 9;
object-fit: cover;
border: 1px dashed #8e8e98;
border-radius: 8px;
background: #f2f3f5;
color: #5c5c66;
padding: 8px;
box-sizing: border-box;
font-size: 0.78rem;
}
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.
// Pull the candidate list apart and check it is coherent.
const candidates = (/srcset="([^"]+)"/.exec(html) || [, ''])[1]
.split(',')
.map((c) => c.trim().split(/\s+/))
.map(([file, descriptor]) => ({ file, width: Number(descriptor.replace('w', '')) }));
const sizes = (/sizes="([^"]+)"/.exec(html) || [, ''])[1];
test('three candidates are offered', candidates.length, 3);
test('their widths are 480, 960 and 1440', candidates.map((c) => c.width), [480, 960, 1440]);
test('the list is in ascending order, which is how it should read', candidates.map((c) => c.width).slice().sort((a, b) => a - b), [480, 960, 1440]);
test('every candidate carries a w descriptor', candidates.every((c) => c.width > 0), true);
test('sizes describes the rendered width, with a fallback last', /100vw$/.test(sizes), true);
test('and a wider case before it', /\(min-width: 44em\) 640px/.test(sizes), true);
test('a plain src is kept for browsers that ignore srcset', /src="bus-960\.jpg"/.test(html), true);
test('intrinsic dimensions and alt text are still present', /width="960" height="540"/.test(html) && /alt="[^"]{15,}"/.test(html), true);
Output
srcset alone is not enough: with no sizes the browser assumes the image will fill the viewport and picks the largest candidate, which is the opposite of the intent. sizes has to be written by hand because the browser chooses the file before it has laid the page out, so it cannot measure the slot — and that also means sizes is a promise you have to keep in step with your CSS. The last entry acts as the fallback, so it goes last with no condition. The file names here resolve to nothing, since this course ships no photographs; the checks are on the markup, which is the part being taught.
Answers CSS Practical 38 in the original set.
39 A visually hidden utility, and what to use it for
fairWrite the reusable class that hides an element from the screen but not from a screen reader, and use it twice: to name an icon-only button and to head a region.
Stuck? Three hints, smallest first
- 1.
display: noneandvisibility: hiddenremove the element from the accessibility tree as well. - 2. Clip it to one pixel and take it out of the flow. Not zero pixels — some readers skip zero-sized elements.
- 3. An icon-only button needs a name from somewhere: hidden text, or
aria-label. Hidden text also survives translation.
<h2 class="visually-hidden">Quick actions</h2>
<button class="icon" type="button">
<span aria-hidden="true">╳</span>
<span class="visually-hidden">Close the fare panel</span>
</button>
<p class="note">The heading above and the button label are announced but not
shown. The cross glyph is hidden the other way round.</p>
/* The standard recipe. One pixel, clipped, out of the flow. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid #8e8e98;
border-radius: 8px;
background: #ffffff;
color: #17171a;
font-size: 1rem;
cursor: pointer;
}
.icon:hover { background: #f2f3f5; }
.icon:focus-visible {
outline: 2px solid #3055d8;
outline-offset: 2px;
}
.note { margin: 10px 0 0; font-size: 0.8rem; color: #5c5c66; max-width: 42ch; }
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 element is taken out of the flow', /position:\s*absolute/.test(css), true);
test('one pixel each way, not zero', /width:\s*1px/.test(css) && /height:\s*1px/.test(css), true);
test('and clipped away', /clip-path:\s*inset\(50%\)/.test(css), true);
test('display: none is not used, since it hides from readers too', /display:\s*none/.test(css), false);
test('nor visibility: hidden', /visibility:\s*hidden/.test(css), false);
test('the utility is reused twice', (html.match(/class="visually-hidden"/g) || []).length, 2);
test('the icon button has a text label, not just a glyph', /<span class="visually-hidden">Close the fare panel<\/span>/.test(html), true);
test('and the glyph itself is hidden from assistive technology', /aria-hidden="true"/.test(html), true);
Output
The two negatives are the exercise: display: none and visibility: hidden hide from eyes and from screen readers, so using either for a button label or a region heading means the people who needed it most get nothing. The pairing in the button is the pattern worth memorising — visible glyph marked aria-hidden, hidden text carrying the name — so exactly one of the two is announced. Hidden text is preferable to aria-label here because it is real text on the page: it gets translated by the browser’s translation feature, and aria-label often does not.
Answers CSS Practical 39 in the original set.
40 A skip link that appears when you need it
fairAdd a link that lets a keyboard user jump past the navigation straight to the content. It should be out of the way until it has focus, and visible the instant it does.
Stuck? Three hints, smallest first
- 1. It must be the first focusable thing in the document, or it is skipped before it can help.
- 2. Hide it with the visually-hidden recipe, then undo that on
:focus. - 3. The target needs
tabindex="-1", or focus moves to the fragment without landing on it in some browsers.
<a class="skip" href="#main">Skip to content</a>
<nav class="bar" aria-label="Main">
<a href="#routes">Routes</a>
<a href="#fares">Fares</a>
<a href="#help">Help</a>
</nav>
<main class="content" id="main" tabindex="-1">
<h2>Departures</h2>
<p>Tab into the preview: the skip link appears first, above the bar.</p>
</main>
/* Hidden until focused. Note :focus, not :focus-visible: reaching this link is
always a keyboard act, so it should show every time. */
.skip {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.skip:focus {
position: static;
width: auto;
height: auto;
clip-path: none;
display: inline-block;
margin-bottom: 8px;
padding: 8px 12px;
border-radius: 6px;
background: #17171a;
color: #ffffff;
text-decoration: none;
outline: 2px solid #3055d8;
outline-offset: 2px;
font-size: 0.85rem;
}
.bar {
display: flex;
gap: 14px;
padding: 10px 12px;
border-radius: 8px;
background: #f2f3f5;
font-size: 0.85rem;
}
.bar a { color: #17171a; }
.content { padding: 10px 2px; }
.content:focus-visible {
outline: 2px solid #3055d8;
outline-offset: 2px;
}
.content h2 { margin: 0 0 6px; font-size: 1rem; }
.content p { margin: 0; font-size: 0.85rem; color: #5c5c66; }
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 skip link is the first element in the document', html.trim().startsWith('<a class="skip"'), true);
test('it points at the main region', /href="#main"/.test(html), true);
test('and that id exists on main', /<main[^>]*id="main"/.test(html), true);
test('the target is focusable, so focus really lands there', /tabindex="-1"/.test(html), true);
test('the link is clipped away until focused', /\.skip\s*\{[^}]*clip-path:\s*inset\(50%\)/.test(css), true);
test('and revealed on :focus, not :focus-visible', /\.skip:focus\s*\{/.test(css) && /\.skip:focus-visible/.test(css) === false, true);
test('the revealed state undoes the clipping', /\.skip:focus\s*\{[\s\S]*?clip-path:\s*none/.test(css), true);
test('the nav landmark is named, since there could be several', /aria-label="Main"/.test(html), true);
Output
Order in the source is the whole mechanism: the link has to come before the navigation or the reader has already tabbed through everything it was meant to skip. :focus rather than :focus-visible is deliberate here — there is no way to reach this link except by keyboard, so there is no mouse case to suppress. And tabindex="-1" on the target is the part that gets left out: without it, several browsers scroll to the fragment but leave focus where it was, so the next Tab carries on from the navigation and the link achieved nothing.
Answers CSS Practical 40 in the original set.
41 Never remove a focus ring, replace it
easyStyle focus on a link and a button so a keyboard user always sees where they are, a mouse user is not given a ring they did not ask for, and no browser is left with nothing.
Stuck? Three hints, smallest first
- 1.
:focus-visiblematches when the browser thinks a visible indicator is warranted — keyboard, not mouse click. - 2. If you must clear the default, clear it inside
:focus:not(:focus-visible)so the ring survives everywhere else. - 3.
outline-offsetgives the ring room; a negative value draws it inside a clipping parent.
<p><a class="link" href="#somewhere">A link, tab to me</a></p>
<button class="btn" type="button">A button, tab to me</button>
<p class="note">Click them with a mouse: no ring. Tab to them: a ring.</p>
.link { color: #3055d8; }
/* The keyboard case gets a clear ring. */
.link:focus-visible,
.btn:focus-visible {
outline: 3px solid #3055d8;
outline-offset: 2px;
border-radius: 3px;
}
/* The mouse case, and only the mouse case, loses it. Note this is scoped to
:focus:not(:focus-visible) rather than a blanket outline: none. */
.link:focus:not(:focus-visible),
.btn:focus:not(:focus-visible) {
outline-color: transparent;
}
.btn {
margin-bottom: 10px;
padding: 9px 16px;
border: 1px solid #3055d8;
border-radius: 8px;
background: #ffffff;
color: #17171a;
font: inherit;
cursor: pointer;
}
.btn:hover { background: #eef1fd; }
.note { margin: 0; font-size: 0.8rem; color: #5c5c66; }
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 comment in the stylesheet names the thing being avoided, so strip the
// comments before asserting about real declarations. Otherwise the check fails
// on the very example that is explaining it.
const rules = css.replace(/\/\*[\s\S]*?\*\//g, '');
test('both controls get a focus-visible ring', /\.link:focus-visible,\s*\.btn:focus-visible/.test(rules), true);
test('the ring is thick enough to see, and offset', /outline:\s*3px solid/.test(rules) && /outline-offset:\s*2px/.test(rules), true);
test('outline: none appears in no rule', /outline:\s*none/.test(rules), false);
test('nor outline: 0', /outline:\s*0[;\s]/.test(rules), false);
test('the mouse case is scoped, not blanket', /:focus:not\(:focus-visible\)/.test(rules), true);
test('and it clears only the colour, leaving the outline in place', /outline-color:\s*transparent/.test(rules), true);
test('the phrase to avoid is still shown in a comment, for recognition', /outline: none/.test(css), true);
test('a real button and a real link, both natively focusable', hasTag(html, 'button') && hasTag(html, 'a'), true);
Output
outline: none is the single most damaging line in front-end CSS: it makes a page unusable by keyboard while looking completely fine, so it survives review. The replacement pattern here is worth adopting wholesale — give :focus-visible a ring you actually like, and if the click ring bothers you, clear it under :focus:not(:focus-visible) so the keyboard case is untouched. Clearing outline-color rather than outline is a small extra safeguard: the outline is still there, so Windows high contrast mode can still draw it.
Answers CSS Practical 41 in the original set.
42 ARIA only where the HTML runs out
hardBuild a disclosure, a navigation with the current page marked, and a toggle button. Use ARIA for exactly one of them and no HTML element twice over.
Stuck? Three hints, smallest first
- 1.
<details>and<summary>are a disclosure widget already. They need no role and noaria-expanded. - 2.
<nav>is already a navigation landmark, sorole="navigation"on it says the same thing twice. - 3. A pressed state has no HTML equivalent, so
aria-pressedon a real<button>is the legitimate case.
<details class="drop">
<summary>Baggage rules</summary>
<p>One bag up to 20kg is included. Extra bags are charged at the window.</p>
</details>
<nav class="bar" aria-label="Fares">
<a href="#std" aria-current="page">Standard</a>
<a href="#exec">Executive</a>
</nav>
<button class="toggle" type="button" aria-pressed="false">Remind me</button>
.drop {
margin-bottom: 10px;
padding: 8px 10px;
border: 1px solid #c9cad1;
border-radius: 8px;
font-size: 0.85rem;
}
.drop summary { cursor: pointer; font-weight: 600; }
.drop summary:focus-visible {
outline: 2px solid #3055d8;
outline-offset: 2px;
}
.drop p { margin: 8px 0 0; color: #5c5c66; }
.bar {
display: flex;
gap: 14px;
margin-bottom: 10px;
font-size: 0.85rem;
}
.bar a { color: #3055d8; }
/* Styled from the ARIA attribute, so the mark and the announcement agree. */
.bar a[aria-current='page'] {
color: #17171a;
font-weight: 700;
text-decoration: none;
border-bottom: 2px solid #17171a;
}
.toggle {
padding: 8px 14px;
border: 1px solid #3055d8;
border-radius: 8px;
background: #ffffff;
color: #17171a;
font: inherit;
cursor: pointer;
}
.toggle[aria-pressed='true'] {
background: #3055d8;
color: #ffffff;
}
.toggle: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 disclosure is native, with no role bolted on', hasTag(html, 'details') && /role=/.test(html) === false, true);
test('and needs no aria-expanded, because the element owns that state', /aria-expanded/.test(html), false);
test('the nav is a landmark already, so no redundant role', hasTag(html, 'nav') && /role="navigation"/.test(html) === false, true);
test('the current page is marked with aria-current', /aria-current="page"/.test(html), true);
test('and styled from that same attribute', /\[aria-current='page'\]/.test(css), true);
test('the toggle is a real button, not a div', /<button class="toggle"/.test(html), true);
test('carrying the one state HTML has no element for', /aria-pressed="false"/.test(html), true);
test('which the stylesheet also reads', /\[aria-pressed='true'\]/.test(css), true);
Output
The rule of thumb is that no ARIA beats bad ARIA, and a native element beats both. <details> already announces expanded and collapsed, so adding aria-expanded gives a screen reader two sources of truth that can disagree the moment your JavaScript slips. role="navigation" on a <nav> is merely redundant, which is harmless but tells the next reader you were guessing. aria-pressed is the honest case: HTML has no element for “this button is currently on”, so the attribute carries it — and styling from [aria-pressed='true'] keeps the visible and the announced state the same fact.
Answers CSS Practical 42 in the original set.
43 Contrast, actually measured
hardChoose a palette and prove it. Compute the WCAG contrast ratio for each text and background pair you use, and check each against the level it has to reach.
Stuck? Three hints, smallest first
- 1. Body text needs 4.5:1. Text at 24px, or 18.66px bold, needs 3:1. Non-text boundaries need 3:1.
- 2. The formula is (lighter + 0.05) / (darker + 0.05) on relative luminance, not on the hex values.
- 3. A colour that passes on one background can fail badly on another, so the pair is what you test, never the colour.
<div class="panel">
<h2 class="head">Fare summary</h2>
<p class="body">Body copy at 4.5:1 or better.</p>
<p class="muted">Secondary copy, still 4.5:1 or better.</p>
<p><a class="cta" href="#book">Reserve a seat</a></p>
</div>
:root {
--paper: #ffffff;
--ink: #17171a;
--muted: #5c5c66;
--brand: #3055d8;
--on-brand: #ffffff;
--hairline: #8e8e98;
}
.panel {
max-width: 260px;
padding: 14px;
border: 1px solid var(--hairline);
border-radius: 10px;
background: var(--paper);
font-size: 0.85rem;
}
.head { margin: 0 0 6px; font-size: 1.5rem; color: var(--ink); }
.body { margin: 0 0 6px; color: var(--ink); }
.muted { margin: 0 0 10px; color: var(--muted); }
.cta {
display: inline-block;
padding: 8px 14px;
border-radius: 6px;
background: var(--brand);
color: var(--on-brand);
text-decoration: none;
}
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 WCAG relative-luminance and contrast formulae, in full.
const lum = (hex) => {
const h = hex.replace('#', '');
const [r, g, b] = [0, 2, 4]
.map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
};
const token = (name) =>
(new RegExp('--' + name + ':\\s*(#[0-9a-f]{6})', 'i').exec(css) || [, ''])[1];
test('body text on paper is 17.89 to 1', ratio(token('ink'), token('paper')), 17.89);
test('secondary text is 6.61 to 1, so it clears AA', ratio(token('muted'), token('paper')), 6.61);
test('white on the brand blue is 6.15 to 1', ratio(token('on-brand'), token('brand')), 6.15);
test('every text pair clears the 4.5 body-text threshold', [['ink', 'paper'], ['muted', 'paper'], ['on-brand', 'brand']].every(([f, b]) => ratio(token(f), token(b)) >= 4.5), true);
test('the hairline border clears the 3 to 1 needed for a boundary', ratio(token('hairline'), token('paper')) >= 3, true);
test('but it would NOT be usable as body text', ratio(token('hairline'), token('paper')) >= 4.5, false);
test('and a light blue that looks fine fails badly on white', ratio('#8ba4ff', '#ffffff'), 2.37);
test('the palette is declared once as tokens', (css.match(/--[a-z-]+:\s*#/g) || []).length, 6);
Output
These checks measure the actual palette rather than asserting a claim: the ratio is computed from the hex values in the stylesheet with the WCAG formula, so changing a token changes the number. Three things fall out of it. Contrast is a property of a pair, so the same blue can pass on white and fail on grey. The thresholds differ by role — 4.5 for body text, 3 for large text and for non-text boundaries like the border here, which is why --hairline passes as a border and is refused as text. And the last-but-one check is the common failure: a light accent that reads perfectly well on a dark background lands at 2.37:1 on white.
Answers CSS Practical 43 in the original set.
44 Animate only what is cheap
fairAnimate a panel sliding in and a card lifting, using only the two properties the compositor can handle alone. Say what the expensive alternatives would have cost.
Stuck? Three hints, smallest first
- 1.
transformandopacitycan be animated on the compositor, off the main thread. - 2. Animating
width,height,top,leftormarginforces layout on every frame. - 3.
will-changeis a hint, not a fix. Adding it to everything costs memory and helps nothing.
<article class="card" tabindex="0">
<h3>Abuja</h3>
<p>Hover or focus me: transform and opacity only.</p>
</article>
<div class="tray">
<p class="slide">Slides in when the card above is hovered or focused.</p>
</div>
.tray {
overflow: hidden;
margin-top: 10px;
border: 1px dashed #c9cad1;
border-radius: 8px;
padding: 8px;
}
.slide {
margin: 0;
font-size: 0.8rem;
color: #5c5c66;
/* transform and opacity: no layout, no paint of anything else. */
transform: translateX(-8px);
opacity: 0.45;
transition: transform 200ms ease, opacity 200ms ease;
}
.card {
box-sizing: border-box;
max-width: 260px;
padding: 12px;
border: 1px solid #c9cad1;
border-radius: 10px;
background: #ffffff;
font-size: 0.8rem;
transition: transform 180ms ease, box-shadow 180ms ease;
}
.card h3 { margin: 0 0 4px; font-size: 0.9rem; }
.card p { margin: 0; color: #5c5c66; }
.card:hover,
.card:focus-visible {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgb(23 23 26 / 12%);
}
/* The tray follows the card in the source, so a sibling combinator reaches it.
It would not work the other way round: CSS cannot select backwards. */
.card:hover ~ .tray .slide,
.card:focus-visible ~ .tray .slide,
.tray:hover .slide {
transform: none;
opacity: 1;
}
.card:focus-visible {
outline: 2px solid #3055d8;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.slide, .card { transition: none; }
}
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 properties are named in every transition list.
const animated = [...css.matchAll(/transition:\s*([^;]+);/g)]
.flatMap((m) => m[1].split(',').map((part) => part.trim().split(/\s+/)[0]))
.filter((p) => p !== 'none');
const expensive = ['width', 'height', 'top', 'left', 'margin', 'padding'];
test('three properties are transitioned in all', animated.length, 4);
test('and they are only transform, opacity and box-shadow', [...new Set(animated)].sort(), ['box-shadow', 'opacity', 'transform']);
test('nothing that forces layout is animated', animated.some((p) => expensive.includes(p)), false);
test('the movement is a transform, not a position offset', /transform:\s*translateY\(-4px\)/.test(css), true);
test('and the fade is opacity, not a colour change', /opacity:\s*0\.45/.test(css), true);
test('will-change is not sprayed around as a fix', /will-change/.test(css), false);
test('reduced motion turns the transitions off', /prefers-reduced-motion: reduce/.test(css), true);
test('keyboard focus triggers the same states as hover', /\.card:focus-visible/.test(css), true);
Output
transform and opacity can be handed to the compositor and animated without touching layout or paint, so they stay smooth even while the main thread is busy. Animating left or width instead forces the browser to re-lay-out and repaint on every frame, which is where dropped frames come from — and the visual result is identical, so there is nothing to trade off. box-shadow is a mild exception: it repaints but does not reflow, which is usually acceptable for a hover. will-change is a promise to the browser to allocate resources ahead of time; on every element it is just memory spent for nothing.
Answers CSS Practical 44 in the original set.
45 A page that prints properly
fairAdd print styles: drop the interface furniture, make the type economical, print link destinations that would otherwise be lost, and stop headings being orphaned at the foot of a page.
Stuck? Three hints, smallest first
- 1.
@media printis a media type, not a feature query, so it takes no parentheses. - 2.
attr(href)in generated content is how you print the URL behind a link. - 3.
break-inside: avoidandbreak-after: avoidare the modern replacements for thepage-break-*properties.
<nav class="bar no-print" aria-label="Main">
<a href="#routes">Routes</a>
<a href="#fares">Fares</a>
</nav>
<article class="ticket">
<h2>Boarding pass</h2>
<p>Lagos to Abuja, 06:30, bay B, seat 14.</p>
<p>Conditions at <a href="https://example.com/terms">example.com/terms</a>.</p>
</article>
<p class="hint no-print">Use your browser’s print preview to see the rules below
applied.</p>
.bar {
display: flex;
gap: 14px;
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 8px;
background: #f2f3f5;
font-size: 0.85rem;
}
.bar a { color: #3055d8; }
.ticket {
padding: 12px;
border: 1px solid #c9cad1;
border-radius: 8px;
font-size: 0.85rem;
}
.ticket h2 { margin: 0 0 6px; font-size: 1rem; }
.ticket p { margin: 0 0 6px; }
.hint { margin: 10px 0 0; font-size: 0.78rem; color: #5c5c66; }
@media print {
/* Interface furniture is not part of the document. */
.no-print { display: none; }
body {
font-size: 11pt; /* pt is the sane unit on paper */
color: #000000;
background: #ffffff;
}
.ticket {
border: 1px solid #000000;
border-radius: 0;
break-inside: avoid; /* keep the pass on one page */
}
h2 {
break-after: avoid; /* never leave a heading stranded at the foot */
}
/* A printed link loses its destination, so print it. */
a[href^='http']::after {
content: ' (' attr(href) ')';
font-size: 9pt;
}
}
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 print = (/@media print \{([\s\S]*?)\n\}\s*$/.exec(css) || [, ''])[1];
test('there is a print block, with no parentheses on the media type', /@media print \{/.test(css), true);
test('the interface furniture is dropped', /\.no-print \{ display:\s*none/.test(print), true);
test('and both such elements are marked in the markup', (html.match(/no-print/g) || []).length, 2);
test('type is sized in points for paper', /font-size:\s*11pt/.test(print), true);
test('the ticket is kept on one page', /break-inside:\s*avoid/.test(print), true);
test('and headings are not stranded at the foot of one', /break-after:\s*avoid/.test(print), true);
test('link destinations are printed from the attribute', /content:\s*' \(' attr\(href\)/.test(print), true);
test('the old page-break properties are not used', /page-break-/.test(css), false);
Output
@media print takes no parentheses because print is a media type, not a feature — @media (print) is invalid and silently does nothing, which is the usual first mistake. The rest is about what paper cannot do: it has no navigation, so the bar goes; it has no hover and no clicking, so a link’s destination has to be printed with attr(href) or the information is simply lost; and it has page boundaries, so break-inside and break-after stop a ticket being split in half or a heading being left alone at the bottom. pt is the right unit here for once, because a physical page has physical dimensions.
Answers CSS Practical 45 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.