Capstone Project
Chapter 12 — Capstone Project: Developer Portfolio
This chapter brings all twelve chapters together in a single build: a dark-themed developer portfolio page with a three-tier token system, cascade layers, fluid responsive layout, accessible interactive components, and scroll-driven animation. Every technique is annotated with the chapter it comes from. By the end you will have a production-quality stylesheet and a mental model for how all the CSS concepts connect to each other in real work.
What you're building. A personal portfolio with: sticky transparent
nav with scroll-driven opacity, hero section with fluid type and radial glow,
animated stat counters, filterable project grid using :has(), skills tag cloud,
contact form with CSS-only validation feedback, dark/light theme toggle using
data-theme, and a read-progress bar. No JavaScript for any of the CSS features
— only the theme toggle and stat counter need JS.
Build phases
Phase 1
Token System + Layer Order
Ch 9 (tokens) + Ch 11 (@layer) + Ch 10 (@property)
Phase 2
Reset + Base Typography
Ch 1 (cascade) + Ch 3 (type) + Ch 10 (logical)
Phase 3
Layout Primitives
Ch 5 (flex) + Ch 6 (grid) + Ch 7 (responsive) + Ch 11 (CUBE)
Phase 4
Components
Ch 4 (selectors) + Ch 5 (flex) + Ch 11 (BEM) + Ch 10 (nesting)
Phase 5
Motion + Interaction
Ch 8 (transitions/animations) + Ch 10 (scroll-driven)
Phase 6
Theming + Accessibility
Ch 3 (color) + Ch 7 (reduced-motion) + Ch 9 (light-dark)
Live Preview
// Available for freelance work
Alex Kovacs
Full-stack developer & CSS enthusiast
47
Projects
6
Years Exp
12
Clients
99%
Satisfaction
Featured Projects
// Python · FastAPI
MealPlanner API
REST API with auth, meal scheduling, and nutrition tracking.
// CSS · Animation
Token Studio
Design token management tool with live theme preview.
// Linux · Bash
Server Monitor
Self-hosted dashboard for Raspberry Pi fleet metrics.
Skills
CSSHTML
PythonJavaScript
FastAPILinux
BashMySQL
GitDocker
Phase 1 — Token System & Layer Order
/* ── main.css — entry point ──────────────────────────────────── */
/* Ch 11: Declare layer order before any imports */
@layer reset, tokens, base, layout, components, theme, utils;
@import 'tokens/primitives.css' layer(tokens);
@import 'tokens/semantic.css' layer(tokens);
@import 'base/reset.css' layer(reset);
@import 'base/typography.css' layer(base);
@import 'layout/primitives.css' layer(layout);
@import 'components/nav.css' layer(components);
@import 'components/hero.css' layer(components);
@import 'components/card.css' layer(components);
@import 'components/form.css' layer(components);
@import 'themes/dark.css' layer(theme);
@import 'utils.css' layer(utils);
/* ── tokens/primitives.css ────────────────────────────────────── */
/* Ch 9: Raw palette — never used directly in components */
:root {
/* Colour ramp in oklch for perceptual uniformity (Ch 3) */
--blue-50: oklch(96% 0.025 250);
--blue-300: oklch(75% 0.12 250);
--blue-500: oklch(60% 0.22 250);
--blue-700: oklch(45% 0.18 250);
--blue-900: oklch(20% 0.08 250);
--blue-950: oklch(12% 0.05 250);
--green-400: oklch(72% 0.18 150);
--amber-400: oklch(78% 0.16 80);
--red-400: oklch(68% 0.22 25);
/* Spacing — 4px base */
--space-1: 0.25rem; --space-2: 0.5rem;
--space-3: 0.75rem; --space-4: 1rem;
--space-6: 1.5rem; --space-8: 2rem;
--space-12: 3rem; --space-16: 4rem;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* Type scale using clamp() — Ch 7 + Ch 9 */
--text-sm: clamp(0.8rem, 1.5vw, 0.875rem);
--text-base: clamp(1rem, 2vw, 1.125rem);
--text-lg: clamp(1.125rem, 2.5vw, 1.375rem);
--text-xl: clamp(1.5rem, 3.5vw, 2.25rem);
--text-2xl: clamp(2rem, 5vw, 3.5rem);
}
/* ── tokens/semantic.css ──────────────────────────────────────── */
/* Light mode defaults (references primitives only) */
:root {
--color-bg: var(--blue-50);
--color-surface: #ffffff;
--color-border: oklch(85% 0.02 250);
--color-text: var(--blue-900);
--color-text-muted: var(--blue-700);
--color-accent: var(--blue-500);
--color-success: var(--green-400);
--color-error: var(--red-400);
}
Phase 2 — Reset & Base Typography
/* ── base/reset.css ───────────────────────────────────────────── */
/* Ch 2: universal box-sizing reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
color-scheme: light dark; /* enables light-dark() — Ch 9 */
scroll-behavior: smooth;
text-size-adjust: none;
}
body {
font-family: system-ui, -apple-system, sans-serif;
font-size: var(--text-base); /* fluid — no media query needed */
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
min-block-size: 100dvh; /* dvh — Ch 7 */
}
/* Ch 3: Images responsive by default */
img, video, svg {
max-inline-size: 100%; /* logical property — Ch 10 */
block-size: auto;
display: block;
}
/* Ch 7: Respect motion preferences — global kill-switch */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ── base/typography.css ──────────────────────────────────────── */
h1 { font-size: var(--text-2xl); line-height: 1.15; font-weight: 700; }
h2 { font-size: var(--text-xl); line-height: 1.2; font-weight: 700; }
h3 { font-size: var(--text-lg); line-height: 1.3; font-weight: 600; }
p { max-inline-size: 65ch; } /* Ch 3: prose line length */
a {
color: var(--color-accent);
text-underline-offset: 3px; /* Ch 3 */
transition: color 0.2s ease;
}
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: var(--radius-sm);
}
Phase 3 — Layout Primitives
/* ── layout/primitives.css — CUBE composition layer ──────────── */
/* Ch 11 (CUBE CSS) + Ch 5 (flex) + Ch 6 (grid) + Ch 10 (logical) */
/* Container — fluid centring without media query (Ch 9 min()) */
.container {
inline-size: min(100% - 2rem, 1200px);
margin-inline: auto;
}
/* Stack — vertical rhythm with custom gap */
.stack {
display: flex;
flex-direction: column;
gap: var(--stack-gap, var(--space-6));
}
/* Cluster — wrapping flex row */
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--cluster-gap, var(--space-4));
align-items: var(--cluster-align, center);
}
/* Auto grid — responsive without media queries (Ch 6 auto-fill) */
.auto-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--grid-min, 280px), 1fr));
gap: var(--grid-gap, var(--space-6));
}
/* Sidebar layout — Ch 5 + 6 RAM pattern */
.with-sidebar {
display: flex;
flex-wrap: wrap;
gap: var(--space-8);
> :first-child { flex-basis: var(--sidebar-w, 240px); }
> :last-child { flex: 1; min-inline-size: 60%; }
}
Phase 4 — Components
/* ── components/nav.css ───────────────────────────────────────── */
/* Ch 5 (flex), Ch 8 (transition), Ch 10 (nesting, scroll-driven) */
.nav {
position: sticky;
top: 0;
z-index: 100; /* Ch 2: stacking context */
display: flex;
align-items: center;
padding-block: var(--space-4); /* Ch 10: logical */
padding-inline: var(--space-6);
backdrop-filter: blur(12px);
background: color-mix(in oklch, var(--color-bg) 80%, transparent);
border-block-end: 1px solid var(--color-border);
/* Ch 8 + Ch 10: scroll-driven opacity — no JS */
animation: nav-fade linear both;
animation-timeline: scroll(root);
animation-range: 0px 120px;
/* Ch 10: nesting */
&__logo {
font-family: 'Courier New', monospace;
color: var(--color-accent);
font-weight: 700;
font-size: var(--text-lg);
}
&__links {
display: flex;
gap: var(--space-6);
margin-inline-start: auto; /* Ch 5: push to end */
list-style: none;
/* Ch 7: hide links on small screens */
@media (max-width: 640px) {
display: none;
}
}
&__link {
color: var(--color-text-muted);
text-decoration: none;
transition: color 0.2s ease;
&:hover, &[aria-current="page"] {
color: var(--color-accent);
}
}
}
@keyframes nav-fade {
from { background: transparent; border-block-end-color: transparent; }
to { background: color-mix(in oklch, var(--color-bg) 80%, transparent); }
}
/* ── components/card.css ──────────────────────────────────────── */
/* Ch 4 (:has()), Ch 8 (transitions), Ch 11 (BEM component tokens) */
.card {
--card-bg: var(--color-surface);
--card-radius: var(--radius-md);
--card-border: var(--color-border);
background: var(--card-bg);
border-radius: var(--card-radius);
border: 1px solid var(--card-border);
overflow: hidden;
transition: transform 0.2s ease, border-color 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-4px);
border-color: var(--color-accent);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
}
/* Ch 4: :has() — layout changes when card has an image */
&:has(.card__media) {
display: grid;
grid-template-rows: auto 1fr;
}
&__media {
aspect-ratio: 16 / 9;
object-fit: cover;
inline-size: 100%;
}
&__body { padding: var(--space-6); }
&__title { font-size: var(--text-lg); margin-block-end: var(--space-2); }
&__meta { color: var(--color-text-muted); font-size: var(--text-sm); }
&__footer { margin-block-start: auto; padding-block-start: var(--space-4); }
/* Variant via CUBE exception pattern (Ch 11) */
&[data-featured] {
--card-border: var(--color-accent);
--card-bg: color-mix(in oklch, var(--color-accent) 5%, var(--color-surface));
}
}
/* ── components/form.css ──────────────────────────────────────── */
/* Ch 4 (:has(), :invalid, :placeholder-shown), Ch 9 (@property animation) */
@property --border-hue {
syntax: '<number>';
inherits: false;
initial-value: 250;
}
.form-group {
display: grid;
gap: var(--space-1);
label {
font-size: var(--text-sm);
font-weight: 600;
transition: color 0.2s ease;
}
input, textarea {
--border-hue: 250;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid oklch(50% 0.12 var(--border-hue));
border-radius: var(--radius-sm);
padding: var(--space-3) var(--space-4);
transition: --border-hue 0.3s ease; /* animates via @property */
&:focus { --border-hue: 250; }
}
/* :has() — parent sees child state — Ch 4 + Ch 10 */
&:has(input:invalid:not(:placeholder-shown)) {
label { color: var(--color-error); }
input { --border-hue: 25; } /* red hue — animated by @property */
.hint { display: block; }
}
.hint {
display: none;
font-size: var(--text-sm);
color: var(--color-error);
}
}
Phase 5 — Motion & Interaction
/* ── Scroll-driven reveal — no IntersectionObserver needed */
/* Ch 8 (scroll-driven) + Ch 10 (modern CSS) */
@keyframes reveal-up {
from { opacity: 0; translate: 0 30px; }
to { opacity: 1; translate: 0 0; }
}
.reveal {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 35%;
}
/* Read-progress bar — no JS */
.progress-bar {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
block-size: 3px;
background: var(--color-accent);
transform-origin: inline-start;
z-index: 200;
animation: grow-bar linear;
animation-timeline: scroll(root block);
}
@keyframes grow-bar {
from { scale: 0 1; }
to { scale: 1 1; }
}
/* Staggered entrance for project cards */
.auto-grid .card {
animation: reveal-up 0.4s ease-out both;
animation-delay: calc(var(--index, 0) * 60ms);
}
/* Set --index on each card via style="--index: N" in HTML */
Phase 6 — Theming & Dark Mode
/* ── themes/dark.css ─────────────────────────────────────────── */
/* Ch 3 (oklch) + Ch 9 (tokens) + Ch 10 (light-dark) */
/* System preference */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: var(--blue-950);
--color-surface: var(--blue-900);
--color-border: oklch(25% 0.04 250);
--color-text: var(--blue-50);
--color-text-muted: var(--blue-300);
}
}
/* Manual toggle — overrides system preference */
[data-theme="dark"] {
--color-bg: var(--blue-950);
--color-surface: var(--blue-900);
--color-border: oklch(25% 0.04 250);
--color-text: var(--blue-50);
--color-text-muted: var(--blue-300);
}
[data-theme="light"] {
--color-bg: var(--blue-50);
--color-surface: #ffffff;
--color-border: oklch(85% 0.02 250);
--color-text: var(--blue-900);
--color-text-muted: var(--blue-700);
}
/* Theme toggle JS — only 10 lines */
// const root = document.documentElement;
// const saved = localStorage.getItem('theme');
// if (saved) root.dataset.theme = saved;
// document.getElementById('theme-toggle').addEventListener('click', () => {
// const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
// root.dataset.theme = next;
// localStorage.setItem('theme', next);
// });
Chapter Reference Map
| Chapter | Feature used in project | Where it appears |
|---|---|---|
| Ch 1 | @layer, cascade, :where() | main.css layer order, reset using :where() for zero-specificity base styles |
| Ch 2 | box-sizing, position sticky, z-index, stacking context | Universal reset, sticky nav with backdrop-filter, modal z-index management |
| Ch 3 | oklch(), @font-face, clamp() type, gradient text, max-inline-size | Entire colour token system, fluid type scale, hero background glow, prose width |
| Ch 4 | :has(), :not(), :focus-visible, :invalid, :placeholder-shown, [attr] | Form validation (no JS), card layout switching, nav active state, data-featured cards |
| Ch 5 | flex, margin-inline-start: auto, align-items, flex-wrap | Nav layout, hero CTA row, cluster primitive, skills tag list |
| Ch 6 | grid, auto-fill, minmax(), subgrid, grid-template-rows: auto 1fr | Project grid (auto-fill), card with image (auto 1fr rows), page template |
| Ch 7 | clamp(), min(), dvh, container queries, prefers-reduced-motion | Fluid type tokens, fluid container, min-block-size: 100dvh, global motion kill-switch |
| Ch 8 | transition, cubic-bezier, @keyframes, animation-timeline: scroll()/view() | Card hover lift, button spring, scroll-driven nav fade, read-progress bar, reveal-on-scroll |
| Ch 9 | var(), @property, clamp(), color-mix(), three-tier tokens | Entire token architecture, animated border-hue in form, color-mix() for glassmorphism nav |
| Ch 10 | @layer, nesting, :has() in layout, logical properties, @starting-style | Layer strategy, BEM components written with & nesting, all margin-block/padding-inline |
| Ch 11 | BEM, CUBE CSS, component tokens, @layer file structure | Card/Nav/Form follow BEM naming, layout primitives follow CUBE, @layer owns cascade order |
| Ch 12 | Everything integrated | This project |
Final File Structure
portfolio/
├── index.html
├── styles/
│ ├── main.css — @layer order + all @imports
│ ├── tokens/
│ │ ├── primitives.css — oklch palette, spacing, radii, type scale
│ │ └── semantic.css — --color-bg, --color-text, --color-accent…
│ ├── base/
│ │ ├── reset.css — box-sizing, img, reduced-motion
│ │ └── typography.css — h1-h3, a, p, :focus-visible
│ ├── layout/
│ │ └── primitives.css — .container, .stack, .cluster, .auto-grid
│ ├── components/
│ │ ├── nav.css — sticky nav + scroll-driven opacity
│ │ ├── hero.css — radial glow, fluid heading, CTA buttons
│ │ ├── card.css — BEM card + :has() image layout
│ │ ├── button.css — primary + ghost variants via component tokens
│ │ └── form.css — @property border animation + :has() validation
│ ├── themes/
│ │ └── dark.css — prefers-color-scheme + [data-theme] overrides
│ └── utils.css — .visually-hidden, .text-muted, .reveal
└── scripts/
└── theme.js — 10-line theme toggle + localStorage
Chapter Summary
| Concept | Decision made in this project |
|---|---|
| Layer order | reset → tokens → base → layout → components → theme → utils. Utils always beats components — no !important needed anywhere. |
| Token architecture | Primitives in oklch for perceptual uniformity. Semantic tokens are the only thing swapped for dark mode. Components reference semantics via component tokens. |
| Fluid without queries | clamp() for type, min(100% - 2rem, 1200px) for container, auto-fill minmax() for grid. Zero breakpoint media queries in the layout layer. |
| Motion | Scroll-driven animations for nav fade and progress bar. view() timeline for section reveals. Global prefers-reduced-motion reset in base layer. |
| Form validation | :has(input:invalid:not(:placeholder-shown)) makes label change colour and hint appear. @property animates the border colour between hues. Zero JavaScript. |
| Component API | BEM names with native nesting. Component-level custom properties (--card-bg, --btn-radius) are the knobs for variants. data-* exceptions for one-offs. |
| Dark mode | prefers-color-scheme handles automatic. [data-theme] handles manual toggle. Both swap only semantic tokens. Components need zero dark-mode-specific rules. |
Final Project Exercises
- Build the token layer: Create
tokens/primitives.csswith a full oklch colour ramp (50–950), spacing scale (space-1 through space-16), and fluid type scale using clamp(). Import both files intomain.cssvia@import layer(tokens). Verify all values are accessible via DevTools custom properties panel on :root. - Build the nav component: Implement the sticky nav using BEM naming and native nesting. Add the scroll-driven opacity animation. Test that it starts transparent over the hero and gains the frosted glass background as you scroll. Verify it doesn't break on screens narrower than 640px (links should hide).
- Build the project grid: Use the
.auto-gridlayout primitive with--grid-min: 280px. Add.cardcomponents with BEM structure. Test that the grid reflows at all viewport widths with no media query in the component CSS. Addstyle="--index: N"to each card and verify the staggered entrance animation. - Build the contact form: Implement a form with name, email, and message fields using the
.form-groupcomponent. Use@property --border-hueand:has(input:invalid:not(:placeholder-shown))so the border animates from blue to red on invalid input without JavaScript. Test with a non-email string in the email field. - Wire up the theme toggle: Add a button with id
theme-toggle. Write the 10-line JS from Phase 6 that reads/writesdata-themeondocument.documentElementand persists to localStorage. Reload the page and confirm the chosen theme persists. Test that it overrides both system light and system dark preference.
▮▮▮
CSS Overview + Deep Dives — Complete
12 chapters covering the full modern CSS landscape: the cascade and specificity,
the box model, typography and colour, selectors, Flexbox, Grid, responsive design,
transitions and animations, custom properties and functions, modern CSS features,
CSS architecture, and a full capstone project. You now have the theoretical
foundation, the practical patterns, and the architectural thinking to write
maintainable, performant, accessible CSS at any scale.