Build a Minimal Design Portfolio
Build a responsive portfolio site with CSS Grid, animations, and vanilla JS.
Introduction
30 Second Summary
Your creative work deserves a home that looks as polished as the work itself. A minimal, elegant portfolio lets your projects take center stage without visual clutter getting in the way.
In this project, you will build a single-page design portfolio website from scratch using pure HTML, CSS, and JavaScript. The site features elegant typography, smooth scroll animations, and an interactive project gallery with category filtering.
What You'll Build
A responsive, single-page portfolio with a hero section, filterable project gallery, about section, and contact area, built entirely with vanilla HTML, CSS, and JavaScript.
By the end of this project, you'll have:
- A responsive layout using CSS Grid and Flexbox that adapts gracefully from desktop to mobile.
- A filterable project gallery with hover effects and smooth category-based filtering built with vanilla JavaScript.
- Smooth scroll-triggered animations powered by the Intersection Observer API.
- Secret Mission: Add a dark mode toggle with localStorage persistence.
Are there any prerequisites?
This project assumes a basic understanding of HTML, CSS, and JavaScript. You do not need any frameworks, build tools, or package managers. Everything runs directly in your browser.
Set Up the Project Files
Every portfolio starts with a well-organized foundation. Before writing any design code, you need a clean project structure with the right files linked together.
In this step, you will create your project folder, set up the three core files, and write the HTML boilerplate that connects your stylesheet, script, and Google Fonts typography.
In this step, get ready to:
- Create the portfolio project folder with three starter files.
- Write the HTML boilerplate with Google Fonts and file links.
- Verify the page loads in your browser with no errors.
Create the project folder and files
You will create a folder on your Desktop so you can easily find it later. Inside, you will add three empty files that form the skeleton of your portfolio site.
- Press Ctrl+` to open the integrated terminal in VS Code.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir portfolio
cd portfolio
- Create the three project files by running this command:
macOS
touch index.html style.css script.js
Windows
New-Item index.html, style.css, script.js
- Confirm the files exist by running:
ls
You should see index.html, style.css, and script.js in the output.
- Now open the portfolio folder in VS Code. Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the portfolio folder, then click Open.
You should now see the three files listed in VS Code's Explorer sidebar on the left.
Don't see your files in the sidebar?
Make sure you selected the portfolio folder itself, not one of the files inside it. VS Code needs to open the folder to display its contents in the Explorer.
If the sidebar is hidden, press Cmd+B (macOS) or Ctrl+B (Windows) to toggle it.
Why can't VS Code find my project folder?
Write the HTML boilerplate
Your HTML file needs a boilerplate structure that links to your stylesheet, your JavaScript file, and the Google Fonts stylesheet. The font links use a preconnect strategy to speed up font loading.
- Click on index.html in the Explorer sidebar to open it.
- Paste the following code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alex Chen - Design Portfolio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
</body>
</html>
What does this code do?
- The two preconnect links tell the browser to start connecting to Google's font servers early. This speeds up font loading so text does not flash unstyled.
- The Google Fonts URL loads two font families: DM Serif Display (for headings) and Inter (for body text) at weights 400, 500, and 600.
- The display=swap parameter prevents invisible text while fonts load. The browser shows a fallback font first, then swaps in the custom font once it arrives.
- The defer attribute on the script tag tells the browser to download the JavaScript file in the background and run it only after the HTML is fully parsed.
- Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Verify your setup in the browser
A blank page with no console errors confirms that your HTML is valid and all linked files are reachable.
- Open index.html in your browser. You can double-click the file in Finder (macOS) or File Explorer (Windows), or use the VS Code Live Server extension if you have it installed.
- Open your browser's developer console by pressing Cmd+Option+J (macOS Chrome) or Ctrl+Shift+J (Windows Chrome).
You should see a completely blank white page. The console should show no red error messages.
Seeing errors in the console?
A 404 error for style.css or script.js means the browser cannot find those files. Make sure both files exist in the same folder as index.html and that the filenames match exactly (all lowercase, no extra spaces).
If you see a font loading warning, check that the Google Fonts URL in your <link> tag matches the code above character for character.
Why am I seeing console errors on my blank page?
✔️ Awesome, I've got everything!
Great. Make sure you have saved index.html before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html file at this point. Compare it character by character with yours.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alex Chen - Design Portfolio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
</body>
</html>
Your project skeleton is ready. Next up, you will build the full HTML structure for every section of your portfolio.
Build the HTML Structure
Your project files are ready and loading in the browser. Now it's time to build out the actual page content.
A portfolio needs distinct sections that visitors can navigate: a hero introduction, a gallery of work, an about section, and contact details. You will build all of these using semantic HTML elements like header, main, section, and footer.
In this step, get ready to:
- Add a header with navigation and a hero section.
- Build a project gallery with filter buttons and six project cards.
- Add the about section and footer with contact info.
Add the header and hero section
The header holds the site navigation, and the hero is the first thing visitors see. Together they set the tone for the entire portfolio.
- In index.html, find the empty <body> tags.
- Add the following code between the opening <body> and closing </body> tags:
<header class="site-header">
<div class="container header-inner">
<a href="#" class="logo">Alex Chen</a>
<nav class="main-nav">
<a href="#work">Work</a>
<a href="#about">About</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main>
<section class="hero">
<div class="container">
<div class="hero-content">
<!-- Large heading with a line break for visual rhythm -->
<h1 class="hero-title">Design that<br>speaks softly</h1>
<p class="hero-tagline">Crafting minimal, purposeful experiences for brands that value clarity.</p>
</div>
</div>
</section>
What does this code do?
- The <header> contains a logo link and three navigation anchors that point to section IDs further down the page.
- The <main> element wraps all primary content. The hero <section> uses a large heading and a short tagline to introduce the designer.
- Classes like container and hero-content are hooks for styling later. They do nothing yet.
- Save index.html.
- Refresh your browser. You should see "Alex Chen", the navigation links, the heading "Design that speaks softly", and the tagline displayed as unstyled text.
Don't see any text in the browser?
Make sure you pasted the code between the <body> and </body> tags, not inside the <head>. Also check that you saved the file after pasting.
Help me figure out why my HTML isn't showing in the browser
Add the work section with project cards
The work section is the heart of the portfolio. It features filter buttons for categories and a grid of project cards, each tagged with a data-category attribute so JavaScript can filter them later.
- In index.html, place your cursor directly after the closing </section> tag of the hero section (the one you just added).
- Add the following code below it, still inside the <main> element:
<section id="work" class="work-section">
<div class="container">
<h2 class="section-title animate-on-scroll">Selected Work</h2>
<!-- Filter buttons for project categories -->
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="branding">Branding</button>
<button class="filter-btn" data-filter="web">Web</button>
<button class="filter-btn" data-filter="print">Print</button>
</div>
<!-- Project card grid -->
<div class="project-grid">
<article class="project-card animate-on-scroll" data-category="branding">
<div class="card-image" style="background-color: #e8e4e0;"></div>
<div class="card-overlay">
<h3 class="card-title">Lumina Rebrand</h3>
<span class="card-category">Branding</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="web">
<div class="card-image" style="background-color: #d4ddd6;"></div>
<div class="card-overlay">
<h3 class="card-title">Aura Website</h3>
<span class="card-category">Web</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="print">
<div class="card-image" style="background-color: #ddd8e4;"></div>
<div class="card-overlay">
<h3 class="card-title">Mono Magazine</h3>
<span class="card-category">Print</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="branding">
<div class="card-image" style="background-color: #e0ddd4;"></div>
<div class="card-overlay">
<h3 class="card-title">Nori Identity</h3>
<span class="card-category">Branding</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="web">
<div class="card-image" style="background-color: #d4d9dd;"></div>
<div class="card-overlay">
<h3 class="card-title">Calm App</h3>
<span class="card-category">Web</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="print">
<div class="card-image" style="background-color: #dde4d4;"></div>
<div class="card-overlay">
<h3 class="card-title">Form Annual Report</h3>
<span class="card-category">Print</span>
</div>
</article>
</div>
</div>
</section>
What does this code do?
- Each filter button has a data-filter attribute ("all", "branding", "web", or "print"). JavaScript will read these to know which category the user selected.
- Each <article> card has a matching data-category attribute. When a filter is clicked, cards whose category does not match will be hidden.
- The card-image div uses an inline background-color as a placeholder. In a real portfolio, you would swap these for actual images.
- The animate-on-scroll class is a hook for scroll animations you will add in a later step.
- Save index.html and refresh your browser.
- You should see the "Selected Work" heading, four filter buttons (All, Branding, Web, Print), and the names of six projects listed as plain text.
Only seeing the hero text but not the projects?
Check that the work section code sits after the closing </section> of the hero, but still inside the <main> element. If you accidentally placed it outside <main> or inside the hero section, the browser may render it unexpectedly.
Help me fix my HTML structure
Add the about section and footer
The about section gives visitors context about the designer. The footer provides contact information and social links.
- In index.html, place your cursor after the closing </section> tag of the work section.
- Add the about section, close the <main> element, and add the footer by pasting this code:
<section id="about" class="about-section">
<div class="container">
<div class="about-grid animate-on-scroll">
<div class="about-text">
<h2 class="section-title">About</h2>
<p>I am a designer who believes in the power of restraint. Good design does not shout. It guides, clarifies, and steps back to let the content breathe.</p>
<p>With over five years of experience in branding, web design, and print, I help businesses communicate with intention and elegance.</p>
</div>
<!-- Placeholder for a profile photo -->
<div class="about-image" style="background-color: #e8e4e0;"></div>
</div>
</div>
</section>
</main>
<footer id="contact" class="site-footer">
<div class="container footer-content animate-on-scroll">
<h2 class="section-title">Get in Touch</h2>
<a href="mailto:hello@alexchen.design" class="contact-email">hello@alexchen.design</a>
<div class="social-links">
<a href="#" class="social-link">Dribbble</a>
<a href="#" class="social-link">Behance</a>
<a href="#" class="social-link">LinkedIn</a>
</div>
</div>
</footer>
What does this code do?
- The about section uses a div.about-grid wrapper that will become a two-column grid layout in a later step (text on the left, image placeholder on the right).
- The </main> closing tag tells the browser that the primary page content ends here.
- The footer uses a mailto: link so clicking the email address opens the visitor's email client.
- Save index.html and refresh your browser.
- You should see the full page of unstyled text: the header, hero, project names, about paragraphs, the email address, and three social link names.
Some sections appear but the footer is missing?
Make sure </main> appears before the <footer> element. If the closing </main> tag is missing, the browser may still render the content, but the structure will be incorrect.
Help me check my HTML nesting
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html file at this point. Compare yours against it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alex Chen - Design Portfolio</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a href="#" class="logo">Alex Chen</a>
<nav class="main-nav">
<a href="#work">Work</a>
<a href="#about">About</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main>
<section class="hero">
<div class="container">
<div class="hero-content">
<h1 class="hero-title">Design that<br>speaks softly</h1>
<p class="hero-tagline">Crafting minimal, purposeful experiences for brands that value clarity.</p>
</div>
</div>
</section>
<section id="work" class="work-section">
<div class="container">
<h2 class="section-title animate-on-scroll">Selected Work</h2>
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="branding">Branding</button>
<button class="filter-btn" data-filter="web">Web</button>
<button class="filter-btn" data-filter="print">Print</button>
</div>
<div class="project-grid">
<article class="project-card animate-on-scroll" data-category="branding">
<div class="card-image" style="background-color: #e8e4e0;"></div>
<div class="card-overlay">
<h3 class="card-title">Lumina Rebrand</h3>
<span class="card-category">Branding</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="web">
<div class="card-image" style="background-color: #d4ddd6;"></div>
<div class="card-overlay">
<h3 class="card-title">Aura Website</h3>
<span class="card-category">Web</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="print">
<div class="card-image" style="background-color: #ddd8e4;"></div>
<div class="card-overlay">
<h3 class="card-title">Mono Magazine</h3>
<span class="card-category">Print</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="branding">
<div class="card-image" style="background-color: #e0ddd4;"></div>
<div class="card-overlay">
<h3 class="card-title">Nori Identity</h3>
<span class="card-category">Branding</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="web">
<div class="card-image" style="background-color: #d4d9dd;"></div>
<div class="card-overlay">
<h3 class="card-title">Calm App</h3>
<span class="card-category">Web</span>
</div>
</article>
<article class="project-card animate-on-scroll" data-category="print">
<div class="card-image" style="background-color: #dde4d4;"></div>
<div class="card-overlay">
<h3 class="card-title">Form Annual Report</h3>
<span class="card-category">Print</span>
</div>
</article>
</div>
</div>
</section>
<section id="about" class="about-section">
<div class="container">
<div class="about-grid animate-on-scroll">
<div class="about-text">
<h2 class="section-title">About</h2>
<p>I am a designer who believes in the power of restraint. Good design does not shout. It guides, clarifies, and steps back to let the content breathe.</p>
<p>With over five years of experience in branding, web design, and print, I help businesses communicate with intention and elegance.</p>
</div>
<div class="about-image" style="background-color: #e8e4e0;"></div>
</div>
</div>
</section>
</main>
<footer id="contact" class="site-footer">
<div class="container footer-content animate-on-scroll">
<h2 class="section-title">Get in Touch</h2>
<a href="mailto:hello@alexchen.design" class="contact-email">hello@alexchen.design</a>
<div class="social-links">
<a href="#" class="social-link">Dribbble</a>
<a href="#" class="social-link">Behance</a>
<a href="#" class="social-link">LinkedIn</a>
</div>
</div>
</footer>
</body>
</html>
Your page structure is complete. Everything shows up as raw, unstyled text right now, and that is exactly what you want. Next up, you will create a design system with CSS custom properties to bring this structure to life.
Create the Design System with CSS Custom Properties
Your HTML structure is in place. Now it's time to give that raw content a visual identity.
Every polished website starts with a design system that defines colors, spacing, and typography in one central place. In this step, you'll create that foundation using CSS custom properties so every future style rule stays consistent.
In this step, get ready to:
- Define a color palette and spacing scale using CSS custom properties.
- Add a CSS reset and base typography for body text and headings.
- Create a container utility class for consistent page layout.
Define your color and spacing tokens
CSS custom properties let you define design values once on the :root element and reuse them everywhere. Changing a single variable updates your entire site.
- Open style.css in VS Code and add the following custom properties at the top of the file:
/* === Design System === */
:root {
--color-bg: #fafaf9;
--color-text: #1a1a1a;
--color-text-light: #6b6b6b;
--color-accent: #a3a3a3;
--color-border: #e5e5e5;
--color-overlay: rgba(26, 26, 26, 0.85);
--font-heading: 'DM Serif Display', serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 8rem;
--max-width: 1200px;
--radius: 8px;
--transition: 0.3s ease;
}
What does this code do?
- The color variables define an off-white background, near-black text, and subtle gray accents for that minimal aesthetic.
- The --font-heading and --font-body variables point to the Google Fonts you linked in your HTML, with system font fallbacks in case they fail to load.
- The spacing scale doubles at each step (0.5, 1, 2, 4, 8 rem) to create a consistent visual rhythm across all sections.
- Save style.css.
- Refresh your browser. You won't see visible changes yet because these variables are only declarations. The next chunk puts them to use.
Add the CSS reset and base typography
Browsers apply default margins, padding, and font sizes that differ between Chrome, Safari, and Firefox. A CSS reset strips those defaults so your design starts from a clean slate.
- In style.css, add the following code below the closing } of your :root block:
/* === Reset === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* === Base Typography === */
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-heading);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
}
a {
color: var(--color-text);
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
What does this code do?
- The * selector with box-sizing: border-box ensures padding and borders are included in element widths. This prevents layout surprises later.
- The body rule applies your Inter font with a comfortable 1.6 line-height for readability.
- Headings get DM Serif Display with tight letter-spacing: -0.02em and font-weight: 400 to keep them elegant and understated.
- The a and img rules remove default link underlines and prevent images from overflowing their containers.
- Save style.css and refresh your browser.
- You should see all spacing collapse (no more gaps between headings and paragraphs). Your body text now appears in the Inter font, and headings display in the elegant DM Serif Display typeface.
Fonts not changing?
- Check that your index.html has the Google Fonts <link> tag before the style.css link. Order matters.
- Open DevTools (F12) and check the Console tab for any 404 errors related to fonts or your CSS file.
- Make sure style.css is in the same folder as index.html. The href="style.css" path assumes they are side-by-side.
Why aren't my Google Fonts loading in my portfolio?
Add the container layout utility
Your content currently stretches edge-to-edge. A container class constrains it to a readable width and centers it on the page.
- Add the following code at the bottom of style.css:
/* === Layout === */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
What does this code do?
- The max-width: var(--max-width) limits content to 1200px so text lines stay readable on wide screens.
- The margin: 0 auto centers the container horizontally within the viewport.
- The padding: 0 var(--space-md) adds breathing room on the left and right so content never touches the screen edge on mobile.
- Save style.css and refresh your browser.
- Your text content now sits within a centered column with padding on both sides. Try resizing the browser window. On wide screens, text stops stretching after 1200px.
Content still stretching full width?
- Confirm the .container class name in your CSS matches the class in your HTML. Check for typos in both files.
- Ensure the :root block above defines --max-width: 1200px. Without this variable, the container has no width limit.
Why isn't my container class centering the content?
✔️ Awesome, I've got everything!
Great. Double check you've saved style.css before moving on.
ⓧ I'd like to double check the full code
Here is the complete style.css file so far. Compare it with yours to spot any differences.
/* === Design System === */
:root {
--color-bg: #fafaf9;
--color-text: #1a1a1a;
--color-text-light: #6b6b6b;
--color-accent: #a3a3a3;
--color-border: #e5e5e5;
--color-overlay: rgba(26, 26, 26, 0.85);
--font-heading: 'DM Serif Display', serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 8rem;
--max-width: 1200px;
--radius: 8px;
--transition: 0.3s ease;
}
/* === Reset === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* === Base Typography === */
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-heading);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
}
a {
color: var(--color-text);
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
/* === Layout === */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
Your design system is locked in. Every color, font, and spacing value lives in one place. Next up, you'll use these tokens to style the hero section and navigation.
Style the Hero and Navigation
Your HTML structure is in place and the design system variables are ready to use. Now it is time to bring the two most important visual areas to life: the navigation bar and the hero section.
A portfolio lives or dies by its first impression. The header needs to feel invisible yet accessible, and the hero needs generous whitespace to let the headline breathe.
In this step, get ready to:
- Style the navigation as a fixed horizontal bar with Flexbox.
- Style the hero section with fluid typography and large vertical padding.
- Add a fade-up entrance animation using CSS keyframes.
Style the navigation bar
The header stays fixed at the top of the viewport so visitors can always reach the navigation links, even after scrolling.
- In style.css, add the following code below your .container rule:
/* === Header === */
.site-header {
padding: var(--space-md) 0;
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: var(--color-bg);
z-index: 100;
}
.header-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: var(--font-heading);
font-size: 1.25rem;
}
.main-nav {
display: flex;
gap: var(--space-md);
}
.main-nav a {
font-size: 0.875rem;
color: var(--color-text-light);
transition: color var(--transition);
}
.main-nav a:hover {
color: var(--color-text);
}
What does this code do?
The .site-header uses position: fixed with top: 0, left: 0, and right: 0 to stick the header to the top of the viewport while the rest of the page scrolls beneath it.
The .header-inner uses Flexbox with justify-content: space-between to push the logo to the left and the nav links to the right. The nav links are styled in a lighter gray that darkens on hover for subtle interactivity.
- Save style.css and refresh your browser.
- You should see "Alex Chen" pinned to the top-left with Work, About, and Contact links on the top-right. Scroll down to confirm the header stays fixed.
Header not sticking to the top?
Make sure you have position: fixed (not absolute) on .site-header. Also check that z-index: 100 is present so the header sits above other content.
If the nav links appear stacked vertically, confirm that .main-nav has display: flex.
Help me debug my fixed navigation header.
Style the hero section with fluid typography
The hero is the first thing visitors see. Massive vertical padding and a viewport-relative font size create the spacious, editorial feel that defines minimal design.
- In style.css, add the following code below your .main-nav a:hover rule:
/* === Hero === */
.hero {
padding: var(--space-xl) 0;
padding-top: calc(var(--space-xl) + 80px);
min-height: 90vh;
display: flex;
align-items: center;
}
.hero-title {
font-size: clamp(3rem, 8vw, 6rem);
margin-bottom: var(--space-md);
}
.hero-tagline {
font-size: clamp(1rem, 2vw, 1.25rem);
color: var(--color-text-light);
max-width: 500px;
}
What does this code do?
The .hero section takes up at least 90% of the viewport height. The extra padding-top: calc(var(--space-xl) + 80px) accounts for the fixed header so the hero content is not hidden beneath it.
The clamp() function creates fluid typography. clamp(3rem, 8vw, 6rem) means the title is never smaller than 3rem on mobile and never larger than 6rem on desktop. It scales smoothly in between without needing breakpoints.
- Save style.css and refresh your browser.
- You should see a large "Design that speaks softly" headline centered vertically in the viewport with a smaller tagline beneath it. Resize the browser window narrower and wider to confirm the title scales fluidly.
Hero text hidden behind the header?
Check that your .hero has padding-top: calc(var(--space-xl) + 80px). The 80px offset pushes the content below the fixed header.
If the title does not scale with the viewport, confirm you are using clamp(3rem, 8vw, 6rem) and not a fixed pixel value.
Help me fix my hero section layout.
Add a fade-up entrance animation
A subtle entrance animation makes the hero feel alive without being distracting. The content fades in and slides up when the page first loads.
- In style.css, add the following code below your .hero-tagline rule:
.hero-content {
animation: fadeUp 0.8s ease forwards;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
What does this code do?
The @keyframes fadeUp defines a two-state animation. It starts with the content invisible (opacity: 0) and shifted 30px downward, then transitions to fully visible at its original position.
The forwards keyword in the animation shorthand tells the browser to keep the final state after the animation finishes. Without it, the content would snap back to invisible.
- Save style.css and refresh your browser.
- Watch the hero content fade in and slide upward over about one second when the page loads. Hard-refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to replay the animation.
Not seeing the animation?
The animation only plays on page load. If you already had the page open, do a hard refresh to see it. Also check that .hero-content matches the class name hero-content in your HTML.
If the hero text never appears at all, make sure you have forwards in your animation shorthand. Without it, the element reverts to opacity: 0 after the animation ends.
Help me debug my CSS animation.
✔️ Awesome, I've got everything!
Double check you have saved style.css before moving on.
ⓧ I'd like to double check the full code
Here is the complete style.css file up to this point:
/* === Design System === */
:root {
--color-bg: #fafaf9;
--color-text: #1a1a1a;
--color-text-light: #6b6b6b;
--color-accent: #a3a3a3;
--color-border: #e5e5e5;
--color-overlay: rgba(26, 26, 26, 0.85);
--font-heading: 'DM Serif Display', serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 8rem;
--max-width: 1200px;
--radius: 8px;
--transition: 0.3s ease;
}
/* === Reset === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* === Base Typography === */
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-heading);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
}
a {
color: var(--color-text);
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
/* === Layout === */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
/* === Header === */
.site-header {
padding: var(--space-md) 0;
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: var(--color-bg);
z-index: 100;
}
.header-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: var(--font-heading);
font-size: 1.25rem;
}
.main-nav {
display: flex;
gap: var(--space-md);
}
.main-nav a {
font-size: 0.875rem;
color: var(--color-text-light);
transition: color var(--transition);
}
.main-nav a:hover {
color: var(--color-text);
}
/* === Hero === */
.hero {
padding: var(--space-xl) 0;
padding-top: calc(var(--space-xl) + 80px);
min-height: 90vh;
display: flex;
align-items: center;
}
.hero-content {
animation: fadeUp 0.8s ease forwards;
}
.hero-title {
font-size: clamp(3rem, 8vw, 6rem);
margin-bottom: var(--space-md);
}
.hero-tagline {
font-size: clamp(1rem, 2vw, 1.25rem);
color: var(--color-text-light);
max-width: 500px;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Your portfolio now has a polished header and a commanding hero section. Next up, you will build the project gallery grid with hover effects and filter buttons.
Build the Project Gallery Grid
Your hero section looks great with its fluid typography and entrance animation. Now it is time to build the heart of any portfolio: the project gallery.
A portfolio gallery needs to do two things well. It needs to display projects in a responsive grid that adapts to any screen size, and it needs to let visitors filter by category. In this step, you will style both the filter buttons and the project cards with interactive hover effects.
In this step, get ready to:
- Style the filter buttons as minimal pill-shaped controls with an active state.
- Create a responsive CSS Grid layout for the project cards.
- Add hover effects with a scale transform and dark overlay.
Style the filter buttons
Filter buttons let visitors narrow the gallery to a specific category. They need to clearly show which category is currently selected.
- In style.css, add the following styles below the @keyframes fadeUp block:
/* === Work Section === */
.work-section {
padding: var(--space-xl) 0;
}
.section-title {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: var(--space-lg);
}
.filter-buttons {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.filter-btn {
padding: var(--space-xs) var(--space-md);
border: 1px solid var(--color-border);
border-radius: 100px;
background: transparent;
font-family: var(--font-body);
font-size: 0.875rem;
color: var(--color-text-light);
cursor: pointer;
transition: all var(--transition);
}
.filter-btn:hover {
border-color: var(--color-text);
color: var(--color-text);
}
.filter-btn.active {
background-color: var(--color-text);
border-color: var(--color-text);
color: var(--color-bg);
}
What does this code do?
The .work-section and .section-title rules give the gallery generous vertical padding and a fluid heading size that scales between 2rem and 3rem.
The .filter-btn uses border-radius: 100px to create a pill shape. The .active class flips the colors so the selected category stands out with a dark background and light text.
- Save style.css and refresh your browser.
- You should see a row of pill-shaped filter buttons below the "Selected Work" heading. The "All" button appears filled in dark because it has the active class in your HTML.
Buttons look unstyled?
Make sure you placed the new CSS after the closing brace of the @keyframes fadeUp block. If the buttons still show browser defaults, check that .filter-btn matches the class name in your HTML exactly (case-sensitive).
Help me figure out why my filter buttons are not styled.
Create the responsive project grid
CSS Grid with auto-fill automatically arranges cards into as many columns as will fit. This means the gallery adapts from three columns on desktop to a single column on mobile without any media queries.
- Add the following grid and card styles below the .filter-btn.active block:
/* === Project Grid === */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-md);
}
.project-card {
position: relative;
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: transform var(--transition), opacity var(--transition);
}
/* Hidden state for filtered-out cards */
.project-card.hidden {
opacity: 0;
transform: scale(0.95);
pointer-events: none;
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
/* Card image placeholder */
.card-image {
aspect-ratio: 4 / 3;
width: 100%;
transition: transform var(--transition);
}
What does this code do?
repeat(auto-fill, minmax(340px, 1fr)) tells the browser to create as many 340px-minimum columns as the viewport can hold. Each column stretches to fill the available space.
The .project-card.hidden class collapses cards completely when they are filtered out. Using opacity: 0 with position: absolute allows a smooth transition that display: none cannot achieve.
aspect-ratio: 4 / 3 gives each card image a consistent shape without needing to set explicit heights.
- Save style.css and refresh your browser.
- You should see six colored rectangles arranged in a responsive grid. Resize your browser window to watch the columns adapt automatically.
Cards stacking in a single column on desktop?
Check that your browser window is wider than 740px. The minmax(340px, 1fr) value means the grid needs at least 680px of content width to fit two columns. Also confirm the .project-grid class in your CSS matches the class in your HTML.
Help me debug my CSS Grid layout for the project cards.
Add hover effects to the cards
A design portfolio needs to feel interactive. When a visitor hovers over a card, the image should subtly zoom and a dark overlay should fade in to reveal the project title.
- Add the hover and overlay styles below the .card-image block:
/* Subtle zoom on hover */
.project-card:hover .card-image {
transform: scale(1.03);
}
/* Dark overlay that fades in on hover */
.card-overlay {
position: absolute;
inset: 0;
background: var(--color-overlay);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--space-md);
opacity: 0;
transition: opacity var(--transition);
}
.project-card:hover .card-overlay {
opacity: 1;
}
.card-title {
font-size: 1.5rem;
color: #ffffff;
margin-bottom: var(--space-xs);
}
.card-category {
font-size: 0.875rem;
color: var(--color-accent);
}
What does this code do?
inset: 0 is shorthand for setting top, right, bottom, and left all to zero. Combined with position: absolute, it stretches the overlay to fill the entire card.
The overlay starts at opacity: 0 (invisible) and transitions to opacity: 1 on hover. The justify-content: flex-end pushes the title and category label to the bottom of the card.
- Save style.css and refresh your browser.
- Hover over any project card. You should see the image gently zoom in and a dark overlay fade in with the project title in white at the bottom.
Overlay not appearing on hover?
The overlay relies on the card having position: relative and overflow: hidden set on .project-card. Without position: relative, the absolutely-positioned overlay has no anchor and may appear outside the card.
Help me fix my card hover overlay effect.
✔️ Awesome, I've got everything!
Great. Double check you have saved style.css before moving on.
ⓧ I'd like to double check the full code
Here is the complete style.css file up to this point. Compare yours against it:
/* === Design System === */
:root {
--color-bg: #fafaf9;
--color-text: #1a1a1a;
--color-text-light: #6b6b6b;
--color-accent: #a3a3a3;
--color-border: #e5e5e5;
--color-overlay: rgba(26, 26, 26, 0.85);
--font-heading: 'DM Serif Display', serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 8rem;
--max-width: 1200px;
--radius: 8px;
--transition: 0.3s ease;
}
/* === Reset === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* === Base Typography === */
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-heading);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
}
a {
color: var(--color-text);
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
/* === Layout === */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
/* === Header === */
.site-header {
padding: var(--space-md) 0;
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: var(--color-bg);
z-index: 100;
}
.header-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: var(--font-heading);
font-size: 1.25rem;
}
.main-nav {
display: flex;
gap: var(--space-md);
}
.main-nav a {
font-size: 0.875rem;
color: var(--color-text-light);
transition: color var(--transition);
}
.main-nav a:hover {
color: var(--color-text);
}
/* === Hero === */
.hero {
padding: var(--space-xl) 0;
padding-top: calc(var(--space-xl) + 80px);
min-height: 90vh;
display: flex;
align-items: center;
}
.hero-content {
animation: fadeUp 0.8s ease forwards;
}
.hero-title {
font-size: clamp(3rem, 8vw, 6rem);
margin-bottom: var(--space-md);
}
.hero-tagline {
font-size: clamp(1rem, 2vw, 1.25rem);
color: var(--color-text-light);
max-width: 500px;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* === Work Section === */
.work-section {
padding: var(--space-xl) 0;
}
.section-title {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: var(--space-lg);
}
.filter-buttons {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.filter-btn {
padding: var(--space-xs) var(--space-md);
border: 1px solid var(--color-border);
border-radius: 100px;
background: transparent;
font-family: var(--font-body);
font-size: 0.875rem;
color: var(--color-text-light);
cursor: pointer;
transition: all var(--transition);
}
.filter-btn:hover {
border-color: var(--color-text);
color: var(--color-text);
}
.filter-btn.active {
background-color: var(--color-text);
border-color: var(--color-text);
color: var(--color-bg);
}
/* === Project Grid === */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-md);
}
.project-card {
position: relative;
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: transform var(--transition), opacity var(--transition);
}
.project-card.hidden {
opacity: 0;
transform: scale(0.95);
pointer-events: none;
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.card-image {
aspect-ratio: 4 / 3;
width: 100%;
transition: transform var(--transition);
}
.project-card:hover .card-image {
transform: scale(1.03);
}
.card-overlay {
position: absolute;
inset: 0;
background: var(--color-overlay);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--space-md);
opacity: 0;
transition: opacity var(--transition);
}
.project-card:hover .card-overlay {
opacity: 1;
}
.card-title {
font-size: 1.5rem;
color: #ffffff;
margin-bottom: var(--space-xs);
}
.card-category {
font-size: 0.875rem;
color: var(--color-accent);
}
Your project gallery is looking polished with responsive columns and smooth hover effects. Next up, you will style the about and contact sections to complete the full page layout.
Style the About and Contact Sections
Your project gallery is looking sharp with responsive cards and smooth hover effects. Now it is time to finish the remaining sections of the page.
A portfolio is not just a gallery. The about section builds trust, and the contact section gives visitors a clear path to reach out. In this step, you will style both of these sections and add responsive adjustments so the entire page looks polished on any screen size.
In this step, get ready to:
- Style the about section as a two-column grid layout.
- Build the footer with a hover-animated email link and social links.
- Add responsive media queries so everything collapses gracefully on mobile.
Style the about section as a two-column grid
The about section uses CSS Grid to place the text and image side by side. This gives the section a balanced, editorial feel.
- In style.css, add the following styles below your .card-category rule:
/* === About Section === */
.about-section {
padding: var(--space-xl) 0;
}
.about-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
align-items: center;
}
.about-text p {
color: var(--color-text-light);
margin-bottom: var(--space-sm);
max-width: 480px;
}
.about-text .section-title {
margin-bottom: var(--space-md);
}
.about-image {
aspect-ratio: 3 / 4;
border-radius: var(--radius);
}
What does this code do?
- .about-grid creates a two-column layout with equal-width columns (1fr 1fr) and vertically centers the text against the image.
- .about-text p limits paragraph width to 480px so long lines stay readable.
- .about-image uses aspect-ratio: 3 / 4 to create a portrait-oriented placeholder without needing explicit height values.
- Save style.css and refresh your browser.
- You should see the about section split into two columns. The text sits on the left with the "About" heading and two paragraphs, and a tall beige placeholder rectangle sits on the right.
About section not showing two columns?
Make sure you saved style.css. Check that your HTML has a div with class about-grid wrapping both the .about-text and .about-image divs.
If the image placeholder is invisible, confirm that your HTML includes style="background-color: #e8e4e0;" on the .about-image div.
Why isn't my about section displaying as two columns?
Style the footer with an animated email link
The footer centers everything and features a large email link with a hover underline that animates from left to right. This effect uses a ::after pseudo-element whose width transitions from 0 to 100% on hover.
- In style.css, add the following styles below the about section code you just wrote:
/* === Footer === */
.site-footer {
padding: var(--space-xl) 0;
text-align: center;
}
.contact-email {
font-family: var(--font-heading);
font-size: clamp(1.5rem, 4vw, 2.5rem);
display: inline-block;
margin-bottom: var(--space-lg);
position: relative;
}
/* Animated underline that grows on hover */
.contact-email::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
width: 0;
height: 2px;
background-color: var(--color-text);
transition: width var(--transition);
}
.contact-email:hover::after {
width: 100%;
}
.social-links {
display: flex;
justify-content: center;
gap: var(--space-md);
}
.social-link {
font-size: 0.875rem;
color: var(--color-text-light);
transition: color var(--transition);
}
.social-link:hover {
color: var(--color-text);
}
How does the underline animation work?
The ::after pseudo-element is a 2px-tall line positioned at the bottom of the email link. It starts with width: 0 so it is invisible.
On hover, the width transitions to 100%, making the line appear to draw itself from left to right. The position: relative on the parent email link is what allows the pseudo-element to position itself correctly.
- Save style.css and refresh your browser.
- Scroll to the bottom of the page. You should see "Get in Touch" centered above a large email address. Hover over the email and watch a thin underline animate in from left to right. Below the email, three social links (Dribbble, Behance, LinkedIn) sit in a row.
Underline not animating on hover?
Make sure .contact-email has position: relative. Without it, the ::after pseudo-element cannot position itself relative to the link.
Also confirm the email link in your HTML uses the class contact-email. A mismatch between class names in HTML and CSS is the most common cause.
Why isn't my CSS hover underline animation working?
Add responsive media queries for mobile
On smaller screens, multi-column layouts need to collapse into a single column. A media query at 768px handles all of these adjustments in one block.
- At the bottom of style.css, add the following responsive styles:
/* === Responsive === */
@media (max-width: 768px) {
.hero {
min-height: 70vh;
padding-top: calc(var(--space-lg) + 80px);
}
.project-grid {
grid-template-columns: 1fr;
}
.about-grid {
grid-template-columns: 1fr;
}
.about-image {
aspect-ratio: 16 / 9;
}
.main-nav {
gap: var(--space-sm);
}
}
What does this code do?
- The hero gets a shorter minimum height and reduced top padding so it does not dominate the screen on mobile.
- .project-grid and .about-grid both collapse to a single column (1fr) so content stacks vertically on narrow screens.
- The about image switches from portrait (3 / 4) to landscape (16 / 9) so it fits the screen width without becoming too tall.
- Navigation link spacing tightens so links do not overflow on small screens.
- Save style.css.
- Open browser DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Toggle Device Toolbar button (the phone/tablet icon) or press Cmd+Shift+M (macOS) or Ctrl+Shift+M (Windows).
- Set the viewport width to around 375px (a typical mobile width) and scroll through the page.
You should see the project cards stacked in a single column, the about section text above the image (now landscape), and the navigation links with tighter spacing.
Layout not changing on mobile viewport?
Make sure your media query uses @media (max-width: 768px) with the parentheses around the condition. A missing parenthesis silently breaks the entire block.
Also confirm the media query is at the very bottom of style.css. If it appears above the rules it is trying to override, specificity may prevent it from working.
My CSS media query is not applying when I resize the browser. Help me figure out why.
✔️ Awesome, I've got everything!
Great. Double check you have saved style.css and that your page looks polished at both desktop and mobile widths.
ⓧ I'd like to double check the full code
Here is the complete style.css file up to this point. Compare your code against it:
/* === Design System === */
:root {
--color-bg: #fafaf9;
--color-text: #1a1a1a;
--color-text-light: #6b6b6b;
--color-accent: #a3a3a3;
--color-border: #e5e5e5;
--color-overlay: rgba(26, 26, 26, 0.85);
--font-heading: 'DM Serif Display', serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 8rem;
--max-width: 1200px;
--radius: 8px;
--transition: 0.3s ease;
}
/* === Reset === */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* === Base Typography === */
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background-color: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-heading);
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
}
a {
color: var(--color-text);
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
/* === Layout === */
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 0 var(--space-md);
}
/* === Header === */
.site-header {
padding: var(--space-md) 0;
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: var(--color-bg);
z-index: 100;
}
.header-inner {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: var(--font-heading);
font-size: 1.25rem;
}
.main-nav {
display: flex;
gap: var(--space-md);
}
.main-nav a {
font-size: 0.875rem;
color: var(--color-text-light);
transition: color var(--transition);
}
.main-nav a:hover {
color: var(--color-text);
}
/* === Hero === */
.hero {
padding: var(--space-xl) 0;
padding-top: calc(var(--space-xl) + 80px);
min-height: 90vh;
display: flex;
align-items: center;
}
.hero-content {
animation: fadeUp 0.8s ease forwards;
}
.hero-title {
font-size: clamp(3rem, 8vw, 6rem);
margin-bottom: var(--space-md);
}
.hero-tagline {
font-size: clamp(1rem, 2vw, 1.25rem);
color: var(--color-text-light);
max-width: 500px;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* === Work Section === */
.work-section {
padding: var(--space-xl) 0;
}
.section-title {
font-size: clamp(2rem, 4vw, 3rem);
margin-bottom: var(--space-lg);
}
.filter-buttons {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.filter-btn {
padding: var(--space-xs) var(--space-md);
border: 1px solid var(--color-border);
border-radius: 100px;
background: transparent;
font-family: var(--font-body);
font-size: 0.875rem;
color: var(--color-text-light);
cursor: pointer;
transition: all var(--transition);
}
.filter-btn:hover {
border-color: var(--color-text);
color: var(--color-text);
}
.filter-btn.active {
background-color: var(--color-text);
border-color: var(--color-text);
color: var(--color-bg);
}
/* === Project Grid === */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--space-md);
}
.project-card {
position: relative;
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition: transform var(--transition), opacity var(--transition);
}
.project-card.hidden {
opacity: 0;
transform: scale(0.95);
pointer-events: none;
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.card-image {
aspect-ratio: 4 / 3;
width: 100%;
transition: transform var(--transition);
}
.project-card:hover .card-image {
transform: scale(1.03);
}
.card-overlay {
position: absolute;
inset: 0;
background: var(--color-overlay);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: var(--space-md);
opacity: 0;
transition: opacity var(--transition);
}
.project-card:hover .card-overlay {
opacity: 1;
}
.card-title {
font-size: 1.5rem;
color: #ffffff;
margin-bottom: var(--space-xs);
}
.card-category {
font-size: 0.875rem;
color: var(--color-accent);
}
/* === About Section === */
.about-section {
padding: var(--space-xl) 0;
}
.about-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
align-items: center;
}
.about-text p {
color: var(--color-text-light);
margin-bottom: var(--space-sm);
max-width: 480px;
}
.about-text .section-title {
margin-bottom: var(--space-md);
}
.about-image {
aspect-ratio: 3 / 4;
border-radius: var(--radius);
}
/* === Footer === */
.site-footer {
padding: var(--space-xl) 0;
text-align: center;
}
.contact-email {
font-family: var(--font-heading);
font-size: clamp(1.5rem, 4vw, 2.5rem);
display: inline-block;
margin-bottom: var(--space-lg);
position: relative;
}
.contact-email::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
width: 0;
height: 2px;
background-color: var(--color-text);
transition: width var(--transition);
}
.contact-email:hover::after {
width: 100%;
}
.social-links {
display: flex;
justify-content: center;
gap: var(--space-md);
}
.social-link {
font-size: 0.875rem;
color: var(--color-text-light);
transition: color var(--transition);
}
.social-link:hover {
color: var(--color-text);
}
/* === Responsive === */
@media (max-width: 768px) {
.hero {
min-height: 70vh;
padding-top: calc(var(--space-lg) + 80px);
}
.project-grid {
grid-template-columns: 1fr;
}
.about-grid {
grid-template-columns: 1fr;
}
.about-image {
aspect-ratio: 16 / 9;
}
.main-nav {
gap: var(--space-sm);
}
}
Your portfolio now has a complete, polished layout from top to bottom. Next up, you will bring it to life with scroll-triggered animations and interactive project filtering using JavaScript.
Add Scroll Animations and Project Filtering
Your portfolio looks polished, but it feels static. Scroll down and everything is just there all at once. Professional sites reveal content progressively as you scroll, creating a sense of flow.
In this step, you will bring your page to life with scroll-triggered fade-in animations using the Intersection Observer API. You will also wire up the filter buttons so visitors can browse your work by category, and add smooth scrolling to the navigation links.
In this step, get ready to:
- Add CSS transition rules that power the scroll animations.
- Write an Intersection Observer that reveals sections as they enter the viewport.
- Build interactive project filtering and smooth scroll navigation with vanilla JavaScript.
Add the scroll animation CSS
Your HTML already has .animate-on-scroll classes on sections and cards. Right now those elements are invisible because there is no corresponding .visible rule to bring them back. You need two CSS rules: one that hides elements by default, and one that fades them in when JavaScript adds the .visible class.
- In style.css, find the /* === Responsive === */ comment near the bottom of the file.
- Add the following CSS directly above that comment:
/* === Scroll Animations === */
.animate-on-scroll {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.animate-on-scroll.visible {
opacity: 1;
transform: translateY(0);
}
What does this CSS do?
The first rule hides every element with the class by setting its opacity to 0 and pushing it 20px down. The transition property ensures the change is smooth rather than instant.
The second rule reverses both properties when the .visible class is added. JavaScript will add that class at the right moment.
- Save style.css.
- Refresh your browser. The page should now appear mostly blank below the hero because the animated elements are hidden, waiting for JavaScript to reveal them.
Page still looks the same as before?
Make sure you saved the file. Check that the new rules sit above the media query block, not inside it.
If sections are still visible, open DevTools and inspect one of the project cards. Confirm it has the animate-on-scroll class in the HTML. Help me figure out why my scroll animation CSS is not hiding elements.
Create the Intersection Observer in JavaScript
The Intersection Observer API watches elements and tells you when they enter the viewport. It is much more performant than listening to scroll events manually.
- Open script.js in VS Code. The file is currently empty.
- Add the Intersection Observer code by pasting the following:
// === Scroll Animations with Intersection Observer === //
// Configure how early the observer triggers
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
// Create the observer that adds .visible when elements scroll into view
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, observerOptions);
// Observe every element with the .animate-on-scroll class
document.querySelectorAll('.animate-on-scroll').forEach((element) => {
observer.observe(element);
});
What does this code do?
- threshold: 0.1 means the callback fires when at least 10% of the element is visible.
- rootMargin: '0px 0px -50px 0px' shrinks the detection area by 50px at the bottom, so elements animate slightly after they appear on screen.
- observer.unobserve(entry.target) stops watching an element after it has animated in. Each section only fades in once, not every time you scroll past.
- Save script.js and refresh your browser.
- Scroll down the page slowly. You should see the "Selected Work" heading, project cards, about section, and footer content fade up into view as you reach them.
Sections not fading in?
Open your browser's DevTools console and look for JavaScript errors. A typo in a class name or variable will stop the script.
Make sure the <script> tag in index.html has the defer attribute. Without it, the script runs before the HTML is parsed and finds no elements to observe. Help me debug why my Intersection Observer is not triggering animations.
Add the project filter and smooth scroll logic
The filter buttons in your HTML already have data-filter attributes, and each project card has a matching data-category attribute. You need JavaScript to connect them so clicking a button shows only the cards that match.
- In script.js, add the following code below the scroll animation section:
// === Project Filtering === //
// Grab all filter buttons and project cards
const filterButtons = document.querySelectorAll('.filter-btn');
const projectCards = document.querySelectorAll('.project-card');
filterButtons.forEach((button) => {
button.addEventListener('click', () => {
const filter = button.dataset.filter;
// Update active state on buttons
filterButtons.forEach((btn) => btn.classList.remove('active'));
button.classList.add('active');
// Show or hide cards based on category match
projectCards.forEach((card) => {
const category = card.dataset.category;
if (filter === 'all' || category === filter) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
});
});
What does this code do?
- button.dataset.filter reads the data-filter attribute from the clicked button (e.g. "branding", "web", "all").
- The code first removes the active class from every button, then adds it to the one that was clicked. This swaps the dark filled style.
- For each card, it compares card.dataset.category to the filter value. Non-matching cards get the .hidden class, which your CSS already styles with opacity 0 and collapsed dimensions.
- Save script.js and refresh your browser.
- Click the Branding filter button. Only the "Lumina Rebrand" and "Nori Identity" cards should remain visible.
- Click All to confirm all six cards reappear.
Clicking buttons does nothing?
Check the DevTools console for errors. The most common cause is a typo in the class name string (e.g. filter-btn vs filter-button).
Also confirm that your HTML filter buttons have data-filter attributes and your cards have matching data-category values. Help me debug my project filter buttons.
The nav links in your header point to #work, #about, and #contact. By default, clicking them causes an instant jump. A smooth scroll feels much more polished.
- Add the following code at the bottom of script.js:
// === Smooth Scroll for Navigation === //
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => {
e.preventDefault();
const target = document.querySelector(anchor.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
What does this code do?
- a[href^="#"] selects every link whose href starts with a hash. This catches all your internal navigation links.
- e.preventDefault() stops the browser's default instant-jump behavior.
- scrollIntoView with behavior: 'smooth' animates the scroll to the target section.
- Save script.js and refresh your browser.
- Click About in the navigation bar. The page should glide smoothly down to the about section instead of jumping.
- Click Work to scroll back up to the project gallery.
Nav links still jump instantly?
Make sure the selector string uses a backslash-escaped quote in your code. A missing escape will cause a syntax error in the console.
Also check that each section's id in the HTML matches the href value in the nav links (e.g. id="work" matches href="#work"). Help me fix my smooth scroll navigation.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete script.js file:
// === Scroll Animations with Intersection Observer === //
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, observerOptions);
document.querySelectorAll('.animate-on-scroll').forEach((element) => {
observer.observe(element);
});
// === Project Filtering === //
const filterButtons = document.querySelectorAll('.filter-btn');
const projectCards = document.querySelectorAll('.project-card');
filterButtons.forEach((button) => {
button.addEventListener('click', () => {
const filter = button.dataset.filter;
filterButtons.forEach((btn) => btn.classList.remove('active'));
button.classList.add('active');
projectCards.forEach((card) => {
const category = card.dataset.category;
if (filter === 'all' || category === filter) {
card.classList.remove('hidden');
} else {
card.classList.add('hidden');
}
});
});
});
// === Smooth Scroll for Navigation === //
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => {
e.preventDefault();
const target = document.querySelector(anchor.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
Your portfolio is now fully interactive. Sections fade in as visitors scroll, the gallery filters instantly, and navigation glides between sections. This is a polished, deployable single-page site built with zero frameworks.
Secret mission
Add a Dark Mode Toggle
Your portfolio looks great, but what about visitors who prefer dark interfaces? Build a toggle that switches between light and dark themes, respects user preference, and persists across page refreshes using localStorage.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with no cloud services or paid tools. There are no ongoing costs to worry about.
Decide whether to keep your project files, or delete them if you no longer need them.
Resources you used:
- portfolio folder on your Desktop.
- index.html file (HTML structure).
- style.css file (all styling and design system).
- script.js file (scroll animations and filtering logic).
Keep everything running
No action needed. Choose this if you plan to continue building on your portfolio or want to deploy it online.
- Your files stay in the portfolio folder on your Desktop.
- You can open index.html in your browser anytime to view your portfolio.
- Consider deploying to a free hosting service like GitHub Pages or Netlify to share it publicly.
Pause - I'll come back to this later
Since this project is just local files, there is nothing to pause or shut down. Your files will stay on your computer until you delete them.
- Your portfolio folder takes up minimal disk space (a few kilobytes).
- No background processes are running from this project.
- When you are ready to return, open the folder in VS Code and continue where you left off.
Delete - I don't want to use this again
Remove the project folder and all its files to start fresh.
- Delete the portfolio folder from your Desktop.
- On macOS, right-click the folder and select Move to Trash.
- On Windows, right-click the folder and select Delete.
- That is everything. No accounts, cloud resources, or packages need to be removed.
Nice Work!
Nice Work!
You just built a complete design portfolio from scratch with zero frameworks, zero build tools, and zero dependencies.
Here is what you accomplished:
- Built a responsive single-page portfolio with a full design system using CSS custom properties for consistent colors, spacing, and typography.
- Created a filterable project gallery with CSS Grid, hover effects, and vanilla JavaScript category filtering.
- Added scroll-triggered animations using the Intersection Observer API for a polished, professional feel.
- Secret Mission: implemented a dark mode toggle with localStorage persistence that remembers the user's preference across page refreshes.
Ready to quiz yourself?