Semantic HTML conveys meaning — not just appearance. Accessibility relies on it.
ARIA landmark roles identify page regions for screen readers. Every page should use them:
<header role="banner">Site title & navigation</header> <nav role="navigation" aria-label="Main">Primary links</nav> <main role="main">Primary content</main> <aside role="complementary">Sidebar</aside> <footer role="contentinfo">Copyright, contact</footer>
Landmarks let assistive-technology users skip directly to any section without tabbing through every element.
tabindex="0" to make non-focusable elements focusable; manage :focus-visible rings for keyboard-only users.aria-label). Avoid "click here."<button aria-label="Close dialog" aria-expanded="true">×</button> <div role="alert" aria-live="polite">Form submitted successfully</div> <img src="chart.png" alt="Bar chart: 2025 sales by quarter">
aria-live="polite" tells screen readers to announce dynamic updates without interrupting the current task. Use aria-live="assertive" only for urgent messages.
These four security mechanisms are the minimum every web developer must understand. Each addresses a different attack vector.
An attacker injects malicious scripts into a trusted website. Three types:
document.referrer) and writes it into the DOM unsafely.Prevention: Always sanitize output — use textContent instead of innerHTML, escape user data in templates, and set the Content-Security-Policy header to block inline scripts.
CSP is an HTTP response header (Content-Security-Policy) that tells the browser which resources are allowed to load. It's the most effective XSS defense:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
Key directives (per the CSP Level 3 specification, W3C, 2025):
default-src — fallback for all fetch directivesscript-src — valid script sources. Use 'nonce-...' or 'strict-dynamic' instead of 'unsafe-inline'object-src 'none' — block Flash/plugins entirelybase-uri 'self' — prevent injection of malicious <base> tagsupgrade-insecure-requests — automatically upgrade HTTP to HTTPSAn attacker tricks an authenticated user into submitting a malicious request. Example: an external page auto-submits a form to POST /transfer-funds using the user's cookies, which are sent automatically.
Prevention: Use anti-CSRF tokens — a cryptographically random value embedded in the form and validated on the server. Modern frameworks (Django, Rails, Express with csurf) handle this. SameSite cookies (SameSite=Strict or Lax) also mitigate CSRF.
Browsers enforce a Same-Origin Policy: by default, a page at https://a.com cannot read responses from https://b.com. CORS is the mechanism that relaxes this selectively:
// Server response headers to allow cross-origin access Access-Control-Allow-Origin: https://trusted-site.com Access-Control-Allow-Methods: GET, POST, PUT Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: true
For "non-simple" requests (e.g., PUT, DELETE, or custom headers), the browser sends a preflight OPTIONS request. The server must respond with the allowed methods and headers before the actual request is sent.
Web Components are three browser-native APIs for creating reusable, encapsulated HTML elements. No framework needed.
Define your own HTML tag with lifecycle callbacks (spec: HTML Standard §4.13, WHATWG, 2026):
class RateCard extends HTMLElement {
connectedCallback() {
// Runs when element is inserted into DOM
const stars = this.getAttribute("stars") || "0";
this.innerHTML = `★`.repeat(stars) + `☆`.repeat(5 - stars);
}
static get observedAttributes() { return ["stars"]; }
attributeChangedCallback(name, oldVal, newVal) {
if (name === "stars") this.connectedCallback();
}
}
customElements.define("rate-card", RateCard);
Usage: <rate-card stars="4"></rate-card>
Lifecycle: constructor → connectedCallback (mount) → attributeChangedCallback (updates) → disconnectedCallback (unmount) → adoptedCallback (move document).
Encapsulates DOM and CSS. Styles inside the shadow tree cannot leak out, and external styles cannot leak in:
class Tooltip extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
.tooltip { background: #333; color: #fff; padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; }
:host { display: inline-block; }
</style>
<div class="tooltip"><slot></slot></div>`;
}
}
customElements.define("my-tooltip", Tooltip);
<slot> projects light-DOM children into the shadow tree. This is how browsers implement <input>, <select>, and <video> — their internal UI is rendered via Shadow DOM.
The <template> element holds inert HTML that is not rendered. Clone it in JS to create reusable fragments:
<template id="card-template">
<style>.card { border: 1px solid #ddd; padding: 1rem; border-radius: 6px; }</style>
<div class="card">
<h3><slot name="title">Card</slot></h3>
<slot></slot>
</div>
</template>
:has() Selector — "Parent Selector"Baseline Widely Available since December 2023 (Chrome 105, Firefox 121, Safari 15.4). :has() selects an element based on its descendants:
/* Style a card only if it contains an image */
.card:has(img) { grid-column: span 2; }
/* Highlight a form group with validation errors */
.form-group:has(input:invalid) { border-color: red; }
/* Style the entire row when a checkbox is checked */
tr:has(input[type="checkbox"]:checked) { background: #f0fdf4; }
This eliminates JavaScript DOM-traversal patterns. Note: :has() is a forgiving selector list — if the browser doesn't support it, the entire rule fails gracefully when used with :is() or :where().
@layer)Before @layer, specificity and source order determined which rules won. Layers let authors define explicit precedence tiers:
@layer reset, base, components, utilities;
@layer reset {
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
}
@layer base {
body { font-family: system-ui; line-height: 1.6; color: #333; }
}
@layer components {
.card { background: white; border-radius: 8px; padding: 1.5rem; }
}
@layer utilities {
.mt-4 { margin-top: 1rem; }
}
Rules in later layers always beat earlier ones regardless of specificity. Unlayered styles win over all layered styles. This completely replaces the old "specificity hack" pattern.
Specificity is calculated as (inline, ID, class, element):
#nav .item a → (0, 1, 1, 1)style="color:red" → (1, 0, 0, 0) — always wins without !importantli.active → (0, 0, 1, 1)ul li → (0, 0, 0, 2)!important inverts the cascade — use sparingly. Layers and :where() (which always contributes 0 specificity) are better alternatives.
@container)Well established across browsers since February 2023 (Chrome 105, Firefox 110, Safari 16). Unlike media queries that respond to the viewport, container queries respond to a parent element's size:
.card-wrapper { container-type: inline-size; container-name: card; }
@container card (min-width: 400px) {
.card { display: grid; grid-template-columns: 200px 1fr; gap: 1rem; }
}
@container card (max-width: 399px) {
.card { display: flex; flex-direction: column; }
}
container-type: inline-size creates a query container on the inline axis. The @container rule conditionally applies styles. This enables truly reusable components that adapt to their parent, not the screen size.
clamp(), min(), max()Fluid responsive design without media queries:
font-size: clamp(1rem, 2.5vw, 2rem); /* Minimum 1rem, preferred 2.5vw, maximum 2rem */ width: min(100%, 1200px); /* Shrink to fit, but never exceed 1200px */ padding: max(1rem, 3vw); /* Always at least 1rem, scale up with viewport */
Child grid items can inherit their parent's grid tracks using subgrid:
.parent { display: grid; grid-template-columns: 200px 1fr 200px; gap: 1rem; }
.child { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; }
This is essential for aligning nested content (e.g., cards in a grid where each card has a header, body, footer that must align across rows).
Instead of margin-left / margin-right, write direction-aware properties:
margin-inline: auto; /* margin-left + margin-right */ padding-block: 1rem; /* padding-top + padding-bottom */ border-inline-start: 2px solid; /* border-left in LTR */
These adapt automatically when the user's language direction changes (e.g., Arabic, Hebrew).
@property@propertyBaseline 2024 (Chrome 85+, Firefox 128+, Safari 16.4+). The @property at-rule (CSS Properties and Values API Level 1, W3C) lets you define type-checked custom properties with inheritance rules:
@property --my-color {
syntax: "<color>";
inherits: false;
initial-value: #3b82f6;
}
@property --spacing {
syntax: "<length>";
inherits: true;
initial-value: 1rem;
}
@property --scale {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
.card {
--spacing: 2rem;
padding: var(--spacing);
background: var(--my-color);
transform: scale(var(--scale));
transition: --scale 0.3s, --my-color 0.3s;
}
.card:hover { --scale: 1.05; --my-color: #ec4899; }
Without @property, the browser cannot animate custom properties because it doesn't know their type. With registration, it knows --scale is a <number> and can interpolate between 1 and 1.05.
registerProperty() in JavaScriptCSS.registerProperty({
name: "--gradient-angle",
syntax: "<angle>",
inherits: false,
initialValue: "0deg"
});
If both @property in CSS and registerProperty() in JS define the same property name, the JavaScript registration wins.
JavaScript is single-threaded with a concurrent model based on an event loop. Understanding this is the single most important step in mastering JS.
The event loop processes tasks in a strict sequence: sync code → microtasks → requestAnimationFrame → macrotasks.
console.log("1: sync"); // 1st
setTimeout(() => console.log("4: macrotask"), 0); // 4th
Promise.resolve().then(() => console.log("2: microtask")); // 2nd
queueMicrotask(() => console.log("3: another microtask")); // 3rd
requestAnimationFrame(() => console.log("3a: RAF")); // ~3a (before render)
console.log("1b: sync"); // 1st
Output: 1: sync, 1b: sync, 2: microtask, 3: another microtask, 3a: RAF, 4: macrotask
Each loop iteration: 1 macrotask → drain all microtasks → render → next macrotask.
This means setTimeout(cb, 0) does not run immediately — it waits for all microtasks to clear first. Never use setTimeout(fn, 0) to defer; use queueMicrotask() or Promise.resolve().then() instead.
A Promise is in one of three states: pending (initial), fulfilled (resolved), or rejected (error thrown). Static methods for coordination:
// Wait for ALL to settle (resolve or reject)
const results = await Promise.allSettled([
fetch("/api/users"),
fetch("/api/courses"),
fetch("/api/grades"),
]);
// Returns [{status:"fulfilled", value}, {status:"rejected", reason}]
// First to fulfill wins (rejects only if ALL reject)
const fastest = await Promise.any([
fetch("https://mirror1.example.com/data"),
fetch("https://mirror2.example.com/data"),
]);
// Settle only when ALL are fulfilled; single reject rejects entire batch
const [users, courses] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/courses").then(r => r.json()),
]);
Promise.withResolvers() (ES2024)Creates a promise with external resolve and reject functions — no executor callback needed:
const { promise, resolve, reject } = Promise.withResolvers();
// Use with event-driven APIs
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = "photo.jpg";
await promise; // resolves when image loads
This simplifies converting callback-based APIs to promises, especially recurring events (streams, WebSocket messages, custom event emitters).
// Pattern 1: try/catch for expected errors
async function loadProfile(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error("Profile load failed:", err);
return { error: true, message: err.message };
}
}
// Pattern 2: Top-level error with .catch() (prevents unhandled rejections)
loadProfile(42).catch(err => reportError(err));
Observes when an element enters or exits the viewport. Much more efficient than scroll-event listeners:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src; // load actual image
img.classList.remove("lazy");
observer.unobserve(img); // stop observing
}
});
}, {
rootMargin: "200px", // trigger 200px before visible
threshold: 0.01, // at least 1% visible
});
document.querySelectorAll("img[data-src]").forEach(img => observer.observe(img));
Fires when an element's size changes. Unlike window resize, it fires per-element and gives exact dimensions:
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { inlineSize, blockSize } = entry.contentBoxSize[0];
entry.target.style.fontSize = `${Math.max(0.8, inlineSize / 50)}rem`;
}
});
ro.observe(document.querySelector(".responsive-text"));
Two critical patterns for rate-limiting expensive operations:
// Debounce: wait until activity stops, then fire ONCE
function debounce(fn, ms = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// Use: input search (wait for user to stop typing)
input.addEventListener("input", debounce(doSearch, 400));
// Throttle: fire at most once per interval
function throttle(fn, ms = 100) {
let last = 0;
return (...args) => {
const now = Date.now();
if (now - last >= ms) { last = now; fn(...args); }
};
}
// Use: scroll position, resize handler
window.addEventListener("scroll", throttle(savePosition, 200));
Cache expensive function results based on arguments:
function memoize(fn) {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn(...args);
cache.set(key, result);
return result;
};
}
const expensiveFib = memoize((n) => n <= 1 ? n : expensiveFib(n - 1) + expensiveFib(n - 2));