HTML & CSS NotesRohit’s interview study guide
Chapter 01

HTML Foundations & Semantics

How the browser parses markup into a DOM, the document skeleton, semantic elements and landmarks, headings, the content model, links, images and media, tables, global attributes, and the interactive elements that work without JavaScript: details, dialog and popover.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#How a browser turns HTML into a page

Added

A browser never "reads" your HTML file as text. It goes through a fixed pipeline: bytes → characters → tokens → DOM tree. The bytes are decoded into characters using the page's encoding (that's what <meta charset="utf-8"> is for). The tokenizer, a state machine, turns the characters into tokens: a doctype, start tags with their attributes, end tags, text, comments. The tree builder takes those tokens one at a time and grows the DOM, keeping a "stack of open elements" that says where the next node goes. Only once there is a DOM (and a CSSOM from the stylesheets) can the browser lay out and paint anything; that later half is covered in the rendering pipeline.

The part that surprises people is that the HTML parser never fails. A JSON or XML parser stops at the first error; the HTML parser has a rule for every mistake. Since HTML5, those recovery rules are written down in the spec, step by step, so every modern browser builds the same DOM from the same broken markup. Before that, each browser guessed differently, and "works in IE, broken in Firefox" was often a parsing difference.

The mental model: the parser is a strict but forgiving copy editor. It never rejects your text, but it silently rewrites it to follow its rulebook, and the rewritten version is what your CSS and JavaScript see. That's why the browser's Elements panel (the DOM) can disagree with View Source (your markup), and why a selector like p > div can never match anything in a parsed HTML page.

