Complete Static Website Development Guide

Build Real
Websites with
Pure Front-End

A complete, practical guide to building professional static websites — from blank file to live deployment. HTML5 structure, CSS design systems, JavaScript interactivity, forms, SEO, accessibility, and performance. No server required.

HTML5 CSS3 JavaScript No Server ● 20 Live Demos

§ 01 — Foundations

What Is a Static Site?

A static website serves pre-built HTML, CSS, and JavaScript files directly to the browser — no database queries, no server-side rendering, no PHP or Java processing. The browser does all the work.

Static Site = HTML + CSS + JS files served as-is. The browser renders everything. User data lives in localStorage, JSON files, or third-party APIs.
✓ Free or near-free hosting (GitHub Pages, Netlify, Vercel)
✓ Instantly fast — no database round trips
✓ Secure — no server means no server vulnerabilities
✓ Works offline with Service Workers
✓ Easy to version-control, maintain, and deploy
✗ No user accounts with private server-side data (use a 3rd-party service)
✗ No server-side logic (use client-side JS instead)
✗ No real-time server push (use a WebSocket service or polling)
how a static site works
// 1. Browser requests index.html
GET /index.html

// 2. Server returns the raw file
// No PHP, no processing, no database
<html>...your pre-built HTML...</html>

// 3. Browser fetches CSS + JS
GET /css/main.css
GET /js/main.js

// 4. JS calls external APIs directly
const data = await fetch(
  'https://api.weather.com/forecast'
);

// 5. Everything renders in the browser
// Your "backend" = 3rd party APIs
// Auth  → Auth0, Firebase, Supabase
// Forms → Formspree, Netlify Forms
// DB    → Supabase, Airtable, Firebase

§ 02 — File Organization

Project Structure

A well-organized project prevents chaos as it grows. Separate folders for CSS, JS, images, and fonts. One CSS file per responsibility. One JS file per feature. A clear naming convention throughout.

my-website/ ├── index.html ← main entry point ├── about.html ├── contact.html │ ├── css/ │ ├── reset.css ← normalize browsers │ ├── variables.css ← custom properties │ ├── layout.css ← grid, flex layouts │ ├── components.css ← buttons, cards, nav │ └── main.css ← @imports all above │ ├── js/ │ ├── utils.js ← helper functions │ ├── nav.js ← navigation logic │ ├── animations.js ← scroll reveals │ └── main.js ← entry point │ ├── img/ │ ├── hero.webp │ ├── logo.svg │ └── icons/ │ ├── fonts/ ← self-hosted fonts ├── favicon.ico ├── robots.txt ← for crawlers └── sitemap.xml ← for SEO
css/main.css
/* Import order matters! */
@import 'reset.css';       /* first */
@import 'variables.css';   /* tokens */
@import 'layout.css';      /* structure */
@import 'components.css';  /* UI pieces */
js/main.js
// Modules (type="module" in HTML)
import { initNav }   from './nav.js';
import { initReveal } from './animations.js';
import { initTheme }  from './theme.js';

document.addEventListener('DOMContentLoaded', () => {
  initNav();
  initReveal();
  initTheme();
});
Use type="module" on your script tag so you can use import/export. Modules are deferred automatically and scoped — no global namespace pollution.

§ 03 — Document Metadata

The HTML Head

The <head> is invisible to users but critical for SEO, social sharing, browser behavior, and performance. Every element here serves a specific purpose.

index.html — complete <head>
<!DOCTYPE html>
<html lang="en">
<head>
  <!-- ENCODING — must be first -->
  <meta charset="UTF-8">

  <!-- VIEWPORT — required for mobile -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <!-- PAGE TITLE — 50-60 chars, appears in browser tab + search -->
  <title>Product Name — Tagline | Brand</title>

  <!-- SEO META TAGS -->
  <meta name="description" content="One compelling sentence, 150-160 chars.">
  <meta name="author"      content="Your Name">
  <meta name="robots"      content="index, follow">
  <link rel="canonical"    href="https://yoursite.com/page">

  <!-- OPEN GRAPH — Facebook, LinkedIn, Discord previews -->
  <meta property="og:type"        content="website">
  <meta property="og:title"       content="Page Title">
  <meta property="og:description" content="Social share description">
  <meta property="og:image"       content="https://yoursite.com/og.jpg">
  <meta property="og:url"         content="https://yoursite.com/">

  <!-- TWITTER CARD -->
  <meta name="twitter:card"  content="summary_large_image">
  <meta name="twitter:site"  content="@yourhandle">
  <meta name="twitter:title" content="Page Title">
  <meta name="twitter:image" content="https://yoursite.com/og.jpg">

  <!-- FAVICONS -->
  <link rel="icon"             href="/favicon.ico">
  <link rel="icon"             href="/icon.svg" type="image/svg+xml">
  <link rel="apple-touch-icon" href="/apple-touch-icon.png">

  <!-- FONTS — preconnect reduces DNS lookup time -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">

  <!-- CSS — always in <head> so it loads before first paint -->
  <link rel="stylesheet" href="/css/main.css">

  <!-- THEME COLOR — browser chrome on mobile -->
  <meta name="theme-color" content="#0ea5e9">
