Search Tech Journey

Find topics, journeys and posts

back to blog
system designbeginner 35m2026-06-10

MS Stack Ch 6 — Modern frontend baseline

Semantic HTML, ARIA + a11y, the CSS cascade + box model + Flexbox + Grid, modern JavaScript (ES2024), fetch, the event loop. The browser-side foundations every React app stands on.

Chapter 6 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.

Why this chapter

React abstracts the DOM. Tailwind abstracts the stylesheet. TypeScript abstracts JavaScript. Vite abstracts the bundler. Four layers of abstraction sit between you and the browser, and every one of them eventually leaks. When a click handler fires twice, when a layout collapses on iOS Safari, when a useEffect runs in a strange order, the answer is almost always in the layer underneath. The layer underneath is the browser: HTML parsed into the DOM, CSS resolved through the cascade into the CSSOM, JavaScript executed on a single thread driven by an event loop. You can ship features without understanding this layer for about six months. After that, every bug becomes a coin flip.

Shipping-vs-expert depth at this layer is stark. The shipping engineer knows that display: flex exists and reaches for it whenever something needs to sit next to something else. The expert knows that flex containers establish a new formatting context, that min-width: 0 is the unlock for shrinking children inside a flex row that contains long text, that align-items: stretch is the default and that flipping it to center is what made the equal-height cards collapse last Tuesday. The shipping engineer writes async/await and is happy. The expert knows that await desugars to a .then() callback that the event loop schedules as a microtask, that microtasks drain to exhaustion before the next macrotask, and that this is why their setTimeout(fn, 0) ran after the promise chain they thought was slower.

You finish this chapter when you can open a fresh HTML file, scaffold a small interactive page with semantic markup, layout it with Flexbox and Grid, hydrate it with modern JavaScript and fetch, and explain — without notes — what happens between the user typing the URL and the first pixel appearing on the screen.

Concepts and depth

Semantic HTML elements

HTML elements carry meaning. A <button> is not a styled <div> — it has a default role of button, is focusable, fires on Enter and Space, participates in form submission when its type is submit, and is announced as a button by screen readers. Replacing it with a <div onClick={...}> strips every one of those defaults, and you spend the rest of the project re-implementing them, badly. The cost of using the right element is zero. The cost of using the wrong one is paid forever.

A good page reads like a document outline: <header> for the banner, <nav> for primary navigation, <main> for the single primary content area, <article> or <section> for chunks of content, <aside> for tangential content, <footer> for the page or section footer. Inside, use <h1><h6> to express the heading hierarchy — never skip a level just because the design uses a smaller font. Use <ul> / <ol> / <dl> for lists, <figure> + <figcaption> for media, <table> only for tabular data (with <caption>, <thead>, <tbody>, <th scope="col|row">). Forms get <form>, <fieldset>, <legend>, <label> (always associated, either by wrapping or htmlFor), and the right <input type="...">email, number, tel, url, date, search — so mobile keyboards switch automatically and the browser's built-in validation kicks in.

The gotcha that bites everyone: a <button> inside a <form> defaults to type="submit". Hit Enter inside a text input and the form submits, sometimes refreshing the page and losing state. Always set type="button" on buttons that aren't submit buttons.

Good enough to ship
  • • Use header / nav / main / footer to outline the page
  • • Pick the right input type and always pair label with input
  • • Never put button behaviour on a div
Expert tier
  • • Know each element's implicit ARIA role by heart
  • • Use dialog / details / progress / meter where they fit
  • • Audit landmark structure with the accessibility tree, not the DOM tree

Accessibility basics — ARIA roles and focus management

Accessibility is not a feature you bolt on at the end. It is a property of the markup you write today. The two questions to ask of every interactive element are: can a keyboard user reach it and operate it? and can a screen-reader user understand what it is and what it will do? If the answer to either is no, you have a bug.

ARIA — Accessible Rich Internet Applications — gives you role, aria-* properties, and aria-* states to describe non-native widgets to assistive tech. The first rule of ARIA is don't use ARIA. If a native element does the job (<button>, <a href>, <input type="checkbox">, <dialog>), use it. If you must build a custom widget (a combobox, a tab panel, a tree view), follow the WAI-ARIA Authoring Practices pattern exactly — half-implemented ARIA is worse than none, because screen readers announce a control that doesn't actually behave the way they promised the user.

Focus management is where most apps fall apart. When a modal opens, focus must move into it; when it closes, focus must return to the element that triggered it. When a route changes in a SPA, focus is left on whatever the user last clicked, which is often invisible — the fix is to programmatically focus the new page's <h1> (with tabIndex={-1}) on navigation. When a list filters down, the focused row may have disappeared — handle that. The visible focus ring is non-negotiable: never outline: none without providing a :focus-visible style that is at least as obvious.