The parser fixing four kinds of broken markup
CSS
.case { border: 1px solid #c7cfe0; border-radius: 6px; padding: 4px 10px; margin-bottom: 8px; }
.case p { margin: 2px 0; background: #e8f0ff; }
.case div { background: #fdeccf; }
.case table { border: 1px dashed #9aa5b8; }
HTML
<div class="case" id="paras"><p>One<p>Two<div>A block</div></p></div>
<div class="case" id="links"><a href="#a">outer <a href="#b">inner</a> tail</a></div>
<div class="case" id="table"><table><tr><td>cell</td></tr>stray text</table></div>
<div class="case" id="tags"><b>bold <i>both</b> italic</i></div>
Resultlive, in your browser
What’s happening
  1. Paragraphs. <p>One<p>Two has no </p>, but a <p> start tag automatically closes an open p, so you get two siblings. Then <div> arrives while Two's p is open; a p can only hold phrasing content, so the parser closes the p first and puts the div after it. The final </p> now has no open p to close, so the spec says to create an empty one. Measured: <p>One</p><p>Two</p><div>A block</div><p></p>, three p elements and zero matches for p > div.
  2. Links. A link can't contain a link. When the inner <a> starts, the parser closes the outer one, so outer is in the first link, inner in the second, and tail is in no link at all (it renders as plain text in the live result).
  3. Table. Text isn't allowed between table rows, so the parser "foster-parents" it: stray text is moved to just before the table. It also inserts the <tbody> you left out. The DOM is stray text<table><tbody>…</tbody></table>, which is why table > tr doesn't match in HTML but table > tbody > tr does.
  4. Misnested tags. <b>bold <i>both</b> italic</i> closes b while i is still open. The "adoption agency" algorithm closes i, closes b, then reopens a fresh i for the rest: <b>bold <i>both</i></b><i> italic</i>. Both "both" and "italic" stay italic, just as you meant.
  5. All four outputs come from the spec's recovery rules, not browser guesswork, so they're the same everywhere. The practical lesson: write valid markup anyway, because the "fixed" tree is often not the tree you wanted (the empty p adds a margin; the tail text loses its link), and your CSS selectors run against the fixed tree.
AdvancedStandards mode and quirks mode

The doctype has one job in modern HTML: it picks the rendering mode. <!doctype html> (any capitalisation) puts the page in standards mode. No doctype, or certain legacy doctypes, put it in quirks mode, where the browser imitates bugs of late-1990s browsers so old pages keep working: unitless lengths are accepted, tables don't inherit font sizes, percentage heights behave differently, and the box model of some elements changes. A third mode, limited quirks, only changes how images in table cells line up.

JavaScript
document.compatMode; // "CSS1Compat" (standards or limited quirks) or "BackCompat" (quirks)

const frame = document.createElement("iframe");
document.body.append(frame);
frame.contentDocument.write("<div style='width:120;height:30px'>box</div>"); // no doctype
frame.contentDocument.compatMode; // "BackCompat"
What’s happening
  1. document.compatMode is the quick check: the demo frame above reports "CSS1Compat" because the page has <!doctype html>. You can run the same line in DevTools on any site.
  2. The test writes the same div into two fresh frames, one without a doctype and one with <!doctype html>. The first reports "BackCompat", the second "CSS1Compat".
  3. In quirks mode width:120 (no unit) is read as 120px: the box measures 120px wide. In standards mode it's an invalid declaration and is dropped, so the box takes the frame's full width: 284px (300px frame minus the default 8px body margin on each side).
  4. Legacy doctypes are matched by their exact text. The HTML 4.01 Transitional doctype without a URL gives "BackCompat"; with its URL it gives limited quirks, which compatMode reports as "CSS1Compat". That's why you sometimes see old sites behave oddly even though they "have a doctype".
  5. Accidental quirks mode usually comes from something printed before the doctype. Whitespace and comments are fine (the test's <!-- built by CMS --> page is still "CSS1Compat"), but any other text, such as stray output from a server-side include, switches to quirks ("x<!doctype html>" is "BackCompat"). If layout looks inexplicably different between two pages, check compatMode first.

#The document skeleton: doctype, head and meta

Added

Every page has the same skeleton: a doctype, an <html> root with a lang, a <head> with metadata (information about the page, not shown in it) and a <body> with the content. The <head> is where you tell the browser how to decode the file, how to size the page on a phone, what to call the tab, what to show in search results, and which styles and scripts to load and in what way.

The order inside <head> matters more than people think, because the parser reads top to bottom and acts as it goes. The encoding must be declared early (the spec requires <meta charset> within the first 1024 bytes of the file), stylesheets should be discovered early so they can start downloading, and a plain <script> stops the parser until it has downloaded and run. A good mental model is a checklist the browser reads before it opens the box: first how to read the label (charset), then how big the box is (viewport), then what's inside (title, description), then what tools it needs (CSS, scripts).

HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Order history · Acme</title>
  <meta name="description" content="Your past orders, invoices and returns.">
  <link rel="stylesheet" href="/css/app.css">
  <script src="/js/app.js" defer></script>
</head>
<body>
  <h1>Order history</h1>
</body>
</html>
What’s happening
  1. <!doctype html> selects standards mode (see the parser topic). It isn't an element and has no closing tag.
  2. lang="en" on <html> tells screen readers which voice and pronunciation to use, lets the browser offer translation, and drives hyphenation and the :lang() selector. It's inherited, so one attribute covers the whole page; override it on any element whose text is in another language.
  3. <meta charset="utf-8"> comes first so the decoder knows the encoding before it meets any non-ASCII text. If the browser had to guess and guessed wrong, é would render as é.
  4. The viewport meta stops phones from rendering the page at a fake 980px desktop width and zooming out; it's covered in the viewport meta tag. <title> is the tab title, the bookmark name and usually the headline in search results; put the specific part first ("Order history · Acme", not "Acme · Order history") so it survives truncation. <meta name="description"> is the snippet search engines may show under it; it doesn't affect ranking directly.
  5. The stylesheet <link> is render-blocking: the browser won't paint until it has the CSS, which avoids a flash of unstyled content. The script has defer, so it downloads in parallel and runs only after the whole document is parsed, which is why it can safely sit in the <head> and still find <h1> in the DOM.
AdvancedOptional tags and where metadata lands

The parser is forgiving here too. <html>, <head> and <body> tags are optional: if you leave them out, the parser creates those elements anyway. But metadata only lands in the head if it comes before any content.

What the parser builds from a minimal page
CSS
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.pair code { display: block; font-size: 12px; background: #f1f4fa; padding: 6px; border-radius: 6px; white-space: pre-wrap; word-break: break-all; }
.pair pre { margin: 6px 0 0; font-size: 12px; background: #eaf7ee; padding: 6px; border-radius: 6px; }
HTML
<div class="pair">
  <div>
    <code id="src1">&lt;!doctype html&gt;&lt;html lang="en"&gt;&lt;meta charset="utf-8"&gt;&lt;title&gt;Hi&lt;/title&gt;&lt;p&gt;Hello</code>
<pre id="tree1">html lang="en"
  head
    meta charset="utf-8"
    title "Hi"
  body
    p "Hello"</pre>
  </div>
  <div>
    <code id="src2">&lt;!doctype html&gt;&lt;title&gt;Hi&lt;/title&gt;&lt;p&gt;Hello&lt;/p&gt;&lt;meta name="description" content="x"&gt;</code>
<pre id="tree2">html
  head
    title "Hi"
  body
    p "Hello"
    meta name="description" content="x"</pre>
  </div>
</div>
Resultlive, in your browser
What’s happening
  1. Each column shows a source string (top) and the element tree the parser built from it (bottom). The test parses the exact source with DOMParser and checks that the printed tree matches the diagram character for character.
  2. Left: there's no <head>, <body> or closing tag anywhere, yet the DOM has all of them. <meta> and <title> are metadata, so the parser opens an implied head for them; the first <p> can't go in a head, so the parser closes it and opens an implied body. document.title is "Hi" and lang is "en".
  3. Right: the <meta name="description"> comes after <p>Hello</p>. Once the body has started, the parser doesn't go back: the meta is inserted in the body, after the paragraph. It's still in the DOM, but it's no longer in the head where crawlers and tools look for it.
  4. This is why the omitted-tags style is legal but risky in templates: one piece of content (even stray text from an include) before a <link> or <meta> silently moves everything after it into the body.
AdvancedScripts: blocking, defer, async and modules

A classic <script src> is parser-blocking: the parser stops, the script downloads and runs, then parsing continues. The script can't see anything below itself, and on a slow network the page stays blank meanwhile. That's why scripts used to go at the end of <body>. Today you put them in the head with an attribute that changes the timing:

ScriptDownloadsRunsOrder kept?
<script src>blocks parsingimmediately, before parsing continuesyes
<script src defer>in parallelafter the document is parsed, before DOMContentLoadedyes, in document order
<script src async>in parallelas soon as it arrives (may interrupt parsing)no
<script type="module">in parallellike defer (add async to make it async)yes
inline <script>—immediately; defer and async are ignoredyes
The order scripts run in
CSS
ol { margin: 0; padding-left: 22px; }
li { margin: 3px 0; padding: 3px 8px; border-radius: 6px; background: #eef2ff; }
li.async { background: #fdf2e0; }
li.event { background: #e8f6ec; }
HTML
<ol id="timeline">
  <li data-id="classic-head">classic &lt;script&gt; in head: runs at once, can't see the body</li>
  <li data-id="inline-defer-ignored">inline &lt;script defer&gt;: defer ignored, runs at once</li>
  <li data-id="classic-end">classic &lt;script&gt; at end of body: sees the whole body</li>
  <li data-id="defer-1">defer #1</li>
  <li data-id="module">type="module" (deferred by default)</li>
  <li data-id="defer-2">defer #2</li>
  <li data-id="DOMContentLoaded" class="event">DOMContentLoaded</li>
  <li data-id="load" class="event">load</li>
</ol>
<p class="async">async: whenever it arrives, but always before <code>load</code></p>
Resultlive, in your browser
What’s happening
  1. The test builds a real page in a child frame with every kind of script; each script reports its name, document.readyState and whether it can find <p id="last"> at the end of the body. The visible list is the order the test measured.
  2. The classic script in the head runs first, with readyState "loading" and #last not found: the parser stopped at it, so the body doesn't exist yet. The inline <script defer> runs right after, because defer only applies to scripts with src.
  3. The classic script at the end of the body sees #last (true) but readyState is still "loading": parsing isn't finished until the closing tags are done.
  4. Then parsing finishes ("interactive") and the deferred scripts run in document order: defer-1, then the module, then defer-2. A module script is deferred by default and keeps its place in that queue. Only after all of them does DOMContentLoaded fire, then load.
  5. async has no fixed slot: it runs when its download completes, which on a real network can be before or after any of the others. The only guarantee checked here is that it runs before load. Use defer (or modules) for your app code, which depends on order and on the DOM; use async for independent scripts like analytics.
Why can't the script find the button?
HTML
<head>
  <script src="app.js"></script>
</head>
<body>
  <button id="buy">Buy</button>
</body>

app.js runs document.getElementById("buy").addEventListener("click", …) and throws TypeError: Cannot read properties of null. Why, and what are two fixes?

Show answer

The script runs before the button exists.

What’s happening
  1. The parser reaches the <script> in the head and stops: a classic script must run before parsing continues, because it could call document.write.
  2. At that moment the DOM has html, head and the script; there is no body yet, so getElementById("buy") returns null and .addEventListener throws. (The demo above measured this: the head script couldn't find #last.)
  3. Fix 1: add defer. The script still downloads early, but it runs after parsing, when the button exists. type="module" behaves the same way.
  4. Fix 2: move the script to the end of <body>, which also works but discovers the download later. Wrapping the code in a DOMContentLoaded listener works too, but the parser still blocks while the file downloads.

#Semantic HTML and why it matters

Added

Semantic HTML means choosing elements for what the content is, not for how it should look. A <button> is a button, a <nav> is a block of navigation links, an <h2> starts a section, a <ul> is a list. Styling is CSS's job; any element can be made to look like anything. What the element choice changes is everything the browser, assistive technology and other tools do for free because they know what the thing is.

The opposite is "div soup": every piece of the page a <div> or <span> with a class name. It can look identical, but to a screen reader, a keyboard user, a search engine or browser features like reader mode, it's undifferentiated text. Each element carries a role in the accessibility tree (the parallel tree that screen readers read), plus built-in behaviour: focusability, keyboard activation, form submission.

Mental model: an element is a job title, not a costume. A div dressed up as a button with CSS still has the job title "generic". Everyone who reads job titles (screen readers, the Tab key, browser features) treats it as generic.

Same looks, different meaning
CSS
.row { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.tag { width: 80px; font-size: 12px; color: #5a6475; }
.btn { display: inline-block; padding: 6px 14px; background: #4f6bed; color: #fff; border: 0; border-radius: 6px; font: inherit; cursor: pointer; }
.links { display: flex; gap: 10px; padding: 0; margin: 0; list-style: none; }
.links a, .links span { color: #3247c4; }
HTML
<div class="row">
  <span class="tag">div soup</span>
  <div class="btn" id="fake" onclick="save()">Save</div>
  <div class="links" id="fakenav"><span>Home</span><span>Orders</span></div>
</div>
<div class="row">
  <span class="tag">semantic</span>
  <button class="btn" id="real" type="button">Save</button>
  <nav aria-label="Main"><ul class="links" id="list"><li><a href="#home">Home</a></li><li><a href="#orders">Orders</a></li></ul></nav>
</div>
Resultlive, in your browser
What’s happening
  1. The two rows look the same: both "Save" controls compute to the same background-color (rgb(79, 107, 237)). The CSS can't tell them apart, and neither can a sighted mouse user.
  2. The accessibility tree can. The div has role generic; the button has role button with the name "Save". A screen reader announces the second as "Save, button" and the first as just "Save", with no hint that it does anything.
  3. Keyboard: the test calls focus() on each, and only the button takes focus; the div can't be focused at all, so it's not in the tab order either. Press Tab in the live result: focus jumps straight past the fake button to the real one. A real button also activates on Enter and Space and can be disabled with one attribute; the div gets none of that.
  4. The navigation row is the same story: nav gives a navigation landmark named "Main" that screen-reader users can jump to, ul/li give list and listitem (screen readers announce "list, 2 items"), and only <a href> is a link. The spans are generic and can't be focused or followed.
  5. The last checks patch the div with role="button" and tabindex="0": now the role is right and it can take focus. But you'd still have to add Enter and Space key handlers, focus styles and disabled handling yourself. That's the first rule of ARIA: use the native element instead.
AdvancedWhat semantics buy you
  • Accessibility. Roles, names and states come from the element. Screen-reader users navigate by landmarks, headings, links and form controls, so a page built from those is navigable, and div soup is one long paragraph.
  • Keyboard and behaviour. Buttons, links, form controls, details and dialog come with focus, keyboard activation and browser behaviour you'd otherwise rebuild (and usually rebuild incompletely).
  • Search engines and other machines. Crawlers, reader modes, browser translation and voice assistants rely on headings, main, article, links and alt text to work out what a page is about and which part is the content. Semantic markup doesn't guarantee a ranking, but it removes guesswork.
  • Readable code. <nav>, <article>, <time datetime="2026-10-20"> say what they are; <div class="x-wrap-2"> doesn't. That also makes your CSS selectors more meaningful.
  • Less CSS and JS. Default styles and behaviour come for free: lists are indented, strong is bold, details opens and closes, forms submit.

#Landmarks and sectioning: header, nav, main, article, section, aside, footer

Added

Landmarks are the big regions of a page: the site header, navigation, the main content, sidebars, the footer. In the accessibility tree they get landmark roles (banner, navigation, main, complementary, contentinfo, region, search, form), and screen-reader users can list them and jump between them the way a sighted user glances at a layout. You create them with the HTML5 sectioning elements; you almost never need the ARIA role attributes for this any more.

Two of the elements depend on context. A <header> or <footer> that belongs to the whole page (not inside an article, aside, main, nav or section) is the page's banner or contentinfo landmark. The same element inside one of those is just that part's header or footer and is not a landmark. And <section> is only a region landmark when it has an accessible name; without one it's generic.

The mental model: landmarks are the floor plan of the page. You'd label the kitchen and the bedrooms, not every cupboard. article is a piece that makes sense on its own (you could syndicate it: a post, a comment, a product card); section is a themed chunk of something bigger that would normally have a heading.

A page and the roles Chrome computes for each region
CSS
body { font-size: 13px; }
.page { display: grid; grid-template-columns: 1fr 150px; gap: 6px; }
.page > * { border-radius: 6px; padding: 6px 10px; }
.site-header, .site-footer, nav { grid-column: 1 / -1; }
.site-header, .site-footer { background: #e0e7ff; }
nav { background: #dcfce7; }
main { background: #fef3c7; }
aside { background: #fce7f3; }
article, section { background: #fff; border: 1px solid #e5d3a1; border-radius: 6px; padding: 4px 8px; margin: 6px 0; }
article header, article footer { background: #f5f5f4; border-radius: 4px; padding: 2px 6px; }
.role { float: right; font-size: 11px; color: #3b4252; }
h1, h2 { font-size: 14px; margin: 2px 0; }
HTML
<div class="page">
  <header class="site-header" id="banner"><span class="role">banner</span>Acme store</header>
  <nav aria-label="Main" id="nav"><span class="role">navigation</span>Home · Shop · Orders</nav>
  <main id="main"><span class="role">main</span>
    <article id="post"><span class="role">article</span>
      <header id="post-header"><span class="role">sectionheader</span><h1>Autumn sale</h1></header>
      <p>Everything 20% off.</p>
      <footer id="post-footer"><span class="role">sectionfooter</span>Posted 20 Oct</footer>
    </article>
    <section id="plain"><span class="role">generic</span>A section with no name</section>
    <section id="named" aria-labelledby="faq"><span class="role">region</span><h2 id="faq">FAQ</h2></section>
  </main>
  <aside id="aside"><span class="role">complementary</span>Related</aside>
  <footer class="site-footer" id="contentinfo"><span class="role">contentinfo</span>© Acme</footer>
</div>
Resultlive, in your browser
What’s happening
  1. Each box's label is the role the test read from computedRole, and the test fails if any label disagrees. The page-level header is banner, the page-level footer is contentinfo, nav is navigation (named "Main" by aria-label), main is main and aside is complementary.
  2. Inside the article, the very same <header> and <footer> elements come out as sectionheader and sectionfooter in Chrome 155: section-level parts, not landmarks. That's the context rule: only the page's own header and footer are banner and contentinfo, so a page with twenty article cards still has one banner.
  3. The article is article: self-contained content.
  4. The unnamed section is generic, exactly like a div. The second one has aria-labelledby="faq", so its name is "FAQ" (taken from its heading) and it becomes a region landmark. If a section deserves a landmark, give it a heading and point aria-labelledby at it.
  5. There's exactly one main. The spec allows more than one <main> only if all but one are hidden, which single-page apps sometimes use for views. "Skip to content" links usually target the main.
AdvancedLandmark rules of thumb

Rules of thumb

  • One main per page, containing the content that's unique to this page (not the site header, nav or footer).
  • article vs section: if the chunk would make sense on its own somewhere else (an RSS feed, a search result, a card in a list), it's an article. If it's a part of something bigger, with its own heading, it's a section. If you only need a styling hook, it's a div.
  • Label repeated landmarks. Two navs (main menu and footer links) should get different names: aria-label="Main" and aria-label="Footer", so a screen reader's landmark list shows "Main navigation, Footer navigation" rather than "navigation, navigation".
  • aside is for content tangentially related to what's around it: a sidebar, a pull quote, related links. At page level it's a complementary landmark.
  • <search> wraps a search form and gives it the search role. It's Baseline since October 2023 (widely available since April 2026); before it you wrote <form role="search">.
  • <form> is a form landmark. Chrome 155 reported form even for an unnamed form in a quick check; the ARIA mapping spec only makes a form a landmark when it has a name, so give important forms a name with aria-label or aria-labelledby and don't rely on the unnamed case.
article or section?

A blog's home page shows ten post previews, each with a title, an excerpt and a "Read more" link. A single post page has a "Comments" area with many comments. Which elements would you use for the previews, the comments area and each comment?

Show answer

Each preview is an article, the comments area is a section, and each comment is an article (nested in the post's article, or in the section).

What’s happening
  1. A preview makes sense on its own: you could show it in a feed or a search result unchanged. That's the article test.
  2. The comments area isn't a standalone piece; it's a themed part of the post, with a heading like "Comments". That's a section (give it aria-labelledby pointing at its heading if it should be a region landmark).
  3. Each comment is self-contained again (author, date, text), so each is an article. The spec explicitly allows nested articles: an inner article is related to the outer one, like a comment to its post.
  4. If you only needed a flex wrapper around the previews, that wrapper is a div (or a ul with each article in an li, since it's a list of posts).

#Headings and document structure

Added

Headings <h1>–<h6> are the outline of the page. Visually they're big bold text, but their real job is structure: each heading starts a section, and its level says how deep that section sits. Screen-reader users rely on them heavily: they pull up a list of headings or jump from heading to heading to skim a page, the same way you skim by scanning the big text. A page whose "headings" are styled divs or strongs has no outline at all.

The rules interviews look for: one h1 that says what the page is, don't skip levels going down (an h2 section's subsections are h3s, not h5s because h5 looked the right size), and never pick a level for its size. Size is CSS: an h2 can be small and a p can be huge. Going back up any number of levels is fine (h4 → h2 when a new major section starts).

Mental model: headings are a book's table of contents. The chapter titles are h2, the sections inside are h3, the book's title is the h1. You wouldn't number a section "Chapter 3.1.1.4" because it should look smaller.

A page outline, and checking it with a script
CSS
h1, h2, h3 { margin: 4px 0; line-height: 1.2; }
h1 { font-size: 22px; }
h2 { font-size: 17px; color: #3247c4; }
h3 { font-size: 15px; }
.eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin: 0; }
.big { font-size: 20px; font-weight: 700; margin: 6px 0; }
HTML
<main>
  <h1>Pixel Watch 3 review</h1>
  <p class="eyebrow">Wearables</p>
  <section aria-labelledby="design">
    <h2 id="design">Design</h2>
    <h3>Display</h3>
    <h3>Bands</h3>
  </section>
  <section aria-labelledby="battery">
    <h2 id="battery">Battery</h2>
    <p class="big">24 hours, with always-on display</p>
  </section>
</main>
Resultlive, in your browser
What’s happening
  1. The page has one h1 (what the page is), two h2 sections and two h3 subsections. The test's audit() lists every heading indented by level and measures the outline as h1 → h2 → h3, h3 → h2, with no warnings.
  2. "Wearables" is a styled paragraph, not a heading. It's a label above the title, not the start of a section, so making it an h2 would put a meaningless entry at the top of a screen reader's heading list. Its role is paragraph.
  3. "24 hours, with always-on display" is set at 20px, bigger than the h2s (17px), but it's a paragraph. Size and level are independent: you style the look, you choose the level by structure.
  4. Each section is named by its heading through aria-labelledby, so it shows up as a region called "Design" or "Battery".
  5. Finally the test appends an h5 under the h2 "Battery" and runs the audit again: it's flagged as skipped a level, because h3 and h4 are missing. That's the bug to look for in reviews; the fix is to make it an h3 and style it smaller if needed.
AdvancedDevTools script: print the heading outline
JavaScript
// Paste into DevTools to print a page's heading outline and flag skipped levels.
let previous = 0;
for (const h of document.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
  const level = Number(h.tagName[1]);
  console.log(`${"  ".repeat(level - 1)}h${level} ${h.textContent.trim()}${previous && level > previous + 1 ? "  ⚠ skipped" : ""}`);
  previous = level;
}
What’s happening
  1. This is the same logic as audit() in the demo's test, written to paste into the console: it walks every heading in document order and reads the level from the tag name ("H3"[1] is "3").
  2. Each heading is indented by its level so the printout looks like a table of contents.
  3. A heading is flagged when it's more than one level deeper than the previous one, which is exactly the check the demo proved: h2 then h5 is flagged, h3 then h2 is not.
  4. Browser extensions and the accessibility pane in DevTools show the same outline, and automated checkers (axe, Lighthouse) report the skipped levels too.
AdvancedThe "one h1" debate and the outline algorithm

HTML5 originally described an outline algorithm: inside each section or article you could start again at h1, and the heading's real level would come from how deeply it was nested. Browsers shrank nested h1s in their default styles to match, but no browser ever gave screen readers those computed levels, so a nested h1 was always announced as level 1. The algorithm was removed from the HTML spec in 2022, and browsers are now removing the matching styles: MDN reports Firefox 140 dropped them, and in Chrome 155 a nested h1 renders full size (measured below). The modern answer is simple: use explicit levels, one h1 for the page, and h2–h6 below it. Several h1s aren't invalid, but they flatten the outline for screen-reader users.

A nested h1 is still a level-1 heading
HTML
<h1 id="top">Page title</h1>
<section aria-labelledby="inner">
  <h1 id="inner">Nested h1</h1>
</section>
Resultlive, in your browser
What’s happening
  1. Both h1s compute to 30px: the default 2em of the demo's 15px font. In older browser stylesheets the nested one would have been shrunk to h2 size (1.5em), which made people believe the outline algorithm worked.
  2. Both are plain headings in the accessibility tree; the visual shrinking never changed the level screen readers got, which is why the advice was always "don't rely on it". If you need different sizes, set them in your own CSS.
What's wrong with this markup?
HTML
<div class="title">Settings</div>
<h4>Profile</h4>
<p>…</p>
<h4>Notifications</h4>
<p>…</p>
Show answer

There's no h1, and the section headings jump straight to level 4.

What’s happening
  1. "Settings" is the page title but it's a div, so it isn't in the heading outline at all. A screen-reader user pulling up the headings list sees only "Profile" and "Notifications", both level 4, and has to guess what the page is.
  2. h4 was probably chosen because its default size looked right. That's choosing the level by size, and it implies two missing levels above it.
  3. Fix: <h1>Settings</h1>, then <h2>Profile</h2> and <h2>Notifications</h2>, and set the sizes in CSS (h2 { font-size: 1rem; }) if the design wants them small.
  4. If "Settings" is visually a small breadcrumb-like label and the design has no visible title, you can still keep a real h1 and style it, rather than dropping it.

#Block, inline and the content model

Added

Two separate ideas get mixed up here, and interviews like to check that you keep them apart. Block and inline are CSS: every element has a default display from the browser's stylesheet: div, p, h1, ul, section are display: block (they start on a new line and fill the available width); span, a, strong, em, code are display: inline (they flow inside a line of text, and width and height don't apply). You can change either with CSS at any time; see display.

The content model is HTML: rules about which elements may contain which. Elements belong to content categories: flow content (almost everything that goes in body), phrasing content (text-level things: span, a, strong, img, input, button…), heading, sectioning, embedded (img, video, iframe, svg), interactive (a href, button, input, details…) and metadata. A p may only contain phrasing content; a ul may only contain li (and script-supporting elements); interactive content may not contain other interactive content.

The mental model: CSS display is how a box is laid out; the content model is what the box may hold. Making a span display: block doesn't let it contain a div, and making a div display: inline doesn't make <p><div> valid. Some content-model violations are silently "fixed" by the parser and some aren't, which is why it pays to know them.

Inline vs block, and what the parser does with bad nesting
CSS
.box { width: 200px; height: 40px; background: #e0e7ff; margin: 4px 0; }
.card { display: block; width: 260px; padding: 8px 10px; border: 1px solid #c7cfe0; border-radius: 8px; color: inherit; text-decoration: none; }
.card div { font-weight: 600; }
p.note { background: #fdf2e0; margin: 4px 0; }
HTML
<span class="box" id="span">span</span><span class="box" id="span2">span</span>
<div class="box" id="div">div</div>
<a class="card" href="#product" id="card">
  <div>Pixel Watch 3</div>
  <p>Fitness tracking and Wear OS.</p>
</a>
<p class="note" id="note">Note: <div>a div inside a p</div></p>
<a href="#buy" id="buylink"><button type="button">Buy</button></a>
Resultlive, in your browser
What’s happening
  1. The two spans have width: 200px; height: 40px, but they're display: inline, so those properties don't apply: each is only as wide as its text (measured under 200px), and both sit on the same line (same top). The div is display: block: it starts a new line below and honours width exactly (200px).
  2. The product card is an <a> wrapping a div and a p. That's valid HTML5: a has a "transparent" content model (it may contain whatever its parent may contain), as long as there's no interactive content inside. The parser leaves it alone: the div and p are children of #card, so the whole card is one link.
  3. <p>Note: <div>…</div></p> is not valid: a p holds phrasing content only. The parser closes the p when the div starts, so #note contains just "Note: "; the div becomes its next sibling, and the stray </p> creates an extra empty <p></p> after it (the same rule as in the parser demo). In the live result the orange background stops after "Note:".
  4. <a href><button> is also invalid (interactive inside interactive), but there's no parser rule for it, so the DOM keeps the button inside the link. Browsers then disagree on what a click or Enter does, and screen readers announce a button inside a link. A validator flags it; the parser doesn't.
  5. The takeaway: block/inline is about layout and can be changed in CSS; validity is about the content model. Run the W3C validator (or your framework's lint rules) because the parser hides some mistakes and keeps others.
AdvancedContent model rules worth remembering
ElementMay containCommon mistake
pphrasing content onlydiv, ul, h2 or table inside a p (parser splits it)
ul, olli (plus script/template)a div wrapper between ul and li
atransparent, no interactive descendantsa button or another a inside a link
buttonphrasing content, no interactive descendantsa link or input inside a button
h1–h6phrasing contenta div or p inside a heading
labelphrasing content, one labelled control, no nested labeltwo inputs inside one label
tablecaption, colgroup, thead, tbody, tfoot, trtext or divs between rows (foster-parented out)
dldt/dd groups (optionally each group in a div)dd without a dt
What does this render as?
HTML
<p>Shipping options:
  <ul>
    <li>Standard</li>
    <li>Express</li>
  </ul>
</p>

How many p elements are in the DOM, and where is the list?

Show answer

Two p elements: the first holds "Shipping options:", then the ul as its sibling, then an empty p.

What’s happening
  1. The parser opens the p and adds the text.
  2. <ul> is flow content that isn't phrasing, so a ul start tag closes the open p first. The list becomes the p's next sibling, not its child.
  3. The final </p> has no open p. The spec's rule is to insert an empty p element there, so you get <p></p> after the list.
  4. Result: p (text), ul, p (empty). CSS like p ul { … } never matches, and the empty p adds an extra paragraph margin. Fix: end the p before the list, or use a div as the wrapper.

#Images, figure and media

Added

An <img> is a replaced element: the browser swaps the element for an external resource it can't know the size of until the bytes arrive. Two attributes do most of the work. alt is the image's text alternative, used by screen readers, shown when the image fails, and read by search engines. width and height give the image's intrinsic size in advance, so the browser can reserve the right amount of space before the download finishes and nothing jumps when it arrives.

That jumping is layout shift (measured by the Core Web Vitals metric CLS). Browsers turn the width and height attributes into a default aspect-ratio, so even a responsive image (width: 100%; height: auto) gets the right height immediately. Without the attributes, the box starts at zero height (or the size of the alt text) and the content below jumps down when the image loads.

Mental model: width and height are a reservation at a restaurant: the table is held at the right size before the party arrives. alt is what you'd say if you described the picture over the phone: what it conveys, not what it is ("Sales doubled in Q3", not "chart.png").

Reserving space before the image arrives
CSS
.slot { width: 300px; border: 1px dashed #9aa5b8; margin-bottom: 8px; font-size: 12px; }
.slot img { display: block; width: 100%; height: auto; background: #e0e7ff; }
.after { background: #fdf2e0; padding: 2px 6px; }
HTML
<div class="slot">
  <img id="with" src="data:image/png;base64," alt="Line chart: sales doubled between July and September" width="400" height="300">
  <div class="after" id="after1">Text below, already in its final place</div>
</div>
<div class="slot">
  <img id="without" src="data:image/png;base64," alt="Line chart: sales doubled between July and September">
  <div class="after" id="after2">Text below, will jump when the image arrives</div>
</div>
Resultlive, in your browser
What’s happening
  1. Both images have a deliberately empty data: URL, so neither ever loads (naturalWidth is 0). The live result shows exactly what a visitor sees while a real image is still downloading: the box the browser reserved, here painted light blue.
  2. The first has width="400" height="300". The browser's own stylesheet turns those into aspect-ratio: auto 400 / 300 (measured). The CSS says width: 100%; height: auto of a 300px slot, so the height comes from the ratio: 225px, reserved before any bytes arrive. The text below already sits at its final position (its top equals the image's bottom).
  3. The second has no attributes, so the computed aspect-ratio is just auto, and the box is only as tall as one line of alt text (under 40px). When a real image arrived, it would grow to 225px and push the text down by about 200px: that's layout shift.
  4. The auto in auto 400 / 300 means "use the real image's ratio once it's known", so the attributes don't distort an image whose real ratio differs; they just fill the gap until then. The attribute values don't have to be the rendered size, only the right ratio: CSS still controls the size.
  5. The alt text is the image's accessible name (computedName). Write it to convey what the image means in context; for a chart, the conclusion it shows.
Advancedalt text rules
  • Informative image: describe what it conveys, briefly. Don't start with "Image of" (screen readers already say "image").
  • Decorative image (a flourish, a background-like photo whose content is already in the text): alt="", an empty alt. The spec maps it to a presentational role so assistive tech skips it. Better still, put purely decorative images in CSS.
  • Missing alt is not the same as empty: the image has no text alternative at all, and screen readers may fall back to reading the file name ("IMG_2041.jpg"). Validators flag it.
  • Image inside a link or button: the alt text is the link's name, so describe the destination or action ("Home", "Search"), not the picture.
  • Complex images (charts, diagrams): a short alt plus the full information in nearby text or a table, often in a figcaption.
  • Icon fonts and inline SVG need a name too: <svg role="img" aria-label="Settings">, or aria-hidden="true" if a visible text label is next to it.
figure and figcaption
CSS
figure { margin: 0; width: 280px; border: 1px solid #c7cfe0; border-radius: 8px; padding: 8px; }
figcaption { font-size: 13px; color: #4b5563; margin-top: 6px; }
HTML
<figure id="fig">
  <svg id="chart" role="img" aria-label="Bar chart of monthly sales" width="260" height="90" viewBox="0 0 260 90">
    <rect x="10" y="50" width="50" height="40" fill="#a5b4fc"/>
    <rect x="80" y="30" width="50" height="60" fill="#818cf8"/>
    <rect x="150" y="10" width="50" height="80" fill="#6366f1"/>
  </svg>
  <figcaption id="cap">Figure 1. Sales doubled from July to September.</figcaption>
</figure>
Resultlive, in your browser
What’s happening
  1. <figure> marks self-contained content (an image, chart, code listing or quote) that's referenced from the main text and could be moved elsewhere, such as an appendix, without breaking the flow. Its role is figure.
  2. The inline SVG has role="img" and an aria-label, so Chrome exposes it as one image named "Bar chart of monthly sales" instead of three unnamed rectangles.
  3. <figcaption> must be the first or last child of the figure; here it's the last. It's the visible caption for the whole figure. In this check Chrome 155 reported an empty computedName for the figure itself, so if the caption must also be the figure's accessible name, add aria-labelledby="cap" rather than relying on it.
  4. Use figure when there's a caption or the content is referenced as a unit ("see Figure 1"); a decorative or inline image doesn't need one.
Advancedloading, decoding and fetchpriority

<img loading="lazy"> defers loading until the image is near the viewport. It's Baseline (widely available since June 2026 per MDN; Chrome 77, Firefox 121, Safari 16.4 for images and iframes). Use it for images below the fold, and never for the main above-the-fold image (the Largest Contentful Paint element), because lazy images start late. For that hero image, fetchpriority="high" (Baseline 2024) asks the browser to fetch it before other images. Lazy loading only reserves the right space if width and height are set, otherwise lazy images make layout shift worse. Responsive images (srcset, sizes, <picture>) are covered in responsive images, and fitting images into boxes in aspect-ratio and object-fit.

AdvancedVideo and audio elements

video and audio

HTML
<video controls width="640" height="360" poster="/media/intro.jpg" preload="metadata">
  <source src="/media/intro.webm" type="video/webm">
  <source src="/media/intro.mp4" type="video/mp4">
  <track kind="captions" src="/media/intro.en.vtt" srclang="en" label="English" default>
  <p>Your browser can't play this video. <a href="/media/intro.mp4">Download it</a>.</p>
</video>

<audio controls src="/media/episode-12.mp3"></audio>
What’s happening
  1. controls gives the browser's built-in play/pause/seek/volume UI, which is keyboard-accessible. Without it, the video has no visible controls and you must build them.
  2. The browser tries each <source> in order and plays the first whose type it supports, so put the most efficient format first. poster is shown before playback; width/height reserve space just like an image.
  3. preload="metadata" fetches only duration and dimensions until the user presses play; "none" fetches nothing, "auto" lets the browser download more.
  4. <track kind="captions"> adds WebVTT captions, which deaf and hard-of-hearing users need and which WCAG requires for prerecorded video with audio. The content inside <video> after the sources is only shown by browsers that don't support <video> at all.
  5. Browsers block autoplay with sound: if you need a silent looping background clip, use autoplay muted loop playsinline. This chapter's demos have no media files, so none of this is executed here; the attributes are as documented on MDN.
What should the alt text be?
  1. A company logo in the header that links to the home page.
  2. A decorative wave graphic between two sections.
  3. A photo of the product on a product page, next to the product name.
  4. A screenshot of an error dialog in a help article, with the error text already in a paragraph below it.
Show answer

1: alt="Acme home", 2: alt="", 3: a short description of what the photo shows that the text doesn't, 4: alt="" or a brief alt, because the text already carries the information.

What’s happening
  1. The logo is the only content of a link, so its alt is the link's accessible name. It must describe the destination: "Acme home" (or just "Acme" if it's understood that the logo goes home). "Acme logo" tells a screen-reader user what the image is but not where the link goes.
  2. The wave is decoration with no information, so alt="": screen readers skip it. Omitting alt entirely would be worse; some screen readers would read the file name.
  3. The product photo is informative: "Black Pixel Watch 3 with a grey sport band, side view". Don't repeat the product name that's already next to it.
  4. The screenshot's information is already in the text, so it's effectively decorative here: alt="", or a short alt="Screenshot of the error dialog" if it helps orientation. Repeating the full error text would make screen-reader users hear it twice.

#Tables for tabular data

Added

A <table> is for data with two dimensions: rows and columns where each cell is meaningful because of its row and column headers, such as a price list, a timetable or a comparison. Screen readers make tables navigable: they can move cell by cell and announce the matching header ("Price, Pro plan, $12") so a user never loses their place. That only works if the headers are marked up as headers.

The parts: <caption> is the table's title (and its accessible name), <thead>, <tbody> and <tfoot> group rows, <th> is a header cell and <td> a data cell. scope="col" or scope="row" on a th says which cells it heads; simple tables get this right automatically, but scope makes it explicit. For complex tables with multi-level headers, headers="id1 id2" on a td lists its headers by id.

Mental model: a table is a spreadsheet with labelled axes. If you'd naturally say "the value in row X, column Y", it's a table. If you're just putting things next to each other, it's layout, and that's a job for grid or flexbox.

A data table with caption and header scopes
CSS
table { border-collapse: collapse; font-size: 14px; }
caption { text-align: left; font-weight: 600; padding-bottom: 6px; }
th, td { border: 1px solid #c7cfe0; padding: 4px 10px; text-align: left; }
thead th { background: #e0e7ff; }
tbody th { background: #f1f4fa; font-weight: 600; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
HTML
<table id="plans">
  <caption>Plans and prices</caption>
  <thead>
    <tr><th scope="col">Plan</th><th scope="col">Storage</th><th scope="col">Price / month</th></tr>
  </thead>
  <tr><th scope="row">Free</th><td class="num">5 GB</td><td class="num">$0</td></tr>
  <tr><th scope="row">Pro</th><td class="num">200 GB</td><td class="num">$12</td></tr>
</table>
Resultlive, in your browser
What’s happening
  1. The caption "Plans and prices" becomes the table's accessible name, so a screen reader announces "Plans and prices, table" and users can tell tables apart. It's also visible, which helps everyone.
  2. The ths in thead with scope="col" are columnheaders; the first th of each body row with scope="row" is a rowheader. When a user moves to $12, the screen reader can announce "Pro" and "Price / month" with it.
  3. The markup has no <tbody>, but the DOM does: the parser created one for the two body rows (tBodies.length is 1). Write it yourself anyway; it makes CSS like tbody tr:nth-child(odd) predictable.
  4. The table DOM API is handy for scripts: t.rows has all 3 rows, t.tHead.rows 1, and t.rows[2].cells[2] is the Pro plan's price, "$12".
  5. Numbers are right-aligned with tabular-nums so digits line up; that's styling, and it doesn't change the semantics.
AdvancedNever use tables for layout

Never for layout

Before CSS layout was usable, whole pages were built from nested tables. A layout table makes screen readers announce "table, 3 columns, 2 rows" and read header relationships that don't exist, it ties the visual order to the source order, and it can't adapt to small screens. Use grid or flexbox. If you inherit a layout table you can't remove yet, role="presentation" (or role="none") removes the table semantics: Chrome then reports the table as none and its cells as generic.

Turning off table semantics on a layout table
CSS
td { padding: 4px 10px; border: 1px dashed #c7cfe0; }
HTML
<table role="presentation" id="layout">
  <tr><td id="c1">Logo</td><td>Menu</td></tr>
</table>
Resultlive, in your browser
What’s happening
  1. role="presentation" tells the browser to drop the table's role from the accessibility tree; computedRole is none.
  2. The cells follow: their role is generic, so a screen reader reads the content as plain text without "row 1, column 1" announcements.
  3. It's a patch for legacy markup or HTML emails (where tables are still the only reliable layout). In new code, use CSS layout instead.
Table or not?

Which of these should be a <table>: (a) a product grid of 12 cards, (b) a comparison of three phones across battery, weight and price, (c) a two-column form with labels on the left and inputs on the right, (d) a monthly calendar?

Show answer

(b) and (d) are tables; (a) and (c) aren't.

What’s happening
  1. (a) Cards are a list of independent items laid out in a grid. There are no column headers that give a cell its meaning, so it's a ul of articles laid out with CSS grid.
  2. (b) Every value is meaningful only with its row header (the phone) and column header (battery, weight, price). That's tabular data: th scope="col" for the attributes, th scope="row" for the phones, and a caption.
  3. (c) Label–input pairs are a form, not data. Use label elements and lay them out with grid; a table would add meaningless "row, column" announcements.
  4. (d) A calendar is genuinely two-dimensional: weekday columns and week rows, and "Tuesday the 14th" depends on both. A table with weekday ths is the accessible choice.

#Global attributes: id, class, data-*, hidden, lang, title

Added

Global attributes are the ones every HTML element accepts. A handful come up constantly: id (a unique identifier: for fragment links, label for, ARIA references and getElementById), class (a space-separated list of names for CSS and JS), data-* (your own data attached to an element), hidden (the element isn't relevant right now), lang (the language of the content) and title (advisory text, usually shown as a tooltip). Others you'll meet: tabindex, style, dir, inert, popover, contenteditable, draggable, spellcheck, translate, autofocus and all the aria-* attributes.

The interview angle is in the details: how data-* names map to dataset properties, why hidden can be beaten by your own CSS, why title is a poor way to convey information, and what an id does to the global scope.

Mental model: global attributes are the luggage tags any element can carry: a name tag (id), group stickers (class), notes for your own scripts (data-*), a "not now" sticker (hidden) and a language sticker (lang).

dataset, hidden, lang and title
CSS
.chip { display: inline-block; padding: 2px 8px; border-radius: 99px; background: #e0e7ff; margin: 2px; }
.toolbar { display: flex; gap: 6px; background: #fdf2e0; padding: 4px; }
:lang(fr) { font-style: italic; color: #7c3aed; }
HTML
<div class="chip" id="user" data-user-id="42" data-is-admin data-last-login="2026-10-20">Rohit</div>
<div id="plain" hidden>Hidden by the attribute</div>
<div class="toolbar" id="toolbar" hidden>Hidden? The CSS says display: flex</div>
<p lang="en">The French say <span id="fr" lang="fr">c'est la vie</span>, and <span id="inherit">this span</span> is English.</p>
<div lang="fr"><p id="nested">Bonjour</p></div>
<button id="icon" title="Delete"><svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14"><rect x="2" y="2" width="10" height="2" fill="#6b7280"/><rect x="3" y="5" width="8" height="8" fill="#6b7280"/></svg></button>
<button id="emoji" title="Delete">🗑</button>
<div id="cart">cart</div>
Resultlive, in your browser
What’s happening
  1. data-*: data-user-id, data-is-admin and data-last-login show up in el.dataset as userId, isAdmin and lastLogin: the data- prefix is dropped and dash-case becomes camelCase. Every value is a string ("42", not 42), and a valueless attribute is "", so test it with "isAdmin" in el.dataset, not truthiness. Writing dataset.theme = "dark" creates data-theme="dark", which CSS can select with [data-theme="dark"].
  2. hidden: on its own, the browser's stylesheet gives it display: none (measured). But that's just a low-priority UA rule: the toolbar has hidden and an author rule .toolbar { display: flex }, and the author rule wins, so it's visible and takes up space, even though el.hidden is true. The common fix is one line in your base CSS: [hidden] { display: none !important; }.
  3. lang: the span marked lang="fr" matches :lang(fr) and is styled purple and italic. lang is inherited: the p inside <div lang="fr"> matches too, while the English sibling span doesn't. Screen readers switch pronunciation for lang changes, which is why foreign phrases should be marked.
  4. title: the first icon button contains only an SVG hidden with aria-hidden="true", so its computedName is "Delete", taken from title as a last resort. The second has the same title but an emoji as its content, and content wins: its name is "🗑" (a screen reader reads the emoji's name, something like "wastebasket"), and the title is demoted to a description. Tooltips also don't appear on touch screens or on keyboard focus, so prefer visible text or aria-label for names.
  5. id: window.cart is the #cart element. Browsers create a global "named access" property for every id (if nothing else uses the name), a legacy feature that can shadow or be shadowed by your variables. Never rely on it; use getElementById. Ids must be unique per document, or label for, aria-labelledby and fragment links pick the first match.
Advancedhidden vs display: none, and until-found

hidden vs display: none

hidden and display: none produce the same rendering, but they mean different things: hidden is semantic ("not relevant now", part of the HTML, works without CSS, readable as el.hidden), while display: none is presentation. Both remove the element from the accessibility tree, so screen readers don't see it either. For the full comparison with visibility: hidden, opacity: 0 and visually-hidden text, see hiding things and hiding content.

hidden="until-found" is a newer variant: the content is hidden with content-visibility: hidden instead of display: none, so the browser's find-in-page (and fragment links) can search it and reveal it automatically, firing a beforematch event. It's useful for collapsed FAQ answers. It isn't Baseline: per MDN in October 2026 it's in Chrome and Edge 102+ and Firefox 148+, not Safari, where it falls back to ordinary hiding.

hidden="until-found"
HTML
<div id="faq" hidden="until-found">The answer is searchable with Ctrl+F.</div>
Resultlive, in your browser
What’s happening
  1. el.hidden returns the string "until-found" instead of true, so code that checks if (el.hidden) still works.
  2. Chrome's stylesheet applies content-visibility: hidden and keeps display: block, so the box exists but its contents aren't rendered. That's what lets find-in-page search the text.
  3. Press Ctrl+F (Cmd+F on a Mac) in a Chrome or Firefox tab with this page and search for "searchable": the browser removes the hidden attribute and shows the text. In Safari it stays hidden and isn't found.

#Built-in interactive elements: details, dialog and popover

Added

Three things that used to need JavaScript widgets are now built into HTML: <details>/<summary> for show/hide disclosure, <dialog> for modal and non-modal dialogs, and the popover attribute for menus, tooltips and toasts. They come with keyboard handling, focus management and accessibility roles that hand-rolled versions usually get wrong. <details> needs no JavaScript at all; dialogs and popovers can be opened with plain buttons too, using the popovertarget attribute or the newer command/commandfor invoker buttons.

The key new idea behind modal dialogs and popovers is the top layer: a layer above the whole page, outside every stacking context, where those elements are rendered. No z-index war, no parent's overflow: hidden clipping your menu. A modal dialog also makes the rest of the page inert (not clickable, not focusable, hidden from screen readers) until it closes.

Mental model: details is a folded note: the summary is what's written on the outside. dialog with showModal() is a receptionist asking you to fill in a form before you go any further: the rest of the office is off-limits until you're done. A popover is a sticky note stuck on top of everything, which falls off when you click elsewhere.

details and summary: an accordion with no JavaScript
CSS
details { border: 1px solid #c7cfe0; border-radius: 8px; padding: 6px 10px; margin-bottom: 6px; width: 320px; }
summary { cursor: pointer; font-weight: 600; }
details[open] { background: #f1f4fa; }
HTML
<details name="faq" id="ship" open>
  <summary id="ship-sum">How long does shipping take?</summary>
  <p>Two to four working days.</p>
</details>
<details name="faq" id="returns">
  <summary id="returns-sum">Can I return an item?</summary>
  <p>Yes, within 30 days.</p>
</details>
<details id="other">
  <summary>Not in the group</summary>
  <p>Opens independently.</p>
</details>
Resultlive, in your browser
What’s happening
  1. Each details shows only its summary until opened; the first starts open because of the open attribute. The closed one is only one summary line tall (under 40px).
  2. The summary is focusable (the test focuses it) and works as a toggle with click, Enter or Space; Chrome exposes the details as a group. The open state is just the open attribute, so details[open] styles it (the grey background) and el.open reads or sets it.
  3. Both FAQ items share name="faq", which makes them an exclusive accordion: the test clicks the second summary, the first closes ([false, true]) and the second grows by more than 20px to show its answer. The name attribute is Baseline 2024 (Chrome 120, Firefox 130, Safari 17.2); older browsers just let several be open at once.
  4. The third details has no name, so it opens without affecting the group ([true, true]). Clicking an open summary closes it again.
  5. Try it in the live result: click the questions, or Tab to them and press Space. The browser fires a toggle event on the details each time, if a script needs to know.
Advanceddialog: modal and non-modal

A <dialog> is hidden until opened. dialog.show() opens it non-modally (in place, page still usable); dialog.showModal() opens it modally: in the top layer, with a ::backdrop behind it, the rest of the page inert, focus moved into it, and the Escape key closing it. A <form method="dialog"> inside closes the dialog when submitted and sets dialog.returnValue to the submitting button's value. <dialog> is Baseline since March 2022 (widely available since September 2024).

JavaScript
const dialog = document.querySelector("#confirm");
openButton.addEventListener("click", () => dialog.showModal());
dialog.addEventListener("close", () => {
  console.log(dialog.returnValue); // "delete" or "cancel"
});

You don't even need that script any more. Invoker commands let a button control a dialog or popover declaratively: <button commandfor="confirm" command="show-modal">. The built-in commands for dialogs are show-modal, close and request-close, and for popovers toggle-popover, show-popover and hide-popover; custom commands start with -- and fire a command event. Invoker commands are Baseline only since December 2025 (Chrome 135, Firefox 144, Safari 26.2), so keep the script fallback if you support older browsers.

A modal dialog opened by an invoker button
CSS
dialog { border: 0; border-radius: 10px; padding: 14px 16px; box-shadow: 0 10px 30px rgb(0 0 0 / 0.25); }
dialog::backdrop { background: rgb(30 35 50 / 0.45); }
.actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
button { font: inherit; padding: 4px 12px; }
HTML
<button id="open" commandfor="confirm" command="show-modal">Delete file…</button>
<button id="behind">Something behind</button>
<dialog id="confirm" aria-labelledby="confirm-title">
  <form method="dialog">
    <h2 id="confirm-title" style="font-size:16px;margin:0">Delete report.pdf?</h2>
    <p>This can't be undone.</p>
    <div class="actions">
      <button value="cancel" id="cancel">Cancel</button>
      <button value="delete" id="delete">Delete</button>
    </div>
  </form>
</dialog>
Resultlive, in your browser
What’s happening
  1. The dialog starts closed: open is false and the browser's stylesheet gives it display: none.
  2. Clicking "Delete file…" runs the button's command="show-modal" against commandfor="confirm", with no script. The dialog is open and matches :modal; its role is dialog, named "Delete report.pdf?" through aria-labelledby. Modal dialogs are position: fixed in the top layer, above everything, with the ::backdrop dimming the page.
  3. Focus moved into the dialog, to its first focusable control (the "Cancel" button). The test then tries to focus the "Something behind" button and fails: everything outside a modal dialog is inert. That's the focus trap you used to build by hand.
  4. Clicking "Delete" submits the <form method="dialog">, which closes the dialog instead of navigating, and returnValue becomes "delete", the button's value. A close event fires, so a script can act on the choice.
  5. show() opens the same dialog non-modally: open but not :modal, and the background button can be focused again. close("cancel") closes it and sets returnValue to "cancel". Finally requestClose() (Baseline 2025) asks the dialog to close the way Escape does: it fires a cancel event first. The test's handler calls preventDefault() the first time and the dialog stays open (that's how you build "discard unsaved changes?"); the second time it doesn't, and the dialog closes. A button with command="request-close" does the same without script (checked too). In the live result, open the dialog and press Escape to see it close.
Advancedpopover: menus, tooltips and toasts

Any element with the popover attribute is hidden until shown, then rendered in the top layer. A <button popovertarget="id"> toggles it with no script. popover (or popover="auto") gives light dismiss: clicking outside or pressing Escape closes it, and opening one auto popover closes any other that isn't its ancestor, which is what you want for menus. popover="manual" only closes when told to (for toasts). The Popover API is Baseline 2025 (newly available since January 2025, when iOS Safari 18.3 caught up; Chrome 116, Firefox 125, Safari 17 on desktop).

Popovers escape overflow and close each other
CSS
.clip { height: 40px; overflow: hidden; border: 1px dashed #9aa5b8; padding: 4px; width: 300px; }
[popover] { border: 1px solid #c7cfe0; border-radius: 8px; padding: 8px 12px; margin: 0; inset: auto; top: 70px; left: 20px; width: 200px; height: 80px; background: #fff; }
#manual { top: 70px; left: 260px; background: #fdf2e0; }
HTML
<div class="clip">
  <button id="menu-btn" popovertarget="menu">Menu</button>
  <button popovertarget="help">Help</button>
  <div id="menu" popover>Menu items</div>
  <div id="help" popover>Help text</div>
</div>
<div id="manual" popover="manual">Saved ✓ (manual)</div>
<dialog id="modal">modal</dialog>
Resultlive, in your browser
What’s happening
  1. Both auto popovers sit inside a 40px-tall box with overflow: hidden. Closed, they're display: none.
  2. Clicking "Menu" (popovertarget="menu") opens the menu with no script. It's rendered in the top layer (position: fixed), so the clipping box doesn't affect it: its bottom is below the box's bottom, and elementFromPoint there finds the menu, so it really is visible and clickable outside the box.
  3. The test then opens "Help" and the manual toast with showPopover(). Opening the second auto popover closed the menu; the manual toast stays open alongside: [false, true, true].
  4. Opening a modal dialog closes open auto popovers too: after showModal(), "Help" is closed. The manual toast only closes on hidePopover() (or a popovertarget button with popovertargetaction="hide"). Finally, an invoker button with command="show-popover" and commandfor="manual" opens the toast with no script, the newer alternative to popovertarget.
  5. Light dismiss can't be clicked by a test, so try it in the live result: open "Menu", then click anywhere outside it or press Escape, and it closes. Positioning a popover next to its button is the job of anchor positioning; animating it in and out uses @starting-style.
What happens here?
HTML
<button popovertarget="a">A</button>
<button popovertarget="b">B</button>
<div id="a" popover>Menu A</div>
<div id="b" popover="manual">Panel B</div>

You click A, then B, then click on an empty part of the page. What's open at each step?

Show answer

After A: Menu A. After B: Menu A closes (the click landed outside it, which light-dismisses it) and Panel B opens. After clicking the page: Panel B is still open.

What’s happening
  1. Clicking A toggles #a, an auto popover, so Menu A opens in the top layer.
  2. Clicking B: the click is outside Menu A, so light dismiss closes Menu A. Then B's popovertarget opens #b. It's manual, so it doesn't take part in auto-popover closing.
  3. Clicking the empty page: light dismiss only applies to auto popovers, so Panel B stays open. It closes when you click B again (popovertarget toggles by default) or call hidePopover().
  4. If #b were also an auto popover, the result after step 2 would be the same (A closes, B opens), but clicking the page in step 3 would close B too.

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.