</head>

§ 04 — Meaningful Markup

Semantic HTML5

Semantic HTML uses elements that describe their meaning rather than their appearance. <nav> instead of <div class="nav">. This helps screen readers, search engines, and future developers understand your page instantly.

page-structure.html
<body>

  <!-- Keyboard users skip to main content -->
  <a href="#main" class="skip-link">Skip to content</a>

  <header>
    <nav aria-label="Main navigation">
      <a href="/" aria-label="Home">
        <img src="logo.svg" alt="Brand Name">
      </a>
      <ul role="list">
        <li><a href="/about">About</a></li>
        <li><a href="/pricing">Pricing</a></li>
      </ul>
    </nav>
  </header>

  <main id="main">  <!-- ONE per page -->

    <section id="hero" aria-label="Hero">
      <h1>Main headline — ONE h1 per page</h1>
      <p>Value proposition.</p>
    </section>

    <section id="features" aria-labelledby="feat-h">
      <h2 id="feat-h">Features</h2>
      <article>       <!-- self-contained piece -->
        <h3>Feature One</h3>
        <p>Description.</p>
      </article>
    </section>

    <section id="pricing"> ... </section>
    <section id="contact"> ... </section>

  </main>

  <footer>
    <nav aria-label="Footer links"> ... </nav>
    <p><small>&copy; 2025 Brand</small></p>
  </footer>

  <!-- JS at end of body: defer load -->
  <script src="/js/main.js" type="module"></script>
</body>
Semantic element reference:
<header> — site or section header
<nav> — navigation links
<main> — primary content (1 per page)
<section> — thematic group with heading
<article> — self-contained (post, card)
<aside> — sidebar, related content
<footer> — copyright, links, address
<figure> — image + figcaption pair
<time> — machine-readable dates
<address> — contact information
<mark> — highlighted/relevant text
<details> — native disclosure widget
Heading hierarchy: One <h1> per page. Use h2 for section titles, h3 for sub-sections. Never skip levels (h1→h3) — screen readers navigate by heading structure.
h1 SEO tip: Your h1 should contain your primary keyword and match (or be close to) your page title tag. Keep it under 60 characters.

§ 05 — CSS Design Tokens

CSS Architecture

Start every project with a CSS reset, a custom property system (design tokens), and a consistent naming convention. This prevents specificity wars and makes the whole codebase predictable and themeable.

css/reset.css + css/variables.css
/* ── MODERN CSS RESET ───────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }
html { scroll-behavior: smooth; text-size-adjust: 100%; }
body { line-height: 1.6; -webkit-font-smoothing: antialiased; }
img, video, svg { display: block; max-width: 100%; }
p, h1, h2, h3 { overflow-wrap: break-word; }
input, button, textarea, select { font: inherit; }

/* ── DESIGN TOKENS ──────────────────────────────────── */
:root {
  /* Colors — HSL for easy programmatic manipulation */
  --color-primary:   hsl(210 100% 56%);
  --color-primary-d: hsl(210 100% 44%);
  --color-success:   hsl(160  84%  39%);
  --color-danger:    hsl(350  89%  60%);
  --color-bg:        hsl(0    0%  100%);
  --color-surface:   hsl(210  20%  98%);
  --color-text:      hsl(222  47%  11%);
  --color-muted:     hsl(215  16%  47%);
  --color-border:    hsl(214  32%  91%);

  /* Typography */
  --font-display: 'Space Grotesk', sans-serif;
  --font-body:    'Inter', system-ui, sans-serif;
  --font-mono:    'Fira Code', monospace;

  /* Fluid type scale (Major Third 1.25×) */
  --text-sm:   clamp(.8rem,  1.4vw, .875rem);
  --text-base: clamp(1rem,   1.6vw, 1.063rem);
  --text-lg:   clamp(1.1rem, 2vw,   1.25rem);
  --text-xl:   clamp(1.3rem, 2.5vw, 1.5rem);
  --text-2xl:  clamp(1.6rem, 3vw,   2rem);
  --text-3xl:  clamp(2rem,   4vw,   3rem);

  /* Spacing — 4px grid */
  --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s6:24px;
  --s8:32px; --s12:48px; --s16:64px; --s24:96px;

  /* Layout */
  --container: 1200px;
  --radius: 8px;   --radius-lg: 16px;
  --shadow: 0 1px 3px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.08);
  --shadow-lg: 0 8px 32px rgba(0,0,0,.12);

  /* Transitions */
  --ease: cubic-bezier(.4,0,.2,1);
  --t-fast: 150ms var(--ease);
  --t-base: 250ms var(--ease);
}

