Build a Responsive Developer Portfolio
Build a mobile-first portfolio page with pure CSS Flexbox and Grid.
Introduction
30 Second Summary
Every website you visit looks different on your phone than on your laptop. Behind that seamless experience is a developer who understood how to make layouts respond to any screen size.
In this project, you will build a responsive developer portfolio page using React and pure CSS. You will write every line of layout code yourself, mastering Flexbox, CSS Grid, and media queries along the way.
What You'll Build
A polished portfolio landing page that you can resize from phone to desktop and watch the layout adapt in real time, with cards reflowing, typography scaling, and navigation reshaping itself at every breakpoint.
By the end of this project, you'll have:
- A sticky navigation bar that holds links in a horizontal row using Flexbox, staying pinned to the top of the page as you scroll.
- A hero section with a headline that scales fluidly between screen sizes using clamp() and a call-to-action button that animates on hover.
- A project card gallery powered by CSS Grid that automatically reflows from one column on mobile to three columns on desktop, with cards that lift and glow when you hover over them.
- Secret Mission: Add container queries so your project cards adapt their internal layout based on their container width, not the viewport.
Are there any prerequisites?
You should be comfortable with React components and JSX basics. You also need Node.js installed on your machine.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Scaffold a React Project with Vite
To build your responsive portfolio, you need a development environment that shows CSS changes instantly as you tweak layout properties.
Vite is a modern build tool that gives you instant hot-reload. In this step, you'll scaffold a React project with Vite and prepare a blank canvas for your portfolio's CSS.
In this step, get ready to:
- Scaffold a Vite + React project on your Desktop.
- Clear the default boilerplate to create a blank canvas.
- Start the dev server and confirm it works in your browser.
Create a new React project with Vite
Vite scaffolds a complete React project in seconds and includes a dev server with instant hot-reload. Every time you save a CSS file, the browser updates without a full page refresh.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (Windows) or Cmd+` (macOS).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your React project by running the Vite scaffold command:
npm create vite@latest my-portfolio -- --template react
What does this command do?
- npm create vite@latest downloads and runs the latest Vite project generator.
- my-portfolio is the name of the folder it creates on your Desktop.
- --template react tells Vite to set up a React project, skipping any interactive prompts.
- Move into your new project folder and install its dependencies by running:
cd my-portfolio
npm install
You should see output listing the packages that were installed, ending with a summary like "added X packages".
Seeing a permission error or command not found?
- If you see command not found: npm, your Node.js installation may not be on your system PATH. Close and reopen your terminal, then try again.
- If you see a permissions error on macOS, prefix the command with sudo or fix your npm permissions following the npm docs.
Help me fix my npm install error
Clear the default boilerplate
The Vite scaffold generates demo content and styling you don't need. You'll strip it out to start with a completely blank canvas for your portfolio.
- Open your my-portfolio folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop, select the my-portfolio folder, and click Open.
You should now see the project's file tree in the Explorer sidebar on the left.
- In the Explorer sidebar, right-click src/App.css and select Delete. Confirm the deletion when prompted.
- Click on src/index.css to open it. Select all the content (Cmd+A on macOS or Ctrl+A on Windows) and delete it so the file is completely empty. Save the file.
- Now replace the contents of src/App.jsx by clicking on it in the Explorer, selecting all the content, deleting it, and pasting this code:
function App() {
return (
<div>
<h1>Portfolio</h1>
</div>
)
}
export default App
What does this code do?
- The App function is your root React component. Every other component you build will render inside it.
- The <h1>Portfolio</h1> heading is a placeholder to confirm the app renders correctly. You'll replace it with real content in the next step.
- Save src/App.jsx.
✔️ Awesome, I've got everything!
Great. Double check you've saved both src/App.jsx and src/index.css.
ⓧ I'd like to double check the full code
Here is what src/App.jsx should look like:
function App() {
return (
<div>
<h1>Portfolio</h1>
</div>
)
}
export default App
Your src/index.css file should be completely empty (no content at all).
Your src/main.jsx should be unchanged from the scaffold:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
Start the dev server
With the boilerplate cleared, you can now start the Vite dev server to see your blank canvas in the browser.
- Open the integrated terminal in VS Code by pressing Ctrl+` (Windows) or Cmd+` (macOS).
- Start the dev server by running:
npm run dev
Before you check the browser, what do you expect to see on the page?
Open your browser and navigate to http://localhost:5173. You should see the word "Portfolio" as a heading on a plain white page with no other content or styling.
Don't see your Portfolio heading?
- If the terminal shows an error about a missing module or import, open src/App.jsx and confirm you removed the import './App.css' line that the scaffold originally had. You deleted the App.css file, so that import would break the app.
- If the page is blank, check that your App.jsx has the export default App line at the bottom.
- If port 5173 is in use, Vite will automatically try the next available port. Check the terminal output for the URL it printed.
Help me fix my Vite dev server
Your development environment is ready. Next up, you'll build the page structure with React components for the nav, hero, and project cards.
Build the Page Structure with Unstyled Components
Your Vite project is scaffolded and running. Next, you'll build the actual page content: a navigation bar, a hero section, and a gallery of project cards.
Writing the structure first, without any styling, reveals the browser's default layout behaviour. You'll see how content stacks vertically and looks plain. This establishes exactly what CSS needs to fix in the steps ahead.
In this step, get ready to:
- Create a Navigation component with a logo and four links.
- Create a Hero component with a headline, subtext, and call-to-action button.
- Create a Projects section that renders six project cards.
Create the Navigation component
The navigation bar is the first thing visitors see on any website. You'll create it as a standalone React component that displays a logo and four page links.
- Create a components folder inside src by running this command in your terminal:
mkdir src/components
- Create a new file called Navigation.jsx inside src/components/ and paste the following code:
function Navigation() {
return (
<nav className="nav">
<div className="nav-logo">DevPortfolio</div>
<ul className="nav-links">
<li><a href="#home">Home</a></li>
<li><a href="#projects">Projects</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
</nav>
)
}
export default Navigation
What does this code do?
- The nav element wraps the entire navigation bar, giving it semantic meaning for screen readers and search engines.
- The nav-logo div holds the site branding text, and nav-links is an unordered list of anchor links.
- The className attributes are hooks for CSS styling that you'll apply in a later step.
- Now replace the contents of src/App.jsx with this code to import and render the Navigation component:
import Navigation from './components/Navigation.jsx'
function App() {
return (
<div className="app">
<Navigation />
</div>
)
}
export default App
What changed in App.jsx?
You replaced the placeholder <h1>Portfolio</h1> with the <Navigation /> component. The import at the top tells React where to find the file.
- Save both Navigation.jsx and App.jsx.
- Check your browser. You should see "DevPortfolio" followed by a bulleted list of links: Home, Projects, About, Contact.
Don't see the navigation links?
- Make sure the file is saved at exactly src/components/Navigation.jsx. A typo in the folder or file name causes the import to fail.
- Check the terminal running your dev server for red error text. A missing closing tag or bracket in JSX usually points to the exact line.
- My Navigation component still isn't rendering. What should I check?
Create the Hero component
The hero section is the large banner area below the nav. It introduces the developer with a headline, a short description, and a call-to-action button.
- Create a new file called Hero.jsx inside src/components/ and paste the following code:
function Hero() {
return (
<section className="hero" id="home">
<div className="hero-content">
<h1 className="hero-title">Hi, I'm a Developer</h1>
<p className="hero-subtitle">
I build modern, responsive, and accessible web experiences.
</p>
<a href="#projects" className="hero-btn">View My Work</a>
</div>
</section>
)
}
export default Hero
What does this code do?
- The section element with id="home" anchors to the nav's "Home" link so clicking it scrolls here.
- The hero-btn is an anchor tag linking to the projects section. It looks like a button once CSS is applied.
- Update src/App.jsx to import and render the Hero component below Navigation:
import Navigation from './components/Navigation.jsx'
import Hero from './components/Hero.jsx'
function App() {
return (
<div className="app">
<Navigation />
<Hero />
</div>
)
}
export default App
- Save both Hero.jsx and App.jsx.
- Check your browser. Below the nav links, you should now see the large heading "Hi, I'm a Developer", a paragraph of subtext, and a blue "View My Work" link.
Hero content not appearing?
- Verify the import path in App.jsx says ./components/Hero.jsx with a leading dot-slash. Without it, the bundler looks in the wrong place.
- Confirm you included <Hero /> inside the <div className="app"> wrapper, not outside it.
- My Hero component isn't rendering. Help me debug my imports and JSX.
Create the ProjectCard and Projects components
The portfolio needs a gallery of project cards. You'll create two components: a reusable ProjectCard that accepts props, and a Projects wrapper that renders six of them.
- Create a new file called ProjectCard.jsx inside src/components/ and paste the following code:
function ProjectCard({ title, description }) {
return (
<article className="project-card">
<h3 className="project-card-title">{title}</h3>
<p className="project-card-description">{description}</p>
</article>
)
}
export default ProjectCard
What does this code do?
- The { title, description } syntax destructures the props object, pulling out two values passed in from the parent component.
- The article element marks each card as a self-contained piece of content, which is good for accessibility.
- Save ProjectCard.jsx.
- Now create a file called Projects.jsx inside src/components/ and paste this code:
import ProjectCard from './ProjectCard.jsx'
const projectData = [
{ title: 'Weather App', description: 'A real-time weather dashboard built with React and a public API.' },
{ title: 'Task Manager', description: 'A drag-and-drop task board with local storage persistence.' },
{ title: 'Recipe Finder', description: 'Search and save recipes with filtering by dietary needs.' },
{ title: 'Chat Interface', description: 'A real-time messaging UI with WebSocket integration.' },
{ title: 'Budget Tracker', description: 'Visualize spending with charts and monthly summaries.' },
{ title: 'Blog Platform', description: 'A markdown-powered blog with syntax highlighting.' },
]
function Projects() {
return (
<section className="projects" id="projects">
<h2 className="projects-heading">My Projects</h2>
<div className="projects-grid">
{projectData.map((project, index) => (
<ProjectCard
key={index}
title={project.title}
description={project.description}
/>
))}
</div>
</section>
)
}
export default Projects
What does this code do?
- The projectData array holds six objects, each with a title and description for one project card.
- The .map() method loops through the array and renders a ProjectCard for each entry, passing the title and description as props.
- The key={index} prop helps React track which items changed when the list re-renders.
- Update src/App.jsx one final time to import and render the Projects component:
import Navigation from './components/Navigation.jsx'
import Hero from './components/Hero.jsx'
import Projects from './components/Projects.jsx'
function App() {
return (
<div className="app">
<Navigation />
<Hero />
<Projects />
</div>
)
}
export default App
What does App.jsx look like now?
App.jsx imports three components and renders them in order: Navigation at the top, then Hero, then Projects. The app className on the wrapper div gives you a hook for page-level styling later.
Before you check the browser, what do you think the page looks like right now? All the content is there, but no CSS has been applied yet.
- Save all files (ProjectCard.jsx, Projects.jsx, and App.jsx).
- Check your browser. You should see everything stacked vertically: "DevPortfolio" and the links, then the heading "Hi, I'm a Developer", the paragraph, the "View My Work" link, a "My Projects" heading, and six project cards each showing a title and description.
The page looks plain and ugly. That's expected. Every piece of content is there, just stacked by the browser's default block layout with no spacing, color, or alignment.
Not seeing all six cards?
- Check the browser console (press F12 and click Console) for a red error. A common mistake is a missing closing parenthesis in the .map() call.
- Verify that Projects.jsx imports from './ProjectCard.jsx' with the correct capitalization. File names are case-sensitive on most systems.
- My project cards aren't rendering. Help me debug my Projects component.
✔️ Awesome, I've got everything!
Great. Make sure all five files are saved: App.jsx, Navigation.jsx, Hero.jsx, ProjectCard.jsx, and Projects.jsx.
ⓧ I'd like to double check the full code
Here is the final App.jsx that ties everything together:
import Navigation from './components/Navigation.jsx'
import Hero from './components/Hero.jsx'
import Projects from './components/Projects.jsx'
function App() {
return (
<div className="app">
<Navigation />
<Hero />
<Projects />
</div>
)
}
export default App
Scroll up to confirm each component file matches the code blocks you pasted earlier: Navigation.jsx, Hero.jsx, ProjectCard.jsx, and Projects.jsx.
Your page structure is complete. Every piece of content is in place, but it looks like a wall of unstyled text. Next up, you'll use Flexbox to transform that navigation and hero into polished, properly aligned sections.
Style the Navigation and Hero with Flexbox
Right now your portfolio page is functional but unusable. The navigation links stack vertically instead of sitting in a row. The hero text has no visual hierarchy. Everything runs together with browser-default spacing.
These are one-dimensional alignment problems. Flexbox is designed exactly for this. In this step, you will use Flexbox to transform the nav into a horizontal bar and give the hero section clear visual structure.
In this step, get ready to:
- Establish a CSS reset and design tokens (custom properties) for consistent styling.
- Style the navigation as a sticky horizontal bar using Flexbox.
- Style the hero section with fluid typography and an animated button.
Add a CSS reset and design tokens
Before writing any visual styles, you need two things. First, a reset that removes inconsistent browser defaults (margins, padding, box-sizing). Second, a set of CSS custom properties that act as design tokens for colors, spacing, and shadows. These tokens prevent magic numbers from scattering through your stylesheet.
- Open src/index.css in VS Code and add the following CSS reset, custom properties, and body styles:
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--color-bg: #f9fafb;
--color-surface: #ffffff;
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.16);
--radius: 8px;
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 1.5rem;
--space-lg: 2rem;
--space-xl: 4rem;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
What does this code do?
- The *, *::before, *::after selector targets every element. Setting box-sizing: border-box means padding and borders are included in width calculations, so elements never unexpectedly overflow.
- The :root block defines reusable variables. Instead of typing #2563eb everywhere, you reference var(--color-primary). Change it once, and the entire site updates.
- The spacing scale (--space-xs through --space-xl) gives you consistent increments so your layout never has random pixel values.
- Save src/index.css.
- Check your browser. The page background is now a light gray instead of white, the text uses a system font, and all default margins have collapsed (elements stack tightly together).
Page still looks white?
Make sure you saved the file. Vite's hot reload only triggers on save.
Confirm your code is in src/index.css (not src/App.css). The main.jsx file imports index.css.
Why isn't my CSS reset showing any changes in the browser?
Style the nav container with Flexbox
The nav currently stacks its children vertically because block elements fill the full width by default. Flexbox turns a container into a flexible row (or column), letting you control alignment and spacing with a few properties.
- In src/index.css, add the following styles below your body block:
/* Navigation */
.nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
background-color: var(--color-surface);
border-bottom: 1px solid #e5e7eb;
position: sticky;
top: 0;
z-index: 100;
}
What does this code do?
- display: flex turns .nav into a flex container. Its direct children (the logo div and the links list) become flex items arranged in a row.
- justify-content: space-between pushes the first child (logo) to the left edge and the last child (links) to the right edge.
- position: sticky with top: 0 keeps the nav pinned to the top of the viewport as the user scrolls.
- Save the file and check your browser.
- The nav now has a white background with a subtle bottom border. The logo text sits on the left and the links cluster on the right. The links are still stacked vertically within that right cluster because the <ul> itself is not yet a flex container.
Now style the nav's children so the links display horizontally with hover interactions.
- Add the following styles below your .nav block:
.nav-logo {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-primary);
}
.nav-links {
display: flex;
gap: var(--space-md);
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--color-text-muted);
font-size: 0.875rem;
transition: color 0.2s ease;
}
.nav-links a:hover {
color: var(--color-primary);
}
What does this code do?
- .nav-logo makes the brand text bold and uses your primary blue color.
- .nav-links applies display: flex to the <ul>, turning the list items into a horizontal row with gap: var(--space-md) spacing between them.
- transition: color 0.2s ease on the links creates a smooth color fade when the user hovers. The hover state switches from muted gray to your primary blue.
- Save the file.
- Your nav is now a complete horizontal bar. The blue "DevPortfolio" logo sits on the left, four gray links sit on the right in a neat row. Hover over any link to see it smoothly turn blue.
Links still stacked vertically?
Double-check that .nav-links has display: flex. Without it, the <ul> remains a block container and its <li> children stack.
Also confirm the class name nav-links in your CSS matches className="nav-links" in your Navigation.jsx component.
Help me debug why my nav links are still stacking vertically.
Style the hero section with Flexbox and fluid typography
The hero section needs to center its content and establish visual hierarchy. A large heading, smaller subtext, and a prominent button tell the visitor exactly where to look first.
- In src/index.css, add the following hero container and typography styles below your navigation styles:
/* Hero */
.hero {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-xl) var(--space-md);
text-align: center;
}
.hero-title {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
margin-bottom: var(--space-sm);
color: var(--color-text);
}
.hero-subtitle {
font-size: clamp(1rem, 2vw, 1.25rem);
color: var(--color-text-muted);
margin-bottom: var(--space-lg);
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
What is clamp() doing here?
clamp(2rem, 5vw, 3.5rem) creates fluid typography. The font smoothly scales between a minimum of 2rem and a maximum of 3.5rem, using 5vw (5% of viewport width) as the preferred size.
This means you get responsive text without writing any media queries. On a phone the heading is 2rem. On a wide monitor it caps at 3.5rem. Everything in between scales fluidly.
- Save the file and check your browser.
- The hero text is now centered on the page with a large bold heading and smaller muted subtitle. Try resizing your browser window to see the heading smoothly shrink and grow.
The last piece is the call-to-action button. It needs to stand out with color and respond to hover.
- Add the button styles below your .hero-subtitle block:
.hero-btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: var(--color-primary);
color: #ffffff;
text-decoration: none;
border-radius: var(--radius);
font-weight: 600;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.hero-btn:hover {
background-color: var(--color-primary-hover);
transform: scale(1.05);
}
What does this code do?
- display: inline-block lets the button respect padding and dimensions while staying inline with the centered text flow.
- transition animates both the background color and the transform over 0.2 seconds, so the hover effect feels smooth rather than abrupt.
- transform: scale(1.05) on hover makes the button grow slightly, giving tactile feedback that invites a click.
- Save src/index.css.
- Your hero section now has a prominent blue button. Hover over it to see it scale up and darken.
Button not changing on hover?
Check that your .hero-btn:hover rule is a separate block from .hero-btn. If you accidentally put the hover properties inside the main block, the transition will not trigger on mouse-over.
Also verify the class name hero-btn matches className="hero-btn" in your Hero.jsx component.
Why isn't my button hover transition working?
Before you move on, take a moment to predict what you will see. Do you think the project cards at the bottom have changed at all?
Scroll down past the hero. The six project cards are still plain unstyled text. That is correct. You only styled the nav and hero so far. The cards get their own treatment in the next step.
✔️ Awesome, I've got everything!
Great. Double-check that you have saved src/index.css with all the styles from this step.
ⓧ I'd like to double check the full code
Your src/index.css should contain these sections in order. Scroll up and confirm each one is present:
- The CSS reset (*, *::before, *::after block).
- The :root block with all color, shadow, radius, and spacing custom properties.
- The body block with font-family, background-color, color, and line-height.
- The navigation styles: .nav, .nav-logo, .nav-links, .nav-links a, and .nav-links a:hover.
- The hero styles: .hero, .hero-title, .hero-subtitle, .hero-btn, and .hero-btn:hover.
Your navigation and hero are polished. The page finally has structure and visual hierarchy. Next up, you will tackle the project cards with CSS Grid to create a responsive gallery that adapts its column count automatically.
Build the Project Grid with CSS Grid
Your navigation is polished and your hero has clear visual hierarchy. But scroll down to the project cards and you hit a wall of unstyled text stacked in a single column.
Six cards need to arrange themselves into rows and columns that adapt to the screen width. Flexbox can wrap items, but it cannot align cards across rows. CSS Grid handles this two-dimensional problem natively with a single line of code.
In this step, get ready to:
- Style the projects section with CSS Grid for automatic column reflow.
- Add card styling with background, shadow, and rounded corners.
- Create hover interactions that give cards tactile feedback.
Style the projects grid layout
The projects section needs padding, a centered heading, and a grid container that automatically decides how many columns to use based on available space.
- In index.css, scroll to the bottom of the file (after the .hero-btn:hover rule) and add the following CSS:
/* Projects Section */
.projects {
padding: var(--space-xl) var(--space-md);
}
.projects-heading {
font-size: clamp(1.5rem, 3vw, 2rem);
text-align: center;
margin-bottom: var(--space-lg);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-md);
max-width: 1200px;
margin: 0 auto;
}
What does this code do?
The magic is in grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)). This single rule tells the browser to create as many columns as will fit, where each column is at least 280px wide but can stretch to fill remaining space.
The grid automatically reflows from one column to three without any media queries. max-width: 1200px with margin: 0 auto centers the grid and prevents it from stretching too wide on large monitors.
Why CSS Grid instead of Flexbox here?
Flexbox is one-dimensional. It lays items out along a single row or column. When flex items wrap, the second row has no relationship to the first. Cards in different rows won't align to the same column edges.
CSS Grid is two-dimensional. It controls both rows and columns simultaneously, so every card aligns to the same grid tracks regardless of which row it falls into.
- Save index.css and check your browser.
- Before you resize, predict: what will happen to the cards when you drag the browser window narrower?
Drag the right edge of your browser window to make it narrower. The cards reflow from three columns to two columns to one column, all without a single media query.
Cards still stacked in one column at full width?
- Make sure you saved index.css. Unsaved changes won't trigger a hot reload.
- Check that your class name projects-grid in CSS matches className="projects-grid" in your Projects.jsx component. Class names are case-sensitive.
- Confirm the display: grid property is on .projects-grid, not on .projects.
Help me debug why my CSS Grid isn't creating columns
Style the project cards
The grid is working, but each card is invisible. There is no background, no border, and no separation between cards. Adding surface styling makes each card feel like a distinct, clickable object.
- Below the .projects-grid rule you just wrote, add the card styles:
/* Project Card */
.project-card {
background-color: var(--color-surface);
padding: var(--space-lg);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.project-card-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: var(--space-xs);
}
.project-card-description {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
}
What does this code do?
- background-color: var(--color-surface) gives each card a white background that stands out from the page's grey background.
- box-shadow: var(--shadow-sm) adds a subtle shadow that makes cards appear to float slightly above the page.
- transition: transform 0.2s ease, box-shadow 0.2s ease tells the browser to animate these two properties smoothly when they change. This prepares the cards for hover effects.
- Save index.css and check your browser.
Each card now appears as a distinct white box with rounded corners and a light shadow. The title text is bold and the description is muted grey.
Cards look right but text is clipping or overflowing?
- Confirm padding: var(--space-lg) is on .project-card. Without padding, text hits the card edges.
- Make sure border-radius: var(--radius) uses the variable name exactly. A typo like --border-radius won't resolve.
Help me fix my project card styling
Add hover effects to the cards
The transition property you just added is waiting for something to animate. A hover state that lifts the card and deepens its shadow gives users tactile feedback that the card is interactive.
- Below the .project-card-description rule, add the hover state:
.project-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
- Save index.css and hover your mouse over any project card in the browser.
The card lifts up by 4 pixels and its shadow deepens, then smoothly settles back when you move away. The transition you defined earlier makes this movement feel natural instead of instant.
Why transitions live on the base state, not on :hover
The transition property on .project-card (not on :hover) means the animation runs in both directions. Hover in animates smoothly, and hover out animates smoothly too. If transition were only on :hover, the card would snap instantly back to its resting position when the mouse leaves.
Now resize your browser one more time. Do you think the grid will still reflow at different widths?
It does. The cards automatically shift from one column on narrow viewports, to two columns on medium widths, to three columns on wide screens. No media queries needed for this behaviour.
✔️ Awesome, I've got everything!
Great. Make sure you have saved index.css with all the project and card styles.
ⓧ I'd like to double check the full code
Your index.css should contain all the styles you have written so far. Scroll through and confirm these sections are present in order:
- The CSS reset (*, *::before, *::after).
- The :root custom properties.
- The body base styles.
- Navigation styles (.nav, .nav-logo, .nav-links).
- Hero styles (.hero, .hero-title, .hero-subtitle, .hero-btn).
- Projects section styles (.projects, .projects-heading, .projects-grid).
- Project card styles (.project-card, .project-card:hover, .project-card-title, .project-card-description).
Your card gallery now adapts itself to any screen width using pure CSS Grid. Next up, you will make the entire page truly responsive by refactoring to a mobile-first approach with layered media queries.
Make it Responsive with Mobile-First Media Queries
Your project cards are sitting in a responsive grid, the nav looks polished, and the hero has smooth hover effects. On a wide monitor, everything looks great. But pull the browser window narrow and the spacing feels cramped on smaller screens.
Mobile-first responsive design solves this by writing base styles that target the smallest screen first. Then you layer on enhancements for larger viewports using media queries. In this step, you will add two breakpoints that give your page more breathing room as the screen gets wider.
In this step, get ready to:
- Add a tablet breakpoint at 768px with enhanced nav and hero spacing.
- Add a desktop breakpoint at 1024px for wider layout enhancements.
- Test all three viewport widths in DevTools to confirm no horizontal overflow.
Add the tablet breakpoint
Your current base styles already use conservative values that work on a 375px screen. The nav links use a small font size and tight gap. The hero has compact padding. These are your mobile defaults.
A @media (min-width: 768px) block activates only when the viewport is 768px or wider. Anything inside it overrides the base styles for tablet-sized screens and above.
- In index.css, scroll to the very bottom of the file (after the .project-card-description rule).
- Add the tablet media query by pasting the following code at the end of the file:
/* Responsive: Tablet */
@media (min-width: 768px) {
.hero {
padding: 6rem var(--space-lg);
}
.nav-links {
gap: var(--space-lg);
}
.nav-links a {
font-size: 1rem;
}
}
What does this code do?
- The @media (min-width: 768px) wrapper means everything inside only applies when the screen is at least 768px wide.
- The hero gets taller vertical padding (6rem) so it breathes on wider screens instead of feeling cramped.
- Nav links get a larger gap and bump up to 1rem font-size so they are easier to read on a tablet.
- Save index.css.
- In your browser, widen the window to at least 768px.
- You should see the hero section gain more vertical breathing room and the nav links spread out with larger text.
No difference when resizing?
- Make sure the media query is outside all other rule blocks. It must be at the top level of your CSS file, not nested inside another selector.
- Check you typed min-width (not max-width). Mobile-first uses min-width to enhance upward.
- Confirm your browser window is actually wider than 768px. Drag it wider until you cross that threshold.
Why isn't my tablet media query applying when I resize the browser?
Add the desktop breakpoint
The tablet breakpoint improved spacing for medium screens. Now add a second breakpoint at 1024px that gives desktop users an even more generous layout with wider grid gaps and taller hero padding.
- In index.css, add the desktop media query directly below the tablet media query you just wrote:
/* Responsive: Desktop */
@media (min-width: 1024px) {
.hero {
padding: 8rem var(--space-xl);
}
.projects {
padding: var(--space-xl) var(--space-lg);
}
.projects-grid {
gap: var(--space-lg);
}
}
What does this code do?
- At 1024px and wider, the hero padding jumps to 8rem vertically. This gives the headline room to command attention on a large display.
- The projects section gets wider horizontal padding (var(--space-lg)) so cards don't hug the viewport edges.
- The grid gap increases from var(--space-md) to var(--space-lg), letting each card breathe on wider screens.
Why mobile-first instead of desktop-first?
Mobile-first means your base CSS handles the hardest constraint (small screens) by default. You only add complexity as space allows. If you wrote desktop-first with max-width queries, you would start with the most complex layout and strip features away for smaller screens.
The mobile-first approach produces less CSS overall and ensures your page always works on the smallest screen without any media query support.
- Save index.css.
- Widen your browser to at least 1024px.
- You should see the hero section expand with even more vertical space, and the project cards sit in a wider grid with more generous gaps between them.
Desktop styles not kicking in?
- Make sure the 1024px block appears after the 768px block. Order matters because both can be active simultaneously on a wide screen, and the later rule wins when properties overlap.
- Verify the closing brace of your tablet media query is correct. A missing } will swallow everything below it into the wrong scope.
Help me figure out why my 1024px desktop media query styles aren't applying.
✔️ Awesome, I've got everything!
Great. Make sure you have saved index.css with both media query blocks at the bottom of the file.
ⓧ I'd like to double check the full code
Your index.css is over 30 lines now, so scroll to the bottom of the file and confirm these two blocks appear in this order after all your base styles:
- The tablet media query: @media (min-width: 768px) containing rules for .hero, .nav-links, and .nav-links a.
- The desktop media query: @media (min-width: 1024px) containing rules for .hero, .projects, and .projects-grid.
All base styles above these blocks should be unchanged from the previous step.
Test all three breakpoints in DevTools
Your CSS now has three layout tiers: base (mobile), tablet (768px+), and desktop (1024px+). Time to verify the page adapts cleanly across all three without any horizontal scrollbar appearing.
- Open your browser's DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the device toolbar toggle button (the phone/tablet icon in the top-left of DevTools) to enter responsive mode.
- Set the viewport width to 375 pixels.
Before you resize to the other widths, predict: what will change when you jump from 375px to 768px? What about from 768px to 1024px?
- Change the viewport width to 768 pixels.
- You should see the hero section grow taller and the nav links space out with larger text.
- Change the viewport width to 1024 pixels.
- You should see the hero expand further, and the card grid gets wider gaps between cards.
- At all three widths, confirm the page scrolls vertically only. No horizontal scrollbar should appear.
What should I see?
- At 375px: one column of cards, compact hero padding, small nav link text. Everything fits without overflow.
- At 768px: cards start forming two columns (the grid auto-fit handles this). Hero gains vertical space. Nav links are larger.
- At 1024px: cards show three columns with generous gaps. Hero has prominent vertical padding. The layout feels spacious.
Seeing a horizontal scrollbar?
- Check that your CSS reset includes box-sizing: border-box on all elements. Without it, padding can push elements wider than the viewport.
- Make sure no element has a fixed pixel width larger than the viewport. The max-width: 1200px on .projects-grid should prevent grid overflow.
Help me find what is causing horizontal overflow in my responsive CSS layout.
Your portfolio now adapts gracefully from a phone screen all the way to a widescreen monitor. Every section has its own responsive behaviour: the grid reflows intrinsically, and the media queries layer on spacing enhancements for larger viewports. Next up, you will tackle a Secret Mission that goes beyond viewport-based responsiveness entirely.
Secret mission
Add Container Queries to Your Project Cards
Your responsive grid adapts to the viewport, but what if a card lives inside a narrow sidebar or a modal? Container queries let components respond to their own container's width, making them truly portable. In this mission, you'll upgrade your project cards so they adapt their internal layout based on how much space their container gives them.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The my-portfolio project folder on your Desktop.
- The Vite dev server running in your terminal.
Keep everything running
No action needed. Choose this if you are still actively building or iterating on your portfolio design.
- Your project folder stays on your Desktop and you can continue editing CSS and components.
- The dev server stays running so you can see changes instantly.
- To restart the dev server later, open a terminal in the my-portfolio folder and run npm run dev.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your files stay on disk so you can pick up where you left off.
- Switch to the terminal where the Vite dev server is running.
- Press Ctrl+C to stop the server.
- Your my-portfolio folder remains on your Desktop. When you are ready to resume, open a terminal in that folder and run npm run dev.
Delete - I don't want to use this again
Remove the project folder entirely. There are no cloud resources to tear down.
- Switch to the terminal where the Vite dev server is running and press Ctrl+C to stop it.
- Delete the project folder by running the command for your operating system.
macOS
cd ~/Desktop
rm -rf my-portfolio
Windows
cd ~/Desktop
rmdir /s /q my-portfolio
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see my-portfolio in the listing.
Nice Work!
Nice Work!
You just built a production-quality responsive developer portfolio page from scratch using React and pure CSS. Every line of layout code is yours.
You've learned how to:
- Use Flexbox for one-dimensional alignment (nav bar, hero content) and CSS Grid with auto-fit/minmax() for an intrinsically responsive card gallery.
- Implement mobile-first responsive design with layered media queries that adapt your layout from 375px through tablet and desktop without horizontal overflow.
- Apply fluid typography with clamp() so text scales smoothly between viewport sizes, and add hover interactions with CSS transitions and transforms.
- Secret Mission: Implemented container queries so project cards adapt their internal layout based on their container width, not the viewport.
Ready to quiz yourself?