This is the second of four parts, one floor above the fundamentals. Part 1 covered markup, the cascade, the language and the network. This part covers what you build on top: accessibility, state management, component design, media, how a frame reaches the screen, rendering strategies, fonts, testing and deployment.
None of these is a feature you learn once and finish with. Each is a set of trade-offs, and the trade-offs only make sense once you know what the browser is doing underneath.
If Part 1 was about knowing the rules, this part is about knowing when each rule stops applying.
Accessibility is not a checklist you bolt on at the end. Most of it falls out of two habits — choosing the right element, and thinking about where focus goes. ARIA covers the gaps those two cannot, and that is a smaller set than people expect.
Every interactive element has to be reachable and operable without a mouse. Native elements — <button>, <a href>, <input>, <select> — arrive with that already attached.
A <div onclick> does not. It takes no focus, ignores Enter and Space, and announces as nothing in particular. Rebuilding that by hand is the single most common reason a component ends up unusable by keyboard.
Tab order follows DOM order, not visual order. CSS can move things freely — flex order, row-reverse, grid placement — without changing where a box sits in the document. When the two disagree, focus appears to jump around at random. Keep the DOM in reading order and let CSS follow.
tabindex has exactly two values worth using:
0 puts an element into the natural tab order at its DOM position.
-1 makes it focusable programmatically via element.focus() while skipping it during tabbing — what you want for a dialog container, or a heading you intend to move focus to.
- Any positive value pulls the element ahead of everything with
0. Mixing positive values produces an order nobody can predict, including you.
Focus must be able to leave. A component that captures the keyboard and will not give it back is a trap, and a keyboard user has no way out short of reloading. The deliberate exception is a modal dialog, where the trap is the point.
A skip link — a "skip to main content" anchor as the first focusable element — lets somebody bypass the header they have already tabbed past on every other page. It is usually hidden until focused, which is why most people never notice it exists.
ARIA is a vocabulary of role, state and property attributes that describe semantics when HTML has no element that already says the same thing. That last clause is the whole rule.
<div role="button" tabindex="0"> plus keydown handlers for Enter and Space is a reconstruction of <button> that will be subtly worse in ways you find out about later.
Three constraints come with it:
- Do not override semantics an element already has.
<button role="heading"> takes away the thing that made the element useful.
- Anything made interactive with ARIA must be keyboard-operable. A role is a promise about behaviour that you are now responsible for keeping.
- Never put
aria-hidden="true" on something focusable, or you get an element reachable by tab that does not exist as far as the screen reader is concerned.
A handful of attributes cover most real use:
aria-label / aria-labelledby supply an accessible name where there is no visible text — an icon-only button being the standard case. The first takes a string, the second points at an id.
aria-describedby attaches supplementary text such as a hint or error, announced after the name.
aria-expanded / aria-selected / aria-checked communicate state the browser cannot infer for a custom widget.
aria-live marks a region whose changes should be announced — polite waits for a pause, assertive interrupts. It must be in the DOM before the content changes, because the browser announces a mutation it observed, not one it arrives to find already made.
Wrong ARIA is worse than no ARIA. A missing role leaves somebody with a generic element they can still inspect; an incorrect one tells them something false with complete confidence.
A screen reader does not read your DOM. It reads the accessibility tree, a parallel structure the browser derives from the DOM by combining native semantics, ARIA and computed style. Anything hidden with display: none or aria-hidden never appears in it.
People using one rarely read top to bottom. They navigate by structure — pulling up a list of headings, jumping between landmarks like <nav> and <main>, cycling through links and form controls. That is the practical reason heading hierarchy matters: the heading levels are a table of contents somebody is actually using, so skipping <h1> to <h4> removes a rung from the ladder.
Each element gets an accessible name, computed in a fixed priority order:
aria-labelledby
aria-label
- The native source — an associated
<label>, or the element's own text
title, as a last resort
When a control announces as "button" and nothing else, that computation ran and found nothing to use.
You are authoring two interfaces at once: the visual one and the one in the accessibility tree. Semantic HTML keeps them in step for free. Every custom widget is a place where they can drift apart.
Contrast is the ratio between the relative luminance of text and its background, from 1:1 for identical colours to 21:1 for black on white.
| Level | Normal text | Large text & UI components |
|---|
| AA | 4.5:1 | 3:1 |
| AAA | 7:1 | 4.5:1 |
Large means roughly 18pt, or 14pt bold — about 24px and 18.66px at default settings. "UI components" covers input borders and focus indicators.
Two things matter beyond hitting the number:
- Colour must never be the only carrier of information. A field that turns red and changes nothing else says nothing to somebody who cannot distinguish the red. Pair it with an icon, a border change, or text.
- Placeholder text is real text. It is routinely set several steps too light and fails a check body copy would pass.
WCAG exempts disabled controls from the ratio — but a disabled control nobody can read is still a control nobody can read.
The browser moves focus sensibly for ordinary links and form controls. Everything that replaces or reorders content is yours to handle.
Client-side navigation is the case people miss most. A real page load resets focus to the top of the new document; a router that swaps components does not, so focus stays where it was while a screen reader carries on reading a page that no longer exists. After navigation, move focus to the new page's main region or heading, with tabindex="-1" so it can receive focus without joining the tab order.
Dialogs need the round trip. On open, move focus in and keep it there. On close, put focus back on the element that opened it. Forgetting the return is the most common bug in the category — it strands somebody at the top of the document with no idea where they were.
1function openDialog(dialog, trigger) {
2 dialog.showModal();
3
4 dialog.addEventListener("close", () => trigger.focus(), { once: true });
5}
The native <dialog> opened with showModal() handles trapping and makes the background inert for you, which is a good reason to prefer it over a hand-built modal.
For the ring itself, style :focus-visible, not :focus. The browser shows it for keyboard interaction and suppresses it after a mouse click — precisely the behaviour people were reaching for when they removed the outline entirely.
This distinction reorganised the whole area, and it is worth getting straight before any library discussion starts.
Client state is owned by the interface. Whether a dropdown is open, what is typed into an input, which tab is selected. You are the source of truth, updates are synchronous, and nothing else has an opinion about the value.
Server state is owned by a backend. You hold a copy, that copy can be stale, other clients are changing the same data, and it has to be fetched, cached, revalidated and reconciled with whatever happens mid-request.
That is a fundamentally different problem, which is why TanStack Query and SWR exist for it specifically. Putting fetched data into a general-purpose store and hand-writing loading and error flags rebuilds those libraries slowly, without the parts that are actually hard.
For client state, the right home scales with scope:
- Component state for anything a single component owns. Most state never leaves.
- Lift to the nearest common parent when two siblings need the same value.
- Context for low-frequency global values — theme, locale, current user. It is a distribution mechanism, not a performance one: every consumer re-renders when the value changes.
- A store (Redux, Zustand, Jotai) when state is genuinely global, changes often, or carries complex update logic. Selectors exist so a component subscribes only to the slice it reads.
Underneath all of it: derive rather than duplicate. If a value can be computed from state you already hold, compute it. A second stored copy is a second thing to keep updated, and the moment one path forgets, you have a bug that only reproduces in a particular order of clicks.
Predictability comes from a small number of constraints, most made explicit by the Flux and Redux lineage:
- Data flows one way. Props down, callbacks up; a child never reaches upward. A given state produces a determined interface, and a given action a determined next state — which is what makes a bug reproducible from a description of what somebody did.
- Each piece of state lives in exactly one place. Two components holding their own copy is the duplication problem one level up.
- Updates produce new state rather than mutating. Immutability makes change detection cheap — a reference comparison instead of a deep walk — and lets a store keep a history you can step back through.
- Update logic stays pure. A reducer shaped
(state, action) => newState maps the same inputs to the same output every time.
State machines push this further by making invalid states impossible to represent. Three independent booleans give you eight combinations, most of them nonsense:
1// Three booleans: eight combinations, most of them contradictions
2type RequestFlags = {
3 isLoading: boolean;
4 isError: boolean;
5 isSuccess: boolean;
6};
7
8// One union: four states, all of them meaningful
9type Request =
10 | { status: "idle" }
11 | { status: "loading" }
12 | { status: "success"; data: User }
13 | { status: "error"; error: Error };
The second version cannot be loading and successful at once, and cannot hand you data in a state where no data exists. XState formalises this with named states and transitions, but most of the benefit arrives with the union type alone.
Composition means assembling interfaces from small focused pieces rather than growing one component until it does everything. The instinct it replaces is configuration — a component accumulating showHeader, headerVariant, hideFooterOnMobile until nobody can say which combinations were ever intended to work.
Three mechanisms, in increasing order of power:
children. A component that wraps whatever it is given does not need to know what that is, so a card never grows a title prop, then subtitle, then titleIcon.
- Compound components, which share state implicitly through context so coordination stays hidden.
- Hooks, which share behaviour instead of markup.
useDisclosure returns open state and handlers and says nothing about what should be rendered.
1<Tabs defaultValue="billing">
2 <TabList>
3 <Tab value="billing">Billing</Tab>
4 <Tab value="team">Team</Tab>
5 </TabList>
6 <TabPanel value="billing">...</TabPanel>
7 <TabPanel value="team">...</TabPanel>
8</Tabs>
Nothing passes the selected tab around by hand — the parent holds it, children read it from context — and the caller can still put anything inside a panel.
The failure mode in the other direction is just as real: split a component into ten files and tracing one interaction means opening all ten. Decompose when a piece has an independent reason to exist, not on principle.
The contract is data down, events up. A parent passes values in; the child reports back by calling a function the parent supplied.
The child does not mutate the props it received. From the child's side they are read-only, and mutating one produces a change the owner does not know about and will overwrite on the next render.
Prop drilling is a value travelling through several layers that have no use for it. The reflexive fix is a global store, which is usually the wrong size of solution:
- Context handles it well when the value really is global.
- Composition handles it better when it is not — pass the finished element down instead of the data it needs, and the intermediate layers go back to knowing nothing about it.
Beyond that, TypeScript types do most of the documentation work. Which props are required, what the defaults are, which shapes are allowed — expressed as types rather than a comment — means the component explains itself at the call site, which is where somebody is standing when they need to know.
A pure component returns the same output for the same props and state, and causes no side effects while rendering. That property is what makes rendering safe to optimise — skipping work is only correct if repeating it would have produced the same thing.
Memoisation depends on it entirely. React.memo, useMemo and useCallback all skip work assuming identical inputs imply identical output. Applied to a component that reads a mutable module variable or writes during render, they skip work that mattered and leave a stale screen.
Side effects therefore belong outside the render body — fetching, subscriptions, timers, direct DOM manipulation. Render can be called more than once for a single visible update, thrown away, or run twice deliberately in development precisely to surface this class of bug.
The caveat: memoisation is not free. Every memoised value costs a comparison and a slot of memory, most components are cheap to re-render, and a codebase where everything is wrapped is harder to read while being measurably no faster. Reach for it when you have measured something genuinely expensive — and note that compiler-level tooling now automates much of this.
The useful version: each unit should have one reason to change.
The old container/presentational split was one implementation. The current one is custom hooks for logic and components for markup. A useUserData hook owns fetching, caching and state transitions; the component that calls it is left with what to render. The hook tests without a DOM, and the component renders with fabricated data.
The honest caveat is that this can be taken much too far. Logic used by exactly one component, and short, is clearer next to the markup it drives than extracted into a file somebody has to go and find.
Images and video are almost always the heaviest thing on a page, often by an order of magnitude over the JavaScript everybody argues about. This is where real-world performance is won or lost.
- AVIF compresses hardest for photographic content.
- WebP sits behind it with wider support and much faster encoding.
- JPEG remains the universal floor.
- PNG is for hard edges and transparency — though WebP and AVIF do transparency too and produce smaller files, so PNG is increasingly a fallback.
- SVG for anything genuinely vector: scales to any size, stays small, and can be styled and animated with CSS because it becomes part of the document.
You do not have to pick one:
1<picture>
2 <source srcset="hero.avif" type="image/avif" />
3 <source srcset="hero.webp" type="image/webp" />
4 <img src="hero.jpg" alt="" width="1200" height="800" />
5</picture>
The <img> is not optional. It is the element that actually renders, and it carries the alt text, the dimensions and every other attribute.
Two different problems hide behind the word responsive.
Resolution switching is the common one: the same image at different pixel sizes depending on viewport and device pixel ratio. srcset lists candidates with their intrinsic widths; sizes tells the browser how wide the image will actually render.
1<img
2 src="photo-800.jpg"
3 srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
4 sizes="(min-width: 48rem) 50vw, 100vw"
5 alt="A harbour at dusk"
6 width="800"
7 height="600"
8/>
sizes is easy to get wrong and easy to leave stale. The browser chooses before layout has happened, so it has no way of knowing the rendered width other than what you tell it. A sizes claiming 100vw for an image rendering in a 300px column downloads a file several times too large — and nothing on the page looks wrong.
Art direction is the other problem: not a different size but a different crop, because a wide hero with the subject off to one side becomes unusable on a phone. That needs <picture> with media conditions, so you are choosing between genuinely different files.
Either way, set explicit width and height, or an aspect-ratio in CSS. The browser uses them to reserve space before the file arrives; without them the page reflows as each image loads.
loading="lazy" on an <img> or <iframe> hands the decision to the browser — no script, no observer to maintain. IntersectionObserver is still worth having for cases the attribute does not cover, but it is no longer the default answer.
The important exception: lazy loading is actively harmful above the fold. An image in the initial viewport is very often the Largest Contentful Paint element, and marking it lazy delays discovery of the exact resource that metric is measuring. A hero should load eagerly, and if it is the LCP candidate it should carry fetchpriority="high". Lazy loading starts below the fold.
Three engines matter: Blink (Chrome, Edge, the Chromium family), WebKit (Safari, and historically every browser on iOS regardless of the name on the icon), and Gecko (Firefox).
What they share is the pipeline every performance discussion points back at:
- Parse — HTML into the DOM, CSS into the CSSOM.
- Render tree — the two combined, holding only elements that will be displayed, each with computed style.
- Layout (reflow) — where every box sits and how large it is.
- Paint — filling in pixels, usually across several layers.
- Composite — assembling those layers into the frame you see, handed to the GPU.
Each stage depends on the one before it, so anything that invalidates an early stage forces every later stage to run again.
| Change | Triggers | Cost |
|---|
width, height, padding, position, font size, adding elements | Reflow → repaint → composite | Highest |
color, background, visibility, box-shadow | Repaint → composite | Medium |
transform, opacity | Composite only | Lowest |
Reflow is expensive because a change to one box can move its siblings, its parent and everything after it. Composite-only changes are applied to a layer that has already been painted, so the browser adjusts how existing pixels combine and never revisits layout or paint.
That is the entire reason the advice about animation is so narrow:
1/* Reflow on every frame */
2.panel { transition: left 200ms ease; }
3
4/* Composite only */
5.panel { transition: transform 200ms ease; }
Layout thrashing is forcing the browser to compute layout repeatedly within a single frame by interleaving reads and writes.
Certain properties cannot be answered from stale information — offsetHeight, getBoundingClientRect(), scrollTop, getComputedStyle(). Read one after a write that invalidated layout and the browser must perform a forced synchronous layout on the spot.
1// Thrashing — each read forces layout
2for (const el of elements) {
3 const height = el.offsetHeight; // read forces layout
4 el.style.height = height * 2 + "px"; // write invalidates layout
5}
The fix is to separate the phases:
1// Read phase
2const heights = elements.map((el) => el.offsetHeight);
3// Write phase
4elements.forEach((el, i) => {
5 el.style.height = heights[i] * 2 + "px";
6});
Layout now happens once rather than once per element, and the difference grows linearly with list size. This is why animation libraries batch reads and writes into separate passes.
Where and when your HTML is generated shapes almost everything else about how a page performs.
- Client-side rendering sends a near-empty shell plus a bundle. Servers stay cheap and navigation after first load is fast. The cost lands entirely on that first load — a blank screen until the bundle downloads, parses and executes, worst on the devices least able to absorb it, and crawlers see very little.
- Server-side rendering builds full HTML per request, so content appears as soon as the document arrives. The costs are server compute on every request, and the window where a page looks ready but does not yet respond to a click.
- Static site generation renders once at build time. Fastest and cheapest — a request is answered from a CDN with no computation anywhere — but content is frozen until the next build.
Between them sit the mixes:
- Incremental static regeneration serves static and rebuilds in the background once stale.
- Streaming SSR sends the document in chunks so the browser renders the shell while slower parts are still being produced.
- React Server Components and the islands model (Astro, Qwik) attack hydration cost directly, shipping JavaScript only for the parts that are genuinely interactive.
No strategy is best in general. The decision is per route: does the content vary per request, how much are first paint and search visibility worth, and how much of the page is actually interactive. Most real applications use more than one.
Web fonts are a common and largely avoidable source of slow first paint and layout shift. The file is discovered late — only after parsing the CSS and finding text that needs it — and until it arrives the browser has to decide what to show.
font-display is that decision:
swap renders immediately in a fallback and swaps when the font arrives. Guarantees readable text, produces a visible flash of unstyled text.
block hides text for around three seconds hoping the font wins the race. Trades readable text for invisible text — usually the worse deal.
optional gives the font roughly 100ms, then keeps the fallback for the rest of that page load. Best for performance and layout stability, at the price of the font sometimes not appearing on a first visit.
Everything else is about making the file arrive sooner:
- Preload critical fonts with
<link rel="preload" as="font" crossorigin>. The crossorigin attribute is required even same-origin, because fonts are always fetched in anonymous CORS mode.
- Self-host rather than using a third-party font service. Browser caches have been partitioned per site for years, so a shared CDN copy is no longer shared in any useful sense.
- Serve WOFF2. It compresses better and support is universal.
- Subset to strip glyphs you will never use — on a font carrying Latin Extended, Cyrillic and Greek, that removes most of the file.
- Variable fonts pack a whole weight axis into one file: a clear win at three or more weights, a loss at one.
The shift happens because the fallback and the web font have different metrics. Characters are wider or narrower, line height differs, and the instant the swap lands a paragraph gains a line and everything below it moves.
Metric overrides let you distort a local fallback so it occupies the same space as the font on its way:
1@font-face {
2 font-family: "Inter Fallback";
3 src: local("Arial");
4 size-adjust: 107%;
5 ascent-override: 90%;
6 descent-override: 22%;
7 line-gap-override: 0%;
8}
9
10body {
11 font-family: "Inter", "Inter Fallback", sans-serif;
12}
With the fallback tuned, the swap changes the shapes on screen and nothing moves. Working the percentages out by hand is tedious, which is why framework font tooling generates them.
The point of a suite is confidence per unit of cost. Every type of test buys a different amount of confidence at a different price, and most arguments about which tests to write are really arguments about that ratio.
| Type | Speed | Catches | Tools |
|---|
| Unit | ms | Pure logic — utilities, reducers, formatters | Jest, Vitest |
| Integration | fast | Seams between units — the best return for frontend | React Testing Library |
| Visual | medium | Layout breaks, contrast, overlap at a breakpoint | Chromatic, Percy, Playwright |
| End-to-end | slow | Whole journeys, highest confidence | Playwright, Cypress |
Integration is usually where the best return sits, because most real bugs are not inside a unit but in the seam between two, and a test at that level survives a refactor that moves logic between files.
End-to-end costs the most — slow, timing-sensitive, unpleasant to debug when it fails for environmental reasons. Reserve it for journeys that must not break: signing up, logging in, checking out.
Whether code is easy to test is mostly settled before any test is written. A pure function needs no scaffolding. A component that reaches into module-level state, fires a request on mount and reads the current clock needs a small ecosystem built around it first.
How you query matters as much as what you assert. Finding elements by role, label or visible text rather than test id or class name gives tests that survive refactoring — and that fail when a control loses its accessible name, which is an accessibility check you get free on every run.
The rule underneath both: test behaviour, not implementation. A suite that asserts on internals turns every refactor into a bill, and eventually people stop refactoring rather than pay it.
The testing trophy is a reasonable default shape: a base of static checks from TypeScript and the linter, a modest layer of unit tests, a thick layer of integration tests, and a thin cap of end-to-end.
Static hosting serves pre-built files with no application server per request. There is nothing to scale, because a file behind a CDN scales by itself, and nothing to keep alive, because there is no process.
Anything dynamic is layered on top — an API on another host, serverless functions, edge functions. The distinction that makes it work: the static layer is the part that has to be fast and cheap, and it stays that way regardless of what the dynamic layer is doing.
Origin is your central server: full runtime, unrestricted APIs, a persistent database connection, as much compute as you want. What it lacks is proximity.
Edge is code at a CDN point of presence, close to the user. Latency drops to a few milliseconds; in exchange the runtime is constrained — a limited API surface rather than full Node, short execution budgets, no local state surviving a request, often no direct database connection.
That makes the edge right for decisions rather than work: redirects, auth checks, A/B routing, geo personalisation, rewriting a request before it goes anywhere. Keep anything heavy or stateful at the origin.
Hashed filenames make this easy. app.4f8a2c.js can be served with Cache-Control: max-age=31536000, immutable — a year, no revalidation even on reload — because its contents cannot change without the hash changing, and a changed hash is a new URL.
That only works if something tells the browser about the new filenames, which is the HTML entry point's job. It gets a short lifetime or no-cache. The pattern is a thin always-fresh layer over a large permanently-cached one.
Atomic deploys keep the two consistent: upload a complete new set of files, then flip a single pointer, so no request sees old HTML asking for assets that are gone. Because each deploy is an immutable snapshot, rolling back is repointing at the previous one — seconds, no rebuild.
Purging is for anything you cannot rename: the HTML entry point, a fixed API path, an image at a stable URL. It works, but propagates more slowly and is easier to get wrong.
The cheapest invalidation is the one you never perform. Rename what you can, cache it forever, and keep the always-fresh surface as small as possible.
That is Tier 2. The through-line is that these are trade-offs rather than techniques: which state lives where, how far to decompose, what to cache and for how long, how much confidence a test is worth buying. Knowing the mechanism is what lets you make the call yourself instead of copying somebody else's.
Tier 3 goes underneath again — build systems and module formats, security, privacy, offline-first patterns, internationalisation, CSS at scale, performance work, and design systems.