/* ── UTILITY CLASSES ────────────────────────────────── */
.container { max-width:var(--container); margin:0 auto; padding:0 var(--s4); }
.sr-only    { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
.text-primary { color:var(--color-primary); }
.visually-hidden { /* same as .sr-only */ }

§ 06 — Responsive Navigation

Navigation Bar

A professional sticky nav: logo left, links center, CTA right. On mobile, links collapse into a hamburger toggle. Scroll position changes the header shadow to indicate depth.

LIVE — Functional navigation
nav.html + nav.css + nav.js
<!-- HTML -->
<header class="site-header" id="header">
  <nav class="nav container" aria-label="Main">
    <a href="/" class="nav__logo">MyBrand</a>
    <ul class="nav__links" id="nav-links" role="list">
      <li><a href="#features">Features</a></li>
      <li><a href="#pricing">Pricing</a></li>
    </ul>
    <button class="nav__hamburger" id="hamburger"
            aria-label="Toggle menu" aria-expanded="false">
      <span></span><span></span><span></span>
    </button>
    <a href="#cta" class="btn btn--primary">Get Started</a>
  </nav>
</header>

/* CSS */
.site-header {
  position: sticky; top: 0; z-index: 100;
  backdrop-filter: blur(12px);
  background: rgba(255,255,255,.92);
  border-bottom: 1px solid var(--color-border);
  transition: box-shadow var(--t-fast);
}
.site-header.scrolled { box-shadow: var(--shadow); }

@media (max-width: 768px) {
  .nav__links { display: none; }
  .nav__links.open {
    display: flex; flex-direction: column;
    position: absolute; top: 100%; inset-inline: 0;
    background: white; border-bottom: 1px solid var(--color-border);
    padding: var(--s4);
  }
  .nav__hamburger { display: flex; }
}

// JavaScript
const header    = document.getElementById('header');
const hamburger = document.getElementById('hamburger');
const navLinks  = document.getElementById('nav-links');

window.addEventListener('scroll', () => {
  header.classList.toggle('scrolled', window.scrollY > 20);
}, { passive: true });

hamburger.addEventListener('click', () => {
  const open = navLinks.classList.toggle('open');
  hamburger.setAttribute('aria-expanded', open);
});

// Close mobile nav on link click
navLinks.addEventListener('click', e => {
  if (e.target.matches('a')) navLinks.classList.remove('open');
});

§ 07 — Above the Fold

Hero Section

The hero is the first thing visitors see. You have about 3 seconds. A compelling headline, a single value proposition, and one primary CTA button. The announcement badge + gradient background pattern works across nearly every product type.

LIVE — Reusable hero component
✦ Now with AI-powered features

Ship Faster with Less Code

The design system that makes building beautiful interfaces feel effortless. Used by 40,000+ developers worldwide.

hero-section.css
.hero {
  min-height: 100svh;              /* full viewport */
  display: grid;
  place-items: center;             /* center vertically */
  text-align: center;
  padding: var(--s16) var(--s4);
  background: radial-gradient(
    ellipse at top,
    hsl(210 100% 97%), white
  );
}

.hero__badge {
  display: inline-flex; align-items: center; gap: var(--s2);
  padding: var(--s1) var(--s3);
  border: 1px solid hsl(210 100% 80%);
  border-radius: 100px;
  background: hsl(210 100% 96%);
  color: var(--color-primary);
  font-size: var(--text-sm);
  margin-bottom: var(--s4);
}

.hero__title {
  font-size: var(--text-3xl);    /* clamp fluid */
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.03em;
  max-width: 16ch;               /* ~16 characters wide */
  margin: 0 auto var(--s4);
}

.hero__ctas {
  display: flex; gap: var(--s3); justify-content: center;
  flex-wrap: wrap; margin-top: var(--s6);
}
One CTA rule: Primary button = filled (your revenue action: sign up, buy). At most one secondary = ghost/outlined (low-commitment: demo, learn more). More than two CTAs causes decision paralysis.

§ 08 — Cards and Grids

Feature Cards

Use CSS Grid with auto-fit and minmax() for a layout that's automatically responsive — no media queries needed for the grid itself. Cards go from 3-column on desktop to 1-column on mobile automatically.

LIVE — Responsive auto-fit grid

Lightning Fast

Optimized for performance from day one. Sub-second load times on any device, anywhere in the world.

🎨

Fully Customizable

Design token system lets you brand every component from one CSS file. No overrides needed.

Accessible by Default

WCAG 2.1 AA compliant. Every component ships with correct ARIA roles and keyboard support.

📱

Mobile-First

Built for small screens, enhanced for large ones. Looks perfect at any viewport width.

🔒

Secure

No server means no server vulnerabilities. Files served over HTTPS from a CDN edge network.

🚀

One-Click Deploy

Push to GitHub and your site deploys automatically to Netlify, Vercel, or GitHub Pages.

responsive-grid.css
/* Auto-responsive — no media queries needed */
.features-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--s6);
  /* 3-col desktop → 2-col tablet → 1-col mobile */
}