Good enough to ship
  • • Tab through the whole page — every interactive thing is reachable
  • • Visible focus ring on every focusable element
  • • axe DevTools shows zero serious violations
Expert tier
  • • Focus trap in modals, restore focus on close
  • • aria-live regions for async status updates
  • • Keyboard shortcuts that respect roving tabindex inside composite widgets

CSS — cascade, specificity, and inheritance

The cascade is the algorithm that decides which CSS rule wins when multiple rules target the same element. The order, roughly: origin and importance (user-agent < user < author < author-important < user-important), then specificity (inline = 1000, ID = 100, class/attribute/pseudo-class = 10, element/pseudo-element = 1), then order of appearance (later wins). The new @layer directive in modern CSS lets you put your reset, your component library, and your overrides in named layers and have layer order win before specificity, which is the cleanest way to manage the cascade in 2026.

Inheritance is what lets you set font-family on <body> and have every descendant pick it up. It applies to text-related properties (color, font-, line-height, text-) by default, and never to box-related properties (border, padding, margin, width). You can opt in with inherit, opt out with initial, reset to the layered cascade value with unset, or revert to the user-agent default with revert.

The specificity gotcha: you cannot beat !important from a stylesheet you don't own without using !important yourself or adding a more specific selector with !important. The way out is to architect with layers and component-scoped styles (CSS Modules, Tailwind, vanilla-extract) so you never end up in a specificity war. The other classic trap: a class selector beats an element selector even if the element selector appears later. Order of appearance is the last tie-breaker, not the first.

Good enough to ship
  • • Use classes, not IDs, for styling
  • • Read specificity from DevTools Styles panel
  • • Avoid !important — only an escape hatch for vendor styles
Expert tier
  • • Architect with @layer for predictable overrides
  • • Use :where() to drop specificity to zero on reset rules
  • • Reason about cascade origin (user-agent vs author vs user)

CSS box model and box-sizing

Every element is a box: content, padding, border, margin — in that order, outward. By default (box-sizing: content-box), the width you set applies to the content only, and padding and border add on top, which is why a width: 200px; padding: 20px element is actually 240px wide. Set box-sizing: border-box (universally, with *, *::before, *::after { box-sizing: border-box; }) and width now includes padding and border, which is what almost everyone wants. Every CSS reset published since 2010 does this; do not ship without it.

Margins collapse vertically between block elements. Two adjacent vertical margins of 20px and 30px don't add to 50px — they collapse to 30px. This is intentional (it makes prose typography look right) and surprising the first time it bites you. Margins do not collapse horizontally, do not collapse through borders or padding, and do not collapse inside a flex or grid container. If you want predictable spacing, use Flexbox or Grid with gap instead of margins.

Good enough to ship
  • • Apply box-sizing: border-box globally
  • • Reach for gap inside flex/grid for spacing
  • • Know padding is inside the border, margin is outside
Expert tier
  • • Predict margin collapse without opening DevTools
  • • Use logical properties (margin-inline, padding-block) for RTL support
  • • Reason about containing blocks and stacking contexts

Flexbox

Flexbox is a one-dimensional layout system: items flow along a main axis and align on a cross axis. You opt in with display: flex on a container; every direct child becomes a flex item. The container properties you reach for daily: flex-direction (row | row-reverse | column | column-reverse), justify-content (main-axis alignment), align-items (cross-axis alignment), gap (spacing between items, no need for negative margins), flex-wrap (whether items wrap to a new line). On items: flex (shorthand for flex-grow flex-shrink flex-basis), align-self (override the container's align-items for one item).

The unlock that everyone misses: a flex item's min-width defaults to auto, which means the item refuses to shrink smaller than its content. If you have a flex row with a long string of unbreakable text inside, the row overflows. Fix: set min-width: 0 on the flex item. Same for vertical flex with min-height: 0. This single rule fixes 80% of "why is my flex layout overflowing" bugs.

The other classic: align-items: stretch is the default, which is why two cards in a row end up the same height even if their content differs. Set it to flex-start if you want each card to size to its own content.

<div class="row">
  <div class="card">Short</div>
  <div class="card">A much longer paragraph that would otherwise force the row to overflow.</div>
</div>

<style>
  .row { display: flex; gap: 1rem; align-items: flex-start; }
  .card { flex: 1 1 0; min-width: 0; padding: 1rem; border: 1px solid #ccc; }
</style>
Good enough to ship
  • • Use flex for navbars, toolbars, button rows
  • • Use gap for spacing, never margins between siblings
  • • Know justify vs align (main vs cross axis)
Expert tier
  • • Reach for min-width: 0 before someone files a bug
  • • Understand flex-basis vs width and which wins
  • • Use flex-wrap + min/max width for responsive without media queries

CSS Grid

Grid is a two-dimensional layout system: you define rows and columns, then place items into the cells. Reach for Grid when you have a true 2D layout (a dashboard with a header, sidebar, main, footer; a card gallery that wraps; a form with aligned label/control pairs). Reach for Flexbox when you have a 1D flow (a navbar, a button group).

The two killer Grid features: grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)) gives you a responsive card grid with zero media queries — columns auto-add up to the available width, each at least 240px and otherwise expanding to fill. And named grid areas (grid-template-areas: "header header" "sidebar main" "footer footer") let you describe the layout visually in CSS and reflow it for mobile by redefining the areas in a media query, without touching the HTML.

