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

ChapterFeature used in projectWhere it appears
Ch 1@layer, cascade, :where()main.css layer order, reset using :where() for zero-specificity base styles
Ch 2box-sizing, position sticky, z-index, stacking contextUniversal reset, sticky nav with backdrop-filter, modal z-index management
Ch 3oklch(), @font-face, clamp() type, gradient text, max-inline-sizeEntire 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 5flex, margin-inline-start: auto, align-items, flex-wrapNav layout, hero CTA row, cluster primitive, skills tag list
Ch 6grid, auto-fill, minmax(), subgrid, grid-template-rows: auto 1frProject grid (auto-fill), card with image (auto 1fr rows), page template
Ch 7clamp(), min(), dvh, container queries, prefers-reduced-motionFluid type tokens, fluid container, min-block-size: 100dvh, global motion kill-switch
Ch 8transition, cubic-bezier, @keyframes, animation-timeline: scroll()/view()Card hover lift, button spring, scroll-driven nav fade, read-progress bar, reveal-on-scroll
Ch 9var(), @property, clamp(), color-mix(), three-tier tokensEntire token architecture, animated border-hue in form, color-mix() for glassmorphism nav
Ch 10@layer, nesting, :has() in layout, logical properties, @starting-styleLayer strategy, BEM components written with & nesting, all margin-block/padding-inline
Ch 11BEM, CUBE CSS, component tokens, @layer file structureCard/Nav/Form follow BEM naming, layout primitives follow CUBE, @layer owns cascade order
Ch 12Everything integratedThis 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

ConceptDecision made in this project
Layer orderreset → tokens → base → layout → components → theme → utils. Utils always beats components — no !important needed anywhere.
Token architecturePrimitives in oklch for perceptual uniformity. Semantic tokens are the only thing swapped for dark mode. Components reference semantics via component tokens.
Fluid without queriesclamp() for type, min(100% - 2rem, 1200px) for container, auto-fill minmax() for grid. Zero breakpoint media queries in the layout layer.
MotionScroll-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 APIBEM names with native nesting. Component-level custom properties (--card-bg, --btn-radius) are the knobs for variants. data-* exceptions for one-offs.
Dark modeprefers-color-scheme handles automatic. [data-theme] handles manual toggle. Both swap only semantic tokens. Components need zero dark-mode-specific rules.
Final Project Exercises
  1. Build the token layer: Create tokens/primitives.css with a full oklch colour ramp (50–950), spacing scale (space-1 through space-16), and fluid type scale using clamp(). Import both files into main.css via @import layer(tokens). Verify all values are accessible via DevTools custom properties panel on :root.
  2. 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).
  3. Build the project grid: Use the .auto-grid layout primitive with --grid-min: 280px. Add .card components with BEM structure. Test that the grid reflows at all viewport widths with no media query in the component CSS. Add style="--index: N" to each card and verify the staggered entrance animation.
  4. Build the contact form: Implement a form with name, email, and message fields using the .form-group component. Use @property --border-hue and :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.
  5. Wire up the theme toggle: Add a button with id theme-toggle. Write the 10-line JS from Phase 6 that reads/writes data-theme on document.documentElement and 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.