.card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--s6);
  transition: transform var(--t-base), box-shadow var(--t-base);
}
.card:hover {
  transform: translateY(-4px);
  box-shadow: var(--shadow-lg);
  border-color: var(--color-primary);
}

§ 09 — Social Proof

Testimonials

Testimonials build trust and handle objections. Store them in a JS array and render dynamically — easy to update without touching HTML. Add structured data markup for rich snippets in search.

LIVE — Testimonial cards rendered from JS array
★★★★★

"We replaced our entire component library in a weekend. The design token system is incredibly thoughtful — our team ships UI twice as fast now."

SL
Sarah Lee
Senior Frontend Engineer · Stripe
★★★★★

"The accessibility built into every component saved us hundreds of hours. We passed our WCAG audit on the first try. That never happens."

MR
Marcus Rodriguez
Head of Design · Linear
testimonials.js — render from data
// Store data as a JS array (or fetch from a JSON file)
const testimonials = [
  { quote:"We ship UI twice as fast now.",
    author:"Sarah Lee", title:"Engineer · Stripe",
    stars:5, initials:"SL", color:"hsl(210,100%,56%)" },
  // add as many as you need...
];

function renderTestimonials() {
  const grid = document.querySelector('.testimonials-grid');
  grid.innerHTML = testimonials.map(t => `
    <article class="card">
      <div class="stars">${'★'.repeat(t.stars)}</div>
      <blockquote><p>${t.quote}</p></blockquote>
      <footer>
        <div class="avatar" style="background:${t.color}">${t.initials}</div>
        <div>
          <cite class="name">${t.author}</cite>
          <small>${t.title}</small>
        </div>
      </footer>
    </article>
  `).join('');
}
renderTestimonials();

§ 10 — Conversion Component

Pricing Table

The three-tier pricing pattern (Starter / Pro / Enterprise) is the industry standard for SaaS products. Highlight the recommended tier. A monthly/annual billing toggle shows value and increases conversions.

LIVE — Click Monthly / Annual to toggle prices
Starter
$0/mo

Perfect for personal projects and learning.

  • 3 projects
  • 10GB storage
  • Community support
  • Basic analytics
Enterprise
$99/mo

For large organizations with custom needs.

  • Everything in Pro
  • Unlimited storage
  • Dedicated support
  • SSO / SAML
  • SLA guarantee

§ 11 — Frequently Asked Questions

FAQ Accordion

An FAQ section reduces support load and helps SEO (Google often shows FAQ rich results). Build it as an accordion — one item open at a time. Use aria-expanded for screen readers.

LIVE — Click any question
Is hosting really free?
Yes — GitHub Pages, Netlify, and Vercel all offer free hosting for static sites including custom domains. You only pay for your domain (~$12/year). There are no server costs because there's no server.
Can I have a contact form without a backend?
Yes! Use Formspree (50 submissions/month free), Netlify Forms (100/month free), or Web3Forms (250/month free). Add an action attribute to your form pointing to their endpoint — they handle email delivery. See §12 below.
How do I store user data without a database?
localStorage stores key-value data that persists across sessions (~5MB per domain). For relational data accessible across devices, use Supabase (free tier: 500MB PostgreSQL + full REST API). For file storage, use Cloudinary or AWS S3.
Is a static site bad for SEO?
The opposite — static sites tend to rank better. Pre-rendered HTML is immediately indexable by Google. Fast load times (a major ranking factor) are easy to achieve without server processing. Proper meta tags, JSON-LD structured data, and a sitemap complete the picture.
How do I add live data like weather or news?
Use fetch() to call third-party REST APIs from JavaScript. Most services (OpenWeatherMap, NewsAPI, CoinGecko) offer free tiers. Your browser fetches the data directly — no server needed. Cache results in localStorage to avoid hitting rate limits on every page load.