subgrid (now supported in every evergreen browser) lets a nested grid inherit the tracks of its parent — finally fixing the case where a child card's rows need to align with its siblings' rows.

<div class="dashboard">
  <header>Header</header>
  <nav>Sidebar</nav>
  <main>Main</main>
  <footer>Footer</footer>
</div>

<style>
  .dashboard {
    display: grid;
    grid-template-columns: 240px 1fr;
    grid-template-rows: auto 1fr auto;
    grid-template-areas:
      "header header"
      "sidebar main"
      "footer footer";
    min-height: 100vh;
  }
  header { grid-area: header; }
  nav    { grid-area: sidebar; }
  main   { grid-area: main; }
  footer { grid-area: footer; }

  @media (max-width: 640px) {
    .dashboard {
      grid-template-columns: 1fr;
      grid-template-areas: "header" "main" "sidebar" "footer";
    }
  }
</style>
Good enough to ship
  • • Use repeat(auto-fit, minmax(...)) for responsive card grids
  • • Use grid-template-areas for page layouts
  • • Know fr unit and gap
Expert tier
  • • Use subgrid to align nested cards with parent tracks
  • • Mix auto-fit vs auto-fill correctly (different behaviour at the end)
  • • Use container queries (@container) instead of media queries for component-level responsiveness

CSS custom properties (variables)

