HTML & CSS NotesRohit’s interview study guide
Chapter 13

Review Questions

Test yourself: interview-style questions across the whole site, with worked answers and live, tested results.

5 topics

#HTML and forms

Added

These questions check that you know what the browser gives you for free when you pick the right element: a role, a name, keyboard behaviour and form behaviour, none of which a styled div has. Answer each one out loud before you open it. The first paragraph of each answer is what you'd say in the interview; the walkthrough is the reasoning behind it. Every "predict" answer comes with a live result whose hidden test proved it in Chrome 155.

Predict: which of these can a keyboard user reach?
HTML
<div class="btn">Save</div>
<button>Save</button>
<a>Docs</a>
<a href="/docs">Docs</a>

Each one has a click handler attached in JavaScript. What does each one expose to a screen reader, and which ones can you Tab to?

Show answer

Only the <button> and the <a href>. The div and the <a> without href both come out as role generic with no name and can't be focused, so a keyboard user can't reach them and a screen reader doesn't announce them as controls. The <button> is a focusable button named "Save" that also activates with Enter and Space; the <a href> is a focusable link named "Docs" that activates with Enter. The rule I'd state: a button does something on this page, a link goes somewhere, and an <a> with no href is neither.

Roles and focus, measured
CSS
.btn { display: inline-block; padding: 4px 12px; border-radius: 6px; background: #2563eb; color: #fff; }
HTML
<div class="btn">Save</div>
<button>Save</button>
<a>Docs</a>
<a href="/docs">Docs</a>
Resultlive, in your browser
What’s happening
  1. The div styled as a button looks identical to a real one, but its role is generic and its name is empty: assistive tech sees an anonymous box. focus() on it does nothing, so Tab skips it and Enter or Space can never trigger the click handler.
  2. The <button> is a button named "Save" (from its text), it's in the tab order, and the browser turns Enter and Space into a click for you. That's the free behaviour you'd otherwise rebuild with tabindex="0", role="button" and a keydown handler.
  3. <a>Docs</a> without href is a placeholder link: role generic, not focusable. Its tabIndex property still reads 0, which is a trap if you check focusability that way; the test only got focus onto elements that are really focusable.
  4. <a href="/docs"> is a link named "Docs" and focusable. A link also gets "open in new tab", copy and bookmark, which is why navigation should be a link and not a button with location.href.
  5. If you changed the div to <div role="button" tabindex="0">, the role and focus would be fixed, but Enter and Space still wouldn't click it: ARIA changes what's announced, never what happens.

Learn more: Semantic HTML and why it matters, Links and navigation and The first rule of ARIA.

Predict: what does this form put in the URL?
HTML
<form action="/search" method="get">
  <input name="q" value="css grid">
  <input value="unnamed">
  <input name="lang" value="en" disabled>
  <input type="checkbox" name="exact" checked>
  <input type="checkbox" name="safe">
  <select name="sort">
    <option>newest</option>
    <option value="top" selected>Top rated</option>
  </select>
  <button name="go" value="1">Search</button>
</form>

The user clicks Search without changing anything. What's the query string?

Show answer

?q=css+grid&exact=on&sort=top&go=1. The form data set contains every control that has a name, isn't disabled and, for checkboxes and radios, is checked, plus the button that submitted the form if it has a name. Values are URL-encoded, so the space becomes +, a checkbox without a value sends on, and a select sends its selected option's value.

The form data set, built by the browser
HTML
<form action="/search" method="get">
  <input name="q" value="css grid">
  <input value="unnamed">
  <input name="lang" value="en" disabled>
  <input type="checkbox" name="exact" checked>
  <input type="checkbox" name="safe">
  <select name="sort">
    <option>newest</option>
    <option value="top" selected>Top rated</option>
  </select>
  <button name="go" value="1">Search</button>
</form>
Resultlive, in your browser
What’s happening
  1. q has a name and a value, so it's sent as q=css+grid: the application/x-www-form-urlencoded format encodes a space as +.
  2. The second input has no name, so it's left out completely; lang is disabled, so it's left out too (a readonly field would have been sent).
  3. exact is checked and has no value attribute, so it sends the default on. safe is unchecked, so the key doesn't appear at all, which is why servers treat a missing checkbox as "false".
  4. The select sends the selected option's value, top, not its text "Top rated". The first option has no value, so when it's selected it sends its text, newest, which the test confirmed.
  5. The button's name=value pair is only included when that button submitted the form. new FormData(form, button) builds the same list the browser sends on that click, and the test confirmed the click submits with the button as event.submitter.

Learn more: How a form submits and select, datalist and textarea.

Predict: which fields are labelled?
HTML
<label for="email">Email</label> <input id="email" type="email">
<label>Phone <input id="phone" type="tel"></label>
<label>City</label> <input id="city">
<input id="search" placeholder="Search">

What accessible name does each input get, and which labels can you click to focus the field?

Show answer

Email and Phone are labelled; City and Search aren't. for/id and wrapping are the two ways to associate a <label>, and both give the input its name and make the label text clickable. A <label> next to an input without for is just text, so City has no name at all. Search does get a name, "Search", because Chrome falls back to the placeholder as a last resort, but a placeholder isn't a label: it disappears as soon as you type and is usually too faint to read.

Names and label clicks
CSS
label, input { margin: 4px 8px 4px 0; }
HTML
<label for="email">Email</label> <input id="email" type="email"><br>
<label>Phone <input id="phone" type="tel"></label><br>
<label>City</label> <input id="city"><br>
<input id="search" placeholder="Search">
Resultlive, in your browser
What’s happening
  1. <label for="email"> points at the input's id, so email.labels has one entry, the name is "Email", and clicking the label text moves focus into the field.
  2. The wrapping label needs no ids: the input inside it is its labelled control, so Phone gets the same two benefits.
  3. The City label has no for and doesn't wrap anything, so the browser can't connect it: labels.length is 0, the name is empty and clicking "City" does nothing. Visually it looks labelled, which is why this bug survives code review.
  4. Search has no label at all. Its name "Search" comes from the placeholder, the last step of the name computation, but the hint vanishes when the user types and placeholder text usually fails contrast, so it's still a failure.

Learn more: Labels and accessible names for inputs and Accessible names and descriptions.

Predict: :invalid vs :user-invalid on page load
CSS
input:invalid      { outline: 3px solid crimson; }
input:user-invalid { background: #fee2e2; }
HTML
<form>
  <input id="email" type="email" required>
  <input id="site" type="url" value="example.com">
  <button>Send</button>
</form>

Which fields have the red outline and which have the pink background when the page loads? And after the user presses Send?

Show answer

On load both fields have the red outline and neither is pink. After Send, both are pink, the submit is cancelled and focus goes to the email field. :invalid matches whenever the value fails a constraint, including before the user has typed anything, which is why forms styled with it shout at people on arrival. :user-invalid waits until the user has changed the field and moved on, or has tried to submit, and that's the one to style errors with.

Before and after a submit attempt
CSS
input { margin-right: 6px; }
input:invalid      { outline: 3px solid crimson; }
input:user-invalid { background: #fee2e2; }
HTML
<form>
  <input id="email" type="email" required>
  <input id="site" type="url" value="example.com">
  <button>Send</button>
</form>
Resultlive, in your browser
What’s happening
  1. The email field is required and empty (valueMissing), and the URL field holds example.com, which has no scheme, so it fails type="url" (typeMismatch). Both match :invalid from the first frame and get the crimson outline.
  2. Neither matches :user-invalid yet, because the user hasn't interacted with either, so neither background is pink. A value written in the markup doesn't count as interaction.
  3. Pressing Send runs interactive validation (the test calls requestSubmit(), which does the same). The browser finds invalid fields, cancels the submit (no submit event fired), focuses the first invalid one and shows its message.
  4. A submit attempt also flips every field's "user validity" flag, so both now match :user-invalid and turn pink at once. That's the behaviour you want: quiet until the user acts, then every problem flagged together.

Learn more: Styling valid and invalid fields and Built-in validation.

Predict: is the hidden banner hidden?
CSS
.banner { display: flex; gap: 8px; padding: 8px; background: #fef3c7; }
HTML
<div class="banner" hidden>Settings saved</div>
Show answer

No, it's visible. The hidden attribute works through one rule in the browser's own stylesheet, [hidden] { display: none }, and author CSS beats the user-agent origin whatever the specificity. So any display you set on the element, here flex, overrides it. The fix most resets include is [hidden] { display: none !important; }, which keeps the attribute in charge.

The attribute loses to display: flex
CSS
.banner { display: flex; gap: 8px; padding: 8px; margin-bottom: 8px; background: #fef3c7; }
.with-reset [hidden] { display: none !important; }
HTML
<div class="banner" id="plain" hidden>Settings saved</div>
<div class="with-reset"><div class="banner" id="reset" hidden>Settings saved</div></div>
Resultlive, in your browser
What’s happening
  1. hidden is an HTML attribute, but it has no magic of its own: Chrome's user-agent stylesheet maps it to display: none, a normal declaration in the lowest origin.
  2. The cascade compares origin before specificity, so the author rule .banner { display: flex } wins even though [hidden] would tie with a class. The test reads display: flex and a banner more than 20px tall: it shows.
  3. In the second box, the reset [hidden] { display: none !important; } (scoped to a wrapper here so both cases fit in one demo) beats the normal display: flex, so the box is gone: display: none, height 0.
  4. That's why !important is acceptable here: the attribute's whole job is to hide, so nothing should be able to override it by accident.

Learn more: Global attributes and !important.

Predict: which button does Enter press?
HTML
<form>
  <input name="q">
  <button>Clear</button>
  <button type="submit">Search</button>
</form>

The Clear button has a click handler that empties the field. The user types and presses Enter. What happens?

Show answer

The browser presses Clear. A <button> without a type is a submit button, and pressing Enter in a text field triggers the form's default button, the first submit button in tree order, by firing a click on it. So the field is emptied by Clear's handler and the form is submitted with Clear as the submitter. The fix is type="button" on every button that isn't meant to submit.

The form's default button
HTML
<form>
  <input name="q">
  <button id="clear">Clear</button>
  <button type="submit" id="search">Search</button>
</form>
Resultlive, in your browser
What’s happening
  1. $("#clear").type is "submit": the missing attribute defaults to submit inside a form. So the form has two submit buttons, and the first in tree order is its default button.
  2. The :default pseudo-class matches exactly that button, and the test found it on Clear. Implicit submission (Enter in a single-line field) fires a click on the default button; the test can't press a real Enter key, so it relies on :default for which button that is.
  3. Clicking Clear submitted the form with event.submitter set to Clear. So with Enter, Clear's handler empties the field and then the form submits, the opposite of what the user wanted.
  4. After setting Clear's type to button, :default moves to Search, and Enter now submits with Search. That's why type="button" belongs on every toggle, "add row" or "clear" button in a form.

Learn more: Buttons in forms.

Predict: how many landmarks does this page have?
HTML
<header>Site name</header>
<main>
  <article>
    <header><h2>Post title</h2></header>
    <p>…</p>
    <footer>Posted 20 Oct</footer>
  </article>
  <section><h2>Comments</h2></section>
  <section aria-labelledby="rel"><h2 id="rel">Related</h2></section>
</main>
<footer>© 2026</footer>

A screen-reader user opens the landmarks list. What's in it?

Show answer

Four: banner, main, the region "Related", and contentinfo. Only the page-level header and footer become the banner and contentinfo landmarks; inside an article they're section header and footer parts, not landmarks. A section is only a region landmark when it has an accessible name, so the unnamed Comments section is a plain generic box. The article itself is exposed as an article, which isn't a landmark either.

Roles of each sectioning element
HTML
<header id="h-page">Site name</header>
<main id="m">
  <article id="art">
    <header id="h-art"><h2>Post title</h2></header>
    <p>Text of the post.</p>
    <footer id="f-art">Posted 20 Oct</footer>
  </article>
  <section id="s-comments"><h2>Comments</h2></section>
  <section id="s-related" aria-labelledby="rel"><h2 id="rel">Related</h2></section>
</main>
<footer id="f-page">© 2026</footer>
Resultlive, in your browser
What’s happening
  1. The top-level <header> and <footer> come out as banner and contentinfo: the landmarks that let a user jump to the site header or footer.
  2. The same elements inside the article come out as sectionheader and sectionfooter in Chrome 155. That context rule keeps a page with twenty cards from having twenty banners.
  3. <main> is the main landmark, and there should be one per page. The article has role article, useful for "next article" navigation but not in the landmarks list.
  4. The Comments section has no accessible name, so it's generic; the Related one is named by its heading through aria-labelledby, which makes it a region called "Related". The test counted exactly four landmark roles.

Learn more: Landmarks and sectioning.

Explain: defer vs async
HTML
<head>
  <script src="analytics.js" async></script>
  <script src="vendor.js" defer></script>
  <script src="app.js" defer></script>
</head>

When does each script run, and why is this the right attribute for each?

Show answer

Both attributes download the script in parallel with parsing; they differ in when it runs. defer scripts run after the whole document is parsed, in document order, just before DOMContentLoaded, so vendor.js always runs before app.js and both can use the full DOM. An async script runs as soon as it arrives, interrupting the parser if it's still going, in no guaranteed order, which suits independent code like analytics. A plain <script src> without either blocks parsing while it downloads and runs, and module scripts behave like defer by default.

What’s happening
  1. The parser meets all three tags at the top of the page and starts all three downloads at once, without stopping: that's what both attributes buy over a plain script.
  2. analytics.js runs the moment its download finishes. That might be before or after the body is parsed, and before or after the other two, so it must not depend on the DOM being complete or on vendor.js.
  3. vendor.js and app.js wait until parsing is done, then run in the order they appear, even if app.js finished downloading first. So app.js can rely on what vendor.js defines and on every element in the page.
  4. DOMContentLoaded fires after the deferred scripts have run; async scripts don't hold it up. Neither attribute has any effect on an inline <script> without src (a module inline script is the exception: it's deferred).
  5. If you changed app.js to async, it could run before vendor.js and fail on an undefined global, intermittently, depending on network timing.

Learn more: The document skeleton and How a browser turns HTML into a page.

#The box model and the cascade

Added

These questions are the "explain why it looks like that" kind: how big a box really is, why a margin escapes its parent, and which of several rules wins. The cascade questions are the ones interviewers love because the obvious answer is wrong: remember the order of the checks (origin and importance, layers, specificity, then source order) and that inheritance only applies when nothing sets the property at all.

Predict: how wide are these two boxes?
CSS
.a, .b { width: 200px; padding: 20px; border: 5px solid #64748b; margin: 10px; }
.b     { box-sizing: border-box; }

Two siblings in a flex row: .a then .b. How wide is each box on screen, how wide is each content area, and how far apart are they?

Show answer

.a is 250px wide with a 200px content area; .b is 200px wide with a 150px content area; they're 20px apart. With the default content-box, width sizes only the content, so padding and border are added on top: 200 + 2 × 20 + 2 × 5. With border-box, width is the border box, so padding and border come out of the content: 200 − 50 = 150. Margins are outside both, and horizontal margins never collapse, so 10 + 10 = 20px.

The two boxes measured
CSS
.row   { display: flex; }
.a, .b { width: 200px; padding: 20px; border: 5px solid #64748b; margin: 10px; background: #e0f2fe; }
.b     { box-sizing: border-box; background: #fef3c7; }
HTML
<div class="row"><div class="a">a</div><div class="b">b</div></div>
Resultlive, in your browser
What’s happening
  1. .a uses content-box: content 200, plus 20 + 20 padding and 5 + 5 border, so the test measured a 250px-wide box. Its clientWidth (content plus padding) is 240, so the content is 200.
  2. .b uses border-box: the border box is exactly 200, so the content shrinks to 200 − 40 − 10 = 150.
  3. Both have margin: 10px. .a's right margin and .b's left margin sit side by side, 20px in total; only vertical margins in block layout collapse, and these are horizontal flex items anyway.
  4. Both report width: 200px in computed style, so reading width doesn't tell you the rendered size. That's why resets set box-sizing: border-box everywhere: width: 100% plus padding then still fits its parent.

Learn more: The box model and box-sizing.

Predict: where does the child's margin go?
CSS
.intro  { height: 20px; }
.parent { background: #bfdbfe; }
.child  { margin-top: 30px; height: 50px; }
HTML
<div class="intro"></div>
<div class="parent"><div class="child"></div></div>

How tall is .parent, and where is the 30px gap?

Show answer

.parent is 50px tall, and the 30px gap appears above it, outside its blue background. The child's top margin touches the parent's top margin (there's no padding, border or content between them), so the two collapse into one margin that belongs to the parent. Fix it with display: flow-root on the parent (or any padding or border), which stops the collapse: the parent becomes 80px tall and the gap is inside it.

The margin escapes, then the fix
CSS
.case   { display: flow-root; width: 200px; margin-bottom: 12px; outline: 1px dashed #94a3b8; }
.intro  { height: 20px; background: #fde68a; }
.parent { background: #bfdbfe; }
.child  { margin-top: 30px; height: 50px; background: #60a5fa; }
.fixed .parent { display: flow-root; }
HTML
<div class="case" id="bug"><div class="intro"></div><div class="parent"><div class="child"></div></div></div>
<div class="case fixed" id="fix"><div class="intro"></div><div class="parent"><div class="child"></div></div></div>
Resultlive, in your browser
What’s happening
  1. .parent has no padding, border or inline content above .child, so the child's 30px top margin is adjoining the parent's own top margin (0). Adjoining vertical margins collapse into one: max(0, 30) = 30px.
  2. The collapsed margin sits on the outside of the parent, so the parent moves down 30px from .intro and the child starts exactly at the parent's top edge: the gap is white, not blue, and the parent is only 50px tall.
  3. display: flow-root makes the parent a new block formatting context, and margins never collapse through a formatting-context root. Now the 30px stays inside: the parent is 80px tall and the child sits 30px below its top.
  4. Each .case wrapper is itself flow-root, so the escaping margin can't escape any further and the two cases measure independently. On a real page the margin would keep escaping until something stopped it, sometimes pushing the whole page down from <body>.

Learn more: Margin collapsing.

Predict: which colour wins?
CSS
:is(#main, .card) .title { color: crimson; }
.card h2.title.big       { color: green; }
:where(#main, .card) .title.big.x { color: navy; }
HTML
<div class="card"><h2 class="title big x">Hello</h2></div>

There's no #main anywhere on the page.

Show answer

Crimson. :is() takes the specificity of its most specific argument, whether or not that argument matched, so the first rule counts as (1, 1, 0): an id beats any number of classes. The second rule is (0, 3, 1). :where() always adds zero, so the third is only (0, 3, 0). Remove the first rule and green wins.

The specificity, proved by removing a rule
CSS
:is(#main, .card) .title { color: crimson; }
.card h2.title.big       { color: green; }
:where(#main, .card) .title.big.x { color: navy; }
HTML
<div class="card"><h2 class="title big x">Hello</h2></div>
Resultlive, in your browser
What’s happening
  1. The page has no #main, so :is(#main, .card) matches through .card. Specificity is worked out from the selector, not from what matched: :is() takes its strongest argument, #main, so the rule scores (1, 1, 0).
  2. .card h2.title.big has three classes and one type: (0, 3, 1). Comparing column by column from the left, the id column decides: 1 beats 0, so crimson wins even though green looks more specific.
  3. :where(#main, .card) contributes nothing, so the third rule is just its three classes, (0, 3, 0), and loses to the second on the type column.
  4. The test deleted the :is() rule from the stylesheet and the heading turned green, which proves the second rule was runner-up. That's why libraries wrap their defaults in :where(): easy to override, and why :is() with an id inside quietly makes a rule hard to beat.

Learn more: Specificity and :is(), :where(), :not() and :has().

Predict: layers, ids and !important
CSS
@layer base, theme;
@layer theme { #note { color: green; } }
p { color: navy; }

@layer base  { .msg { color: crimson !important; } }
@layer theme { .msg { color: purple !important; } }
.loud { color: orange !important; }
HTML
<p id="note">A</p>
<p class="msg">B</p>
<p class="msg loud">C</p>

What colour is each paragraph?

Show answer

A is navy, B is crimson, C is crimson. Layers are compared before specificity. For normal declarations, unlayered styles beat every layer, so a bare p beats #note in a layer. For !important the order flips: the earliest layer wins, and important declarations in any layer beat unlayered important ones. So base's crimson beats theme's purple and the unlayered orange.

Three paragraphs, three cascade decisions
CSS
@layer base, theme;
@layer theme { #note { color: green; } }
p { color: navy; }

@layer base  { .msg { color: crimson !important; } }
@layer theme { .msg { color: purple !important; } }
.loud { color: orange !important; }
HTML
<p id="note">A</p>
<p class="msg">B</p>
<p class="msg loud">C</p>
Resultlive, in your browser
What’s happening
  1. A has two normal declarations: #note in the theme layer, (1, 0, 0), and p unlayered, (0, 0, 1). The cascade checks layers before specificity, and unlayered normal styles count as the last, strongest layer, so navy wins and the id never gets compared.
  2. B has two important declarations, in base and theme. Importance reverses the layer order, so the first-declared layer, base, is the strongest: crimson. The reversal is deliberate: a reset layer can mark something !important and later layers can't take it away.
  3. C adds an unlayered .loud { color: orange !important }, with higher specificity than .msg. It still loses: among important declarations unlayered ones are the weakest, so crimson again.
  4. The pattern to remember: for normal rules the order is base < theme < unlayered; for !important it's unlayered < theme < base. That's why third-party CSS imported into an early layer can't be beaten by your unlayered !important.

Learn more: Cascade layers with @layer, The cascade and !important.

Predict: what does the link inherit?
CSS
.notice { color: #166534; font-weight: bold; border: 2px solid; padding: 8px; }
HTML
<p class="notice">Read the <a href="#terms">terms</a>, then <button>Accept</button></p>

What colour and weight are the link text and the button text, and what colour is the border?

Show answer

The link is the browser's link blue (rgb(0, 0, 238)) but bold; the button is black, normal weight and a smaller size; the border is green. Inheritance only fills in a property that no rule sets at all, and the browser's own stylesheet sets color on links and color and font on buttons, so those don't inherit. Nothing sets font-weight on a link, so it inherits bold. The border has no colour, so it uses currentColor, the element's own green. That's why resets add a { color: inherit } and button { font: inherit; color: inherit }.

Inherited and not inherited
CSS
.notice { color: #166534; font-weight: bold; border: 2px solid; padding: 8px; }
HTML
<p class="notice">Read the <a href="#terms">terms</a>, then <button>Accept</button></p>
Resultlive, in your browser
What’s happening
  1. color is an inherited property, but the user-agent stylesheet has a rule for :link that sets it to rgb(0, 0, 238). Any matching declaration beats inheritance (an inherited value has no specificity at all), so the link stays blue.
  2. The browser doesn't set font-weight on links, so the property is unset on the <a> and it inherits the paragraph's 700: the link is bold.
  3. Buttons get color: buttontext and a whole font shorthand from the browser, so they ignore the paragraph's colour, weight and size: black, 400 and 13.3333px in Chrome.
  4. border isn't inherited, so the link has none. The paragraph's border: 2px solid names no colour, so it uses the initial currentColor, which resolves to the paragraph's own green.

Learn more: Inheritance and the keywords and Resets and normalize.

Predict: which hidden child can come back?
HTML
<div style="visibility: hidden"><span style="visibility: visible">A</span></div>
<div style="display: none"><span style="display: block">B</span></div>
<div style="opacity: 0"><span style="opacity: 1">C</span></div>

Which letters can you see, and which can you click?

Show answer

You see only A. A and C can be clicked; B doesn't exist on screen at all. visibility is inherited, so a child can set visibility: visible and reappear inside a hidden parent, which still keeps its space. display: none removes the whole subtree from layout, and nothing a child sets can bring it back. opacity applies to the element and everything inside it as one group, so a child's opacity: 1 can't undo the parent's 0, but the invisible C still takes clicks and focus.

Three ways to hide, with children opting back in
CSS
.row { display: flex; gap: 10px; }
.box { width: 120px; height: 40px; background: #bfdbfe; }
.box span { display: block; width: 40px; height: 40px; background: #fbbf24; text-align: center; line-height: 40px; }
HTML
<div class="row">
  <div class="box" id="v" style="visibility: hidden"><span id="a" style="visibility: visible">A</span></div>
  <div class="box" id="d" style="display: none"><span id="b" style="display: block">B</span></div>
  <div class="box" id="o" style="opacity: 0"><span id="c" style="opacity: 1">C</span></div>
</div>
Resultlive, in your browser
What’s happening
  1. The first box is visibility: hidden: it keeps its 120px of space but isn't painted or hit-tested; the test's point inside it, beside A, didn't land on it. Its child sets visibility: visible, which wins over the inherited hidden, so A is painted and clickable.
  2. The second box is display: none, so neither it nor B has a box: both measure 0 wide, and the third box sits 10px (the gap) after the first, as if the second weren't there. B's display: block changes nothing because its parent generates no box for it to live in.
  3. The third box has opacity: 0. Opacity is applied to the rendered group, element plus descendants, so C's own opacity: 1 is multiplied by the parent's 0: it's invisible (check the live result above: there's no C). The test can't read pixels, so that part is the spec's rule and what you can see in the frame.
  4. But C is still hit-tested: elementFromPoint at its position returned C. An opacity: 0 element is still clickable, focusable and announced, which is why it's a trap as a final "hidden" state.

Learn more: Hiding things and Hiding content.

Predict: em vs rem
CSS
.page  { font-size: 20px; }
.card  { font-size: 1.25em; padding: 1em; }
.title { font-size: 1.5em; padding-left: 1em; margin-bottom: 1rem; }
HTML
<div class="page"><div class="card"><h2 class="title">Title</h2></div></div>

The root font size is the browser default, 16px. Work out the card's font size and padding, and the title's font size, left padding and bottom margin.

Show answer

Card: 25px font, 25px padding. Title: 37.5px font, 37.5px left padding, 16px bottom margin. In font-size, em is relative to the parent's font size; in any other property, it's relative to the element's own computed font size, so em compounds as you nest. rem is always the root's font size, so 1rem is 16px wherever it's used. I default to rem for type and spacing and use em where something should scale with its component, like a button's padding.

Computed sizes
CSS
.page  { font-size: 20px; }
.card  { font-size: 1.25em; padding: 1em; background: #f1f5f9; }
.title { font-size: 1.5em; padding-left: 1em; margin: 0 0 1rem; }
HTML
<div class="page"><div class="card"><h2 class="title">Title</h2></div></div>
Resultlive, in your browser
What’s happening
  1. .card { font-size: 1.25em } multiplies the parent's size: 1.25 × 20 = 25px. Its padding: 1em is then relative to its own font size, 25px.
  2. .title { font-size: 1.5em } multiplies the card's 25px: 37.5px. Its padding-left: 1em uses its own 37.5px. Three levels of em have already turned "1em" into three different lengths.
  3. margin-bottom: 1rem ignores all of that and uses the root's 16px. The 20px on .page doesn't change rem, because rem looks only at <html>.
  4. If you changed .page to 30px, every em value would grow and the rem margin would stay 16px. That compounding is useful inside a component and painful across a page, which is why rem is the default for type.

Learn more: CSS units.

Predict: percentage padding and height
CSS
.parent { width: 300px; }
.child  { height: 50%; padding-top: 50%; }

The parent has no height set and .child has no content. How tall is .child on screen?

Show answer

150px, all of it padding. Percentage padding, vertical included, is resolved against the containing block's width, so 50% of 300px is 150px. A percentage height needs a definite height on the parent, and this parent's height is auto, so height: 50% behaves as auto, which is 0 for an empty box. This is the old padding-top aspect-ratio trick, which aspect-ratio has replaced.

The child measured
CSS
.parent { width: 300px; background: #e2e8f0; }
.child  { height: 50%; padding-top: 50%; background: #93c5fd; }
HTML
<div class="parent"><div class="child"></div></div>
Resultlive, in your browser
What’s happening
  1. padding-top: 50% resolves against the containing block's width, 300px, giving 150px. All four paddings and margins use the width, so a box's padding can't depend on its own height.
  2. height: 50% would need the parent's height, but the parent's height is auto, worked out from its children, which would be circular. So the percentage is treated as auto, and an empty block's auto height is 0px.
  3. The rendered box is padding plus content, 150 + 0 = 150px, with the 300px width giving a 2:1 shape. Before aspect-ratio, this was how you sized a responsive video box.
  4. Give the parent height: 200px and the child's height: 50% would become 100px, with the 150px padding added on top (in content-box).

Learn more: Width, height and intrinsic sizing and aspect-ratio and object-fit.

#Layout: positioning, flexbox and grid

Added

Layout questions are where interviewers ask you to predict sizes and positions. Keep three models in your head: positioned boxes are placed against a containing block, flex items start from a basis and share free space, and grid tracks are sized first, then items placed in them. Most surprises come from a default you forgot: min-width: auto on flex items, the stacking context an ancestor created, or the parent a sticky header can't leave.

Explain: centre a box both ways

"Centre this card inside its container, horizontally and vertically. Give me more than one way."

Show answer

Grid with place-items: center, flexbox with justify-content and align-items (or margin: auto on the child), or absolute positioning with inset: 0; margin: auto for a known size or translate(-50%, -50%) for an unknown one. Grid and flex are the default answers; the absolute versions are for overlays that shouldn't affect layout. For only horizontal centring of a block, margin-inline: auto with a width is enough.

Three centring techniques, measured
CSS
.box   { width: 200px; height: 120px; margin-bottom: 10px; background: #f1f5f9; }
.card  { width: 60px; height: 40px; background: #93c5fd; }
.grid  { display: grid; place-items: center; }
.flex  { display: flex; }
.flex .card { margin: auto; }
.abs   { position: relative; }
.abs .card { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
HTML
<div class="box grid"><div class="card"></div></div>
<div class="box flex"><div class="card"></div></div>
<div class="box abs"><div class="card"></div></div>
Resultlive, in your browser
What’s happening
  1. Grid: place-items: center is align-items plus justify-items, so the single item sits in the middle of its one grid area: 70px from the sides (200 − 60 = 140, halved) and 40px from the top and bottom (120 − 40 = 80, halved).
  2. Flex: margin: auto on a flex item absorbs the free space on both axes equally, giving the same offsets. Unlike justify-content: center, auto margins stay "safe": if the card were bigger than the box, it would overflow at the end instead of being cut off at the start.
  3. Absolute: top: 50%; left: 50% puts the card's top-left corner at the centre of the positioned parent, and translate(-50%, -50%) moves it back by half its own size, so it works without knowing that size.
  4. All three gave identical offsets of 70px and 40px in the test. In an interview, mention the condition each needs: a grid or flex parent, or a positioned ancestor for the absolute version.

Learn more: Centering things: every way.

Predict: flex: 1 vs flex: auto
CSS
.row { display: flex; width: 400px; }
.one  > div { flex: 1; }
.auto > div { flex: auto; }

Each row has two items. The first item's content is 100px wide, the second's is 20px. How wide is each item in each row?

Show answer

With flex: 1 both are 200px; with flex: auto they're 240px and 160px. flex: 1 is 1 1 0%: a zero basis, so all 400px is free space and it's split equally regardless of content. flex: auto is 1 1 auto: each item starts at its content size, 100 and 20, and only the remaining 280px is split, 140 each.

Both rows measured
CSS
.row { display: flex; width: 400px; margin-bottom: 8px; outline: 1px dashed #94a3b8; }
.row > div { height: 30px; background: #e0f2fe; outline: 1px solid #7dd3fc; }
.row i { display: block; height: 30px; background: #fbbf24; }
.one  > div { flex: 1; }
.auto > div { flex: auto; }
HTML
<div class="row one"><div><i style="width: 100px"></i></div><div><i style="width: 20px"></i></div></div>
<div class="row auto"><div><i style="width: 100px"></i></div><div><i style="width: 20px"></i></div></div>
Resultlive, in your browser
What’s happening
  1. flex: 1 expands to flex-grow: 1; flex-shrink: 1; flex-basis: 0%, which the test read from computed style. Both items start at 0, the free space is the full 400px, and equal grow factors split it 200/200.
  2. The content doesn't matter here because both items' minimum (their content, 100 and 20) is below 200. If one item's content were wider than its share, min-width: auto would stop it shrinking further (next question).
  3. flex: auto is 1 1 auto: the basis is the item's content size, 100 and 20. The free space is 400 − 120 = 280px, split 140 each.
  4. So the items end at 100 + 140 = 240 and 20 + 140 = 160. Use flex: 1 for equal columns and flex: auto when bigger content should get more room.

Learn more: The flex shorthand and flex-grow, flex-shrink and flex-basis.

Predict: why does the layout overflow?
CSS
.layout { display: flex; width: 300px; }
.side   { flex: none; width: 100px; }
.main   { flex: 1; }
.wide   { width: 400px; }   /* say, a code block or a table inside .main */

How wide is .main, and what do you change so it stays inside the 300px layout?

Show answer

.main is 400px, so the layout sticks out by 200px. Flex items default to min-width: auto, which means they won't shrink below their content's minimum width, here the 400px child, even with flex: 1. Setting min-width: 0 on .main removes that floor: it shrinks to the 200px it was given, and the wide content overflows inside it, where overflow-x: auto makes it scroll.

The overflow and the fix
CSS
.layout { display: flex; width: 300px; margin-bottom: 12px; outline: 2px dashed #94a3b8; }
.side   { flex: none; width: 100px; height: 30px; background: #fde68a; }
.main   { flex: 1; background: #e0f2fe; }
.wide   { width: 400px; height: 30px; background: linear-gradient(90deg, #93c5fd, #c4b5fd); }
.fixed .main { min-width: 0; overflow-x: auto; }
HTML
<div class="layout" id="bug"><div class="side"></div><div class="main"><div class="wide"></div></div></div>
<div class="layout fixed" id="fix"><div class="side"></div><div class="main"><div class="wide"></div></div></div>
Resultlive, in your browser
What’s happening
  1. The free space is 300 − 100 = 200px, and flex: 1 asks for .main to be 200px. But its automatic minimum is its min-content width, which is the 400px .wide box, and a flex item never shrinks below its minimum.
  2. So .main is 400px and its right edge is 200px past the layout's; on a phone that's the page wider than the screen and a sideways scroll.
  3. min-width: 0 replaces the automatic minimum with zero, so .main gets exactly its 200px share and lines up with the layout's edge.
  4. The wide content now overflows .main itself, and overflow-x: auto turns that into a scroll area: scrollWidth is 400 inside a 200px box. Any overflow other than visible also removes the automatic minimum on its own; the same problem in a column is min-height: auto.

Learn more: Why flex items overflow.

Predict: auto-fill vs auto-fit with two items
CSS
.fill { display: grid; width: 500px; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
.fit  { display: grid; width: 500px; grid-template-columns: repeat(auto-fit,  minmax(100px, 1fr)); }

Each grid has only two items. How wide is each item?

Show answer

100px in the auto-fill grid and 250px in the auto-fit grid. Both create as many 100px-minimum columns as fit, five in 500px. auto-fill keeps the three empty columns, so they still take their share of the space and the items stay 100px. auto-fit collapses the empty columns to zero, so the two items' 1fr tracks share the whole row.

Two items, five possible columns
CSS
.g { display: grid; width: 500px; margin-bottom: 8px; outline: 1px dashed #94a3b8; }
.g div { height: 30px; background: #c7d2fe; outline: 1px solid #818cf8; }
.fill { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
.fit  { grid-template-columns: repeat(auto-fit,  minmax(100px, 1fr)); }
HTML
<div class="g fill"><div></div><div></div></div>
<div class="g fit"><div></div><div></div></div>
Resultlive, in your browser
What’s happening
  1. Both grids first count how many tracks fit using the minimum: floor(500 / 100) = 5. So both have five columns before anything else happens.
  2. auto-fill leaves all five in place. The 1fr maximum shares the leftover space between five tracks, but there's none left, so each is 100px and the two items sit in the first two, with three empty columns after them.
  3. auto-fit then collapses every track that has no item to 0px, which the test read from grid-template-columns: 250px 250px 0px 0px 0px. The two real tracks split the 500px.
  4. With five or more items the two behave identically. Pick auto-fill when cards should keep a consistent size, auto-fit when a few items should spread out.

Learn more: repeat(), minmax() and auto-fill vs auto-fit.

Predict: z-index 9999 that loses
CSS
.header { position: absolute; top: 0;    width: 240px; height: 50px; z-index: 2; }
.page   { position: absolute; top: 30px; width: 240px; height: 90px; z-index: 1; }
.modal  { position: absolute; top: -20px; left: 40px; width: 120px; height: 60px; z-index: 9999; }
HTML
<div class="header"></div>
<div class="page"><div class="modal"></div></div>

The modal is inside .page and overlaps the header. Which one is on top where they overlap?

Show answer

The header. .page is positioned with z-index: 1, so it creates a stacking context, and everything inside it is painted as one unit at level 1. The modal's 9999 only orders it within .page; against the header, it's .page's 1 against the header's 2. The fix is to change the ancestor: remove .page's z-index (or move the modal out, or use <dialog>, which goes to the top layer).

The trap, and the ancestor fixed
CSS
.stage  { position: relative; isolation: isolate; height: 130px; margin-bottom: 10px; }
.header { position: absolute; top: 0; left: 0; width: 240px; height: 50px; z-index: 2; background: #fde68a; }
.page   { position: absolute; top: 30px; left: 0; width: 240px; height: 90px; z-index: 1; background: #e2e8f0; }
.modal  { position: absolute; top: -20px; left: 40px; width: 120px; height: 60px; z-index: 9999; background: #f9a8d4; }
.fixed .page { z-index: auto; }
HTML
<div class="stage" id="bug"><div class="header"></div><div class="page"><div class="modal"></div></div></div>
<div class="stage fixed" id="fix"><div class="header"></div><div class="page"><div class="modal"></div></div></div>
Resultlive, in your browser
What’s happening
  1. The modal's top edge is 20px above .page's, so its upper part overlaps the header (y 10 to 50 in the stage). The test picks a point in that overlap and asks the browser which element is hit there.
  2. In the first stage, the stage's stacking context holds two children: the header at z-index: 2 and .page at z-index: 1, which forms its own context because it's positioned with a z-index. The header wins, and the modal inside .page goes down with it.
  3. The 9999 is real, but it's only compared with .page's other descendants. No number inside a context can lift a child above something that beats the context itself.
  4. In the second stage .page has z-index: auto, so it no longer forms a context. The modal now competes directly in the stage's context, 9999 against 2, and wins. Remember that opacity, transform and filter create contexts too, without any z-index.

Learn more: z-index and stacking contexts.

Predict: where does the fixed toast go?
CSS
.card  { transform: translateX(0); margin: 40px 0 0 60px; width: 200px; height: 80px; }
.toast { position: fixed; top: 0; left: 0; }
HTML
<div class="card"><div class="toast">Saved</div></div>

Is the toast at the top-left of the viewport?

Show answer

No, it's at the top-left of the card. A transform on an ancestor (even one that does nothing, like translateX(0)) makes that ancestor the containing block for fixed-position descendants, so top: 0; left: 0 is measured from the card and the toast scrolls with it. filter, perspective, contain: layout and will-change: transform do the same. Put toasts and modals outside transformed ancestors, or in the top layer with popover or <dialog>.

Fixed inside a transformed card
CSS
.card  { margin: 40px 0 0 60px; width: 200px; height: 80px; background: #e0e7ff; }
.moved { transform: translateX(0); }
.toast { position: fixed; top: 0; left: 0; width: 80px; height: 24px; background: #fca5a5; }
HTML
<div class="card moved" id="moved"><div class="toast">Saved</div></div>
<div class="card" id="plain"><div class="toast">Saved</div></div>
Resultlive, in your browser
What’s happening
  1. Normally a fixed element's containing block is the viewport, which is what the second card's toast shows: it's at (0, 0), even though its parent is further down the page.
  2. The first card has transform: translateX(0). Visually that changes nothing, but any transform other than none makes the element a containing block for absolute and fixed descendants.
  3. So the first toast's top: 0; left: 0 resolves against the card's padding box, and the test found it exactly at the card's top-left corner, (72, 40) in the frame (the 12px body margin plus the card's 60px; the card's 40px top margin collapses with the body's 12px), not at the viewport's.
  4. In real code the transform usually comes from an animation library or a will-change someone added for performance, far from the toast's own CSS. When a fixed element scrolls or is clipped, look up its ancestors for one of these properties.

Learn more: position: fixed and Transforms.

Predict: why doesn't the header stick?
CSS
.scroller { height: 150px; overflow: auto; }
.sticky   { position: sticky; top: 0; height: 30px; }
HTML
<div class="scroller">
  <div class="wrap"><h3 class="sticky">Inbox</h3></div>
  <div class="content"><!-- 400px of messages --></div>
</div>

The user scrolls the list down by 100px. Where's the header?

Show answer

It scrolls away with the list. A sticky element can only stick while it stays inside its containing block, here .wrap, and .wrap is exactly as tall as the header, so there's no room to move: as soon as .wrap scrolls off, the header goes with it. Make the header a direct child of the element that contains the content (or make the wrapper contain the content too), and it sticks.

Scrolled by 100px
CSS
.scroller { height: 150px; width: 220px; overflow: auto; margin-bottom: 12px; outline: 1px solid #94a3b8; }
.sticky   { position: sticky; top: 0; height: 30px; margin: 0; background: #fde68a; }
.content  { height: 400px; background: linear-gradient(#e0f2fe, #bfdbfe); }
HTML
<div class="scroller" id="bug"><div class="wrap"><h3 class="sticky">Inbox</h3></div><div class="content"></div></div>
<div class="scroller" id="fix"><h3 class="sticky">Inbox</h3><div class="content"></div></div>
Resultlive, in your browser
What’s happening
  1. Both headers have position: sticky; top: 0 and both scrollers are the nearest scroll container, so the threshold is the same: "don't go above the scroller's top edge".
  2. In the first scroller, the header's containing block is .wrap, 30px tall. Sticky offsets are limited to the containing block, and a 30px header in a 30px box has 0px to travel, so after scrolling 100px it's 100px above the scroller's top, out of view.
  3. In the second scroller, the header's containing block is the scroller's content (430px tall), so it has room: it stays at offset 0 while the messages scroll under it.
  4. The other two classic causes: an ancestor between the header and the intended scroller with overflow: hidden or auto (that becomes the scroll container instead), and a missing top. Use overflow: clip if you need clipping without breaking sticky.

Learn more: position: sticky.

Predict: justify-content in a column
CSS
.col  { display: flex; flex-direction: column; justify-content: center; width: 200px; height: 200px; }
.item { width: 60px; height: 40px; }

Where does the single item end up?

Show answer

Centred vertically, at the left. justify-content always works on the main axis, and in a column the main axis is vertical, so it centres the item top to bottom. The horizontal position is the cross axis, controlled by align-items, whose default stretch can't stretch an item with a fixed width, so it sits at the start. Add align-items: center to centre it both ways.

The item in a column
CSS
.col  { display: flex; flex-direction: column; justify-content: center; width: 200px; height: 200px; background: #f1f5f9; }
.item { width: 60px; height: 40px; background: #93c5fd; }
HTML
<div class="col"><div class="item"></div></div>
Resultlive, in your browser
What’s happening
  1. flex-direction: column makes the main axis run top to bottom, so justify-content: center splits the free vertical space, 200 − 40 = 160, equally: 80px above the item.
  2. The cross axis is now horizontal. align-items defaults to normal, which behaves as stretch in flexbox, but stretch only applies to items with an auto cross size, and this item is 60px wide.
  3. So the item sits at the cross-start edge, the left: 0px from the container's left side.
  4. The quick rule: justify-* follows flex-direction, align-* is the other axis. In grid it's different: justify is always the inline axis and align the block axis.

Learn more: justify-content, align-items and align-content and The flex model.

Explain: grid or flexbox?

"When would you use grid and when flexbox?"

Show answer

Flexbox is one-dimensional and content-out; grid is two-dimensional and layout-in. Flexbox lays items along one line and sizes them from their content, and when they wrap each line is independent, so it suits toolbars, nav bars, button groups and tags. Grid defines rows and columns first and places items into them, so things line up in both directions: page skeletons, galleries, forms with aligned labels, and sibling cards aligned with subgrid. Most pages use both, grid for the macro layout and flex inside components.

What’s happening
  1. Start from the question "do I need alignment in both directions?" If items in one row must line up with items in the next row, that's grid; flex rows don't know about each other.
  2. Then ask who drives the sizes. In flexbox the content does: a long label makes its item wider. In grid the tracks do, and content only influences them through auto, min-content or fr minimums.
  3. A wrapping flex row with a short last line stretches those last items wider; repeat(auto-fill, minmax(…)) in grid keeps them in the same columns. That's the usual "my card grid looks wrong" answer.
  4. Both share gap and the alignment properties, so switching costs little. Mention subgrid (aligning the insides of sibling cards) as the thing only grid can do.

Learn more: Grid or flexbox? and Common flexbox layouts.

#Responsive and modern CSS

Added

These questions cover how a layout adapts (media queries, container queries, fluid values) and the modern features that interviewers now expect you to know. Two things catch people out repeatedly: media queries add no specificity, so source order still decides, and var() is resolved late, so a bad value doesn't fall back the way a normal invalid declaration does.

Predict: the base rule after the media query
CSS
@media (width >= 600px) {
  .nav { display: flex; }
}
.nav { display: block; }

.menu { display: block; }
@media (width >= 600px) {
  .menu { display: flex; }
}

What's the display of .nav and of .menu at 360px and at 800px?

Show answer

.nav is block at both widths; .menu is block at 360px and flex at 800px. A media query is only a condition; it adds nothing to specificity. At 800px both .nav rules apply with equal specificity, so the later one, display: block, wins. Mobile-first only works when the base styles come first and the min-width queries after them.

The two orders at two widths
CSS
@media (width >= 600px) {
  .nav { display: flex; }
}
.nav { display: block; }

.menu { display: block; }
@media (width >= 600px) {
  .menu { display: flex; }
}
.nav, .menu { gap: 8px; padding: 6px; margin-bottom: 8px; background: #f1f5f9; }
.nav span, .menu span { display: inline-block; padding: 2px 8px; background: #c7d2fe; }
HTML
<nav class="nav"><span>Home</span> <span>Docs</span></nav>
<nav class="menu"><span>Home</span> <span>Docs</span></nav>
Resultlive, in your browser
What’s happening
  1. At 360px the condition (width >= 600px) is false, so only the unconditional rules apply: both elements are block.
  2. At 800px both .nav rules match. They have the same specificity, (0, 1, 0), and being inside @media doesn't change that, so the cascade falls through to source order: the base rule comes later and wins. The test read block at both widths.
  3. .menu has the same rules in mobile-first order: base first, enhancement after. At 800px the later rule is the media query's, so flex wins.
  4. In a real codebase this happens when someone appends a "fix" at the end of a file. Keep each component's base rule above its queries, or nest the queries inside the rule so the order is obvious.

Learn more: Responsive design and mobile-first and Media queries.

Predict: media query or container query?
CSS
.sidebar { width: 250px; container-type: inline-size; }
.main    { flex: 1; container-type: inline-size; }
.card    { display: grid; }
@media (width >= 600px)     { .card.mq { grid-template-columns: 80px 1fr; } }
@container (width >= 400px) { .card.cq { grid-template-columns: 80px 1fr; } }

On an 800px-wide screen, the sidebar holds one .mq card and one .cq card, and the main column (about 500px) holds a .cq card. Which cards go side by side (image next to text)?

Show answer

The .mq card in the sidebar and the .cq card in the main column; the .cq card in the sidebar stays stacked. A media query asks about the viewport, which is 800px everywhere on the page, so the .mq card switches to two columns even when it's squeezed into a 250px sidebar. A container query asks about the nearest container, so the same .cq component is stacked in the 250px sidebar and side by side in the wider main column.

One viewport, two containers
CSS
.layout  { display: flex; gap: 10px; }
.sidebar { width: 250px; container-type: inline-size; }
.main    { flex: 1; container-type: inline-size; }
.card    { display: grid; gap: 6px; margin-bottom: 10px; padding: 6px; background: #f1f5f9; }
.thumb   { height: 40px; background: #c7d2fe; }
.text    { height: 40px; background: #fde68a; }
@media (width >= 600px)     { .card.mq { grid-template-columns: 80px 1fr; } }
@container (width >= 400px) { .card.cq { grid-template-columns: 80px 1fr; } }
HTML
<div class="layout">
  <aside class="sidebar">
    <div class="card mq" id="side-mq"><div class="thumb"></div><div class="text"></div></div>
    <div class="card cq" id="side-cq"><div class="thumb"></div><div class="text"></div></div>
  </aside>
  <div class="main">
    <div class="card cq" id="main-cq"><div class="thumb"></div><div class="text"></div></div>
  </div>
</div>
Resultlive, in your browser
What’s happening
  1. The viewport is 800px, so (width >= 600px) is true for every element on the page. The .mq card gets its two columns in the sidebar too, leaving only about 150px for the text: the classic "the component looks crushed in the sidebar" bug.
  2. container-type: inline-size on .sidebar and .main makes each a size container. @container looks up from the card to its nearest container and tests that container's width.
  3. For the sidebar's .cq card that's 250px, below 400, so it stays one column: the text sits under the image. For the main column's card it's 516px (776px of body width minus 250 and the 10px gap), so it goes side by side.
  4. Same markup, same class, two layouts: that's the point of container queries. Media queries stay the right tool for page-level layout and user preferences like prefers-reduced-motion.

Learn more: Container queries and Media queries.

Predict: clamp() at three widths
CSS
h1 { font-size: clamp(1rem, 0.5rem + 2.5vw, 2rem); }

What font size does the heading get on a 360px, an 800px and a 1400px viewport?

Show answer

17px, 28px and 32px. clamp(min, preferred, max) returns the preferred value limited to the range. The preferred value here is 8px plus 2.5% of the viewport width: 8 + 9 = 17px at 360px, 8 + 20 = 28px at 800px, and 8 + 35 = 43px at 1400px, which the 2rem (32px) maximum caps. Keeping a rem part means the size still follows the user's font-size setting.

The heading at each width
CSS
h1 { font-size: clamp(1rem, 0.5rem + 2.5vw, 2rem); margin: 0; }
HTML
<h1>Fluid heading</h1>
Resultlive, in your browser
What’s happening
  1. 0.5rem is 8px and 1vw is 1% of the viewport width, so the preferred value is 8 + 0.025 × width px.
  2. At 360px: 8 + 9 = 17px, inside the range 16 to 32, so 17px is used. The test read 17px.
  3. At 800px: 8 + 20 = 28px, still inside the range: 28px.
  4. At 1400px: 8 + 35 = 43px, above the 32px maximum, so clamp() returns 32px. Working backwards, the size is flat at 16px below 320px wide, grows linearly to 32px at 960px, and is flat above that.
  5. If the preferred value were plain 4vw, zooming the page wouldn't make the text bigger, which fails WCAG's resize-text requirement; the rem term and rem limits keep zoom working.

Learn more: Fluid typography with clamp() and Math functions.

Predict: a custom property of the wrong type
CSS
:root  { --gap: 20px; --brand: #2563eb; }
.box   { color: navy; }
.box p { color: crimson; color: var(--gap); padding: 12px; padding: var(--brand); }
HTML
<div class="box"><p>Text</p></div>

What colour and padding does the paragraph get?

Show answer

Navy, and 0 padding: not crimson and 12px. A normal invalid declaration is dropped at parse time, so the earlier one would win. But the browser can't know what var() will contain until it computes the value, so color: var(--gap) is accepted, wins the cascade as the later declaration, and only then turns out to be invalid. At that point the property becomes unset: color is inherited, so it takes the parent's navy; padding isn't, so it takes its initial value, 0.

Invalid at computed-value time
CSS
:root  { --gap: 20px; --brand: #2563eb; }
.box   { color: navy; }
.box p { color: crimson; color: var(--gap); padding: 12px; padding: var(--brand); background: #f1f5f9; }
HTML
<div class="box"><p>Text</p></div>
Resultlive, in your browser
What’s happening
  1. At parse time, color: var(--gap) is valid syntax: any declaration containing var() is kept, because its value isn't known yet. So in the cascade it's simply the later of the two color declarations, and it wins.
  2. At computed-value time, the browser substitutes 20px into color and finds it's not a colour. The crimson declaration lost already and can't come back; instead the property behaves as unset.
  3. unset on an inherited property means inherit, so color becomes the parent's navy. The test read rgb(0, 0, 128).
  4. padding isn't inherited, so unset means initial, 0px. If you want a fallback for a possibly wrong value, use @property to register the variable's type with an initial value, or a fallback inside var(--x, fallback), which only helps when the variable is missing, not when it has the wrong type.

Learn more: Custom properties and Feature detection with @supports.

Predict: which cards does :has() match?
CSS
.card { border: 3px solid #cbd5e1; }
.card:has(> img)     { border-color: #2563eb; }
.card:not(:has(img)) { border-style: dashed; }
HTML
<div class="card" id="one"><img alt="" src="…"><p>Direct image</p></div>
<div class="card" id="two"><figure><img alt="" src="…"></figure><p>Image in a figure</p></div>
<div class="card" id="three"><p>No image</p></div>

What border does each card get?

Show answer

Card one: blue and solid. Card two: grey and solid. Card three: grey and dashed. :has() matches an element by what's inside it, and its argument is a relative selector: > img means a direct child image, so only card one turns blue. Card two has an image, but deeper, so it isn't blue, and it isn't dashed either because plain :has(img) looks at all descendants. Card three has no image at all, so :not(:has(img)) matches.

Three cards
CSS
.row  { display: flex; gap: 8px; }
.card { width: 120px; padding: 6px; border: 3px solid #cbd5e1; }
.card:has(> img)     { border-color: #2563eb; }
.card:not(:has(img)) { border-style: dashed; }
.card img { display: block; width: 40px; height: 24px; }
.card figure { margin: 0; }
.card p { margin: 4px 0 0; font-size: 13px; }
HTML
<div class="row">
  <div class="card" id="one"><img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 24'%3E%3Crect width='40' height='24' fill='%2393c5fd'/%3E%3C/svg%3E"><p>Direct image</p></div>
  <div class="card" id="two"><figure><img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 24'%3E%3Crect width='40' height='24' fill='%2393c5fd'/%3E%3C/svg%3E"></figure><p>Image in a figure</p></div>
  <div class="card" id="three"><p>No image</p></div>
</div>
Resultlive, in your browser
What’s happening
  1. .card:has(> img) is read as "a .card that has an img as its direct child". Card one's image is a direct child, so its border is blue (rgb(37, 99, 235)).
  2. Card two's image sits inside a <figure>, so > img doesn't match from the card, and the border stays grey. It still contains an image, so :not(:has(img)) doesn't match either: solid.
  3. Card three has no image anywhere inside, so :has(img) is false, :not() flips it, and the border is dashed.
  4. :has() takes the specificity of its argument, like :is(), and it's Baseline since late 2023. It also reaches siblings: h2:has(+ p) styles a heading followed by a paragraph, the "previous sibling" selector CSS never had.

Learn more: :is(), :where(), :not() and :has().

Predict: logical properties in right-to-left text
CSS
.tag { margin-inline-start: 20px; border-inline-start: 4px solid #2563eb; }
HTML
<p dir="ltr"><span class="tag">English</span></p>
<p dir="rtl"><span class="tag">عربي</span></p>

Which physical side gets the margin and the border in each paragraph?

Show answer

The left side in the LTR paragraph and the right side in the RTL one. Logical properties are relative to the writing direction: the inline axis runs the way text runs, and inline-start is where a line begins. In right-to-left text a line begins on the right, so the same rule mirrors the component with no extra CSS. That's why new code should prefer margin-inline-start over margin-left.

The same rule in both directions
CSS
.tag { display: inline-block; padding: 2px 8px; background: #e0e7ff; margin-inline-start: 20px; border-inline-start: 4px solid #2563eb; }
HTML
<p dir="ltr"><span class="tag" id="ltr">English</span></p>
<p dir="rtl"><span class="tag" id="rtl">عربي</span></p>
Resultlive, in your browser
What’s happening
  1. In the LTR paragraph, inline-start is the left, so the computed margin-left is 20px and border-left-width 4px; the right side gets nothing.
  2. dir="rtl" sets direction: rtl, which is inherited by the span. Now a line starts on the right, so the same declarations compute to margin-right: 20px and border-right-width: 4px.
  3. The span also lines up from the right in RTL, 20px in from the paragraph's right edge, which the test measured.
  4. The mirror is free because the browser maps logical to physical per element when it computes styles. Note that four-value shorthands like margin: 0 0 0 20px and inset stay physical.

Learn more: Logical properties.

Explain: 100vh on phones

"Why does a height: 100vh hero get cut off on a phone, and what do you use instead?"

Show answer

Because on mobile there are two viewport heights, and vh means the larger one. The browser's address bar shows on load and retracts as you scroll, so lvh is the height with the UI retracted, svh with it shown, and dvh whatever is visible right now. Plain vh equals lvh, so a 100vh hero is taller than the screen on load and its bottom is hidden behind the toolbar. I use 100svh for things that must fit on load, 100dvh for app-like full-height layouts, and keep vh or lvh for backgrounds.

What’s happening
  1. On a phone, the visible area changes as the browser UI collapses. If vh followed it, every vh layout would relayout and jump during scrolling, so browsers made vh the large, stable value.
  2. 100svh is the small viewport: guaranteed visible with the UI showing. A hero at 100svh fits on first load, with a gap at the bottom once the toolbar retracts.
  3. 100dvh tracks the current size, so it's always exactly the visible area, at the cost of layout work while the UI animates. That's the right trade-off for chat apps or full-screen tools.
  4. The units can't be shown in this page's demos: on a desktop browser, and in the headless Chrome the tests use, the small, large and dynamic viewports are all the same size. Test them on a real phone or in device emulation.

Learn more: Viewport units on phones and The viewport meta tag.

Explain: srcset and sizes
HTML
<img src="photo-800.jpg"
     srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
     sizes="(width >= 900px) 50vw, 100vw"
     width="1600" height="900" alt="Harbour at dusk">

"How does the browser decide which file to download?"

Show answer

It works out the slot width from sizes, multiplies by the device pixel ratio, and picks the smallest candidate that covers it. srcset with w descriptors only lists files and their real widths; sizes tells the browser how wide the image will be displayed, because it chooses the file before the CSS has been applied. On a 390px phone at 3× that's 100vw × 3 = 1170 pixels, so it fetches photo-1600.jpg; on a 1200px laptop at 1× it's 50vw = 600 pixels, so photo-800.jpg. The browser is allowed to pick differently, for example a smaller file on a slow connection or one it already has in cache.

What’s happening
  1. sizes is a list of media conditions and lengths, checked in order: at 900px and wider the image is half the viewport, otherwise the full width. Without sizes, the browser assumes 100vw, which over-downloads for images in narrow columns.
  2. The browser multiplies the chosen length by the pixel ratio to get the pixels it needs, then picks a candidate from srcset whose w value is enough.
  3. width and height don't pick a file; they give the box its aspect ratio before anything loads, so the layout doesn't shift when the image arrives.
  4. For different crops at different sizes (art direction) or for format fallbacks like AVIF, use <picture> with <source media> or <source type>; the inner <img> is still required and carries alt.
  5. A live demo can't show this choice reliably: which file is picked depends on the device's pixel ratio and the browser's own heuristics, so check it in DevTools' Network panel with device emulation.

Learn more: Responsive images and Images, figure and media.

#Performance and accessibility

Added

Performance questions test whether you know what each change costs the browser: layout, paint or only compositing, and what blocks the first paint. Accessibility questions test the accessibility tree: what role and name each element ends up with, what can be focused, and what's hidden from whom. Both are judged on the details, so the answers below quote what Chrome actually computed.

Explain: reflow vs repaint
CSS
.a { width: 300px; }               /* change 1 */
.b { color: crimson; }             /* change 2 */
.c { transform: translateX(20px); } /* change 3 */
.d { opacity: 0.5; }               /* change 4 */

"Which of these changes make the browser recalculate layout, which only repaint, and which can skip both?"

Show answer

Changing width needs layout, then paint and composite; color needs only paint and composite; transform and opacity on an element with its own compositor layer need only compositing. Layout (reflow) recalculates geometry, and because boxes affect each other, one change can move many elements. Paint redraws pixels without moving anything. Compositing just moves or blends already-painted layers, often on the GPU and off the main thread, which is why transform and opacity are what you animate.

What’s happening
  1. Every change starts with style recalculation: the browser works out which elements' computed values changed.
  2. width is geometric: the element's box changes, its children re-wrap and its siblings move. So layout runs, then everything it moved has to be repainted and composited. margin, padding, top/left, font-size and adding DOM nodes are in the same group.
  3. color (and background-color, box-shadow, outline) changes pixels but no geometry, so layout is skipped and only paint and composite run.
  4. transform and opacity don't affect layout at all. If the element is on its own layer (animated ones are promoted, or will-change asks for it), the compositor just redraws the frame with the layer moved or faded.
  5. What makes reflow expensive in practice is forcing it from JavaScript: reading offsetHeight or getBoundingClientRect() right after a style write runs layout synchronously, and doing that in a loop is layout thrashing.

Learn more: Reflow and repaint and The rendering pipeline.

Predict: which nudge moves the neighbour?
CSS
.row > div { width: 60px; height: 40px; }
.t { transform: translateX(100px); }
.r { position: relative; left: 100px; }
.m { margin-left: 100px; }
HTML
<div class="row"><div class="t"></div><div class="next"></div></div>
<div class="row"><div class="r"></div><div class="next"></div></div>
<div class="row"><div class="m"></div><div class="next"></div></div>

Each row is a flex row. In which rows does .next move? And what do offsetLeft and getBoundingClientRect() report for the shifted box?

Show answer

Only the margin row moves its neighbour. transform and position: relative both paint the box 100px to the right without changing its place in layout, so .next stays put. margin-left changes the layout itself, so the box and everything after it moves. offsetLeft reports the layout position, so it ignores the transform but includes the relative offset; getBoundingClientRect() reports where the box is drawn, so it shows all three shifts. Animate transform for exactly this reason: no layout work per frame.

Three ways to shift a box by 100px
CSS
.row { display: flex; height: 40px; margin-bottom: 8px; }
.row > div { width: 60px; height: 40px; background: #93c5fd; }
.row > .next { background: #fde68a; }
.t { transform: translateX(100px); }
.r { position: relative; left: 100px; }
.m { margin-left: 100px; }
HTML
<div class="row"><div class="t"></div><div class="next" id="nt"></div></div>
<div class="row"><div class="r"></div><div class="next" id="nr"></div></div>
<div class="row"><div class="m"></div><div class="next" id="nm"></div></div>
Resultlive, in your browser
What’s happening
  1. All three blue boxes are drawn 100px from their row's left edge: getBoundingClientRect() measures the rendered box, after transforms and relative offsets.
  2. With transform, the layout still has the box at 0, so .next sits at 60px and offsetLeft says 0. The transform is applied at paint and composite time, which is why animating it doesn't run layout.
  3. With position: relative; left: 100px, the box keeps its layout slot too (.next at 60px), but the offset is part of positioning, so offsetLeft includes it: 100. Animating left still runs layout every frame, even though nothing else moves.
  4. With margin-left, the box's slot itself is 100px further right, so .next follows to 160px. Animating margins reflows the whole row every frame.
  5. will-change: transform can promote the box to its own layer before an animation starts, but each layer costs memory and it creates a stacking context and a containing block for fixed children, so add it just before animating and remove it after.

Learn more: Animating cheaply and Transforms.

Explain: what blocks the first paint?
HTML
<head>
  <link rel="stylesheet" href="app.css">
  <link rel="stylesheet" href="print.css" media="print">
  <style>@import url("fonts.css");</style>
  <script src="app.js"></script>
</head>

"What delays the first paint here, and how would you fix it?"

Show answer

app.css, the @import chain and the plain script all delay it; print.css doesn't. A stylesheet link whose media matches is render-blocking, so the page isn't painted until it's downloaded and parsed; one with media="print" downloads at low priority without blocking. @import is only discovered after the file containing it is parsed, so it adds a round trip in series. A classic script in the head blocks the parser and also waits for the stylesheets before it runs. I'd inline the critical CSS, turn @import into a <link> (or preload), add defer to the script, and give fonts font-display: swap with a preload for the one the first screen needs.

What’s happening
  1. app.css matches every medium, so it's render-blocking: no pixels until it arrives. Keeping the critical CSS small, or inlining the part the first screen needs, shortens that wait.
  2. print.css has media="print", which doesn't match a screen, so the browser fetches it without holding up rendering. The same trick works for stylesheets for wide screens only (media="(width >= 1000px)").
  3. The @import inside <style> can only start downloading fonts.css once that style block is parsed, and anything fonts.css imports waits again. Separate <link> tags download in parallel.
  4. <script src="app.js"> without defer stops the parser while it downloads and runs, and it won't run until the stylesheets above it have loaded, because it might read styles. defer removes both waits.
  5. Fonts are the second wait: text in a web font can't be drawn until it arrives. font-display: swap shows a fallback first; a fallback with matched metrics (size-adjust) keeps the swap from shifting layout. Measure with Lighthouse or the Performance panel rather than guessing.

Learn more: Loading performance and defer and async.

Predict: what is each button called?
HTML
<button id="a" aria-label="Close dialog">✕</button>
<button id="b"><svg aria-hidden="true">…</svg></button>
<button id="c"><svg aria-hidden="true">…</svg><span class="visually-hidden">Delete</span></button>
<button id="d" aria-labelledby="t">Save</button> <span id="t" hidden>Save draft</span>
<button id="e" title="Settings"><svg aria-hidden="true">…</svg></button>

What accessible name does a screen reader announce for each button?

Show answer

"Close dialog", nothing, "Delete", "Save draft" and "Settings". The name computation takes the first source that produces text: aria-labelledby, then aria-label, then the element's content (for buttons and links), then title as a last resort. aria-label replaces the ✕ text; an icon hidden with aria-hidden contributes nothing, so button B is an unnamed button; visually hidden text still counts; aria-labelledby can point at hidden text and wins over the visible "Save"; and title names E, though it's a weak fallback that touch and keyboard users never see.

Five buttons, five names
CSS
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
button { min-width: 36px; height: 32px; margin-right: 6px; }
svg { width: 16px; height: 16px; vertical-align: middle; }
HTML
<button id="a" aria-label="Close dialog">✕</button>
<button id="b"><svg aria-hidden="true" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="#64748b"/></svg></button>
<button id="c"><svg aria-hidden="true" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" fill="#dc2626"/></svg><span class="visually-hidden">Delete</span></button>
<button id="d" aria-labelledby="t">Save</button> <span id="t" hidden>Save draft</span>
<button id="e" title="Settings"><svg aria-hidden="true" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4" fill="#2563eb"/></svg></button>
Resultlive, in your browser
What’s happening
  1. A: aria-label comes before content in the computation, so the ✕ character is ignored and the name is "Close dialog". Good for icon buttons, but if the button showed a visible word, aria-label should contain it (Label in Name, for voice control).
  2. B: the only content is an SVG with aria-hidden="true", which removes it from the accessibility tree, so the name is empty. A screen reader says just "button": the most common failure on icon-only buttons.
  3. C: the same icon plus a visually hidden span. That span is clipped to 1px but still in the accessibility tree, so its text becomes the name, "Delete".
  4. D: aria-labelledby is the first step and it reads the referenced element's text even when that element is hidden (display: none), so the visible "Save" is replaced by "Save draft". Powerful, and easy to get out of sync with what sighted users see.
  5. E: no labelledby, no label, no content text, so the computation falls through to title: "Settings". It works for screen readers, but the tooltip only shows on mouse hover, so prefer visible text or a visually hidden label.

Learn more: Accessible names and descriptions and Hiding content.

Explain: :focus vs :focus-visible
CSS
button:focus { outline: none; }                    /* the designer's request */
:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }

"The designer wants no focus ring when people click buttons. What's wrong with outline: none, and what do you do instead?"

Show answer

outline: none on :focus removes the ring for keyboard users too, which fails WCAG 2.4.7 (Focus Visible). :focus matches any focused element, including a button just clicked with the mouse. :focus-visible matches only when the browser decides a ring is useful: after keyboard navigation, and always for text inputs. So I drop the outline: none and style rings with :focus-visible: mouse users don't see a ring on buttons, keyboard users always do.

A text input always shows the ring
CSS
input, button { margin-right: 8px; }
:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
HTML
<input id="name" aria-label="Name"> <button id="save">Save</button>
Resultlive, in your browser
What’s happening
  1. :focus matches whenever an element has focus, however it got there. Removing the outline there hides the indicator from everyone, and keyboard users then can't see where they are on the page.
  2. :focus-visible uses the browser's heuristics: a text field always gets it, because you need to see where you're typing; a button gets it after Tab but not after a mouse click. The test focused the input from script and it matched, with the 3px solid ring applied.
  3. The mouse-click case can't be proven here (the test can't make a real pointer click), so try it in the live result: Tab to the Save button and the ring appears; click it with the mouse and Chrome normally doesn't show one.
  4. A good ring has at least 3:1 contrast against what's around it and an offset so it doesn't merge with the border. Use outline rather than only box-shadow, because forced-colours mode removes shadows.

Learn more: Visible focus and Keyboard access and focus order.

Predict: what does this ARIA actually do?
HTML
<h2 id="h" role="button">Shipping details</h2>
<div id="d" role="button">Pay now</div>
<button id="b" aria-hidden="true">Menu</button>

For each element: what role and name does it expose, and can a keyboard user focus it?

Show answer

The heading is now a button and no longer a heading, and it can't be focused; the div is announced as "Pay now", button, but can't be focused or activated by keyboard; the aria-hidden button has no name for screen readers but can still be focused. An explicit role replaces the element's native semantics; it doesn't add behaviour. And aria-hidden on something focusable creates a control that keyboard users land on while the screen reader says nothing useful. Each of these is fixed by using native HTML: a <button> inside the <h2>, a real <button> for Pay now, and hidden or inert when the menu button really should be gone.

Roles, names and focus
HTML
<h2 id="h" role="button">Shipping details</h2>
<div id="d" role="button">Pay now</div>
<button id="b" aria-hidden="true">Menu</button>
Resultlive, in your browser
What’s happening
  1. role="button" on the <h2> replaces the heading role, so the page loses a heading that screen-reader users navigate by, and gains a "button" that Tab can't reach (focus() failed) and Enter does nothing on. Two failures from one attribute.
  2. The div gets the right announcement, "Pay now, button", but ARIA added no behaviour: no tab stop, no Enter or Space, no form submission. That's the "looks fine in the accessibility tree, broken for the keyboard" case automated tools often miss.
  3. The aria-hidden button is removed from the accessibility tree (its name is empty), but it's still in the tab order: focus() succeeded. A keyboard user lands on it and hears nothing meaningful.
  4. The first rule of ARIA covers all three: use the native element. <h2><button aria-expanded="false">Shipping details</button></h2> keeps the heading and gives a real button; for content that should be hidden from everyone, use hidden, inert or display: none.

Learn more: The first rule of ARIA and ARIA roles, states and properties.

Predict: which pairs pass WCAG AA?
CSS
.p1 { color: #767676; background: #fff; font-size: 16px; }
.p2 { color: #777777; background: #fff; font-size: 16px; }
.p3 { color: #949494; background: #fff; font-size: 24px; }
.p4 { color: #fff;    background: #2563eb; font-size: 16px; }

Which of these text styles pass WCAG 2.2 level AA contrast?

Show answer

.p1, .p3 and .p4 pass; .p2 fails. AA needs 4.5:1 for normal text and 3:1 for large text (24px, or about 18.66px bold). #767676 on white is 4.54:1, just over; #777777 is 4.48:1, just under, even though you can't see the difference. #949494 at 24px only needs 3:1 and has 3.03:1. White on #2563eb is 5.17:1. The ratio is (lighter luminance + 0.05) / (darker + 0.05), so check it with a tool, not by eye.

Contrast computed from the rendered colours
CSS
p { margin: 4px 0; padding: 4px 8px; }
.p1 { color: #767676; background: #fff; font-size: 16px; }
.p2 { color: #777777; background: #fff; font-size: 16px; }
.p3 { color: #949494; background: #fff; font-size: 24px; }
.p4 { color: #fff;    background: #2563eb; font-size: 16px; }
HTML
<p class="p1">Normal text, #767676</p>
<p class="p2">Normal text, #777777</p>
<p class="p3">Large text, #949494</p>
<p class="p4">White on blue</p>
Resultlive, in your browser
What’s happening
  1. Each colour is converted to relative luminance: channels are linearised (the sRGB curve) and weighted 0.2126 red, 0.7152 green, 0.0722 blue, because eyes are most sensitive to green. The test did this with the computed colours.
  2. #767676 on white comes out at 4.54:1, the lightest grey that passes for body text. One step lighter, #777777, is 4.48:1 and fails: AA is a hard threshold, there's no rounding up.
  3. .p3 is 24px, which counts as large text, so its threshold is 3:1, and #949494 passes at 3.03:1. The same colour at 16px would fail.
  4. White on #2563eb is 5.17:1, a pass for any size. The same 3:1 minimum also applies to non-text things that matter, like input borders, icons and focus rings, and colour must never be the only way information is shown.

Learn more: Colour contrast and not relying on colour alone.

Predict: which links can Tab reach?
CSS
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
HTML
<a id="skip" class="visually-hidden" href="#main">Skip to content</a>
<a id="gone" href="/old" style="display: none">Old page</a>
<a id="ghost" href="/help" aria-hidden="true">Help</a>
<a id="faded" href="/new" style="opacity: 0">New</a>

Which links does a keyboard user reach, and which does a screen reader announce?

Show answer

Tab reaches skip, ghost and faded; a screen reader announces skip and faded. The visually hidden skip link is out of sight but fully present: focusable and announced (a skip link should then become visible on focus). display: none removes the link from everything. aria-hidden removes it only from the accessibility tree, so it's still a tab stop that announces nothing, a bug. opacity: 0 hides nothing from the keyboard or the screen reader, only from the eyes.

Four hidden links
CSS
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
a { margin-right: 10px; }
HTML
<a id="skip" class="visually-hidden" href="#main">Skip to content</a>
<a id="gone" href="/old" style="display: none">Old page</a>
<a id="ghost" href="/help" aria-hidden="true">Help</a>
<a id="faded" href="/new" style="opacity: 0">New</a>
Resultlive, in your browser
What’s happening
  1. The skip link uses the visually hidden pattern: a 1px clipped box, but still rendered, so it's focusable and its name "Skip to content" is in the tree. Skip links normally add a :focus-visible rule that brings them on screen when tabbed to.
  2. display: none removes the box, so the link is neither focusable nor named. The same goes for the hidden attribute and visibility: hidden.
  3. aria-hidden="true" leaves the link visible and focusable but removes it from the accessibility tree, so its name is empty: a screen-reader user tabs onto something silent. Never put aria-hidden on a focusable element or its ancestor; use inert if a whole region should be unreachable.
  4. opacity: 0 changes only painting: the link is focusable and announced as "New" while being invisible, which confuses both sighted keyboard users and screen-reader users.

Learn more: Hiding content and Keyboard access and focus order.

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.