Project: Build a Complete Website
Time to put everything together. In this chapter you will build a multi-page personal website using only HTML, CSS, and vanilla JavaScript. This is the site we will deploy to a VPS in the next chapter.
What we are building
A personal portfolio site with:
- Home page -- introduction and featured projects
- Projects page -- filterable project cards loaded from data
- Contact page -- form with client-side validation
- Shared navigation with active page highlighting
- Dark/light theme toggle with localStorage persistence
- Responsive design that works on all screen sizes
Project structure
my-website/
├── index.html # Home page
├── projects.html # Projects page
├── contact.html # Contact page
├── css/
│ └── styles.css # All styles
├── js/
│ ├── main.js # Shared functionality (theme, nav)
│ ├── projects.js # Projects page logic
│ └── contact.js # Contact form logic
└── data/
└── projects.json # Project data
Create this folder structure before starting. All paths below are relative to the my-website/ root.
Step 1: shared styles
Create css/styles.css with the full stylesheet. This covers the layout, components, and responsive behavior for all
pages:
/* css/styles.css */
/* ============================================
Reset & Base
============================================ */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #ffffff;
--color-text: #1a1a2e;
--color-text-muted: #555;
--color-primary: #0066cc;
--color-primary-hover: #0052a3;
--color-border: #e0e0e0;
--color-card-bg: #f8f9fa;
--color-nav-bg: #ffffff;
--color-footer-bg: #f8f9fa;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--radius: 8px;
--max-width: 1000px;
}
body.dark-theme {
--color-bg: #1a1a2e;
--color-text: #e0e0e0;
--color-text-muted: #aaa;
--color-primary: #4dabf7;
--color-primary-hover: #74c0fc;
--color-border: #333;
--color-card-bg: #16213e;
--color-nav-bg: #0f3460;
--color-footer-bg: #0f3460;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: var(--color-text);
background: var(--color-bg);
min-height: 100vh;
display: flex;
flex-direction: column;
}
a {
color: var(--color-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
height: auto;
}
/* ============================================
Navigation
============================================ */
.site-nav {
background: var(--color-nav-bg);
border-bottom: 1px solid var(--color-border);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow);
}
.nav-inner {
display: flex;
justify-content: space-between;
align-items: center;
max-width: var(--max-width);
margin: 0 auto;
height: 60px;
}
.nav-logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 24px;
align-items: center;
list-style: none;
}
.nav-links a {
color: var(--color-text-muted);
font-weight: 500;
padding: 4px 0;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.nav-links a:hover,
.nav-links a.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
text-decoration: none;
}
.theme-toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 6px 12px;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text);
}
.theme-toggle:hover {
background: var(--color-card-bg);
}
/* ============================================
Main Content
============================================ */
.main-content {
flex: 1;
max-width: var(--max-width);
margin: 0 auto;
padding: 48px 24px;
width: 100%;
}
/* ============================================
Hero Section
============================================ */
.hero {
text-align: center;
padding: 48px 0;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 16px;
}
.hero p {
font-size: 1.25rem;
color: var(--color-text-muted);
max-width: 600px;
margin: 0 auto;
}
/* ============================================
Cards
============================================ */
.card-grid {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-top: 32px;
}
.card {
flex: 1 1 280px;
background: var(--color-card-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.card h3 {
margin-bottom: 8px;
font-size: 1.125rem;
}
.card p {
color: var(--color-text-muted);
font-size: 0.9375rem;
}
.card .tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.tag {
display: inline-block;
background: var(--color-primary);
color: #fff;
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
}
/* ============================================
Filters
============================================ */
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.filter-btn {
padding: 6px 16px;
border: 1px solid var(--color-border);
border-radius: 20px;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
font-size: 0.875rem;
transition: background 0.2s, color 0.2s;
}
.filter-btn:hover,
.filter-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
/* ============================================
Forms
============================================ */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 6px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 1rem;
font-family: inherit;
background: var(--color-bg);
color: var(--color-text);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.15);
}
.form-group .error-text {
color: #e74c3c;
font-size: 0.8125rem;
margin-top: 4px;
display: none;
}
.form-group.has-error input,
.form-group.has-error textarea {
border-color: #e74c3c;
}
.form-group.has-error .error-text {
display: block;
}
.submit-btn {
padding: 12px 32px;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover {
background: var(--color-primary-hover);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-success {
background: #d4edda;
color: #155724;
padding: 16px;
border-radius: var(--radius);
margin-top: 16px;
display: none;
}
/* ============================================
Footer
============================================ */
.site-footer {
background: var(--color-footer-bg);
border-top: 1px solid var(--color-border);
text-align: center;
padding: 24px;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* ============================================
Responsive
============================================ */
@media (max-width: 640px) {
.nav-inner {
flex-direction: column;
height: auto;
padding: 12px 0;
gap: 12px;
}
.nav-links {
gap: 16px;
}
.hero h1 {
font-size: 1.75rem;
}
.hero p {
font-size: 1rem;
}
.card {
flex: 1 1 100%;
}
}
Step 2: shared navigation HTML
Every page includes the same <nav> and <footer>. Here is the structure (you will repeat it in each HTML file):
<!-- Navigation -- same on every page -->
<nav class="site-nav">
<div class="nav-inner">
<a href="index.html" class="nav-logo">MyPortfolio</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="projects.html">Projects</a></li>
<li><a href="contact.html">Contact</a></li>
<li><button class="theme-toggle" id="theme-toggle">Dark Mode</button></li>
</ul>
</div>
</nav>
<!-- Page content goes here -->
<!-- Footer -- same on every page -->
<footer class="site-footer">
<p>© 2025 MyPortfolio. Built with vanilla HTML, CSS, and JavaScript.</p>
</footer>
Step 3: shared JavaScript (js/main.js)
This file handles the theme toggle and active navigation link. It is loaded on every page:
// js/main.js
"use strict";
// ── Theme Toggle ──────────────────────────────────────────────
function initTheme() {
const toggle = document.querySelector("#theme-toggle");
if (!toggle) return;
const savedTheme = localStorage.getItem("theme");
if (savedTheme === "dark") {
document.body.classList.add("dark-theme");
toggle.textContent = "Light Mode";
}
toggle.addEventListener("click", () => {
document.body.classList.toggle("dark-theme");
const isDark = document.body.classList.contains("dark-theme");
toggle.textContent = isDark ? "Light Mode" : "Dark Mode";
localStorage.setItem("theme", isDark ? "dark" : "light");
});
}
// ── Active Navigation Link ────────────────────────────────────
function initActiveNav() {
const currentPage = window.location.pathname.split("/").pop() || "index.html";
const links = document.querySelectorAll(".nav-links a");
for (const link of links) {
const href = link.getAttribute("href");
if (href === currentPage) {
link.classList.add("active");
}
}
}
// ── Initialize ────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
initTheme();
initActiveNav();
});
Step 4: the Home page (index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyPortfolio -- Home</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<nav class="site-nav">
<div class="nav-inner">
<a href="index.html" class="nav-logo">MyPortfolio</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="projects.html">Projects</a></li>
<li><a href="contact.html">Contact</a></li>
<li><button class="theme-toggle" id="theme-toggle">Dark Mode</button></li>
</ul>
</div>
</nav>
<main class="main-content">
<section class="hero">
<h1>Hi, I'm a Web Developer</h1>
<p>I build things for the web using HTML, CSS, and JavaScript.
Welcome to my portfolio -- have a look around.</p>
</section>
<section>
<h2>Featured Projects</h2>
<div class="card-grid">
<div class="card">
<h3>Weather App</h3>
<p>A weather dashboard that fetches real-time data from an API and displays forecasts.</p>
<div class="tags">
<span class="tag">JavaScript</span>
<span class="tag">API</span>
</div>
</div>
<div class="card">
<h3>Task Manager</h3>
<p>A to-do list app with drag-and-drop, categories, and local storage persistence.</p>
<div class="tags">
<span class="tag">JavaScript</span>
<span class="tag">CSS</span>
</div>
</div>
<div class="card">
<h3>Portfolio Site</h3>
<p>This very website -- a multi-page portfolio built entirely with vanilla technologies.</p>
<div class="tags">
<span class="tag">HTML</span>
<span class="tag">CSS</span>
<span class="tag">JavaScript</span>
</div>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<p>© 2025 MyPortfolio. Built with vanilla HTML, CSS, and JavaScript.</p>
</footer>
<script src="js/main.js"></script>
</body>
</html>
Step 5: project data (data/projects.json)
Store project data as JSON so we can load and filter it dynamically:
[
{
"id": 1,
"title": "Weather App",
"description": "A weather dashboard that fetches real-time data from a public API and displays current conditions and 5-day forecasts.",
"tags": ["JavaScript", "API", "CSS"],
"url": "#"
},
{
"id": 2,
"title": "Task Manager",
"description": "A full-featured to-do list with categories, priority levels, drag-and-drop reordering, and local storage persistence.",
"tags": ["JavaScript", "CSS", "LocalStorage"],
"url": "#"
},
{
"id": 3,
"title": "Portfolio Site",
"description": "A multi-page personal portfolio built with vanilla HTML, CSS, and JavaScript. Includes theme switching and form validation.",
"tags": ["HTML", "CSS", "JavaScript"],
"url": "#"
},
{
"id": 4,
"title": "Calculator",
"description": "A fully functional calculator with keyboard support, history, and a clean responsive interface.",
"tags": ["JavaScript", "HTML", "CSS"],
"url": "#"
},
{
"id": 5,
"title": "Quiz Game",
"description": "An interactive quiz with timed questions, score tracking, and a results summary at the end.",
"tags": ["JavaScript", "API"],
"url": "#"
},
{
"id": 6,
"title": "Markdown Previewer",
"description": "A split-pane editor that converts Markdown to HTML in real time as you type.",
"tags": ["JavaScript", "HTML"],
"url": "#"
}
]
Step 6: the Projects page (projects.html + js/projects.js)
projects.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyPortfolio -- Projects</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<nav class="site-nav">
<div class="nav-inner">
<a href="index.html" class="nav-logo">MyPortfolio</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="projects.html">Projects</a></li>
<li><a href="contact.html">Contact</a></li>
<li><button class="theme-toggle" id="theme-toggle">Dark Mode</button></li>
</ul>
</div>
</nav>
<main class="main-content">
<h1>Projects</h1>
<p>Filter by technology or browse all projects.</p>
<div class="filters" id="filters"></div>
<div class="card-grid" id="project-list"></div>
<p id="no-results" style="display: none; color: var(--color-text-muted);">
No projects match this filter.
</p>
</main>
<footer class="site-footer">
<p>© 2025 MyPortfolio. Built with vanilla HTML, CSS, and JavaScript.</p>
</footer>
<script src="js/main.js"></script>
<script src="js/projects.js"></script>
</body>
</html>
js/projects.js
// js/projects.js
"use strict";
async function initProjects() {
const listContainer = document.querySelector("#project-list");
const filtersContainer = document.querySelector("#filters");
const noResults = document.querySelector("#no-results");
if (!listContainer || !filtersContainer) return;
// ── Load project data ─────────────────────────────────────
let projects = [];
try {
const response = await fetch("data/projects.json");
if (!response.ok) {
throw new Error(`Failed to load projects: ${response.status}`);
}
projects = await response.json();
} catch (error) {
listContainer.innerHTML = "";
const errorP = document.createElement("p");
errorP.textContent = `Error loading projects: ${error.message}`;
errorP.style.color = "red";
listContainer.appendChild(errorP);
return;
}
// ── Build filter buttons ──────────────────────────────────
const allTags = [...new Set(projects.flatMap((p) => p.tags))].sort();
function createFilterButton(label, isActive) {
const btn = document.createElement("button");
btn.classList.add("filter-btn");
if (isActive) btn.classList.add("active");
btn.textContent = label;
return btn;
}
const allBtn = createFilterButton("All", true);
filtersContainer.appendChild(allBtn);
for (const tag of allTags) {
filtersContainer.appendChild(createFilterButton(tag, false));
}
// ── Render projects ───────────────────────────────────────
function renderProjects(filter) {
listContainer.innerHTML = "";
const filtered = filter === "All"
? projects
: projects.filter((p) => p.tags.includes(filter));
if (filtered.length === 0) {
noResults.style.display = "block";
return;
}
noResults.style.display = "none";
for (const project of filtered) {
const card = document.createElement("div");
card.classList.add("card");
const title = document.createElement("h3");
title.textContent = project.title;
const desc = document.createElement("p");
desc.textContent = project.description;
const tags = document.createElement("div");
tags.classList.add("tags");
for (const tag of project.tags) {
const span = document.createElement("span");
span.classList.add("tag");
span.textContent = tag;
tags.appendChild(span);
}
card.appendChild(title);
card.appendChild(desc);
card.appendChild(tags);
listContainer.appendChild(card);
}
}
// ── Filter click handler (delegation) ─────────────────────
let activeFilter = "All";
filtersContainer.addEventListener("click", (event) => {
if (!event.target.classList.contains("filter-btn")) return;
activeFilter = event.target.textContent;
// Update active state
for (const btn of filtersContainer.querySelectorAll(".filter-btn")) {
btn.classList.toggle("active", btn.textContent === activeFilter);
}
renderProjects(activeFilter);
});
// ── Initial render ────────────────────────────────────────
renderProjects("All");
}
document.addEventListener("DOMContentLoaded", initProjects);
Step 7: the Contact page (contact.html + js/contact.js)
contact.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyPortfolio -- Contact</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<nav class="site-nav">
<div class="nav-inner">
<a href="index.html" class="nav-logo">MyPortfolio</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="projects.html">Projects</a></li>
<li><a href="contact.html">Contact</a></li>
<li><button class="theme-toggle" id="theme-toggle">Dark Mode</button></li>
</ul>
</div>
</nav>
<main class="main-content">
<h1>Contact Me</h1>
<p>Send me a message and I will get back to you as soon as possible.</p>
<form id="contact-form" style="max-width: 500px; margin-top: 32px;" novalidate>
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required minlength="2">
<div class="error-text">Please enter your name (at least 2 characters).</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<div class="error-text">Please enter a valid email address.</div>
</div>
<div class="form-group">
<label for="subject">Subject</label>
<input type="text" id="subject" name="subject" required minlength="3">
<div class="error-text">Please enter a subject (at least 3 characters).</div>
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required minlength="10"></textarea>
<div class="error-text">Please enter a message (at least 10 characters).</div>
</div>
<button type="submit" class="submit-btn">Send Message</button>
</form>
<div class="form-success" id="success-message">
Thank you! Your message has been sent successfully.
</div>
</main>
<footer class="site-footer">
<p>© 2025 MyPortfolio. Built with vanilla HTML, CSS, and JavaScript.</p>
</footer>
<script src="js/main.js"></script>
<script src="js/contact.js"></script>
</body>
</html>
js/contact.js
// js/contact.js
"use strict";
function initContactForm() {
const form = document.querySelector("#contact-form");
const successMessage = document.querySelector("#success-message");
if (!form) return;
// ── Validation rules ──────────────────────────────────────
function validateField(input) {
const group = input.closest(".form-group");
let isValid = true;
// Required check
if (input.hasAttribute("required") && input.value.trim() === "") {
isValid = false;
}
// Minlength check
const minLength = input.getAttribute("minlength");
if (minLength && input.value.trim().length < Number(minLength)) {
isValid = false;
}
// Email format check
if (input.type === "email" && input.value.trim() !== "") {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(input.value.trim())) {
isValid = false;
}
}
// Update UI
if (isValid) {
group.classList.remove("has-error");
} else {
group.classList.add("has-error");
}
return isValid;
}
// ── Real-time validation on blur ──────────────────────────
const inputs = form.querySelectorAll("input, textarea");
for (const input of inputs) {
input.addEventListener("blur", () => {
validateField(input);
});
// Clear error while typing
input.addEventListener("input", () => {
const group = input.closest(".form-group");
if (group.classList.contains("has-error")) {
validateField(input);
}
});
}
// ── Form submission ───────────────────────────────────────
form.addEventListener("submit", (event) => {
event.preventDefault();
// Validate all fields
let allValid = true;
for (const input of inputs) {
if (!validateField(input)) {
allValid = false;
}
}
if (!allValid) {
// Focus the first invalid field
const firstError = form.querySelector(".has-error input, .has-error textarea");
if (firstError) {
firstError.focus();
}
return;
}
// Collect form data
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// In a real app, you would send this to a server.
// For this demo, we log it and show a success message.
console.log("Form submitted:", data);
// Show success message
form.style.display = "none";
successMessage.style.display = "block";
// Reset after 5 seconds (for demo purposes)
setTimeout(() => {
form.reset();
form.style.display = "block";
successMessage.style.display = "none";
// Clear any error states
for (const group of form.querySelectorAll(".form-group")) {
group.classList.remove("has-error");
}
}, 5000);
});
}
document.addEventListener("DOMContentLoaded", initContactForm);
Step 8: testing locally
Option 1: VS Code Live Server
If you use VS Code, install the Live Server extension. Right-click index.html and select "Open with Live Server".
It starts a local server with auto-reload.
Option 2: Node.js http-server
Install a simple static server:
npm install -g http-server
Navigate to your project folder and run:
http-server -p 8080
Open http://localhost:8080 in your browser.
Option 3: Python's built-in server
If you have Python installed:
# Python 3
python3 -m http.server 8080
What to test
Walk through this checklist:
- All three pages load without errors (check the browser console)
- Navigation links work and the active page is highlighted
- Theme toggle switches between light and dark mode
- Theme preference persists across page reloads
- Projects page loads project data and displays cards
- Filter buttons work -- clicking a technology shows only matching projects
- Contact form validates on blur and on submit
- Invalid fields show error messages and highlight red
- Submitting a valid form shows the success message
- The site looks good on mobile (resize your browser or use dev tools responsive mode)
What you have built
This project uses everything from the previous chapters:
| Concept | Where it is used |
|---|---|
| Variables & types | Data storage, form values, boolean flags |
| Control flow | Validation logic, filter conditions |
| Functions | initTheme, initProjects, validateField, renderProjects |
| Arrays | Project list, filtering with .filter(), iterating with for...of |
| Objects | Project data, form data with Object.fromEntries |
| HTML & CSS | Page structure, flexbox layout, CSS custom properties |
| DOM manipulation | createElement, querySelector, classList, textContent |
| Events | Click handlers, form submit, input/blur for validation, delegation |
| Fetch API | Loading projects.json |
| localStorage | Theme persistence |
Multi-page site architecture
Our portfolio has three pages that share navigation, a footer, styles, and a theme toggle. This works, but as a site grows, the duplicated HTML becomes a maintenance burden. This section covers how to link between pages properly, how to structure a larger site, and how to eliminate repetition.
Linking between pages
Relative vs absolute paths
Use relative paths when linking between pages on the same site:
<!-- From index.html (root) -->
<a href="projects.html">Projects</a>
<a href="blog/post-1.html">First Post</a>
<!-- From blog/post-1.html (subfolder) -->
<a href="../index.html">Home</a>
<a href="../projects.html">Projects</a>
<a href="post-2.html">Next Post</a>
| Path | Meaning |
|---|---|
projects.html | File in the same folder |
blog/post-1.html | File in a subfolder |
../index.html | File one folder up |
../../about.html | File two folders up |
/index.html | Absolute from the site root |
Relative paths are preferred because they work regardless of where the site is hosted (a subfolder, a different
domain, or locally). Absolute paths (starting with /) work too but break if the site is not at the domain root.
Anchor links (same-page navigation)
Link to a section within the same page using # and an id:
<!-- Link -->
<a href="#contact-section">Jump to contact</a>
<!-- Target -->
<section id="contact-section">
<h2>Contact</h2>
<!-- ... -->
</section>
Combine with page links to jump to a section on a different page:
<!-- From any page, jump to the "skills" section on the about page -->
<a href="about.html#skills">My Skills</a>
Navigation patterns
For consistent navigation across pages, always use the same HTML structure:
<nav class="site-nav">
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="projects.html">Projects</a></li>
<li><a href="blog/index.html">Blog</a></li>
<li><a href="contact.html">Contact</a></li>
</ul>
</nav>
The initActiveNav() function in main.js automatically highlights the current page. This pattern works for any number
of pages -- just add more <li> entries.
External links
Always use the full URL and open in a new tab:
<a href="https://github.com/username" target="_blank" rel="noopener noreferrer">GitHub</a>
rel="noopener noreferrer" is a security best practice -- it prevents the linked page from accessing your
window.opener object.
Structuring a larger site
The three-page portfolio is simple, but real sites grow. Here is how to organize a site with more pages:
Flat structure (small sites -- up to ~10 pages)
my-website/
├── index.html
├── about.html
├── projects.html
├── contact.html
├── css/
│ └── styles.css
├── js/
│ ├── main.js
│ ├── projects.js
│ └── contact.js
├── images/
│ ├── hero.jpg
│ └── logo.svg
└── data/
└── projects.json
All HTML pages live in the root. Works well when there are few pages.
Nested structure (medium to large sites)
my-website/
├── index.html
├── about.html
├── contact.html
├── blog/
│ ├── index.html # Blog listing page
│ ├── post-1.html
│ ├── post-2.html
│ └── post-3.html
├── projects/
│ ├── index.html # Projects listing page
│ ├── weather-app.html # Individual project pages
│ └── task-manager.html
├── css/
│ ├── base.css # Reset, variables, typography
│ ├── layout.css # Nav, footer, grid, containers
│ └── components.css # Cards, buttons, forms, tags
├── js/
│ ├── main.js # Shared (theme, nav)
│ ├── blog.js # Blog-specific
│ ├── projects.js # Projects-specific
│ └── contact.js # Contact-specific
├── images/
│ ├── blog/
│ ├── projects/
│ └── shared/
└── data/
└── projects.json
Key principles:
- Group pages by section -- blog posts in
blog/, project pages inprojects/ - Each section gets an
index.html-- this is the listing or landing page for that section - Split CSS by concern -- base styles, layout, and components in separate files
- Mirror the folder structure in images and JS -- keeps things predictable
- Keep shared assets at the top level --
css/,js/, andimages/shared/
Loading multiple CSS files
Split styles across files and load them in order:
<head>
<link rel="stylesheet" href="/css/base.css">
<link rel="stylesheet" href="/css/layout.css">
<link rel="stylesheet" href="/css/components.css">
</head>
base.css defines variables and resets, layout.css uses them for page structure, and components.css styles
individual elements. This order matters -- later files can override earlier ones.
Reusing code across pages
The biggest pain point of vanilla multi-page sites is duplicated HTML. Every page repeats the same <nav>, <footer>,
and <head> content. Here are several strategies to reduce that repetition.
Strategy 1: JavaScript-injected components
Load shared HTML from a separate file using fetch():
Create components/nav.html:
<nav class="site-nav">
<div class="nav-inner">
<a href="/index.html" class="nav-logo">MyPortfolio</a>
<ul class="nav-links">
<li><a href="/index.html">Home</a></li>
<li><a href="/projects.html">Projects</a></li>
<li><a href="/blog/index.html">Blog</a></li>
<li><a href="/contact.html">Contact</a></li>
<li><button class="theme-toggle" id="theme-toggle">Dark Mode</button></li>
</ul>
</div>
</nav>
Create components/footer.html:
<footer class="site-footer">
<p>© 2025 MyPortfolio. Built with vanilla HTML, CSS, and JavaScript.</p>
</footer>
Add a loader function to js/main.js:
async function loadComponent(selector, path) {
const element = document.querySelector(selector);
if (!element) return;
try {
const response = await fetch(path);
if (!response.ok) throw new Error(`Failed to load ${path}`);
element.innerHTML = await response.text();
} catch (error) {
console.error(`Error loading component: ${error.message}`);
}
}
Now each page just has placeholder elements:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyPortfolio -- About</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<!-- Placeholders for shared components -->
<div id="nav-placeholder"></div>
<main class="main-content">
<h1>About Me</h1>
<p>This is the only content unique to this page.</p>
</main>
<div id="footer-placeholder"></div>
<script src="js/main.js"></script>
<script>
document.addEventListener("DOMContentLoaded", async () => {
await loadComponent("#nav-placeholder", "components/nav.html");
await loadComponent("#footer-placeholder", "components/footer.html");
// Run init functions after components are loaded
initTheme();
initActiveNav();
});
</script>
</body>
</html>
Pros: Single source of truth for nav and footer. Change once, every page updates.
Cons: Requires a web server (will not work by opening file:// directly). A brief flash may occur before components
load.
Strategy 2: reusable CSS utility classes
Instead of writing custom CSS for every element, create reusable utility classes:
/* Spacing */
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mt-3 { margin-top: 24px; }
.mt-4 { margin-top: 32px; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
/* Text */
.text-center { text-align: center; }
.text-muted { color: var(--color-text-muted); }
.text-small { font-size: 0.875rem; }
/* Display */
.hidden { display: none; }
.flex { display: flex; }
.flex-wrap { flex-wrap: wrap; }
.gap-1 { gap: 8px; }
.gap-2 { gap: 16px; }
/* Containers */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 24px;
}
Now new pages need less custom CSS:
<section class="container mt-4">
<h2 class="mb-2">Latest Posts</h2>
<div class="card-grid">
<!-- cards -->
</div>
</section>
Strategy 3: CSS custom properties for theming
We already use CSS custom properties for the theme. Take this further to create a consistent design system:
:root {
/* Spacing scale */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--space-2xl: 48px;
/* Font sizes */
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 2rem;
/* Consistent radius and shadow */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
}
Every component uses these variables instead of hardcoded values. When you want to adjust spacing or sizing across the entire site, change one variable.
Strategy 4: shared JavaScript modules
Reuse logic by splitting it into focused files:
js/
├── main.js # Entry point -- loads everything
├── theme.js # Theme toggle logic
├── nav.js # Active nav highlighting
├── validation.js # Form validation (reusable)
└── api.js # Shared fetch helpers
Create a reusable validation module (js/validation.js):
"use strict";
function validateRequired(value) {
return value.trim() !== "";
}
function validateMinLength(value, min) {
return value.trim().length >= min;
}
function validateEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
}
function validateField(input) {
const group = input.closest(".form-group");
let isValid = true;
if (input.hasAttribute("required") && !validateRequired(input.value)) {
isValid = false;
}
const minLength = input.getAttribute("minlength");
if (minLength && !validateMinLength(input.value, Number(minLength))) {
isValid = false;
}
if (input.type === "email" && input.value.trim() !== "") {
if (!validateEmail(input.value)) {
isValid = false;
}
}
group.classList.toggle("has-error", !isValid);
return isValid;
}
Now any page with a form can reuse the same validation:
<!-- contact.html -->
<script src="js/validation.js"></script>
<script src="js/contact.js"></script>
<!-- newsletter.html -->
<script src="js/validation.js"></script>
<script src="js/newsletter.js"></script>
Both forms get the same validation behavior without duplicating a single line.
Strategy 5: data-driven pages
Instead of creating a separate HTML file for each project or blog post, generate pages from data:
// Read the project ID from the URL: project.html?id=3
const params = new URLSearchParams(window.location.search);
const projectId = Number(params.get("id"));
async function loadProject() {
const response = await fetch("data/projects.json");
const projects = await response.json();
const project = projects.find(p => p.id === projectId);
if (!project) {
document.querySelector(".main-content").innerHTML = "<h1>Project not found</h1>";
return;
}
document.querySelector("#project-title").textContent = project.title;
document.querySelector("#project-description").textContent = project.description;
// ... render the rest
}
document.addEventListener("DOMContentLoaded", loadProject);
Now one project.html template serves every project. Link to individual projects like this:
<a href="project.html?id=1">Weather App</a>
<a href="project.html?id=2">Task Manager</a>
This avoids creating dozens of nearly identical HTML files. The same pattern works for blog posts, team members, product pages -- any content that shares a structure.
When to reach for a framework
These strategies work well for small to medium sites (up to ~20-30 pages). Beyond that, you start fighting the limitations of vanilla HTML:
| Problem | Framework solution |
|---|---|
| Duplicated HTML across pages | Component-based architecture (React, Vue, Svelte) |
| No templating language | JSX, Vue templates, Svelte markup |
| Manual routing | Client-side router or file-based routing (Next.js, Astro) |
| No build step for optimization | Bundlers (Vite, webpack) with minification, tree-shaking |
| Managing state across pages | Global state management (Redux, Zustand, Pinia) |
If you find yourself spending more time managing repetition than building features, it is time to consider a framework. But understanding vanilla HTML/CSS/JS first makes learning any framework much easier -- you know what the framework is abstracting away.
Before jumping to a framework though, there is a built-in browser feature that solves the component problem without any library: Web Components.
Web Components
Web Components are a set of browser-native APIs that let you create custom, reusable HTML elements with their own encapsulated markup, styles, and behavior. No framework, no build step, no npm install -- they work in every modern browser.
The three core APIs
| API | What it does |
|---|---|
| Custom Elements | Define new HTML tags (<my-nav>, <project-card>) |
| Shadow DOM | Encapsulate styles so they do not leak in or out |
| HTML Templates | Define reusable markup fragments with <template> |
Your first custom element
Let us solve our portfolio's biggest problem -- the duplicated nav -- with a Web Component:
// js/components/site-nav.js
class SiteNav extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<nav class="site-nav">
<div class="nav-inner">
<a href="index.html" class="nav-logo">MyPortfolio</a>
<ul class="nav-links">
<li><a href="index.html">Home</a></li>
<li><a href="projects.html">Projects</a></li>
<li><a href="contact.html">Contact</a></li>
<li><button class="theme-toggle" id="theme-toggle">Dark Mode</button></li>
</ul>
</div>
</nav>
`;
}
}
customElements.define("site-nav", SiteNav);
Now every page just uses the custom tag:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MyPortfolio -- About</title>
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<site-nav></site-nav>
<main class="main-content">
<h1>About Me</h1>
<p>Only the unique content lives in the HTML file.</p>
</main>
<site-footer></site-footer>
<script src="js/components/site-nav.js"></script>
<script src="js/components/site-footer.js"></script>
<script src="js/main.js"></script>
</body>
</html>
Change the nav once in site-nav.js and every page updates. No fetch(), no server required, no flash of unstyled
content.
How it works:
class SiteNav extends HTMLElement-- create a class that extends the base HTML elementconnectedCallback()-- runs automatically when the element is inserted into the pagecustomElements.define("site-nav", SiteNav)-- register the tag name (must contain a hyphen)
Adding the Shadow DOM for style encapsulation
Without the Shadow DOM, a component's styles can conflict with the rest of the page. The Shadow DOM creates an isolated scope:
// js/components/alert-banner.js
class AlertBanner extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const type = this.getAttribute("type") || "info";
const message = this.textContent;
const colors = {
info: { bg: "#e3f2fd", border: "#1976d2", text: "#0d47a1" },
success: { bg: "#e8f5e9", border: "#388e3c", text: "#1b5e20" },
warning: { bg: "#fff3e0", border: "#f57c00", text: "#e65100" },
error: { bg: "#ffebee", border: "#d32f2f", text: "#b71c1c" },
};
const c = colors[type] || colors.info;
this.shadowRoot.innerHTML = `
<style>
.banner {
padding: 12px 16px;
border-left: 4px solid ${c.border};
background: ${c.bg};
color: ${c.text};
border-radius: 4px;
font-family: inherit;
margin: 8px 0;
}
</style>
<div class="banner">${message}</div>
`;
}
}
customElements.define("alert-banner", AlertBanner);
Use it anywhere:
<alert-banner type="success">Your message has been sent.</alert-banner>
<alert-banner type="error">Something went wrong. Please try again.</alert-banner>
<alert-banner type="info">New features are available.</alert-banner>
The .banner styles inside the Shadow DOM will never conflict with .banner classes elsewhere on the page. This is
true encapsulation -- no CSS naming conventions or BEM needed.
Using attributes and properties
Components become truly reusable when they accept configuration through attributes:
// js/components/project-card.js
class ProjectCard extends HTMLElement {
connectedCallback() {
const title = this.getAttribute("title") || "Untitled";
const description = this.getAttribute("description") || "";
const tags = (this.getAttribute("tags") || "").split(",").filter(Boolean);
const url = this.getAttribute("url") || "#";
this.innerHTML = `
<div class="card">
<h3><a href="${url}">${title}</a></h3>
<p>${description}</p>
<div class="tags">
${tags.map(tag => `<span class="tag">${tag.trim()}</span>`).join("")}
</div>
</div>
`;
}
}
customElements.define("project-card", ProjectCard);
Now the HTML is clean and declarative:
<div class="card-grid">
<project-card
title="Weather App"
description="A weather dashboard that fetches real-time data from an API."
tags="JavaScript, API, CSS"
url="#"
></project-card>
<project-card
title="Task Manager"
description="A to-do list with drag-and-drop and local storage."
tags="JavaScript, CSS"
url="#"
></project-card>
</div>
Reacting to attribute changes
Use observedAttributes and attributeChangedCallback to update the component when attributes change:
class UserBadge extends HTMLElement {
static observedAttributes = ["name", "role"];
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const name = this.getAttribute("name") || "Anonymous";
const role = this.getAttribute("role") || "user";
this.shadowRoot.innerHTML = `
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.875rem;
font-family: inherit;
background: ${role === "admin" ? "#e3f2fd" : "#f5f5f5"};
border: 1px solid ${role === "admin" ? "#1976d2" : "#ddd"};
}
.role {
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
}
</style>
<span class="badge">
<span>${name}</span>
<span class="role">${role}</span>
</span>
`;
}
}
customElements.define("user-badge", UserBadge);
<user-badge name="Ada" role="admin"></user-badge>
<user-badge name="Grace" role="editor"></user-badge>
Change an attribute from JavaScript and the component re-renders automatically:
document.querySelector("user-badge").setAttribute("role", "viewer");
Using <template> and <slot>
The <template> element defines markup that is not rendered until cloned. Combine it with <slot> to let the consumer
pass content into the component:
<!-- Define the template (once, anywhere in the page) -->
<template id="card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
font-family: inherit;
}
.card-header {
font-weight: 700;
margin-bottom: 8px;
}
</style>
<div class="card">
<div class="card-header"><slot name="title">Default Title</slot></div>
<div class="card-body"><slot>Default content</slot></div>
</div>
</template>
class FancyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });
const template = document.getElementById("card-template");
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define("fancy-card", FancyCard);
Use it with slotted content:
<fancy-card>
<span slot="title">My Custom Title</span>
<p>This paragraph goes into the default slot.</p>
</fancy-card>
Slots let consumers inject their own content while the component controls the layout and styling. This is the same concept as "children" or "slots" in React, Vue, and Svelte.
Lifecycle methods summary
| Method | When it runs |
|---|---|
constructor() | Element is created (use for Shadow DOM setup) |
connectedCallback() | Element is added to the page |
disconnectedCallback() | Element is removed from the page |
attributeChangedCallback(name, oldVal, newVal) | An observed attribute changes |
adoptedCallback() | Element is moved to a new document (rare) |
Organizing components in a project
my-website/
├── js/
│ ├── main.js
│ └── components/
│ ├── site-nav.js
│ ├── site-footer.js
│ ├── project-card.js
│ ├── alert-banner.js
│ └── user-badge.js
├── css/
│ └── styles.css
└── index.html
Each component lives in its own file. Load them with <script> tags or, in a project with a build step, use import:
// With ES modules (requires type="module" on the script tag)
import "./components/site-nav.js";
import "./components/site-footer.js";
import "./components/project-card.js";
When to use Web Components vs a framework
| Use Web Components when... | Use a framework when... |
|---|---|
| You want zero dependencies | You need complex state management |
| You are building a small/medium site | You are building a large single-page app |
| You want components that work with any framework | You need server-side rendering |
| You are building a shared design system / component library | You need a rich ecosystem of plugins and tooling |
| You want maximum browser compatibility | You want a large community and hiring pool |
Web Components and frameworks are not mutually exclusive. Many teams build Web Components for their shared design system and use them inside React, Vue, or Angular apps. The browser-native approach means the components work everywhere without framework lock-in.
Summary
You now have a complete, working multi-page website built entirely with vanilla HTML, CSS, and JavaScript. The site is:
- Responsive -- works on phones, tablets, and desktops
- Accessible -- semantic HTML, proper labels, keyboard-navigable forms
- Interactive -- theme toggle, filterable projects, form validation
- Persistent -- theme preference saved in localStorage
Next up: Deploying to a VPS with Nginx -- putting your site on the internet for the world to see.