§ 12 — Forms Without a Backend

Contact Form

A fully-validated contact form that sends real emails via Formspree. Client-side validation prevents bad submissions. Change the action URL to your Formspree endpoint to activate it.

LIVE — Fully validated (try submitting)
0/500
✉️

Message Sent!

We'll get back to you within 24 hours.

contact-form.html + form-validation.js
<!-- 1. Create free account at formspree.io
   2. Create a new form, get your endpoint
   3. Paste endpoint as the action attribute -->
<form action="https://formspree.io/f/YOUR_FORM_ID"
      method="POST" novalidate id="contact">
  <input type="text"  name="name"    required>
  <input type="email" name="email"   required>
  <textarea           name="message" required></textarea>
  <button type="submit">Send</button>
</form>

// Client-side validation
function validate() {
  const rules = [
    { id:'name',    test: v => v.trim().length >= 2,
      msg: 'Name must be at least 2 characters' },
    { id:'email',   test: v => /^[^@]+@[^@]+\.[^@]+$/.test(v),
      msg: 'Please enter a valid email address' },
    { id:'message', test: v => v.trim().length >= 10,
      msg: 'Message must be at least 10 characters' },
  ];
  let valid = true;
  rules.forEach(rule => {
    const field = document.getElementById(rule.id);
    const errEl = document.getElementById('err-' + rule.id);
    const ok    = rule.test(field.value);
    errEl.textContent = ok ? '' : rule.msg;
    field.classList.toggle('error', !ok);
    if (!ok) valid = false;
  });
  return valid;
}

// Submit with fetch (JSON response, no page reload)
form.addEventListener('submit', async e => {
  e.preventDefault();
  if (!validate()) return;
  const res = await fetch(form.action, {
    method: 'POST',
    body:    new FormData(form),
    headers: { Accept: 'application/json' }
  });
  if (res.ok) showSuccess();
  else        showError('Something went wrong.');
});

§ 13 — JavaScript for Static Sites

JS Patterns

Five patterns cover 90% of static-site JavaScript: event delegation, the fetch API, the module pattern, debouncing for search/resize, and template literals for dynamic HTML rendering.

event-delegation + fetch
// ── EVENT DELEGATION ──────────────────────
// One listener on the parent handles all
// children — including dynamically added ones
document.querySelector('.card-list')
  .addEventListener('click', e => {
    const card = e.target.closest('.card');
    if (!card) return;
    handleCard(card.dataset.id);
  });

// ── FETCH PATTERN ─────────────────────────
async function getData(url) {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(res.status);
    return await res.json();
  } catch(err) {
    console.error('Fetch error:', err);
    showErrorUI();
    return null;
  }
}

// ── DEBOUNCE ──────────────────────────────
// Prevents firing too often on input/resize
function debounce(fn, ms = 200) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

searchInput.addEventListener('input',
  debounce(e => filterItems(e.target.value))
);
module-pattern + live-filter
// ── MODULE PATTERN ────────────────────────
// Group related code, avoid global scope
const CartModule = (function() {
  let items = loadFromStorage();

  function loadFromStorage() {
    return JSON.parse(
      localStorage.getItem('cart') ?? '[]'
    );
  }
  function save() {
    localStorage.setItem('cart', JSON.stringify(items));
  }
  return {
    add(item)  { items.push(item); save(); },
    remove(id) { items=items.filter(i=>i.id!==id); save(); },
    total()    { return items.reduce((s,i)=>s+i.price,0); },
    count()    { return items.length; },
  };
})();

// ── LIVE SEARCH / FILTER ──────────────────
function filterItems(query) {
  const q = query.toLowerCase().trim();
  document.querySelectorAll('.item').forEach(el => {
    const text = el.textContent.toLowerCase();
    el.style.display = text.includes(q) ? '' : 'none';
  });
}

// ── TEMPLATE RENDERING ────────────────────
const Card = ({ title, desc, img }) => `
  <article class="card">
    <img src="${img}" alt="" loading="lazy">
    <h3>${title}</h3>
    <p>${desc}</p>
  </article>
`;
container.innerHTML = data.map(Card).join('');

