Project: style a complete webpage from scratch

Part A of 3 — Foundation & HTML · Steps 1–3

🏗 Chapter 12 — Project: Lumen Studio

This is the capstone chapter of the CSS Beginner course. Rather than introducing new properties, we apply everything from Chapters 1–11 to build a complete, professional, responsive landing page for a fictional creative agency called Lumen Studio. By the end of all three parts you will have a page you can deploy as-is or adapt into your own portfolio.

The project is split across three parts to keep each lesson manageable:

  • Part A (this file) — Project intro, HTML structure, and CSS Steps 1–3: foundation variables, reset, utilities, and buttons.
  • Part B — CSS Steps 4–8: navigation, hero, clients strip, services grid, and stats bar.
  • Part C — CSS Steps 9–13: about section, testimonials, CTA, footer, JavaScript helpers, and "Go Further" challenges.

1 — What We're Building

Lumen Studio is a single-page marketing site for a creative design agency. It uses a clean light theme (white background, dark navy text, blue accent) with a dark navy footer and stats section for contrast. Every major layout and styling technique from the course appears somewhere on the page.

Page structure — wireframe overview
lumen.studio
Navigation — sticky header Ch.6 display · Ch.7 position:sticky · Ch.8 Flexbox · Ch.10 transition
Hero — text column
h1 · clamp() · flex actions
✦ Brand Strategy
◈ UI / UX Design
⬡ Development
Hero — 2-col Grid Ch.9 Grid · Ch.10 @keyframes animation · Ch.11 clamp() + responsive
Clients strip Ch.8 Flexbox
Services — 6-card auto-fit grid Ch.9 Grid auto-fit · Ch.10 hover transition · Ch.7 ::before
Brand Strategy
UI / UX
Development
Growth & SEO
Mobile
Support
Stats bar — dark background Ch.4 background · Ch.9 Grid · Ch.11 clamp()
About — image + badge
position:absolute badge
About — text + checklist
::before checkmarks
About — 2-col Grid Ch.7 position:absolute · Ch.9 Grid · Ch.5 Typography
Testimonials — 3-card auto-fit grid Ch.9 Grid · Ch.3 Box Model
CTA — dark gradient + pseudo-element circles Ch.4 gradient · Ch.7 position:absolute ::before/::after

Skills used — chapter map

Page sectionCSS techniqueTaught in
Entire pageCSS custom properties (:root variables)Ch.9
All textTypography, clamp(), rem, line-heightCh.5 Ch.11
All spacingBox model: margin, padding, gapCh.3
Colors & gradientsCustom properties, rgba(), linear-gradient()Ch.4
Buttonsdisplay: inline-flex, transition, transformCh.6 Ch.10
Navigationposition: sticky, Flexbox, backdrop-filterCh.7 Ch.8
HeroCSS Grid 2-col, @keyframes float animationCh.9 Ch.10
Services, TestimonialsGrid auto-fit / minmax()Ch.9
Service card top bar::before pseudo-element + transform: scaleX()Ch.10
About badgeposition: absolute on a position: relative parentCh.7
CTA circles::before / ::after decorative elementsCh.7
FooterGrid auto-fit, Flexbox footer-bottomCh.9 Ch.8
Responsive layoutViewport meta, clamp(), @media (min-width)Ch.11
Scroll revealIntersectionObserver + CSS opacity / transform transitionCh.10

2 — File Structure

The project uses three files. Keep them in the same folder so the relative paths work without any configuration.

lumen-studio/ ├── index.html ← The page markup ├── style.css ← All CSS (built across Parts A, B, C) └── main.js ← Two small JavaScript helpers (Part C)
Working in VS Code? Install the Live Server extension (right-click index.html → "Open with Live Server"). It auto-refreshes the browser every time you save — essential for a CSS-heavy project like this.

3 — The HTML

Build this first. The HTML is the skeleton — we add no CSS yet. Every class name here will be targeted in Parts A, B, and C. Read the comments carefully; they are your map through the CSS walkthrough that follows.

🌐 index.html — complete page markup
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Lumen Studio — Creative Design Agency</title> <link rel="stylesheet" href="style.css"> </head> <body> <!-- ── NAVIGATION ─────────────────────────────────── --> <header class="site-header"> <nav class="nav container"> <a href="#" class="nav-logo">Lumen<span>.</span></a> <ul class="nav-links"> <li><a href="#services">Services</a></li> <li><a href="#about">About</a></li> <li><a href="#work">Work</a></li> <li><a href="#contact">Contact</a></li> </ul> <a href="#contact" class="btn btn-primary btn-sm">Start a project</a> <button class="hamburger" aria-label="Open menu"></button> </nav> </header> <!-- ── HERO ───────────────────────────────────────── --> <section class="hero"> <div class="container hero-inner"> <div class="hero-text"> <span class="hero-eyebrow">Creative Studio</span> <h1>We design brands that <em>stand out</em></h1> <p>Award-winning design and development for companies that want to be remembered.</p> <div class="hero-actions"> <a href="#work" class="btn btn-primary btn-lg">See our work</a> <a href="#about" class="btn btn-ghost">Learn more</a> </div> </div> <div class="hero-visual" aria-hidden="true"> <div class="h-card h-card--a"> <div class="h-card-icon"></div> <div class="h-card-title">Brand Strategy</div> <div class="h-card-sub">Identity · Positioning · Voice</div> </div> <div class="h-card h-card--b"> <div class="h-card-icon"></div> <div class="h-card-title">UI / UX Design</div> <div class="h-card-sub">Web · Mobile · Systems</div> </div> <div class="h-card h-card--c"> <div class="h-card-icon"></div> <div class="h-card-title">Development</div> <div class="h-card-sub">React · Next.js · CMS</div> </div> </div> </div> </section> <!-- ── CLIENTS STRIP ──────────────────────────────── --> <div class="clients"> <div class="container clients-inner"> <span class="clients-label">Trusted by</span> <div class="clients-logos"> <span class="client-logo">Meridian</span> <span class="client-logo">Vantage</span> <span class="client-logo">Halcyon</span> <span class="client-logo">Prism</span> <span class="client-logo">Solaris</span> </div> </div> </div> <!-- ── SERVICES ───────────────────────────────────── --> <section class="services" id="services"> <div class="container"> <div class="services-header reveal"> <span class="section-label">What we do</span> <h2 class="section-title">Everything you need to grow</h2> <p class="section-sub">From brand discovery through to launch and beyond.</p> </div> <div class="services-grid"> <div class="s-card reveal"><div class="s-icon">🎯</div><h3>Brand Strategy</h3> <p>We help you find and own your place in the market.</p></div> <div class="s-card reveal"><div class="s-icon">🎨</div><h3>UI / UX Design</h3> <p>Intuitive interfaces that guide users to what matters.</p></div> <div class="s-card reveal"><div class="s-icon"></div><h3>Web Development</h3> <p>Fast, accessible builds in React, Next.js or your stack.</p></div> <div class="s-card reveal"><div class="s-icon">📈</div><h3>Growth & SEO</h3> <p>Content strategy that turns visitors into customers.</p></div> <div class="s-card reveal"><div class="s-icon">📱</div><h3>Mobile Design</h3> <p>Native-feeling experiences with craft users expect.</p></div> <div class="s-card reveal"><div class="s-icon">🔧</div><h3>Ongoing Support</h3> <p>Monthly retainers so your presence keeps evolving.</p></div> </div> </div> </section> <!-- ── STATS ───────────────────────────────────────── --> <section class="stats"> <div class="container stats-grid"> <div class="stat reveal"><span class="stat-num">140+</span><span class="stat-label">Projects delivered</span></div> <div class="stat reveal"><span class="stat-num">98%</span><span class="stat-label">Client satisfaction</span></div> <div class="stat reveal"><span class="stat-num">12</span><span class="stat-label">Industry awards</span></div> <div class="stat reveal"><span class="stat-num">8yr</span><span class="stat-label">In business</span></div> </div> </section> <!-- ── ABOUT ───────────────────────────────────────── --> <section class="about" id="about"> <div class="container about-inner"> <div class="about-image-wrap reveal"> <div class="about-img">🎨</div> <div class="about-badge"> <span class="badge-num">8+</span> Years of craft </div> </div> <div class="about-text reveal"> <span class="section-label">About us</span> <h2>A small studio with big ambitions</h2> <p>We're a tight-knit team of designers, developers, and strategists who believe thoughtful craft and commercial results reinforce each other.</p> <p>Every project is a partnership. We ask the awkward questions and sweat the small details most agencies skip.</p> <ul class="about-checklist"> <li>Dedicated project lead on every engagement</li> <li>Weekly progress calls and shared Figma access</li> <li>Fixed-price packages — no surprise invoices</li> <li>Post-launch support included as standard</li> </ul> <a href="#contact" class="btn btn-primary">Work with us</a> </div> </div> </section> <!-- ── TESTIMONIALS ────────────────────────────────── --> <section class="testimonials"> <div class="container"> <div class="testi-header reveal"> <span class="section-label">Testimonials</span> <h2 class="section-title">What our clients say</h2> <p class="section-sub">We let the work speak — then let the clients speak for the work.</p> </div> <div class="testi-grid"> <div class="t-card reveal"> <div class="t-stars">★★★★★</div> <blockquote>The best creative decision we made this year. On time, on budget, beyond expectation.</blockquote> <div class="t-author"> <div class="t-avatar">SA</div> <div class="t-author-info"> <strong>Sarah Adeyemi</strong><span>CEO, Meridian Labs</span> </div> </div> </div> <div class="t-card reveal"> <div class="t-stars">★★★★★</div> <blockquote>They redesigned how we think about our digital presence. Conversion rates are up 34%.</blockquote> <div class="t-author"> <div class="t-avatar">JK</div> <div class="t-author-info"> <strong>James Kowalski</strong><span>Head of Product, Vantage</span> </div> </div> </div> <div class="t-card reveal"> <div class="t-stars">★★★★★</div> <blockquote>Extraordinary attention to detail. Every micro-interaction felt considered.</blockquote> <div class="t-author"> <div class="t-avatar">ML</div> <div class="t-author-info"> <strong>Maria Leclaire</strong><span>Design Director, Halcyon</span> </div> </div> </div> </div> </div> </section> <!-- ── CTA ────────────────────────────────────────── --> <section class="cta-section" id="contact"> <div class="container cta-inner reveal"> <span class="section-label" style="color: rgba(255,255,255,0.5)">Get started</span> <h2>Ready to build something great?</h2> <p>Tell us about your project. We'll reply within one business day.</p> <a href="mailto:hello@lumen.studio" class="btn btn-white btn-lg">Start a project →</a> </div> </section> <!-- ── FOOTER ─────────────────────────────────────── --> <footer class="site-footer"> <div class="container footer-grid"> <div class="footer-brand"> <a href="#" class="nav-logo">Lumen<span>.</span></a> <p>Creative studio in London, working worldwide.</p> </div> <div class="footer-col"> <h4>Services</h4> <ul> <li><a href="#">Brand Strategy</a></li> <li><a href="#">UI / UX Design</a></li> <li><a href="#">Web Development</a></li> <li><a href="#">Growth & SEO</a></li> </ul> </div> <div class="footer-col"> <h4>Company</h4> <ul> <li><a href="#">About</a></li> <li><a href="#">Work</a></li> <li><a href="#">Careers</a></li> <li><a href="#">Blog</a></li> </ul> </div> <div class="footer-col"> <h4>Contact</h4> <ul> <li><a href="mailto:hello@lumen.studio">hello@lumen.studio</a></li> <li><a href="#">+44 20 7946 0000</a></li> <li><a href="#">London, UK</a></li> </ul> </div> </div> <div class="container footer-bottom"> <p>© 2026 Lumen Studio Ltd.</p> <div><a href="#">Privacy</a> &nbsp;<a href="#">Terms</a></div> </div> </footer> <script src="main.js"></script> </body> </html>
The reveal class is added to elements that will fade in as the user scrolls. It does nothing until JavaScript activates it in Part C — so leave it on now and the page will look fine without the JS, just without scroll animations.
💡 Open the page now — Before writing any CSS, open index.html in your browser. You'll see unstyled plain text. That's correct. Every CSS step in Parts A–C progressively improves what you see, which is the best way to understand what each block of CSS actually does.
1
Custom Properties & Reset → style.css

