Module 11: Projects
Project 1: A Responsive, Themeable Landing Page
Build a complete marketing page for a fictional product with a responsive navbar, fluid hero, auto-fitting feature grid, pricing cards, dark mode and accessible focus handling.
You are building the landing page for Lumen, a made-up focus-timer app. The brief is realistic: marketing wants a page that looks right on a phone and a wide monitor, loads fast, works for keyboard users, and follows the visitor's light or dark preference. You will touch almost every module in this course.
Try each step on your own before reading the reference solution at the end. Where you get stuck, go back to the lesson that covers the technique. The point of the project is the struggle.
Requirements#
Structure and content
- A sticky header with a logo, three links, and a call-to-action button.
- A hero section with a headline, a short paragraph, two buttons and a visual.
- A features section with six cards.
- A pricing section with three plans, one marked as featured.
- A final call-to-action band and a footer.
Layout and responsiveness
- Mobile-first. At 320px wide, everything is a single column with no horizontal scroll.
- The header collapses its links into a toggle menu below 768px.
- The hero becomes two columns at about 830px.
- The feature and pricing grids must resize without a single media query (use
auto-fitandminmax). - The headline and section titles use
clamp()for fluid size. - Text lines never exceed about 65 characters.
Design system
- All colors, radii, spacing and shadows come from custom properties.
- Use
@layerto order reset, tokens, base, layout and components. - Buttons have default, hover, active, focus-visible and disabled states.
Theming and accessibility
- Light and dark themes follow
prefers-color-schemeandcolor-schemeis set. - Every interactive element has a visible focus ring.
- A skip link lets keyboard users jump past the header.
- Text and interactive colors meet WCAG AA contrast in both themes.
- Animations run only when the user has not asked for reduced motion.
- The menu button reports its state with
aria-expanded.
File structure#
lumen/
├── index.html
├── styles.css
└── menu.js
Steps#
- Write the HTML first, with no CSS. Use semantic elements:
header,nav,main,section,article,footer. Check that the unstyled page reads sensibly top to bottom, because that is what a screen reader will experience. - Declare your layers and tokens. Put your palette, spacing scale, radius and shadow in
:root. Add the dark values in aprefers-color-schemeblock. - Add the reset and base styles. Border-box, zero margins, responsive images, inherited form fonts, body typography, and the global
:focus-visiblerule. - Build the
.containerutility withmin(100% - 2rem, 70rem). - Build the header and navbar using the mobile-first pattern from Module 9. Make it sticky.
- Build the hero. One column by default, a two-column grid at a wider breakpoint. Make the visual with CSS alone.
- Build the feature grid with
repeat(auto-fit, minmax(min(16rem, 100%), 1fr)). - Build the pricing cards. Use
position: relativeand a pseudo-element for the "Most popular" label on the featured plan. - Add the CTA band and footer.
- Add motion inside
prefers-reduced-motion: no-preference: a staggered fade-in for the hero copy and lift-on-hover for cards. - Test. Resize from 320px up. Tab through everything. Switch to dark mode in DevTools. Check the contrast on muted text and buttons.
Reference solution#
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumen: focus in sprints</title>
<meta name="description" content="Lumen is a focus timer that helps you work in short, deliberate sprints.">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="site-header">
<nav class="nav container" aria-label="Main">
<a class="nav__logo" href="#top">Lumen</a>
<button class="nav__toggle btn btn--secondary btn--small" aria-expanded="false" aria-controls="menu">Menu</button>
<ul class="nav__links" id="menu">
<li><a href="#features">Features</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a class="btn btn--small" href="#cta">Get started</a></li>
</ul>
</nav>
</header>
<main id="main">
<section class="hero container" id="top">
<div class="hero__copy">
<h1 style="--i: 0">Work in focused sprints.</h1>
<p class="lead" style="--i: 1">Lumen turns your day into short, deliberate stretches of deep work, with breaks you actually take.</p>
<div class="hero__actions" style="--i: 2">
<a class="btn" href="#cta">Start free</a>
<a class="btn btn--secondary" href="#features">See how it works</a>
</div>
</div>
<div class="hero__visual" aria-hidden="true">
<div class="ring"><span>25:00</span></div>
</div>
</section>
<section class="section container" id="features">
<h2>Everything you need, nothing you do not</h2>
<div class="grid">
<article class="card"><span class="card__tag">01</span><h3>Smart sprints</h3><p>Pick a length that suits the task. Lumen remembers what works for you.</p></article>
<article class="card"><span class="card__tag">02</span><h3>Gentle breaks</h3><p>Reminders that nudge you to stand up without yanking you out of flow.</p></article>
<article class="card"><span class="card__tag">03</span><h3>Daily review</h3><p>See where your focus time went in a single, readable summary.</p></article>
<article class="card"><span class="card__tag">04</span><h3>Quiet mode</h3><p>Silences distractions for the length of the sprint and restores them after.</p></article>
<article class="card"><span class="card__tag">05</span><h3>Team rooms</h3><p>Sprint together with a shared timer and no meetings required.</p></article>
<article class="card"><span class="card__tag">06</span><h3>Works offline</h3><p>Your timer keeps running on a train, on a plane, or in a cabin.</p></article>
</div>
</section>
<section class="section container" id="pricing">
<h2>Simple pricing</h2>
<div class="grid">
<article class="card plan">
<h3>Solo</h3>
<p class="plan__price">$0<span>/month</span></p>
<p>Everything you need for personal focus.</p>
<a class="btn btn--secondary" href="#cta">Choose Solo</a>
</article>
<article class="card plan plan--featured">
<h3>Pro</h3>
<p class="plan__price">$6<span>/month</span></p>
<p>Reviews, history, and sync across devices.</p>
<a class="btn" href="#cta">Choose Pro</a>
</article>
<article class="card plan">
<h3>Team</h3>
<p class="plan__price">$5<span>/user/month</span></p>
<p>Shared rooms and team-wide summaries.</p>
<a class="btn btn--secondary" href="#cta">Choose Team</a>
</article>
</div>
</section>
<section class="cta" id="cta">
<div class="container">
<h2>Ready for your first sprint?</h2>
<p>Start free. No credit card, no setup.</p>
<a class="btn btn--light" href="#top">Create your account</a>
</div>
</section>
</main>
<footer class="site-footer container">
<p>© 2026 Lumen</p>
<ul>
<li><a href="#">Privacy</a></li>
<li><a href="#">Terms</a></li>
<li><a href="#">Contact</a></li>
</ul>
</footer>
<script src="menu.js"></script>
</body>
</html>
styles.css:
@layer reset, tokens, base, layout, components;
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
img, svg { display: block; max-width: 100%; }
button, input { font: inherit; color: inherit; }
ul { padding: 0; list-style: none; }
}
@layer tokens {
:root {
color-scheme: light dark;
--bg: #ffffff;
--surface: #f4f6fb;
--text: #101828;
--muted: #475467;
--border: #e4e7ec;
--brand: #2f5bea;
--brand-strong: #2245c4;
--on-brand: #ffffff;
--radius: 14px;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 3rem;
--space-5: clamp(3rem, 8vw, 6rem);
--shadow: 0 1px 2px rgb(16 24 40 / 0.06), 0 8px 24px rgb(16 24 40 / 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c111d;
--surface: #161b2b;
--text: #f2f4f7;
--muted: #98a2b3;
--border: #272f45;
--brand: #7b9bff;
--brand-strong: #a3b8ff;
--on-brand: #0c111d;
--shadow: 0 1px 2px rgb(0 0 0 / 0.4), 0 8px 24px rgb(0 0 0 / 0.35);
}
}
}
@layer base {
body { font: 1rem/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
h1, h2, h3 { line-height: 1.15; text-wrap: balance; }
h1 { font-size: clamp(2.25rem, 1.4rem + 4vw, 4rem); }
h2 { font-size: clamp(1.75rem, 1.3rem + 2vw, 2.5rem); margin-bottom: var(--space-3); }
h3 { font-size: 1.2rem; margin-bottom: 0.5rem; }
p { max-width: 65ch; color: var(--muted); }
a { color: var(--brand); }
:focus-visible { outline: 3px solid var(--brand); outline-offset: 3px; }
section[id] { scroll-margin-top: 5rem; }
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } }
}
@layer layout {
.container { width: min(100% - 2rem, 70rem); margin-inline: auto; }
.section { padding-block: var(--space-5); }
.grid {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
}
.hero { display: grid; gap: var(--space-4); align-items: center; padding-block: var(--space-5); }
@media (min-width: 52rem) {
.hero { grid-template-columns: 1.1fr 0.9fr; }
}
}
@layer components {
.skip {
position: absolute; left: 1rem; top: -4rem; z-index: 200;
padding: 0.5rem 1rem; border-radius: 8px;
background: var(--brand); color: var(--on-brand);
}
.skip:focus { top: 1rem; }
/* Buttons */
.btn {
--btn-bg: var(--brand); --btn-fg: var(--on-brand); --btn-border: transparent;
display: inline-flex; align-items: center; justify-content: center;
min-height: 2.75rem; padding: 0 1.25rem;
border: 1px solid var(--btn-border); border-radius: 10px;
background: var(--btn-bg); color: var(--btn-fg);
font-weight: 600; text-decoration: none; cursor: pointer;
transition: background-color 120ms ease, transform 120ms ease;
}
.btn:hover { --btn-bg: var(--brand-strong); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--secondary { --btn-bg: transparent; --btn-fg: var(--brand); --btn-border: currentColor; }
.btn--secondary:hover { --btn-bg: color-mix(in srgb, var(--brand) 12%, transparent); }
.btn--small { min-height: 2.25rem; padding: 0 1rem; font-size: 0.9rem; }
.btn--light { --btn-bg: #fff; --btn-fg: #101828; }
.btn--light:hover { --btn-bg: #e4e7ec; }
/* Header and nav */
.site-header {
position: sticky; top: 0; z-index: 100;
background: color-mix(in srgb, var(--bg) 88%, transparent);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
}
.nav { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); padding-block: 0.75rem; }
.nav__logo { margin-right: auto; font-size: 1.25rem; font-weight: 800; color: var(--text); text-decoration: none; }
.nav__links { display: none; width: 100%; flex-direction: column; gap: 0.25rem; }
.nav__links.is-open { display: flex; }
.nav__links a:not(.btn) { display: block; padding: 0.6rem 0; color: var(--text); text-decoration: none; }
.nav__links a:not(.btn):hover { color: var(--brand); }
@media (min-width: 48rem) {
.nav__toggle { display: none; }
.nav__links { display: flex; width: auto; flex-direction: row; align-items: center; gap: 1.5rem; }
}
/* Hero */
.lead { font-size: 1.2rem; margin-block: var(--space-2) var(--space-3); }
.hero__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.ring {
position: relative; display: grid; place-items: center;
width: min(18rem, 70vw); aspect-ratio: 1; margin-inline: auto; border-radius: 50%;
background: conic-gradient(var(--brand) 0 68%, var(--surface) 0);
box-shadow: var(--shadow);
}
.ring::before { content: ""; position: absolute; inset: 14%; border-radius: 50%; background: var(--bg); }
.ring span { position: relative; font-size: clamp(2rem, 8vw, 3rem); font-weight: 700; font-variant-numeric: tabular-nums; }
/* Cards and plans */
.card {
position: relative;
padding: var(--space-3);
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
}
.card__tag { display: inline-block; margin-bottom: 0.75rem; font-weight: 700; color: var(--brand); }
.plan { display: flex; flex-direction: column; gap: 0.75rem; align-items: flex-start; }
.plan .btn { margin-top: auto; }
.plan__price { font-size: 2.25rem; font-weight: 800; color: var(--text); }
.plan__price span { font-size: 0.9rem; font-weight: 500; color: var(--muted); }
.plan--featured { border-color: var(--brand); box-shadow: var(--shadow); }
.plan--featured::before {
content: "Most popular";
position: absolute; top: 0; right: var(--space-3); transform: translateY(-50%);
padding: 0.2rem 0.7rem; border-radius: 999px;
background: var(--brand); color: var(--on-brand); font-size: 0.75rem; font-weight: 700;
}
/* CTA and footer */
.cta { padding-block: var(--space-5); background: linear-gradient(135deg, #2245c4, #5b3fd0); color: #fff; }
.cta h2 { color: #fff; }
.cta p { color: #e0e7ff; margin-bottom: var(--space-3); }
.site-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); padding-block: var(--space-4); }
.site-footer ul { display: flex; gap: var(--space-3); }
/* Motion */
@media (prefers-reduced-motion: no-preference) {
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
.hero__copy > * { animation: rise 600ms ease-out both; animation-delay: calc(var(--i, 0) * 90ms); }
.card { transition: transform 150ms ease, box-shadow 150ms ease; }
.card:hover { transform: translateY(-3px); box-shadow: var(--shadow); }
}
}
menu.js:
const toggle = document.querySelector(".nav__toggle");
const menu = document.querySelector("#menu");
toggle.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
toggle.setAttribute("aria-expanded", String(open));
});
Acceptance checklist#
Go through it with DevTools open. Do not tick a box on a guess.
- At 320px there is no horizontal scrollbar. (Use the Console overflow snippet from Module 10.)
- The hero switches to two columns at the expected width, with no overlap just before it.
- Resizing from 320px to 1600px reflows the feature and pricing cards smoothly, with no media queries in those rules.
- Browser zoom at 200% keeps every line readable and nothing clipped.
- Tab order is logical, the skip link appears on first Tab, and every focus ring is visible.
- The menu toggle updates
aria-expandedand works with Enter and Space. - Dark mode, emulated through DevTools, passes the contrast check on muted text, the buttons and the "Most popular" label.
- With "prefers-reduced-motion: reduce" emulated, nothing animates and smooth scrolling is off.
- Every pricing card's button sits at the bottom of its card, regardless of text length.
- The built-in print preview does not show a mess. Add a small
printstylesheet that hides the header.
Stretch goals#
- Add a manual theme toggle that overrides the OS setting, following the pattern in Module 9, and avoid the flash on load.
- Convert the pricing cards to use a container query so the price and label rearrange when a card is narrow.
- Add a Playwright test that checks for horizontal overflow at five widths, and a screenshot test for the hero at two widths.
- Run Stylelint with
selector-max-specificityset to0,2,0and fix every violation. - Replace the placeholder CTA gradient with a themeable pair of custom properties, and check contrast in both themes.
Practice#
- Without looking at the reference code, rebuild the hero section from scratch in a fresh file, then diff your version against the reference. Write down every difference and decide which version you prefer, and why.
- Break the page on purpose in three ways (remove the viewport meta tag, remove
min-width: 0-style safeguards orbox-sizing, give the header anoverflow: hiddenparent). For each, write down what you saw, and which DevTools panel exposed the cause. - Re-skin the page for a different fictional product by changing only the custom properties in the
tokenslayer. If you had to edit a component rule, the token was missing. Add it.