§ 14 — Browser Storage

localStorage & State

Replace a database with the browser's built-in storage. localStorage persists across sessions (5MB). sessionStorage clears when the tab closes. IndexedDB handles large structured data.

LIVE — Notes persist across page reloads (try it!)
storage-helper.js
// ── STORAGE HELPER ────────────────────────
// Wraps localStorage with JSON + error handling
const Store = {
  get(key, fallback = null) {
    try {
      return JSON.parse(localStorage.getItem(key)) ?? fallback;
    } catch { return fallback; }
  },
  set(key, val) {
    localStorage.setItem(key, JSON.stringify(val));
  },
  remove(key) { localStorage.removeItem(key); },
  update(key, updater, fallback) {
    const current = this.get(key, fallback);
    this.set(key, updater(current));
  },
};

// Usage examples
Store.set('theme', 'dark');
Store.get('theme', 'light');          // → 'dark'
Store.update('cart', c => [...c, item], []);

// ── WHAT TO STORE ─────────────────────────
// ✓ User preferences (theme, font size, lang)
// ✓ Form drafts (auto-save on input)
// ✓ Shopping cart, favorites, bookmarks
// ✓ UI state (sidebar collapsed, tab active)
// ✓ Cached API responses (with timestamp)
// ✗ Passwords or payment info — never
// ✗ Large files — use IndexedDB instead

§ 15 — Entrance Animations

Scroll Animations

IntersectionObserver triggers CSS classes when elements enter the viewport — no scroll event listeners, no jank. GPU-composited opacity and transform ensure 60fps performance.

LIVE — These cards animated in as this section scrolled
📦
Fade Up
opacity + translateY
🎯
Slide In
opacity + translateX
Staggered
animation-delay each
animations.css + animations.js
/* CSS — hidden by default, revealed on scroll */
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity .6s ease, transform .6s ease;
}
.reveal.in { opacity: 1; transform: translateY(0); }

/* Stagger items in a grid */
.card:nth-child(1) { transition-delay: 0ms;   }
.card:nth-child(2) { transition-delay: 100ms; }
.card:nth-child(3) { transition-delay: 200ms; }

/* Respect user's motion preference! */
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
}

// JavaScript — IntersectionObserver
const prefersReduced = window
  .matchMedia('(prefers-reduced-motion: reduce)').matches;

if (!prefersReduced) {
  const io = new IntersectionObserver(entries => {
    entries.forEach(e => {
      if (e.isIntersecting) {
        e.target.classList.add('in');
        io.unobserve(e.target);   // animate once only
      }
    });
  }, { threshold: .15, rootMargin: '0px 0px -40px 0px' });

  document.querySelectorAll('.reveal, .reveal-left')
    .forEach(el => io.observe(el));
} else {
  // Show everything immediately
  document.querySelectorAll('.reveal').forEach(el => {
    el.classList.add('in');
  });
}

§ 16 — Color Scheme Toggle

Dark / Light Mode

Detect the OS preference with prefers-color-scheme, allow manual override via a toggle button, and persist the choice in localStorage. Apply the theme in <head> to prevent flash.

Dark Mode

Deep backgrounds, light text. Avoid pure #000 — use dark-gray (#0d1117) for a premium feel. Brand color lightness should be 55-65% to pop against dark surfaces.

Primary
Outlined

Light Mode