The first block in style.css does two things: it defines all colours, fonts, and sizes as CSS variables (so changing one value updates the whole page), and it strips the browser's built-in default styles so we start from a clean baseline.

🎨 style.css — Step 1: Custom properties and reset
/* ── Custom properties ───────────────────────────── */ :root { /* Colours */ --clr-bg: #ffffff; --clr-bg-alt: #f7f8fc; --clr-dark: #0d1117; --clr-navy: #111827; --clr-text: #1f2937; --clr-muted: #6b7280; --clr-border: #e5e7eb; --clr-accent: #4f8ef7; --clr-accent-dark: #3a7ae0; /* Font */ --font: system-ui, -apple-system, sans-serif; /* Reused values */ --radius: 10px; --container: 1200px; } /* ── Reset ───────────────────────────────────────── */ *, *::before, *::after { box-sizing: border-box; /* padding/border inside width — always */ margin: 0; padding: 0; } img, video { max-width: 100%; height: auto; display: block; } ul { list-style: none; } a { text-decoration: none; color: inherit; } button { font-family: inherit; cursor: pointer; } body { font-family: var(--font); color: var(--clr-text); background-color: var(--clr-bg); line-height: 1.6; -webkit-font-smoothing: antialiased; /* crisper text on Mac/iOS */ }
Defining colours as :root variables means you can completely rebrand the page by changing 9 values. Try swapping --clr-accent to #e85d3f and watch the entire page update.
2
Utility Classes → style.css

These three classes are reused throughout the page. Defining them once here prevents repetition across every section. .container is especially important — it applies the max-width + margin: auto centering pattern from Chapter 11 to every section consistently.

🎨 style.css — Step 2: Utility classes
/* ── Container — centered, max-width, fluid padding ─ */ .container { max-width: var(--container); /* 1200px — never wider */ margin: 0 auto; /* horizontally centered */ padding: 0 clamp(1rem, 4vw, 2.5rem); /* fluid side padding */ } /* ── Section typography helpers ──────────────────── */ .section-label { display: inline-block; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--clr-accent); margin-bottom: 0.6rem; } .section-title { font-size: clamp(1.6rem, 3.5vw, 2.5rem); font-weight: 700; color: var(--clr-dark); margin-bottom:0.6rem; line-height: 1.2; } .section-sub { color: var(--clr-muted); max-width: 55ch; line-height:1.7; }
3
Button System → style.css

Buttons appear in the nav, hero, about section, and CTA. Defining a base .btn class plus modifier classes (.btn-primary, .btn-ghost, .btn-white, .btn-lg, .btn-sm) means any element can become any button variant just by adding HTML classes — no duplicated CSS.