Custom properties (--brand: #0066cc) live in the CSSOM, cascade and inherit like regular properties, can be read and written at runtime with element.style.setProperty('--brand', '#ff0000'), and are how every theming system in 2026 actually works (Tailwind's dark mode, shadcn/ui's theme tokens, your own dashboard's colour palette). You consume them with color: var(--brand, fallback).

The killer pattern: scope custom properties to a parent class or attribute and switch the whole subtree by changing one variable. A dark mode toggle is one line: :root { --bg: white; --fg: black; } [data-theme="dark"] :root { --bg: black; --fg: white; }. Every component that reads var(--bg) updates automatically.

Custom properties are not Sass variables — they are dynamic, they cascade, they can be animated (well, with @property and a registered type). Use them where you would have reached for a CSS-in-JS theme provider five years ago.

Good enough to ship
  • • Define design tokens as :root --* variables
  • • Toggle themes by swapping a data-attribute on body
  • • Use var() with fallbacks everywhere
Expert tier
  • • Register typed custom properties with @property for animation
  • • Drive component variants from inherited variables
  • • Animate gradients and shadows via interpolated variables

Responsive design and media queries

Responsive design is layout that adapts to the viewport. The mental model: mobile-first — write the base styles for the smallest screen, then layer on @media (min-width: ...) rules that add complexity as the viewport grows. This avoids the override pile-up you get when you start desktop-first and have to undo styles for mobile.

Pick breakpoints based on the content, not the device. The standard four (640px, 768px, 1024px, 1280px) are fine defaults. The real win in 2026 is container queries (@container (min-width: 480px) { ... }) — they let a component respond to its own container's width, not the viewport, which is what you actually want when a card is dropped into both a sidebar and a main column.

The viewport meta tag is non-negotiable: <meta name="viewport" content="width=device-width, initial-scale=1">. Without it, mobile browsers assume a 980px desktop layout and shrink everything to fit, breaking touch targets and font sizes. Test on a real device the first time, not just Chrome DevTools' responsive mode — iOS Safari has a hundred quirks Chrome will not show you.

Good enough to ship
  • • Mobile-first with min-width media queries
  • • Viewport meta in every page
  • • Touch targets ≥ 44×44 px
Expert tier
  • • Container queries over media queries for component design
  • • prefers-reduced-motion, prefers-color-scheme, prefers-contrast respected
  • • Use clamp() for fluid typography instead of step-based breakpoints

Modern JavaScript — let/const, arrow functions, destructuring, spread/rest

var is dead. Use const by default, let only when you reassign. const is block-scoped (the surprise for ex-var users is that for (const x of arr) works because each iteration gets a fresh binding) and prevents you from accidentally re-declaring or hoisting across scopes — both of which were common var bugs.

Arrow functions inherit this lexically from the enclosing scope, which is why every callback in modern JS is an arrow (arr.map(x => x * 2), setTimeout(() => doThing(), 100)). They have no arguments object, no prototype, and cannot be used as constructors — which is intentional. Reach for a function declaration when you need this to be dynamic (object methods, event handlers attached via .addEventListener on a class instance with shared state).

Destructuring (const { name, age } = user, const [first, ...rest] = arr) and spread ({...defaults, ...overrides}, fn(...args)) collapse what used to be five lines of boilerplate into one. Combine with default values (const { theme = "light" } = props) and renaming (const { user: currentUser } = ctx) and you can read function signatures as data contracts.

const config = { host: "localhost", port: 3000, timeout: 5000 };
const { port, timeout = 10000, ...rest } = config;
// port = 3000, timeout = 5000 (provided wins over default), rest = { host: "localhost" }

const merged = { ...config, port: 4000 };
// later spread wins; merged.port === 4000
Good enough to ship
  • • const by default, let only when reassigning
  • • Destructure props, config, API responses
  • • Spread to merge objects and arrays immutably
Expert tier
  • • Know when arrow vs function — this binding rules
  • • Nested destructure with defaults and rename in one line
  • • Be aware spread is shallow; reach for structuredClone for deep

Template literals, optional chaining, nullish coalescing

Template literals (`Hello, ${name}!`) replace string concatenation and support multiline strings out of the box. Tagged template literals (html`<div>${x}</div>`) hand the parts to a function and are how libraries like lit-html and styled-components work. They are also the safest way to build SQL queries when paired with a tagged-template-aware driver.

Optional chaining (user?.address?.street) short-circuits to undefined if any link in the chain is null or undefined. It also works for method calls (callback?.()) and bracket access (arr?.[0]). Use it where you previously wrote user && user.address && user.address.street. The gotcha: it short-circuits on null and undefined, but not on empty strings, 0, or false. That's usually what you want.

Nullish coalescing (value ?? fallback) is || minus the falsy-but-valid bug. port || 3000 falls back to 3000 when port is 0; port ?? 3000 only falls back when port is null or undefined. Use ?? for "did the user provide a value", || for "is this truthy".

const port = userConfig.port ?? 3000;        // user can set port: 0
const name = user?.profile?.displayName ?? user?.email ?? "anonymous";
const click = handlers.onClick?.(event);     // no-op if not provided
Good enough to ship
  • • ?? for defaults, ?. for safe navigation
  • • Template literals for any string with a variable in it
  • • Multiline string interpolation for prompts and queries
Expert tier
  • • Tagged templates for HTML, SQL, GraphQL safety
  • • Know ??= and ?.() and ?.[] forms
  • • Avoid double-?? bugs (operator precedence with ||)

ES modules (import / export)

ES modules are the standard module format in JavaScript. They are static (imports are resolved at parse time, not runtime), single-instance (the module body executes once and exports are bindings, not copies), and strict-mode by default. The static-ness is what enables tree-shaking — bundlers can prove an export is unused and drop it.

Named exports (export const foo = ...) and default exports (export default Foo) coexist. Prefer named exports: they refactor better (rename works), they keep the import name honest, and they avoid the "what do I call this default" lottery. Dynamic imports (const mod = await import('./foo.js')) return a promise and are the basis for code-splitting and lazy loading in every modern bundler.

The gotcha for Node devs: ESM and CommonJS have subtly different semantics — ESM is async, has no __dirname, no require, no module.exports. Use import.meta.url and fileURLToPath to recover paths. Vite, Next.js, and modern Node all default to ESM; CommonJS is a legacy interop layer.

// math.js
export const add = (a, b) => a + b;
export const PI = 3.14159;

// app.js
import { add, PI } from './math.js';
const lazy = await import('./heavy.js'); // code-split
Good enough to ship
  • • Named exports by default
  • • Use dynamic import() for code-splitting
  • • Know type="module" on script tags
Expert tier
  • • Reason about live bindings vs CJS snapshot copies
  • • Import maps for browser-native module resolution
  • • Build dual-publish packages (ESM + CJS) without breaking either

fetch, promises, async/await

fetch is the browser's built-in HTTP client. It returns a Promise<Response> that resolves on any HTTP response — including 404 and 500. Network errors and aborts reject the promise; HTTP error statuses do not. The mistake everyone makes the first month: assuming await fetch(url) throws on 500. It doesn't. Always check response.ok and throw yourself.

Promises are state machines: pending → fulfilled or rejected. async/await is syntactic sugar over .then()/.catch()async functions always return a promise, await pauses the function (not the thread) until the promise settles, and try/catch around await catches both rejections and synchronous throws inside the awaited body. Promise.all([...]) waits for all (fast-fails on first reject); Promise.allSettled([...]) waits for all regardless; Promise.race([...]) resolves with the first to settle; Promise.any([...]) resolves with the first to fulfil.

The AbortController pattern is the modern way to cancel a fetch: create a controller, pass controller.signal to fetch, call controller.abort() later. This is critical inside React useEffect cleanups — without it, the unmounted component sets state and React warns.

async function loadUsers(signal) {
  const res = await fetch('/api/users', { signal });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

const controller = new AbortController();
try {
  const users = await loadUsers(controller.signal);
} catch (e) {
  if (e.name === 'AbortError') return; // expected on cleanup
  throw e;
}
Good enough to ship
  • • Check response.ok before parsing
  • • async/await with try/catch
  • • Promise.all for parallel fetches
Expert tier
  • • AbortController to cancel and on unmount cleanup
  • • Stream the body with response.body.getReader()
  • • Retry, backoff, and timeout primitives on top of fetch

The event loop — microtasks, macrotasks, rendering

JavaScript is single-threaded. The event loop is the algorithm: pull one task off the macrotask queue, run it to completion, then drain the entire microtask queue to exhaustion, then (if needed) render a frame, then pull the next macrotask. Macrotasks include script execution, setTimeout callbacks, setInterval callbacks, I/O callbacks, and message events. Microtasks include resolved promise callbacks (.then, await continuations) and queueMicrotask calls.

The order trap: Promise.resolve().then(() => console.log('A')) runs before setTimeout(() => console.log('B'), 0) — even with a zero timeout — because the promise callback is a microtask drained at the end of the current task, while the setTimeout callback is a macrotask scheduled for the next iteration. This is why React batches state updates inside the same microtask but flushes between event handlers.

requestAnimationFrame(fn) schedules fn to run before the next paint, which is the right place to do any DOM mutation you want reflected visually in the same frame. setTimeout(fn, 16) is the wrong place — it can fire after the frame deadline and your animation will jank.

Good enough to ship
  • • Know promises run before setTimeout
  • • Use requestAnimationFrame for animations
  • • Avoid long synchronous loops on the main thread
Expert tier
  • • Reason about microtask starvation (infinite promise chain blocks render)
  • • Use scheduler.postTask with priorities (Chrome)
  • • Move CPU-heavy work to a Web Worker via postMessage

Iterators, generators, and for-await

An iterable is anything with a [Symbol.iterator]() method that returns an iterator (an object with next() returning { value, done }). Arrays, strings, Maps, Sets, and NodeLists are all iterable — which is why for...of works on all of them. Plain objects are not iterable by default; you reach for Object.entries(obj) to get an iterable of [key, value] pairs.

Generators (function*) are a concise way to write iterators — yield pauses, the next .next() call resumes. Async iterators add Symbol.asyncIterator and for await...of, which is how you consume streaming sources cleanly: a paginated API, a server-sent events stream, a Node.js Readable. Response.body is an async iterable — for await (const chunk of res.body) { ... } lets you stream bytes without buffering the full response.

async function* paginate(url) {
  let cursor = undefined;
  do {
    const u = cursor ? `${url}?cursor=${cursor}` : url;
    const res = await fetch(u);
    const { items, next } = await res.json();
    for (const item of items) yield item;
    cursor = next;
  } while (cursor);
}

for await (const item of paginate('/api/things')) {
  process(item);
}
Good enough to ship
  • • for...of over arrays, Maps, Sets, NodeLists
  • • Object.entries / keys / values for plain objects
  • • for await...of for paginated APIs and streams
Expert tier
  • • Write generators for lazy sequences
  • • Implement Symbol.iterator on custom collections
  • • Combine with AbortController for cancellable streams

DOM APIs you actually use

React buries the DOM, but you still need it for refs, integrations with non-React libraries, and the moments React doesn't cover. The set worth memorising: document.querySelector / querySelectorAll (the latter returns a static NodeList — won't auto-update); element.classList (add / remove / toggle / contains); element.dataset (read and write data-* attributes); element.addEventListener(type, fn, options) with { once, passive, capture, signal } options.

Observers replace polling for DOM state: IntersectionObserver for lazy-load / scroll-spy / virtualised lists; ResizeObserver for elements that need to react to their own size changes (chart libraries depend on this); MutationObserver for watching subtree changes (rare; usually a smell that you should own that subtree).

The signal option on addEventListener (Chrome 90+, all browsers now) is the cleanest cleanup pattern: pass controller.signal, call controller.abort() once to detach every listener at once — perfect for React effects.

const c = new AbortController();
btn.addEventListener('click', onClick, { signal: c.signal });
window.addEventListener('resize', onResize, { signal: c.signal });
// cleanup:
c.abort(); // both listeners detached
Good enough to ship
  • • querySelector, classList, dataset, addEventListener
  • • IntersectionObserver for lazy load
  • • ResizeObserver for size-aware components
Expert tier
  • • Use AbortController signal to cleanup listeners
  • • Passive listeners for scroll/touch perf
  • • Event delegation on a root element instead of N listeners

setTimeout, setInterval, requestAnimationFrame

setTimeout(fn, ms) and setInterval(fn, ms) queue a macrotask after at least ms milliseconds. The "at least" matters: browsers clamp to 4ms minimum for nested timers, and a busy main thread can push the actual delay to seconds. Never use setInterval for animations — if a tick is delayed, the next tick fires immediately after, producing visual jank. Use requestAnimationFrame for visuals and setInterval only for non-visual periodic work where exact timing doesn't matter.

clearTimeout / clearInterval cancel a pending timer. Always cancel timers in cleanup functions, especially inside React effects — otherwise a closure holds a reference to stale state and your "update once per second" code becomes "update 47 times per second after the seventh re-mount". The same applies to event listeners (always remove what you add) and observer callbacks (always disconnect).

useEffect(() => {
  const id = setInterval(poll, 5000);
  return () => clearInterval(id); // cleanup is non-optional
}, []);
Good enough to ship
  • • rAF for animation, setInterval for polling
  • • Always cancel timers in cleanup
  • • Know setTimeout(fn, 0) is not zero
Expert tier
  • • Drift-corrected interval (compute next from start, not from last fire)
  • • Use Page Visibility API to throttle when tab is hidden
  • • queueMicrotask vs setTimeout vs rAF — pick the right primitive

Worked examples

Example 1 — a fully accessible toggle button

Build a toggle button that announces its state to screen readers, is keyboard-operable, and respects prefers-reduced-motion.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Accessible toggle</title>
  <style>
    :root {
      --bg: #fff; --fg: #111;
      --accent: #0066cc; --accent-fg: #fff;
      --radius: 6px;
    }
    @media (prefers-color-scheme: dark) {
      :root { --bg: #111; --fg: #eee; }
    }
    body { background: var(--bg); color: var(--fg); font: 16px/1.5 system-ui; padding: 2rem; }
    button.toggle {
      display: inline-flex; align-items: center; gap: .5rem;
      min-height: 44px; padding: .5rem 1rem;
      background: transparent; color: var(--fg);
      border: 1px solid currentColor; border-radius: var(--radius);
      cursor: pointer; transition: background .15s ease;
    }
    button.toggle[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
    button.toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
    @media (prefers-reduced-motion: reduce) {
      button.toggle { transition: none; }
    }
  </style>
</head>
<body>
  <button type="button" class="toggle" aria-pressed="false" id="dark">
    <span aria-hidden="true">🌙</span>
    <span>Dark mode</span>
  </button>
  <script type="module">
    const btn = document.getElementById('dark');
    btn.addEventListener('click', () => {
      const pressed = btn.getAttribute('aria-pressed') === 'true';
      btn.setAttribute('aria-pressed', String(!pressed));
      document.documentElement.dataset.theme = !pressed ? 'dark' : 'light';
    });
  </script>
</body>
</html>
  • type="button" prevents accidental form submission.
  • aria-pressed makes the button a toggle; screen readers announce "pressed" or "not pressed".
  • min-height: 44px is the touch-target minimum.
  • :focus-visible shows the focus ring only for keyboard users, not on every mouse click.
  • prefers-reduced-motion respects the user's OS setting.

Example 2 — a responsive dashboard layout with Grid

A three-area layout (header, sidebar, main) that collapses to a single column on mobile, with no JavaScript.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    *, *::before, *::after { box-sizing: border-box; }
    body { margin: 0; font: 16px/1.5 system-ui; }
    .app {
      display: grid;
      grid-template-columns: 240px 1fr;
      grid-template-rows: auto 1fr;
      grid-template-areas:
        "header header"
        "sidebar main";
      min-height: 100vh;
    }
    .app > header  { grid-area: header;  padding: 1rem; background: #eef; }
    .app > nav     { grid-area: sidebar; padding: 1rem; background: #f4f4f8; border-right: 1px solid #ddd; }
    .app > main    { grid-area: main;    padding: 1rem; min-width: 0; }
    .cards {
      display: grid;
      gap: 1rem;
      grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
    }
    .card { padding: 1rem; border: 1px solid #ddd; border-radius: 8px; }
    @media (max-width: 640px) {
      .app {
        grid-template-columns: 1fr;
        grid-template-areas: "header" "main" "sidebar";
      }
      .app > nav { border-right: 0; border-top: 1px solid #ddd; }
    }
  </style>
</head>
<body>
  <div class="app">
    <header>Dashboard</header>
    <nav>Sidebar links</nav>
    <main>
      <div class="cards">
        <div class="card">Metric 1</div>
        <div class="card">Metric 2</div>
        <div class="card">Metric 3</div>
        <div class="card">Metric 4</div>
      </div>
    </main>
  </div>
</body>
</html>
  • grid-template-areas describes the layout visually — the media query just redefines the areas.
  • repeat(auto-fit, minmax(240px, 1fr)) is the responsive-grid-without-media-queries pattern.
  • min-width: 0 on main is what stops long content from forcing horizontal scroll.
  • Global box-sizing: border-box makes width arithmetic predictable.

Example 3 — fetch with abort and timeout

Wrap fetch with a timeout, abort, and friendly error handling — the version you actually want to use across an app.

async function httpGet(url, { signal, timeoutMs = 8000 } = {}) {
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(new Error('timeout')), timeoutMs);
  // Combine caller's signal with our timeout signal
  if (signal) signal.addEventListener('abort', () => ctrl.abort(signal.reason), { once: true });

  try {
    const res = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json' } });
    if (!res.ok) {
      const body = await res.text().catch(() => '');
      throw new Error(`HTTP ${res.status} ${res.statusText} — ${body.slice(0, 200)}`);
    }
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

// usage
try {
  const data = await httpGet('/api/users', { timeoutMs: 3000 });
  console.log(data);
} catch (e) {
  if (e.name === 'AbortError') console.warn('aborted or timed out');
  else console.error('fetch failed', e);
}
  • Combine the caller's signal with an internal timeout signal so either can cancel.
  • Always parse response.ok before response.json() — error bodies are often plain text.
  • clearTimeout in finally so a successful response doesn't leak a pending timer.
  • A 200 character preview of the error body is enough to debug; full bodies blow up logs.

Example 4 — debounced input handler without dependencies

Type-as-you-search inputs need debouncing — wait until the user stops typing, then fire one request. Vanilla JS, ten lines.

function debounce(fn, wait = 250) {
  let t;
  return function debounced(...args) {
    clearTimeout(t);
    t = setTimeout(() => fn.apply(this, args), wait);
  };
}

const input = document.querySelector('input[type="search"]');
const status = document.getElementById('status');

const search = debounce(async (q) => {
  if (!q) { status.textContent = ''; return; }
  status.textContent = 'searching…';
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
    const data = await res.json();
    status.textContent = `${data.length} results`;
  } catch {
    status.textContent = 'failed';
  }
}, 300);

input.addEventListener('input', (e) => search(e.target.value));
  • clearTimeout(t) before re-arming is what makes it debounced (vs throttled).
  • The closure captures t per-debouncer instance, so multiple debouncers don't collide.
  • encodeURIComponent on user input is non-negotiable for query strings.
  • A live region (aria-live="polite" on #status) would announce the result count to screen readers.

Hands-on exercises

  1. Semantic-only rewrite. Goal: rewrite a div-soup page using semantic HTML and zero ARIA.

    1. Find a personal project (or a CodePen) that uses <div> for everything.
    2. Replace each <div> with the right semantic element (<header>, <nav>, <main>, <article>, <section>, <aside>, <footer>).
    3. Make sure every heading level (<h1><h6>) is in order.
    4. Run WAVE and axe DevTools and fix every violation.
    5. You're done when: axe shows zero serious or critical violations and you can tab through the page reaching every interactive element with a visible focus ring.
  2. CSS cascade puzzle. Goal: predict which rule wins on paper, then verify.

    1. Pick five components from a real site (use DevTools).
    2. For each, screenshot the Styles pane and write down which rule wins and why.
    3. Toggle off the winning rule — confirm the next rule takes over.
    4. Try to override the winning rule from a new stylesheet without !important.
    5. You're done when: you can predict the winner without opening DevTools 8/10 times.
  3. Grid dashboard scaffold. Goal: build a 3-column responsive dashboard from scratch.

    1. Start with a blank HTML file. Add a header, sidebar, main, footer.
    2. Use grid-template-areas for the layout.
    3. Add a card grid in main using repeat(auto-fit, minmax(240px, 1fr)).
    4. Make it collapse to single column under 640px.
    5. You're done when: you can resize the browser from 320px to 1920px without any element overflowing or breaking.
  4. fetch with retry and abort. Goal: write a httpGet helper with timeout, retry, and abort support.

    1. Start with the Worked Example 3 helper.
    2. Add exponential backoff retry on 5xx (3 attempts: 0ms, 200ms, 600ms).
    3. Do not retry on 4xx or AbortError.
    4. Write 3 tests with a mocked fetch: success, 500-then-success, persistent 500.
    5. You're done when: tests pass and the helper is under 60 lines.
  5. Event-loop quiz machine. Goal: build a mental model of microtask vs macrotask order.

    1. In a <script type="module">, write code that logs 1, then schedules 2 via setTimeout(_, 0), 3 via Promise.resolve().then, 4 via queueMicrotask, 5 via requestAnimationFrame, then logs 6.
    2. Predict the output order on paper. Then run it.
    3. If you got it wrong, draw the queue state after each step.
    4. Try wrapping the body in a button click handler — does the order change?
    5. You're done when: you can predict the order for any combination of setTimeout, Promise, queueMicrotask, and rAF without running it.
  6. Lighthouse hardening. Goal: take a real page from a Lighthouse score of ~80 to ≥95 on Performance and Accessibility.

    1. Run Lighthouse against a personal page; record the score.
    2. Fix the top 5 issues (likely: missing alt text, low-contrast text, no <title>, render-blocking CSS, oversized images).
    3. Add a viewport meta and a meaningful <title>.
    4. Compress hero images and serve them as webp/avif.
    5. You're done when: Lighthouse scores ≥95 on both Performance and Accessibility across two runs.

Self-check questions

  1. Why is a <button> better than a <div onClick> even if the styling is identical?
  2. Explain the cascade: in what order does the browser resolve which rule wins?
  3. What is margin collapse, and where does it not happen?
  4. When do you reach for Flexbox vs Grid? Give one example of each.
  5. What is the min-width: 0 trick on flex items, and what problem does it solve?
  6. Difference between || and ??. Give a case where they produce different results.
  7. Why does Promise.resolve().then(...) run before setTimeout(..., 0)?
  8. What does AbortController solve, and why do React effects need it?
  9. Difference between ESM and CommonJS — list at least three.
  10. What does prefers-reduced-motion do, and when must you respect it?
  11. Why is setInterval a bad fit for visual animations?
  12. Walk through the event loop for one tick: macrotask, microtask drain, render — what runs in what order?
  13. What is event delegation, and when is it preferable to attaching N listeners?
  14. Explain IntersectionObserver and one use case that would otherwise need scroll listeners.
  15. Difference between a for...of loop and a for...in loop — and why you almost never want for...in on an array.

High-signal resources

Official docs

Books or courses

  • CSS in Depth by Keith Grant (2nd ed., Manning, 2024).
  • Eloquent JavaScript by Marijn Haverbeke (eloquentjavascript.net) — free, current.
  • web.dev Learn courses — Google's structured tutorials on HTML, CSS, Accessibility, Forms, PWA.

Practitioner posts

Weekly milestones

  1. Day 1 — Read the MDN HTML element reference top to bottom (it's shorter than you think). Do Exercise 1.
  2. Day 2 — Read MDN's CSS Cascade and Specificity pages. Do Exercise 2.
  3. Day 3 — Do Josh Comeau's Flexbox guide. Read MDN Grid layout. Do Exercise 3.
  4. Day 4–5 — Read web.dev's JavaScript course. Do Exercises 4 and 5. Answer self-check 1-6.
  5. Day 6–7 — Watch Jake Archibald's event loop talk. Do Exercise 6. Answer self-check 7-15.

If you finish early, drop into a CodePen and rebuild any single-page site you visit often (a news site front page, a docs site, a settings panel) from scratch in semantic HTML with no JS. The exercise of constraining yourself to the platform is where the reflexes form.

How it shows up in the capstone

The analytics dashboard is, at its bones, a semantic HTML document: a <header> with the app shell, a <nav> sidebar with route links, a <main> containing a grid of <section>s (each a chart card), and an <aside> for filters. Every interactive control — date pickers, drilldown buttons, dropdowns — uses native elements or, when not possible, follows an ARIA pattern verbatim. The whole thing must pass axe with zero violations and be operable with a keyboard only.

Layout is CSS Grid for the page shell, Flexbox for in-card toolbars, container queries for cards that may sit in a sidebar or main column with different widths. Theme tokens (--bg, --fg, --accent, --grid-line) are custom properties so dark mode is a one-attribute swap with no React re-render. fetch is wrapped in the timeout/abort helper from Worked Example 3 and lives behind TanStack Query in chapter 10 — but the helper itself is plain fetch, because TanStack Query is just a cache around fetch.

You will feel this chapter most when you debug a layout bug at 11pm and the answer turns out to be "the flex item didn't have min-width: 0". The minute that wiring becomes reflex, you stop fighting the browser and start using it. Every later chapter (TypeScript, Vite, React, routing, charts) layers on top of this foundation. The same fetch is what TanStack Query wraps. The same Grid and Flexbox are what your React components render into. The same event loop is what schedules every useEffect and every promise resolution. Without this layer, everything above it is cargo cult.

The minute that wiring becomes reflex, you stop fighting the browser and start using it.

Next chapter → Ch 7 — TypeScript Previous chapter → Ch 5 — ASP.NET Core