Off-white (#f8fafc) backgrounds, very dark text. Brand color needs to be darker (40-50% lightness) to maintain contrast on light surfaces. Shadows are more important here.

Primary
Outlined
dark-mode.css + theme.js
/* ── CSS VARIABLES APPROACH ───────────────── */
:root {                        /* light defaults */
  --bg:        hsl(210 20% 99%);
  --text:      hsl(222 47% 11%);
  --surface:   hsl(210 20% 97%);
  --border:    hsl(214 32% 91%);
  --primary:   hsl(210 100% 40%); /* darker on light */
}
html[data-theme="dark"] {
  --bg:        hsl(222 47%  5%);
  --text:      hsl(210 40% 96%);
  --surface:   hsl(222 47%  8%);
  --border:    hsl(222 47% 16%);
  --primary:   hsl(210 100% 56%); /* brighter on dark */
}
* { transition: background-color .3s, color .3s, border-color .3s; }

// ── THEME SCRIPT (in <head> prevents flash) ──
const theme = localStorage.getItem('theme')
  ?? (window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark' : 'light');

document.documentElement.dataset.theme = theme;

// Toggle button click handler
document.getElementById('theme-toggle')
  .addEventListener('click', () => {
    const next = document.documentElement.dataset.theme === 'dark'
      ? 'light' : 'dark';
    document.documentElement.dataset.theme = next;
    localStorage.setItem('theme', next);
  });

§ 17 — Search Engine Optimization

SEO & Open Graph

Three layers: meta tags (page title, description), Open Graph (social share previews), and JSON-LD structured data (rich results in Google). Add all three before launch — retrofitting is painful.

seo-structured-data.html
<!-- JSON-LD — Google reads this for rich results -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type":    "WebSite",
  "name":     "Your Site Name",
  "url":      "https://yoursite.com",
  "potentialAction": {
    "@type":       "SearchAction",
    "target":      "https://yoursite.com/?q={q}",
    "query-input": "required name=q"
  }
}
</script>

<!-- For a local business: -->
<script type="application/ld+json">
{
  "@context":    "https://schema.org",
  "@type":       "LocalBusiness",
  "name":        "Acme Corp",
  "telephone":   "+1-555-000-0000",
  "address": {
    "@type":          "PostalAddress",
    "streetAddress":  "123 Main St",
    "addressLocality":"Suwanee",
    "addressRegion":  "GA",
    "postalCode":     "30024",
    "addressCountry": "US"
  }
}
</script>

<!-- FAQ schema — shows as accordion in Google -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type":    "FAQPage",
  "mainEntity": [
    { "@type": "Question",
      "name": "Is hosting really free?",
      "acceptedAnswer": { "@type":"Answer",
        "text": "Yes, GitHub Pages and Netlify..." } }
  ]
}
</script>

<!-- robots.txt (in site root) -->
User-agent: *
Allow: /
Sitemap: https://yoursite.com/sitemap.xml

<!-- sitemap.xml (in site root) -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://yoursite.com/</loc>
    <lastmod>2025-07-01</lastmod>
    <priority>1.0</priority>
  </url>
</urlset>
✓ Test OG tags at metatags.io before sharing
✓ Test JSON-LD at search.google.com/test/rich-results
✓ Validate your sitemap at Google Search Console
✓ Page title 50-60 chars, description 150-160 chars

§ 18 — Inclusive Design

Accessibility

15% of users have a disability. Accessible sites rank better in search, convert better, and avoid legal liability (ADA and WCAG compliance is legally required for some organizations). The basics take less than an hour.

accessibility-essentials.html + a11y.css
<!-- SKIP LINK — lets keyboard users jump past nav -->
<a href="#main" class="skip-link">Skip to main content</a>

/* CSS */
.skip-link {
  position: absolute;
  top: -100%; left: 1rem;
  padding: 8px 16px;
  background: var(--color-primary);
  color: white;
  border-radius: 0 0 8px 8px;
  z-index: 9999;
  transition: top .1s;
}
.skip-link:focus { top: 0; }  /* visible on Tab */

<!-- FOCUS STYLES — visible for keyboard navigation -->
:focus-visible {
  outline: 3px solid var(--color-primary);
  outline-offset: 2px;
}
:focus:not(:focus-visible) { outline: none; }

<!-- ARIA LABELS ——————————————————————————————— -->
<button aria-label="Close dialog">✕</button>
<button aria-expanded="false" aria-controls="menu-id">Menu</button>
<div    aria-live="polite" id="status"></div>
<img    alt="Descriptive text for screen readers" src="...">
<img    alt="" role="presentation" src="decorative.svg">

<!-- ACCESSIBLE FORMS ——————————————————————————— -->
<label for="email">Email address</label>
<input type="email" id="email"
       aria-describedby="email-hint"
       aria-required="true"
       aria-invalid="false">
<span id="email-hint">We never share your email.</span>

<!-- REDUCED MOTION ————————————————————————————— -->
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.01ms !important;
      transition-duration: 0.01ms !important; }
}
✓ Test with keyboard only: Tab, Shift+Tab, Enter, Escape, Arrow keys
✓ Run the axe DevTools Chrome extension — catches 80% of issues automatically
✓ Test at 200% browser zoom — nothing should overflow or break
✓ Check color contrast: minimum 4.5:1 for body text (use the checker in our CSS guide)

§ 19 — Core Web Vitals

Performance

Google uses Core Web Vitals as a ranking signal. A static site naturally wins on server response time — but image format, JS loading order, and font loading still determine your Lighthouse score.