🎨 style.css — Step 3: Button system
/* ── Base button ─────────────────────────────────── */ .btn { display: inline-flex; /* icon + text alignment */ align-items: center; gap: 0.4em; padding: 0.65em 1.4em; border-radius: 6px; font-weight: 600; font-size: 0.95rem; border: 2px solid transparent; white-space: nowrap; transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease, border-color 0.2s ease, color 0.2s ease; } .btn:hover { transform: translateY(-2px); } /* ── Variants ────────────────────────────────────── */ .btn-primary { background: var(--clr-accent); color: #fff; } .btn-primary:hover { background: var(--clr-accent-dark); box-shadow: 0 6px 20px rgba(79, 142, 247, 0.35); } .btn-ghost { background: transparent; border-color: var(--clr-border); color: var(--clr-text); } .btn-ghost:hover { border-color: var(--clr-accent); color: var(--clr-accent); } .btn-white { background: #fff; color: var(--clr-accent); } .btn-white:hover { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); } /* ── Size modifiers ──────────────────────────────── */ .btn-lg { padding: 0.8em 2em; font-size: 1.05rem; } .btn-sm { padding: 0.45em 1em; font-size: 0.85rem; }
Live button preview — hover to see the transitions
💡 Why inline-flex on a button? It keeps the button as an inline element (so it sits in text flow and sizes to its content) while enabling Flexbox alignment internally. This is how you get an icon and label perfectly vertically centred without extra positioning tricks — a pattern used in almost every real-world component library.
4
Navigation → style.css · Ch.7 position:sticky · Ch.8 Flexbox · Ch.10 transition

The nav uses position: sticky so it stays at the top of the screen as the user scrolls (Chapter 7). The inner .nav element is a Flexbox row that pushes the logo to the left and the links+button to the right with justify-content: space-between (Chapter 8). On mobile the nav links are hidden with display: none and replaced by a hamburger button — a single @media query at 768px reverses this (Chapter 11). The backdrop-filter: blur() creates the frosted-glass effect as page content scrolls beneath the bar.

🎨 style.css — Step 4: Navigation
/* ── Sticky header shell ─────────────────────────── */ .site-header { position: sticky; top: 0; z-index: 100; /* sits above all page content */ background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(12px); /* frosted-glass effect */ -webkit-backdrop-filter: blur(12px); /* Safari prefix */ border-bottom: 1px solid var(--clr-border); transition: box-shadow 0.2s; } /* Added by JavaScript once the user scrolls — deeper shadow */ .site-header.scrolled { box-shadow: 0 2px 16px rgba(0, 0, 0, 0.08); } /* ── Nav row — Flexbox ───────────────────────────── */ .nav { display: flex; align-items: center; justify-content: space-between; gap: 2rem; height: 68px; /* fixed height — consistent feel */ } /* ── Logo ────────────────────────────────────────── */ .nav-logo { font-weight: 800; font-size: 1.3rem; color: var(--clr-dark); letter-spacing: -0.02em; } .nav-logo span { color: var(--clr-accent); } /* blue dot */ /* ── Nav links — hidden on mobile ────────────────── */ .nav-links { display: none; /* hidden until 768px breakpoint */ align-items:center; gap: 1.75rem; } .nav-links a { font-size: 0.9rem; font-weight:500; color: var(--clr-muted); transition:color 0.2s; } .nav-links a:hover { color: var(--clr-text); } /* ── Hamburger — shown on mobile only ────────────── */ .hamburger { display: flex; align-items: center; justify-content:center; background: none; border: 1px solid var(--clr-border); border-radius:6px; padding: 0.4em 0.6em; font-size: 1.1rem; color: var(--clr-text); transition: border-color 0.2s; } .hamburger:hover { border-color: var(--clr-accent); } /* ── Responsive: tablet+ shows links, hides hamburger */ @media (min-width: 768px) { .nav-links { display: flex; } .hamburger { display: none; } }
The .scrolled class is applied by a 3-line JavaScript snippet in Part C. Without it the nav looks fine — the border-bottom alone separates it from the page. The JS shadow is a polish detail, not a requirement for the layout.
Live nav preview — hover the links and the CTA button
↕ On your real page, this bar sticks to the top as you scroll — the backdrop-filter blurs whatever scrolls beneath it
💡 Why z-index: 100? The sticky header needs to sit above all other page content. The floating hero cards use CSS animations that can paint above sibling elements. z-index: 100 guarantees the nav always wins without needing to guess what other stacking contexts exist on the page.
5
Hero Section → style.css · Ch.9 Grid · Ch.10 @keyframes · Ch.11 clamp()

The hero is the most complex section — it combines several techniques at once. The outer .hero has a subtle diagonal gradient background and generous clamp() padding that scales from comfortable on mobile to dramatic on desktop. Inside, .hero-inner is a CSS Grid that stacks on mobile and splits into two columns (text left, cards right) at 900px. The three floating cards use @keyframes animations with different durations and alternate direction to create a natural, unsynchronised floating effect.

🎨 style.css — Step 5: Hero section
/* ── Section shell ───────────────────────────────── */ .hero { padding: clamp(5rem, 12vw, 10rem) 0 clamp(4rem, 8vw, 7rem); background: linear-gradient(160deg, #eef3ff 0%, #ffffff 55%); overflow: hidden; /* card animations can't escape the section */ } /* ── Two-column grid ─────────────────────────────── */ .hero-inner { display: grid; gap: 3rem; align-items: center; } @media (min-width: 900px) { .hero-inner { grid-template-columns: 1.15fr 1fr; } } /* ── Eyebrow pill ────────────────────────────────── */ .hero-eyebrow { display: inline-flex; align-items: center; gap: 0.5em; font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--clr-accent); background: rgba(79, 142, 247, 0.1); padding: 0.35em 0.9em; border-radius: 999px; margin-bottom: 1.25rem; } .hero-eyebrow::before { /* small blue dot */ content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--clr-accent); } /* ── Heading ─────────────────────────────────────── */ .hero-text h1 { font-size: clamp(2.5rem, 5.5vw, 4.25rem); font-weight: 800; line-height: 1.08; color: var(--clr-dark); letter-spacing:-0.03em; margin-bottom: 1.25rem; } .hero-text h1 em { font-style: normal; color: var(--clr-accent); /* blue highlight word */ } /* ── Subheading ──────────────────────────────────── */ .hero-text > p { font-size: clamp(1rem, 2vw, 1.2rem); color: var(--clr-muted); max-width: 48ch; line-height: 1.7; margin-bottom:2rem; } /* ── CTA button row ──────────────────────────────── */ .hero-actions { display: flex; gap: 1rem; flex-wrap: wrap; /* buttons wrap on very narrow screens */ } /* ── Floating cards grid ─────────────────────────── */ .hero-visual { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; padding: 1rem; } .h-card { background: #fff; border: 1px solid var(--clr-border); border-radius:12px; padding: 1.25rem 1.5rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); transition: transform 0.35s ease, box-shadow 0.35s ease; } .h-card:hover { transform: translateY(-5px); box-shadow: 0 16px 32px rgba(0, 0, 0, 0.1); } /* Card A spans full width (both columns) */ .h-card--a { grid-column: 1 / -1; animation: floatUp 5s ease-in-out infinite alternate; } .h-card--b { animation: floatDown 6s ease-in-out infinite alternate; } .h-card--c { animation: floatUp 4.5s 0.5s ease-in-out infinite alternate; } .h-card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; } .h-card-title { font-weight: 700; font-size: 0.95rem; color: var(--clr-dark); margin-bottom: 0.2rem; } .h-card-sub { font-size: 0.78rem; color: var(--clr-muted); } /* ── Float keyframes ─────────────────────────────── */ @keyframes floatUp { from { transform: translateY(0); } to { transform: translateY(-10px); } } @keyframes floatDown { from { transform: translateY(0); } to { transform: translateY(8px); } }
Live hero preview — cards are animated; hover them for the lift effect
Creative Studio

We design brands that stand out

Award-winning design and development for companies that want to be remembered.

Brand Strategy
Identity · Positioning · Voice
UI / UX Design
Web · Mobile
Development
React · Next.js

How the float animation works

Three cards, three separate animations — different durations (5s, 6s, 4.5s) and different offsets (card C has a 0.5s delay) — mean they never move in perfect sync. alternate makes the animation reverse direction at the end of each cycle instead of jumping back to the start, creating a smooth infinite loop. ease-in-out makes each float feel organic rather than mechanical.

Animation timing — each card on its own cycle
.h-card--a
5s · floatUp · no delay
.h-card--b
6s · floatDown · no delay
.h-card--c
4.5s · floatUp · 0.5s delay
6
Clients Strip → style.css · Ch.8 Flexbox

A simple trust signal between the hero and services. One Flexbox row with the "Trusted by" label on the left and logo names on the right. The logo names are rendered in the border colour (--clr-border, a light grey) rather than actual images — a common placeholder technique during build. They lighten to muted grey on hover, suggesting interactivity without being distracting.

🎨 style.css — Step 6: Clients strip
.clients { padding: 2.5rem 0; border-top: 1px solid var(--clr-border); border-bottom: 1px solid var(--clr-border); } .clients-inner { display: flex; align-items: center; gap: 2.5rem; flex-wrap: wrap; } .clients-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--clr-muted); white-space: nowrap; } .clients-logos { display: flex; gap: 2rem; flex-wrap: wrap; align-items: center; } .client-logo { font-weight: 800; font-size: 1rem; color: var(--clr-border); /* very light — placeholder look */ letter-spacing: -0.02em; transition: color 0.2s; } .client-logo:hover { color: var(--clr-muted); }
Replace the <span> elements with <img> tags (SVG logos, filter: grayscale(1), opacity: 0.4) when you have real client logos. The CSS structure stays identical — you're just swapping text for images.
Live clients strip
Trusted by
7
Services Grid → style.css · Ch.9 Grid auto-fit · Ch.10 transition + ::before

The services section uses repeat(auto-fit, minmax(260px, 1fr)) — the same responsive grid pattern from Chapter 9. No media queries needed: the browser works out how many columns fit, moving from 1 to 2 to 3 columns automatically as the viewport grows. Each card has a hidden ::before pseudo-element — a 3px blue line at the top — that slides in from the left on hover using transform: scaleX(). This is the same performance-friendly animation technique from Chapter 10.

🎨 style.css — Step 7: Services grid
.services { padding: clamp(4rem, 8vw, 7rem) 0; background: var(--clr-bg-alt); /* slight off-white — visual break */ } .services-header { margin-bottom: 3rem; } /* ── Responsive auto-fit grid ────────────────────── */ .services-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1.5rem; } /* ── Card — with hidden top-border pseudo-element ─── */ .s-card { background: #fff; border: 1px solid var(--clr-border); border-radius:var(--radius); padding: 2rem; position: relative; /* ::before is positioned relative to this */ overflow: hidden; /* clips ::before when scaleX(0) */ transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; } /* Top-edge reveal bar — hidden by default */ .s-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: var(--clr-accent); transform: scaleX(0); /* invisible: scaled to zero width */ transform-origin: left; /* grows from the left edge */ transition: transform 0.3s ease; } .s-card:hover { transform: translateY(-5px); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08); border-color: rgba(79, 142, 247, 0.3); } .s-card:hover::before { transform: scaleX(1); /* slides in on hover */ } .s-icon { width: 48px; height: 48px; border-radius: 10px; background: rgba(79, 142, 247, 0.1); display: flex; align-items: center; justify-content:center; font-size: 1.4rem; margin-bottom: 1.25rem; } .s-card h3 { font-size: 1.05rem; font-weight: 700; color: var(--clr-dark); margin-bottom:0.6rem; } .s-card p { color: var(--clr-muted); font-size: 0.9rem; line-height: 1.65; }
Live service cards — hover each card to see the lift + top-bar animation
🎯

Brand Strategy

From positioning to visual identity. We help you own your place in the market.

🎨

UI / UX Design

Intuitive interfaces that guide users to the actions that matter most.

Web Development

Fast, accessible builds in React, Next.js or your stack of choice.

💡 Why overflow: hidden on the card? The ::before pseudo-element starts at scaleX(0) — technically zero width, but it still occupies space at its full width at the top of the card. Without overflow: hidden, this invisible element could extend outside the card's border-radius corners. Clipping it inside the card keeps the animation clean.
8
Stats Bar → style.css · Ch.4 Backgrounds · Ch.9 Grid · Ch.11 clamp()

The stats bar provides a strong visual contrast between the light services section above and the light about section below. Its dark navy background (--clr-navy) paired with the blue accent number creates a focused, high-impact moment. The grid uses the same auto-fit / minmax() pattern as the services — four stats on a wide screen, two-by-two on tablet, stacked on mobile with no media queries required.

🎨 style.css — Step 8: Stats bar
.stats { background: var(--clr-navy); padding: clamp(3rem, 6vw, 5rem) 0; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 2rem; text-align: center; } .stat-num { display: block; font-size: clamp(2.25rem, 5vw, 3.5rem); font-weight: 800; color: var(--clr-accent); line-height: 1; margin-bottom:0.4rem; } .stat-label { font-size: 0.82rem; color: rgba(255, 255, 255, 0.5); text-transform: uppercase; letter-spacing: 0.07em; }
Live stats bar
140+ Projects delivered
98% Client satisfaction
12 Industry awards
8yr In business
Accessibility note: The stats bar uses dark background + blue text. Check that --clr-accent on --clr-navy meets WCAG AA contrast (4.5:1 for normal text). The values in this project pass — blue #4f8ef7 on navy #111827 gives a ratio of ~5.2:1. If you change the accent colour, verify contrast with a tool like WebAIM Contrast Checker.
9
About Section — two-column grid skeleton

What we're building

The About section is a classic image + text layout. On desktop it sits side by side; on mobile it stacks. This substep wires up the outer section and the two-column grid — no image styling or badges yet, just the grid scaffold.

Here's the HTML we're targeting (already written in Part A):

🏷 HTML — about section structure
<section class="about" id="about"> <div class="container about-inner"> <!-- LEFT: image block (styled in C2 + C3) --> <div class="about-image-wrap reveal"> <div class="about-img">🎨</div> <div class="about-badge"> <span class="badge-num">8+</span> Years of craft </div> </div> <!-- RIGHT: text block --> <div class="about-text reveal"> <p class="section-label">Our story</p> <h2 class="section-title">Crafted with intention</h2> <p>We've spent eight years turning complex briefs...</p> <ul class="about-checklist">...</ul> <a class="btn btn-primary">Meet the team</a> </div> </div> </section>

Two direct children inside .about-inner: the image wrap (left) and the text block (right). Grid will place them automatically.

The CSS — outer section

First we style the .about section itself — background colour and generous vertical padding. We use clamp() so spacing breathes on all screen sizes:

🎨 style.css — .about outer section
/* ── Step 9 · About ──────────────────────────────── */ .about { padding: clamp(4rem, 10vw, 7rem) 0; background: var(--clr-bg); /* white — contrasts with the dark stats bar above */ }
Why white here? The Stats bar above uses --clr-dark (#0d1117). A white About section creates a strong contrast break that signals a new page "chapter" to the reader.

The CSS — two-column grid

.about-inner gets a two-column grid. Both columns are equal width (1fr 1fr) and vertically centered with align-items: center so the text doesn't float to the top when the image is taller.

🎨 style.css — .about-inner grid
.about-inner { display: grid; grid-template-columns: 1fr 1fr; /* image | text, equal halves */ gap: clamp(2.5rem, 6vw, 5rem); align-items: center; } /* Mobile: stack columns */ @media (max-width: 899px) { .about-inner { grid-template-columns: 1fr; /* image above, text below */ } }

The gap uses clamp() — wider on large screens where the columns have more room to breathe, narrower on tablet.

Why align-items: center?

Without it, grid children stretch to the tallest cell height by default. If the text column is taller than the image, the image would pin to the top — misaligned. center keeps both columns vertically centered regardless of which side is taller.

Tip — gap vs margin: Using gap on the grid container is cleaner than adding margin-right to one column. Gap disappears automatically when the grid collapses to a single column — no media query needed to clear the gap.

Live demo — grid structure

Toggle the grid outlines on to see exactly how the two columns sit. Toggle "Mobile" to watch the single-column stack.

🎨

Our story

Crafted with intention

We've spent eight years turning complex briefs into clear, purposeful design — for brands that mean business.

Meet the team

What this gives us

  • Two equal columns on desktop — image left, text right
  • Fluid gap that scales with viewport width via clamp()
  • Vertical centering so neither column floats to the top
  • A single-column stack on screens narrower than 900 px
Next up — C2: We'll style the .about-image-wrap and .about-img elements. The image wrap becomes position: relative — the foundation the absolute-positioned badge will attach to in C3.
About — Image Wrap position: relative + aspect-ratio

The key concept: position: relative as a containing block

The most important line in this substep is one you won't see change anything visually — yet. Setting position: relative on the image wrap creates a containing block for the badge that's coming in C3.

When you set position: absolute on a child element, the browser walks up the DOM tree looking for the nearest ancestor that has a position other than static. That ancestor becomes the child's reference frame for top, right, bottom, and left. If there's no positioned ancestor, the badge anchors to the viewport — almost certainly not what we want.

.about-image-wrap — position: relative ← containing block
.about-badge — position: absolute (C3)
8+ Years of craft

In C3 we'll set bottom: -1rem; right: -1rem on this badge — those values will be relative to the image wrap, not the page.

The CSS

🎨 style.css — .about-image-wrap
/* ── Step 9 · About image wrap ───────────────────── */ .about-image-wrap { position: relative; /* containing block for the badge */ border-radius: var(--radius); }

No overflow: hidden here — we need the badge to be able to peek outside the wrap boundary in C3.

🎨 style.css — .about-img
.about-img { aspect-ratio: 4 / 3; /* locks the proportions */ border-radius: var(--radius); background: var(--clr-bg-alt); /* light grey when no real photo */ overflow: hidden; /* clips a real <img> to the border-radius */ display: flex; align-items: center; justify-content: center; font-size: clamp(3rem, 8vw, 5rem); /* scales the emoji placeholder */ transition: transform 0.4s ease; /* subtle zoom on hover */ } .about-img:hover { transform: scale(1.03); /* gentle zoom — image stays clipped */ }

overflow: hidden on .about-img (not the wrap) clips the zoom effect and any real photo to the rounded corners.

Why aspect-ratio: 4 / 3?

Without a fixed height or aspect ratio, a grid cell with no content height would collapse. aspect-ratio tells the browser: "make the height 75% of the width, always." This works for both the emoji placeholder and a real <img> tag — you swap the background for an image later without touching the layout.

✗ Without aspect-ratio
.about-img { background: #f0f0f0; /* no height set → collapses to 0 */ }
✓ With aspect-ratio
.about-img { aspect-ratio: 4 / 3; background: #f0f0f0; /* height derived from width ✓ */ }
Real photo swap: In production you'd replace the emoji div with <img src="team.jpg" alt="..."> and add object-fit: cover; width: 100%; height: 100%; to the img. The aspect-ratio and overflow: hidden on the parent handle the rest.

Live demo

Hover the image to see the scale transition. Toggle the annotation to label the key CSS properties.

🎨

Crafted with intention

Eight years turning complex briefs into clear, purposeful design — for brands that mean business.

Meet the team

What overflow: hidden does — and where to put it

This is a subtle but important detail that trips up beginners:

Common mistake: Adding overflow: hidden to the wrap to "fix" the border-radius — then wondering why the absolutely-positioned badge disappears. Clip on the image, not the container.

What we have so far

Next — C3: We add the floating .about-badge (position: absolute anchored to the wrap) and the .about-checklist with ::before checkmark pseudo-elements.

Part 1 — The floating badge

The badge is a small dark card that hovers at the bottom-right corner of the image, partially overlapping it. It uses position: absolute anchored to the position: relative wrap we set in C2.

How the coordinates work

With bottom: -1.1rem and right: -1.1rem, the badge starts below and to the right of the wrap's boundary — the negative values pull it outside the box. This creates the overlapping "sticker" effect.

.about-image-wrap (position: relative)
8+ Years of craft
↑ bottom: -1.1rem ← right: -1.1rem
🎨 style.css — .about-badge
/* ── About badge — anchored to image wrap ─────── */ .about-badge { position: absolute; bottom: -1.1rem; /* pull below the image wrap */ right: -1.1rem; /* pull right of the image wrap */ background: var(--clr-dark); /* #0d1117 — dark card */ color: var(--clr-bg); border-radius: var(--radius); padding: 0.75rem 1.1rem; display: flex; align-items: center; gap: 0.5rem; font-weight: 600; font-size: 0.9rem; white-space: nowrap; /* never wrap to two lines */ box-shadow: 0 8px 28px rgba(0,0,0,0.2); transition: transform 0.3s ease, box-shadow 0.3s ease; } .about-badge:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(0,0,0,0.28); } .badge-num { font-size: 1.5rem; font-weight: 800; color: var(--clr-accent); /* accent blue for the number */ line-height: 1; }
Remember: the badge only anchors to the image wrap because .about-image-wrap has position: relative. Without that (from C2), the badge would anchor to the nearest positioned ancestor further up the tree — likely the whole page — and appear in the wrong place entirely.

Part 2 — The checklist with ::before

The checklist uses list-style: none to remove the default bullet, then adds a custom circular checkmark via the ::before pseudo-element on each li.

🎨 style.css — .about-checklist
.about-checklist { list-style: none; /* remove default bullet */ padding: 0; margin: 1rem 0; display: flex; flex-direction: column; gap: 0.6rem; } .about-checklist li { display: flex; align-items: center; gap: 0.75rem; font-size: 0.95rem; color: var(--clr-text); } .about-checklist li::before { content: '✓'; /* the checkmark character */ display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: rgba(79,142,247,0.12); /* tinted blue circle */ color: var(--clr-accent); font-size: 0.72rem; font-weight: 900; flex-shrink: 0; /* never squish the circle */ transition: background 0.2s, color 0.2s, transform 0.2s; } .about-checklist li:hover::before { background: var(--clr-accent); /* solid on hover */ color: #fff; transform: scale(1.15); }

flex-shrink: 0 on the ::before stops the circle collapsing if the text beside it is long.

Why display: inline-flex on a pseudo-element? ::before is inline by default, so width and height have no effect. Switching it to inline-flex (or flex) makes it a proper box you can size and centre content inside.

Live demo — complete About section

The full section with grid, image, badge, text, and checklist all together. Hover the badge and checklist items to see transitions.

🎨
8+ Years of craft

Crafted with intention

Eight years turning complex briefs into clear, purposeful design — for brands that mean business.

  • Strategy-first approach to every project
  • Pixel-perfect execution, on time
  • Transparent process, zero surprises
Meet the team

Step 9 — complete

Next — C4: Moving on to the Testimonials section — an auto-fit grid of cards, each with a star rating, blockquote, and avatar.

HTML structure

Three testimonial cards sit inside a responsive grid. The section also has a centred header — label, heading, and subheading — before the grid.

🏷 HTML — testimonials section
<section class="testimonials"> <div class="container"> <!-- Centred header --> <div class="testi-header reveal"> <p class="section-label">Kind words</p> <h2 class="section-title">Clients who became partners</h2> <p class="section-sub">Don't take our word for it.</p> </div> <!-- Card grid --> <div class="testi-grid"> <div class="t-card reveal"> <div class="t-stars">★★★★★</div> <blockquote>Lumen took our scrappy idea...</blockquote> <div class="t-author"> <div class="t-avatar">SM</div> <div class="t-author-info"> <strong>Sara M.</strong> <span>Founder, Bloom</span> </div> </div> </div> <!-- × 2 more .t-card --> </div> </div> </section>

Each .t-card has four children: stars, blockquote, and author (avatar + info). The card details are styled in C5.

CSS — section and header

🎨 style.css — .testimonials + .testi-header
/* ── Step 10 · Testimonials ──────────────────────── */ .testimonials { padding: clamp(4rem, 10vw, 7rem) 0; background: var(--clr-bg-alt); /* soft grey — contrast from white About */ } .testi-header { text-align: center; max-width: 600px; margin: 0 auto clamp(2.5rem, 6vw, 4rem); }

max-width + margin: 0 auto keeps the header from stretching too wide on large screens — the same centred-container pattern from Chapter 11.

CSS — the auto-fit grid

This is the same auto-fit + minmax pattern used for the Services grid in Part B — it creates a responsive layout with no media queries at all.

🎨 style.css — .testi-grid
.testi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; }

With 3 cards and a 280 px minimum: three columns on wide screens, two on tablet, one on mobile — all automatic.

How auto-fit + minmax works — visualised

Grid behaviour at different container widths

Wide (≥ 900 px)

Card 1
Card 2
Card 3

Tablet (≥ 580 px)

Card 1
Card 2
Card 3

Mobile (< 580 px)

Card 1
Card 2
Card 3
auto-fit (what we use)
auto-fit /* Collapses empty tracks — cards stretch to fill the full row width */
auto-fill (alternative)
auto-fill /* Keeps empty tracks — cards don't stretch, leaving gaps on the right */
Rule of thumb: use auto-fit when you want cards to grow and fill the row. Use auto-fill when you want items to stay at their minimum size and leave trailing space — useful for horizontally scrolling carousels.

CSS — base card styles

🎨 style.css — .t-card
.t-card { background: var(--clr-bg); /* white card on grey section */ border-radius: var(--radius); padding: clamp(1.5rem, 3vw, 2rem); border: 1px solid var(--clr-border); box-shadow: 0 2px 12px rgba(0,0,0,0.06); display: flex; flex-direction: column; gap: 1rem; transition: transform 0.3s ease, box-shadow 0.3s ease; } .t-card:hover { transform: translateY(-5px); box-shadow: 0 14px 36px rgba(0,0,0,0.11); }

display: flex; flex-direction: column stacks the four child elements (stars → quote → author) vertically with consistent spacing via gap.

Why a border AND a box-shadow? The subtle 1 px border defines the card edge cleanly on white backgrounds where a shadow alone can look blurry. The shadow adds depth. Together they give a polished, lifted look.

Live demo — grid + card base

Cards have placeholder blocks where the quote, stars, and avatar will go in C5. Hover a card to see the lift. Toggle the card outline to inspect the flex column layout.

Clients who became partners

Don't take our word for it.

What we have so far for Step 10

Next — C5: We fill in the card details — the ::before decorative opening quote on the blockquote, star rating colour, and the avatar initials circle.

Card anatomy

Each .t-card has four styled children. The flex-direction: column + gap from C4 spaces them automatically — we just need to style each piece.

.t-stars Five star characters coloured amber — simple, no SVG needed.
blockquote + ::before The quote text sits below a large decorative " generated by ::before, positioned absolutely at the top-left of the blockquote.
.t-author Flex row holding the avatar and info. margin-top: auto pins it to the bottom of the card so all cards align their author rows — even when quote lengths differ.
.t-avatar A coloured circle with initials — no image required. Each card gets a different accent colour via :nth-child.

CSS — stars

🎨 style.css — .t-stars
.t-stars { color: #fbbf24; /* amber — familiar rating colour */ font-size: 1rem; letter-spacing: 2px; /* breathe the stars apart */ line-height: 1; }

CSS — blockquote + decorative opening quote

The large " is injected by ::before and positioned absolutely at the top-left of the blockquote. You already know this pattern from C3 — a position: relative parent and a position: absolute child.

🎨 style.css — blockquote + ::before
.t-card blockquote { margin: 0; padding: 0.75rem 0 0; /* top gap — room for the quote mark */ position: relative; /* containing block for ::before */ font-size: 0.95rem; color: var(--clr-muted); line-height: 1.7; font-style: normal; /* browsers italicise blockquote by default */ } .t-card blockquote::before { content: '\201C'; /* Unicode left double quotation mark " */ position: absolute; top: -0.6rem; left: -0.2rem; font-size: 4rem; line-height: 1; font-family: Georgia, serif; /* serif gives a classic quote character */ color: var(--clr-accent); opacity: 0.15; /* subtle watermark — not in the way */ pointer-events: none; }

pointer-events: none on the pseudo-element means it never intercepts mouse events — the text behind it stays selectable.

Unicode escape \201C: In CSS content, Unicode characters are written as a backslash followed by the hex code point — no u or braces. \201C = " (left double quote), \201D = " (right). Alternatively you can write the character directly: content: '"'.

CSS — author row + avatar

🎨 style.css — .t-author + .t-avatar
.t-author { display: flex; align-items: center; gap: 0.75rem; margin-top: auto; /* push to bottom of flex column — equal-height alignment */ padding-top: 0.75rem; border-top: 1px solid var(--clr-border); } .t-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--clr-accent); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.8rem; flex-shrink: 0; letter-spacing: 0.03em; } /* Different accent colour per card */ .testi-grid .t-card:nth-child(2) .t-avatar { background: #7c5cbf; } .testi-grid .t-card:nth-child(3) .t-avatar { background: #0d9488; } .t-author-info { display: flex; flex-direction: column; gap: 2px; } .t-author-info strong { font-size: 0.88rem; color: var(--clr-text); } .t-author-info span { font-size: 0.78rem; color: var(--clr-muted); }
margin-top: auto — the equal-height trick. In a flex column, margin-top: auto on the last child absorbs all remaining space, pinning that element to the bottom. When your three cards have quotes of different lengths, the author row stays level across all of them — no JavaScript height-matching needed.

Live demo — complete testimonial cards

Hover cards for the lift. Toggle the annotations to highlight the ::before quote mark and avatar circles.

★★★★★
Lumen took our scrappy idea and turned it into a brand we're genuinely proud to show the world.
SM
Sara M. Founder, Bloom
★★★★★
Working with Lumen felt less like hiring an agency and more like gaining a creative partner who genuinely cared.
JK
James K. CTO, Forma Labs
★★★★★
On time, on budget, and genuinely beautiful. I've worked with a lot of studios — Lumen is in a different league.
AO
Aisha O. Head of Brand, Velo

Step 10 — complete

Next — C6: The CTA section — a dark gradient band with decorative ::before/::after circles and a single centred call-to-action.

Live demo

Hover the button to see the invert transition. Toggle the controls to reveal the hidden circles underneath the content.

Ready to start?

Let's build something worth remembering

Tell us about your project and we'll get back to you within one business day.

Start a project

Step 11 — complete

Next — C7: The footer — a 4-column auto-fit grid with brand column, three link columns, and a bottom bar.

CSS — the four-column grid

Another auto-fit grid — the same pattern we used for Services (Part B, Step 7) and Testimonials (C4). On a wide screen the brand column sits beside three link columns; on mobile all four stack.

🎨 style.css — .footer-grid
.footer-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: clamp(2rem, 5vw, 3.5rem); padding-bottom: clamp(3rem, 6vw, 4rem); border-bottom: 1px solid rgba(255,255,255,0.08); }

minmax(180px, 1fr) gives four columns on screens wider than ~800 px and gracefully collapses to fewer columns below that — no breakpoints written.

CSS — brand column

🎨 style.css — .footer-brand + .footer-logo + .footer-tagline
.footer-brand { display: flex; flex-direction: column; gap: 1rem; } .footer-logo { font-size: 1.4rem; font-weight: 800; color: #fff; letter-spacing: -0.03em; text-decoration: none; } .footer-logo span { color: var(--clr-accent); } .footer-tagline { font-size: 0.85rem; color: rgba(255,255,255,0.4); /* dimmer than body text */ line-height: 1.65; max-width: 22ch; /* wraps after ~22 characters */ }

CSS — link columns

🎨 style.css — .footer-col + .footer-col-heading
.footer-col { display: flex; flex-direction: column; gap: 0.6rem; } .footer-col-heading { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(255,255,255,0.45); margin-bottom: 0.25rem; } /* Base link — no hover yet (added in C8) */ .footer-col a { font-size: 0.88rem; color: rgba(255,255,255,0.55); text-decoration: none; display: block; /* full-width click target */ }

Links are display: block so the entire row is clickable — not just the text characters. Hover colour and transition come in C8.

Colour opacity for hierarchy: Using rgba(255,255,255, opacity) rather than hardcoded hex values lets us express a clear dimming hierarchy on the dark footer — logo at full white, tagline at 40%, headings at 45%, links at 55% — all relative to the background, automatically.

Live demo

Toggle the grid outlines to see how the four columns are placed. The footer-bottom bar is intentionally unstyled here — it gets its layout in C8.

Step 12 so far

Next — C8: Link hover transitions (colour + translateX nudge) and the .footer-bottom flex bar — copyright on the left, legal links on the right.

Part 1 — Link hover transitions

Footer links have a two-part hover effect: they brighten to full white and nudge 4 px to the right with translateX. The nudge gives a directional feel — a subtle visual cue that the link leads somewhere.

The golden rule: put transition on the base state

.footer-col a { transition: color 0.2s, transform 0.2s; } Transition defined here — plays in both directions
.footer-col a:hover { color: #fff; transform: translateX(4px); } Only the end values — no transition needed here
⚠ If you put transition only on :hover, the animation plays going IN but snaps back instantly going OUT — always define it on the base state.
🎨 style.css — .footer-col a hover transition
.footer-col a { font-size: 0.88rem; color: rgba(255,255,255,0.55); text-decoration: none; display: block; transition: color 0.2s ease, transform 0.2s ease; /* ← base state */ } .footer-col a:hover { color: #fff; /* full white */ transform: translateX(4px); /* rightward nudge */ }

Two properties transition independently — color and transform can have different durations or easings if you want (e.g. a faster colour change than the nudge).

Why transform instead of margin-left or padding-left? transform: translateX() moves the element visually without affecting layout — no reflow, no pushing neighbouring elements. It's also GPU-composited, making it buttery smooth. Animating margin or padding triggers layout recalculation on every frame.

Part 2 — The footer-bottom bar

A thin bar below the grid containing copyright text (left) and legal links (right). The classic justify-content: space-between flex pattern handles the split:

← copyright (left) legal links (right) →
© 2026 Lumen Studio
Privacy Terms
🎨 style.css — .footer-bottom
.footer-bottom { display: flex; align-items: center; justify-content: space-between; /* copyright left | legal right */ gap: 1rem; /* gap when they wrap on mobile */ flex-wrap: wrap; /* stack gracefully on narrow screens */ padding: 1.25rem 0; font-size: 0.8rem; color: rgba(255,255,255,0.28); /* dimmer than the link cols */ } .footer-legal { display: flex; gap: 1.5rem; } .footer-legal a { color: rgba(255,255,255,0.3); text-decoration: none; transition: color 0.2s; } .footer-legal a:hover { color: rgba(255,255,255,0.75); }

flex-wrap: wrap means on narrow screens copyright stacks above the legal links rather than overflowing — both items left-align naturally when they wrap.

Why no translateX nudge on the legal links? The footer-bottom bar is purely functional — it doesn't need the same navigational energy as the main link columns. A simple colour brightening is sufficient and keeps the bar feeling calm.

Live demo — complete footer

Hover any footer link to see the colour + nudge transition. Toggle the footer-bottom annotation to see the flex split.

Step 12 — complete

Next — C9: The JavaScript layer — IntersectionObserver for scroll-reveal animations on .reveal elements, plus the scroll-shadow effect on the sticky nav. Also the prefers-reduced-motion accessibility override.
All CSS sections are complete. This substep adds the two JavaScript helpers in main.js, the .reveal CSS classes, and the prefers-reduced-motion accessibility override.
13
JavaScript Helpers — scroll reveal + nav shadow

Part 1 — The .reveal CSS

Before writing any JavaScript, we define the two CSS states the observer will switch between. Elements start hidden and translated down; the observer adds .visible to animate them into place.

🎨 style.css — .reveal states
/* ── Scroll reveal ───────────────────────────────── */ .reveal { opacity: 0; transform: translateY(24px); /* starts 24 px below final position */ transition: opacity 0.6s ease, transform 0.6s ease; } .reveal.visible { opacity: 1; transform: translateY(0); } /* Accessibility: skip animation for users who prefer reduced motion */ @media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; /* immediately visible */ transform: none; transition: none; } } /* Nav shadow when scrolled */ .site-header.scrolled { box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
♿ prefers-reduced-motion: Some users enable this OS setting because motion causes discomfort, dizziness, or triggers vestibular disorders. The @media (prefers-reduced-motion: reduce) block makes all .reveal elements immediately visible — no fade, no slide. Always include this when adding scroll animations.

Part 2 — IntersectionObserver (the reveal)

The old approach to scroll animations was a scroll event listener that ran on every pixel of scroll and called getBoundingClientRect() — expensive and janky. IntersectionObserver is the modern replacement: it tells the browser "notify me when this element crosses the viewport threshold" and fires asynchronously on the main thread only when needed.

1
Create the observer
Pass a callback and options. threshold: 0.15 means "fire when 15% of the element is visible."
2
Observe every .reveal element
Loop through querySelectorAll('.reveal') and register each one.
3
Callback fires when element enters viewport
entry.isIntersecting is true — add the .visible class to trigger the CSS transition.
4
Stop watching (fire once)
Call unobserve(entry.target) — once revealed, the element stays revealed. No need to keep watching it.
⚡ main.js — IntersectionObserver scroll reveal
// ── Scroll reveal ────────────────────────────────── const revealObserver = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('visible'); revealObserver.unobserve(entry.target); // fire once only } }); }, { threshold: 0.15 } // trigger when 15% visible ); document.querySelectorAll('.reveal').forEach(el => { revealObserver.observe(el); });

The observer fires asynchronously — it doesn't block the main thread on every scroll event, unlike a window.addEventListener('scroll', …) approach.

Part 3 — Nav scroll shadow

The sticky nav gains a box-shadow once the user scrolls past 20 px. A scroll event listener toggles the .scrolled class; the CSS transition on box-shadow (already on .site-header) makes it fade in smoothly.

⚡ main.js — nav scroll shadow
// ── Nav scroll shadow ────────────────────────────── const header = document.querySelector('.site-header'); window.addEventListener('scroll', () => { header.classList.toggle('scrolled', window.scrollY > 20); }, { passive: true }); // passive: true → browser can scroll without waiting

classList.toggle(className, condition) is a one-liner: adds the class when condition is true, removes it when false. No if/else needed.

passive: true tells the browser this listener will never call preventDefault(), so it doesn't need to wait for the listener to finish before scrolling. This eliminates the small lag that scroll listeners can introduce on mobile.

Live demo 1 — scroll reveal

Cards start hidden. Scroll down inside the box to watch them fade and rise into view. The status dots below track which cards have been revealed. Click Reset to run it again.

Card 1 Card 2 Card 3 Card 4

↓ scroll down to reveal cards

🎨

Brand Identity

Logos, colour systems, and visual language that last.

💻

Web Design

Sites that perform as beautifully as they look.

🎬

Motion

Animation that adds meaning, not just movement.

📐

Strategy

Decisions grounded in research, not guesswork.

Live demo 2 — nav scroll shadow

Scroll inside the box — the sticky nav gains a shadow as soon as you leave the top.

scroll below ↓
Section content — scroll up to see the nav shadow appear and disappear.
More content below…
Keep scrolling…
And back up — the shadow removes itself cleanly.
.scrolled class: inactive

Step 13 — complete

Next — C10 (final): The complete combined CSS in a toggleable reference block, followed by 5 "Go Further" challenges that extend the Lumen Studio page beyond the course.

Steps 9–10 — Testimonials (grid, cards, quote, avatar)

.testimonials { padding: clamp(4rem, 8vw, 6rem) 0; }
.testi-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1.5rem; margin-top: 3rem;
}
.t-card {
  background: var(--clr-bg); border-radius: var(--radius);
  padding: clamp(1.5rem, 3vw, 2rem); border: 1px solid var(--clr-border);
  box-shadow: 0 2px 12px rgba(0,0,0,.06);
  display: flex; flex-direction: column; gap: 1rem;
  transition: transform .3s ease, box-shadow .3s ease;
}
.t-card:hover { transform: translateY(-5px); box-shadow: 0 14px 36px rgba(0,0,0,.11); }
.t-stars  { color: #f59e0b; font-size: .9rem; letter-spacing: .1em; }
.t-card blockquote       { position: relative; padding-top: .75rem; font-style: normal; font-size: .93rem; color: var(--clr-text); }
.t-card blockquote::before {
  content: '\201C'; position: absolute; top: -.6rem; left: -.2rem;
  font-size: 4rem; line-height: 1; font-family: Georgia, serif;
  color: var(--clr-accent); opacity: .15; pointer-events: none;
}
.t-author  { display: flex; align-items: center; gap: .75rem; margin-top: auto; }
.t-avatar  { width: 40px; height: 40px; border-radius: 50%; background: var(--clr-accent); color: #fff; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 700; flex-shrink: 0; }
.t-card:nth-child(2) .t-avatar { background: #7c5cbf; }
.t-card:nth-child(3) .t-avatar { background: #0d9488; }
.t-name   { font-weight: 600; font-size: .88rem; }
.t-role   { font-size: .78rem; color: var(--clr-muted); }

Step 11 — CTA section (gradient + glow circles)

.cta-section {
  background: linear-gradient(135deg, var(--clr-dark) 0%, var(--clr-navy) 60%, #1a1f35 100%);
  padding: clamp(4rem, 8vw, 6rem) 0; text-align: center;
  position: relative; overflow: hidden;
}
.cta-section::before, .cta-section::after {
  content: ''; position: absolute; border-radius: 50%;
  background: var(--clr-accent); pointer-events: none;
}
.cta-section::before { width: 500px; height: 500px; top: -200px; left: -150px; opacity: .1; filter: blur(80px); }
.cta-section::after  { width: 400px; height: 400px; bottom: -180px; right: -100px; opacity: .08; filter: blur(70px); }
.cta-inner { position: relative; z-index: 1; color: #fff; max-width: 600px; margin-inline: auto; }
.cta-inner h2 { color: #fff; }
.cta-inner p  { color: rgba(255,255,255,.65); margin: 1rem 0 2rem; }
.cta-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }

Steps 12–13 — Footer (grid, transitions, footer-bottom)

.site-footer { background: var(--clr-dark); border-top: 1px solid rgba(255,255,255,.06); padding-top: clamp(3rem, 6vw, 5rem); }
.footer-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: clamp(2rem, 5vw, 3.5rem);
  padding-bottom: clamp(3rem, 6vw, 4rem);
  border-bottom: 1px solid rgba(255,255,255,.08);
}
.footer-logo    { font-size: 1.1rem; font-weight: 700; color: #fff; margin-bottom: .75rem; }
.footer-tagline { font-size: .83rem; color: rgba(255,255,255,.4); max-width: 22ch; }
.footer-col h4  { font-size: .75rem; letter-spacing: .1em; text-transform: uppercase; color: rgba(255,255,255,.35); margin-bottom: 1rem; }
.footer-col a   {
  color: rgba(255,255,255,.55); display: block; font-size: .88rem;
  margin-bottom: .5rem;
  transition: color .2s ease, transform .2s ease;
}
.footer-col a:hover { color: #fff; transform: translateX(4px); }
.footer-bottom {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; flex-wrap: wrap; padding: 1.25rem 0;
  color: rgba(255,255,255,.28); font-size: .82rem;
}
.footer-legal    { display: flex; gap: 1.5rem; }
.footer-legal a  { color: rgba(255,255,255,.28); transition: color .2s; }
.footer-legal a:hover { color: rgba(255,255,255,.75); }

JavaScript — Scroll reveal + nav shadow

/* CSS — add to stylesheet */
.reveal { opacity: 0; transform: translateY(24px); transition: opacity .6s ease, transform .6s ease; }
.reveal.visible { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
}

// JS — add before </body>
const header = document.querySelector('.site-header');
window.addEventListener('scroll', () => {
  header.classList.toggle('scrolled', window.scrollY > 20);
}, { passive: true });

const revealObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      revealObserver.unobserve(entry.target);
    }
  });
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el));

Go Further — 5 Challenges

The Lumen Studio page is complete. These challenges extend it with real-world features — each one teaches something new beyond the 13 steps you've already done.

1
Dark mode toggle
CSS custom properties data-theme attribute JS toggle

Add a ☀/☾ button to the nav that switches the entire page between light and dark mode by toggling a data-theme="dark" attribute on <html>.

Override the :root custom properties inside [data-theme="dark"] — every component updates automatically because they all read from var(--clr-bg) etc.

CSS pattern:

[data-theme="dark"] {
  --clr-bg:     #0d1117;
  --clr-bg-alt: #161b22;
  --clr-text:   #e6edf3;
  --clr-border: #30363d;
  --clr-muted:  #8b949e;
}

JS pattern:

document.querySelector('#theme-btn').addEventListener('click', () => {
  const html = document.documentElement;
  html.dataset.theme = html.dataset.theme === 'dark' ? '' : 'dark';
});
/* ── Dark mode overrides ── */
[data-theme="dark"] {
  --clr-bg:       #0d1117;
  --clr-bg-alt:   #161b22;
  --clr-text:     #e6edf3;
  --clr-border:   #30363d;
  --clr-muted:    #8b949e;
}
.theme-btn {
  background: none; border: 1px solid var(--clr-border);
  border-radius: var(--radius); padding: .4rem .7rem; cursor: pointer;
  color: var(--clr-text); font-size: .9rem;
  transition: background .2s;
}
.theme-btn:hover { background: var(--clr-bg-alt); }

// JS
const themeBtn = document.querySelector('.theme-btn');
themeBtn.addEventListener('click', () => {
  const html = document.documentElement;
  const isDark = html.dataset.theme === 'dark';
  html.dataset.theme = isDark ? '' : 'dark';
  themeBtn.textContent = isDark ? '☾' : '☀';
  localStorage.setItem('theme', html.dataset.theme);
});
// Persist across page loads:
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.dataset.theme = saved;
2
Hamburger menu for mobile
flexbox JS classList toggle responsive design

Currently .nav-links is hidden on narrow screens with display: none. Add a hamburger button (≡) that appears below 700 px and toggles a dropdown nav.

Add a .nav-open class to .site-header when the burger is clicked. Then show the nav links in a column when that class is present:

@media (max-width: 699px) {
  .site-header.nav-open .nav-links {
    display: flex; flex-direction: column;
    padding: 1rem 0; gap: .75rem;
  }
}
/* ─ HTML: add button inside .nav-inner ─ */
<button class="burger" aria-label="Toggle menu">☰</button>

/* ─ CSS ─ */
.burger { display: none; background: none; border: none; font-size: 1.4rem; cursor: pointer; color: var(--clr-text); }
@media (max-width: 699px) {
  .burger { display: block; }
  .site-header.nav-open .nav-links {
    display: flex; flex-direction: column;
    position: absolute; top: 100%; left: 0; right: 0;
    background: rgba(255,255,255,.97);
    border-bottom: 1px solid var(--clr-border);
    padding: 1rem clamp(1rem, 4vw, 2rem); gap: .75rem;
  }
}

// JS
document.querySelector('.burger').addEventListener('click', () => {
  document.querySelector('.site-header').classList.toggle('nav-open');
});
3
Coloured service-card top-bar on hover
::before pseudo-element CSS variables nth-child

The .s-card::before accent bar is currently always var(--clr-accent) blue. Make each card reveal a different colour on hover: card 1 → blue, card 2 → purple, card 3 → green, card 4 → orange.

Add a --bar-color custom property to each card variant. The ::before reads var(--bar-color, var(--clr-accent)) so it falls back to the default if the variable isn't set.

.s-card::before {
  /* change this line: */
  background: var(--bar-color, var(--clr-accent));
}

/* Per-card colours */
.services-grid .s-card:nth-child(2) { --bar-color: #7c5cbf; }
.services-grid .s-card:nth-child(3) { --bar-color: #0d9488; }
.services-grid .s-card:nth-child(4) { --bar-color: #f59e0b; }

/* Optional: matching hover shadow tint */
.services-grid .s-card:nth-child(2):hover { box-shadow: 0 12px 32px rgba(124,92,191,.15); }
.services-grid .s-card:nth-child(3):hover { box-shadow: 0 12px 32px rgba(13,148,136,.15); }
.services-grid .s-card:nth-child(4):hover { box-shadow: 0 12px 32px rgba(245,158,11,.15); }
4
Portfolio / work gallery section
CSS Grid aspect-ratio overlay on hover new section

Add a "Our Work" section between Services and About. Display 6 project thumbnails in a responsive grid. On hover each card should darken and reveal a project title overlay using a semi-transparent ::before.

Use a position: relative card with overflow: hidden. The image fills aspect-ratio: 4/3 and scales on hover. The ::before starts at opacity: 0 and fades in on hover using a dark gradient background.

.work-card::before {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
  opacity: 0; transition: opacity .3s;
}
.work-card:hover::before { opacity: 1; }
/* ─ HTML ─ */
<section class="portfolio">
  <div class="container">
    <p class="section-label"><span class="section-tag">Our work</span></p>
    <h2>Projects we're proud of</h2>
    <div class="work-grid">
      <div class="work-card">
        <div class="work-img">🏠</div>
        <div class="work-overlay">
          <span>Brand Identity</span>
        </div>
      </div>
      <!-- repeat for each card -->
    </div>
  </div>
</section>

/* ─ CSS ─ */
.portfolio  { padding: clamp(4rem, 8vw, 6rem) 0; }
.work-grid  { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; margin-top: 2.5rem; }
.work-card  { position: relative; overflow: hidden; border-radius: var(--radius); cursor: pointer; }
.work-img   { aspect-ratio: 4/3; background: var(--clr-bg-alt); display: flex; align-items: center; justify-content: center; font-size: 3rem; transition: transform .4s ease; }
.work-card:hover .work-img { transform: scale(1.06); }
.work-overlay {
  position: absolute; inset: 0;
  background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
  display: flex; align-items: flex-end; padding: 1rem;
  opacity: 0; transition: opacity .3s;
}
.work-card:hover .work-overlay { opacity: 1; }
.work-overlay span { color: #fff; font-weight: 600; font-size: .9rem; }
5
Styled contact form
form styling :focus-visible :invalid pseudo-class input

Replace the two CTA buttons with a short contact form (name, email, message, submit). Style it to look clean on the dark CTA background, with clear :focus-visible rings and subtle red border on :invalid inputs.

Use :focus-visible instead of :focus so keyboard users see the ring but mouse users don't get a flicker on click. Use the :invalid pseudo-class only after the user has interacted with the field — combine with :not(:placeholder-shown) so the red border doesn't show on an untouched empty field.

.contact-input:not(:placeholder-shown):invalid {
  border-color: #f85149;
}
/* ─ HTML (inside .cta-inner) ─ */
<form class="contact-form" novalidate>
  <div class="form-row">
    <input class="contact-input" type="text"  placeholder="Your name"  required>
    <input class="contact-input" type="email" placeholder="Your email" required>
  </div>
  <textarea class="contact-input" rows="4" placeholder="Your message" required></textarea>
  <button type="submit" class="btn btn-white">Send message</button>
</form>

/* ─ CSS ─ */
.contact-form { display: flex; flex-direction: column; gap: .75rem; max-width: 540px; margin-inline: auto; text-align: left; }
.form-row     { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
@media (max-width: 499px) { .form-row { grid-template-columns: 1fr; } }
.contact-input {
  width: 100%; padding: .7rem 1rem; border-radius: var(--radius);
  border: 1px solid rgba(255,255,255,.15);
  background: rgba(255,255,255,.07); color: #fff;
  font: inherit; font-size: .9rem;
  transition: border-color .2s, background .2s;
  outline: none; resize: vertical;
}
.contact-input::placeholder                   { color: rgba(255,255,255,.4); }
.contact-input:focus-visible                   { border-color: var(--clr-accent); background: rgba(255,255,255,.1); box-shadow: 0 0 0 3px rgba(79,142,247,.25); }
.contact-input:not(:placeholder-shown):invalid  { border-color: #f85149; }
All 10 substeps complete!

Parts A, B, and all 10 C substeps are now ready to combine.
Send the prompt below to generate the full chapter lesson file:

CSS 12-1 All

That will read all 12 files and produce css_beginner_12_all.html — the complete Lumen Studio lesson.