TARGET SCORES — achievable for any well-built static site
98
Performance
100
Accessibility
100
Best Practices
96
SEO
performance-checklist.html
<!-- ── IMAGES: the biggest win ──────────────── -->
<img
  src="hero-800.webp"
  srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1600.webp 1600w"
  sizes="(max-width:640px) 100vw, 800px"
  width="800" height="450"      <!-- prevents layout shift -->
  loading="lazy"                <!-- defer off-screen images -->
  decoding="async"              <!-- off-main-thread decode -->
  fetchpriority="high"          <!-- hero image only -->
  alt="Hero image description">

<!-- Use Squoosh.app to convert images to WebP/AVIF -->
<!-- Rule: hero ≤ 200KB, cards ≤ 50KB, icons = SVG -->

<!-- ── JAVASCRIPT ───────────────────────────── -->
<script src="main.js"     type="module"></script>
<!-- type="module" is deferred automatically -->
<script src="analytics.js" async></script>
<!-- async: execute immediately when fetched -->

<!-- ── CSS ──────────────────────────────────── -->
<!-- Critical CSS (above-fold styles) inline in head -->
<style> /* nav, hero: max 3KB */ </style>
<!-- Rest of CSS via link tag -->
<link rel="stylesheet" href="styles.css">

<!-- ── FONTS ─────────────────────────────────── -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<!-- OR self-host for fastest loading: -->
<style>
  @font-face {
    font-family: 'Inter';
    src: url('/fonts/inter.woff2') format('woff2');
    font-display: swap; <!-- shows fallback, then swaps -->
    unicode-range: U+0000-00FF; /* Latin only */
  }
</style>

§ 20 — Going Live

Deployment

Three excellent free platforms for static sites — each includes HTTPS, a global CDN, and automatic deployment on every git push. Choose based on what you need beyond hosting.

STEP BY STEP — Choose your platform
Netlify
GitHub Pages
Vercel
1

Sign up at netlify.com — no credit card

Free account. Connect to GitHub, GitLab, or Bitbucket. Or drag-and-drop your project folder directly onto the Netlify dashboard for an instant deploy.

2

New Site → Import from Git

Select your repository. Netlify detects it's a static site. Leave build command empty (unless you use a build tool like Vite). Set publish directory to / for root-level projects.

3

Click Deploy Site

Your site is live at a Netlify subdomain (project.netlify.app) in under 60 seconds. HTTPS is automatic via Let's Encrypt.

4

Add your custom domain

Site Settings → Domain Management → Add custom domain. Point your domain's DNS to Netlify's nameservers. SSL certificate provisions automatically.

5

Auto-deploy is now active

Every git push to main triggers a new deploy. Pull requests get preview URLs automatically — perfect for client review before going live.

✓ Free: 100GB bandwidth/month, 300 build minutes/month
✓ Netlify Forms: 100 submissions/month free — replaces Formspree!
✓ Netlify Functions: 125k serverless invocations/month free
✓ Split testing, analytics, edge functions all available
1

Push your site to a public GitHub repo

Create a repository at github.com and push your HTML/CSS/JS files. The repository name can be anything for a project site, or username.github.io for your root domain.

2

Enable GitHub Pages

Repository → Settings → Pages → Source: Deploy from a branch → Branch: main → Folder: / (root) → Save.

3

Your site is live

After ~60 seconds: username.github.io/repo-name. HTTPS is included. No build step for plain HTML/CSS/JS.

4

Custom domain (optional)

Add a file called CNAME to your repo root containing just your domain: www.yourdomain.com. Then add a CNAME DNS record pointing to username.github.io. SSL provisions automatically.

✓ Completely free for public repositories
✓ Directly integrated with GitHub — no extra account needed
✓ Perfect if you already use GitHub for version control
1

Sign up at vercel.com with GitHub

Free Hobby plan: unlimited projects, 100GB bandwidth/month, automatic HTTPS. One click to authorize Vercel to access your GitHub repositories.

2

New Project → Import your repo

Click "New Project" → select your GitHub repository → Framework Preset: "Other" for plain HTML/CSS/JS → Deploy. Done in 30 seconds.

3

Instant global CDN

Vercel's edge network serves your files from 100+ locations worldwide. Users in Tokyo, London, and São Paulo all get sub-100ms response times.

4

Every PR gets a preview URL

Vercel automatically creates a unique preview deployment for every pull request. Share the URL with clients for review before merging to production.

✓ Fastest CDN of the three — excellent global edge network
✓ Preview deployments for every pull request automatically
✓ Vercel Analytics: 2,500 page views/day free
✓ Serverless Functions: 100GB-hours/month free