Build an Ecommerce Developer Portfolio
Build and deploy an ecommerce developer portfolio with Next.js.
Introduction
30 Second Summary
Recruiters often meet your work before they meet you. If your strongest projects only appear as a list of technologies, the problem-solving behind them stays hidden.
In this project, you will build a responsive ecommerce developer portfolio with Next.js that puts your strongest evidence first. It guides recruiters from a clear specialty to detailed case studies before giving you a shareable live link.
What You'll Build
A recruiter follows a clear path from your ecommerce specialty on the homepage to focused case studies on the work page.
By the end of this project, you'll have:
- A responsive recruiter-focused homepage that makes your specialty, skills, experience, and contact path easy to scan.
- A reusable case-study gallery on /work that turns typed TypeScript data into accessible Radix Dialogs.
- A live production deployment on Vercel with restrained GSAP entrance motion that respects reduced-motion preferences.
- Secret Mission: Add a third real ecommerce case study through the typed data model without duplicating any UI code.
Are there any prerequisites?
You'll need a Windows computer with Node.js, Git, Visual Studio Code, GitHub, and a Vercel account available. Basic familiarity with HTML and CSS helps, but you do not need prior React, TypeScript, or Next.js experience.
Before We Start
Your portfolio needs a clear audience before you shape its story. This checkpoint helps you identify the ecommerce developer role or client you want to attract and the two projects that best prove your problem-solving skills.
Set Up the Pinned Next.js Project
A recruiter-facing portfolio needs a stable foundation before its content can shine. Version drift can create setup failures that have nothing to do with your code.
You'll confirm Node.js 20.9.0 or newer. Then you'll create a pinned Next.js App Router project with one routing model.
In this step, get ready to:
- Confirm the supported Node.js version.
- Create the pinned TypeScript App Router project.
- Run the starter homepage on your computer.
Confirm the Node.js version
Next.js 16.3.4 requires Node.js 20.9.0 or newer. This check stops an unsupported runtime from breaking the project scaffold.
- Press the Windows key to open Windows search.
- Type PowerShell.
- Press Enter to open Windows PowerShell.
- Check your installed Node.js version by running this command:
node --version
What Does This Command Do?
The command asks Node.js to print its installed version. You can compare that result with the minimum required by Next.js.
✔️ I see version 20.9.0 or higher
Your Node.js runtime meets the project requirement. The pinned scaffold can run on this version.
ⓧ I see an older version
The installed runtime is below the version required by Next.js. Install the current Node.js LTS Windows build before creating the project.
- Visit the official Node.js download page.
- Download the current Node.js LTS Windows installer.
- Run the downloaded installer.
- Complete the installation with the default options.
A fresh PowerShell session picks up the updated Node.js installation.
- Close Windows PowerShell.
- Reopen Windows PowerShell through Windows search.
- Check the updated version by running this command:
node --version
What Should the Updated Check Show?
The version output should report Node.js 20.9.0 or newer. This confirms the upgraded runtime is ready.
ⓧ Command not found
PowerShell cannot currently access Node.js. Installing the current LTS Windows build adds the runtime required by this project.
- Visit the official Node.js download page.
- Download the current Node.js LTS Windows installer.
- Run the downloaded installer.
- Complete the installation with the default options.
Windows PowerShell needs a fresh session before it can find the new installation.
- Close Windows PowerShell.
- Reopen Windows PowerShell through Windows search.
- Check the installation by running this command:
node --version
What Should the Installation Check Show?
The version output should report Node.js 20.9.0 or newer. This proves PowerShell can access the supported runtime.
Scaffold and pin the project
The project scaffold creates the starter files for your portfolio. The pinned manifest keeps every dependency on the tested version used throughout this project.
Why Use App Router Only?
The App Router already turns folders into URL routes. Adding React Router would create a second routing model inside the same project.
One routing model keeps the portfolio easier to understand. It also gives you direct practice with the structure recruiters expect in a Next.js project.
- Move Windows PowerShell to your Desktop by running this command:
Set-Location -Path "$HOME\Desktop"
What Does This Command Do?
The Set-Location command moves PowerShell into the Desktop folder stored under $HOME. The new project folder will be easy to find from Windows.
Your PowerShell prompt should now point to the Desktop folder.
The first scaffold can take a few minutes while npm downloads the starter packages. The --yes option prevents the command from pausing for setup questions.
- Create the pinned Next.js starter by running this command:
npx create-next-app@16.3.4 ecommerce-portfolio --ts --app --src-dir --no-tailwind --no-linter --use-npm --yes
What Does This Command Create?
- The --ts option creates a TypeScript project.
- The --app option selects the App Router.
- The --src-dir option places the application inside src.
- The remaining options use npm without Tailwind or a linter.
PowerShell should return after creating the ecommerce-portfolio folder. The scaffold output should finish without terminal errors.
The Scaffold Did Not Finish?
Confirm that the Node.js check reported 20.9.0 or newer. Check your internet connection before retrying the scaffold.
Ask for targeted help with troubleshooting the Next.js scaffold.
Visual Studio Code gives you an editor for replacing the generated manifest. You may see a folder trust prompt after opening the project.
- Press the Windows key to open Windows search.
- Type Visual Studio Code.
- Press Enter to open Visual Studio Code.
Visual Studio Code opens to its starting workspace.
- Click File in the top menu bar.
- Click Open Folder.
The folder picker lets you select the project that the scaffold created.
- Select the ecommerce-portfolio folder on your Desktop.
- Confirm the folder selection.
A folder trust prompt may appear because this is the first time Visual Studio Code has opened the project.
- Confirm that you trust the folder you created if prompted.
The Explorer sidebar should list package.json alongside the generated src folder.
The package.json file defines the project scripts. It also locks the framework and library versions used in later steps.
- Select package.json in the Explorer sidebar.
The editor now shows the manifest generated by the starter.
- Press Ctrl+A to select the existing contents.
- Replace the selected contents with this pinned project definition:
{"name":"ecommerce-portfolio","version":"0.1.0","private":true,"engines":{"node":">=20.9.0"},"scripts":{"dev":"next dev","build":"next build","start":"next start","vercel:login":"vercel login","deploy":"vercel --prod","cleanup":"vercel remove ecommerce-portfolio"},"dependencies":{"@gsap/react":"2.1.2","gsap":"3.15.0","next":"16.3.4","radix-ui":"1.6.7","react":"19.2.8","react-dom":"19.2.8"},"devDependencies":{"@types/node":"26.4.1","@types/react":"19.2.18","@types/react-dom":"19.2.5","typescript":"5.9.3","vercel":"59.11.2"}}
What Does This File Control?
The engines field records the minimum Node.js version. The scripts field gives later steps tested commands for development and deployment.
Each dependency uses an exact version. Future installations will keep the same project stack.
- Press Ctrl+S to save package.json.
✔️ Awesome, I've got everything!
Your pinned manifest now matches the project baseline.
ⓧ I'd like to double check the full code
- Compare your package.json with this complete file:
{"name":"ecommerce-portfolio","version":"0.1.0","private":true,"engines":{"node":">=20.9.0"},"scripts":{"dev":"next dev","build":"next build","start":"next start","vercel:login":"vercel login","deploy":"vercel --prod","cleanup":"vercel remove ecommerce-portfolio"},"dependencies":{"@gsap/react":"2.1.2","gsap":"3.15.0","next":"16.3.4","radix-ui":"1.6.7","react":"19.2.8","react-dom":"19.2.8"},"devDependencies":{"@types/node":"26.4.1","@types/react":"19.2.18","@types/react-dom":"19.2.5","typescript":"5.9.3","vercel":"59.11.2"}}
What Should Match?
The file should name the project ecommerce-portfolio. It should include the Node.js engine requirement and every exact dependency pin shown here.
Installing the pinned dependency set can take a few minutes on the first run. PowerShell may stay quiet while packages download.
- Click Terminal in the Visual Studio Code menu bar.
- Select New Terminal.
The integrated PowerShell prompt should end with ecommerce-portfolio.
- Install the pinned dependencies by running this command:
npm install
What Does This Command Do?
The command reads both dependency groups from package.json. It installs the exact versions into the project.
npm also updates package-lock.json with the resolved package tree. That lock file keeps future installations consistent.
The installation should complete without dependency errors. The terminal should return to a prompt inside ecommerce-portfolio.
The Dependencies Did Not Install?
Confirm that the terminal path ends with ecommerce-portfolio. Compare package.json with the complete file above to catch missing quotes or braces.
Ask for targeted help with diagnosing the npm installation.
Start and verify the development server
A development server turns the generated project into a page you can inspect in your browser. It also reports startup problems directly in the integrated terminal.
Before you start the server, what do you expect the browser to show at the local project URL?
- Start the Next.js development server by running this command:
npm run dev
What Does This Command Do?
The command runs the dev script from package.json. That script starts the local Next.js development server.
The terminal remains occupied while the server is running. Keep it open while you test the starter page.
The terminal should report that the local server is ready. It should show http://localhost:3000 without startup errors.
- Open your web browser from the Windows taskbar.
- Enter http://localhost:3000 in the address bar.
You'll see the Next.js starter homepage. This proves the TypeScript App Router project loads successfully.
- Return to the integrated PowerShell terminal in Visual Studio Code.
You should see the server still running without terminal errors. You've cleared the setup hurdle with a pinned local project that is ready to customize.
The Starter Page Does Not Load?
Confirm that the integrated terminal is still running the development server. Check that your browser address uses port 3000.
Ask for targeted help with diagnosing the local Next.js server.
Your pinned Next.js starter is running. Next, you'll replace the generic page with a recruiter-focused ecommerce homepage.
Build the Recruiter-Focused Homepage
Your running Next.js starter proves that the project foundation works. The generic screen still gives a recruiter no reason to remember you.
A recruiter needs to understand your ecommerce specialty within the first screen. This step turns the starter into a responsive homepage with clear evidence of your skills and experience.
In this step, get ready to:
- Build the shared navigation, metadata, contact link, and footer.
- Replace the starter page with recruiter-focused homepage content.
- Style the homepage and personalize its claims with truthful details.
Build the shared site shell
The root layout wraps every route in your portfolio. Shared navigation belongs here because later pages inherit the same brand, contact path, and footer.
Why Use the App Router Layout?
The App Router uses layout.tsx for interface elements shared across routes. The same shell stays consistent when the portfolio gains its dedicated work page.
This keeps routing inside Next.js and gives every page one recognizable frame.
- In Visual Studio Code from earlier, select src/app/globals.css in the ecommerce-portfolio file tree.
- Replace its starter content with the complete shared stylesheet in the double-check tab below.
✔️ I've updated the shared stylesheet
Your shared stylesheet now defines the color system and site-wide interface. Keep the development server running while you update the layout.
ⓧ I'd like to double check the full code
- Replace the complete contents of src/app/globals.css with this file:
:root {
--background: #07111f;
--surface: #0d1b2d;
--surface-strong: #13243a;
--text: #f4f7fb;
--muted: #9fb0c6;
--accent: #6ee7b7;
--accent-dark: #062d24;
--border: rgba(255, 255, 255, 0.12);
--max-width: 1120px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(45, 212, 191, 0.12), transparent 32rem),
var(--background);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
}
.siteHeader {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid var(--border);
background: rgba(7, 17, 31, 0.84);
backdrop-filter: blur(16px);
}
.siteHeaderInner,
.siteFooter {
width: min(calc(100% - 2rem), var(--max-width));
margin: 0 auto;
}
.siteHeaderInner {
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
font-weight: 800;
letter-spacing: -0.03em;
}
.siteNav {
display: flex;
align-items: center;
gap: 1rem;
}
.navLink {
color: var(--muted);
}
.navLink:hover,
.navLink:focus-visible {
color: var(--text);
}
.contactLink {
border-radius: 999px;
background: var(--accent);
color: var(--accent-dark);
padding: 0.55rem 0.9rem;
font-weight: 800;
}
.siteFooter {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 2rem 0 3rem;
border-top: 1px solid var(--border);
color: var(--muted);
}
:focus-visible {
outline: 3px solid var(--accent);
outline-offset: 3px;
}
@media (max-width: 640px) {
.siteHeaderInner,
.siteFooter {
width: min(calc(100% - 1.25rem), var(--max-width));
}
.siteNav {
gap: 0.65rem;
}
.siteFooter {
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
What Does This Stylesheet Do?
- The custom properties create one color and spacing system for the entire portfolio.
- The shared header remains visible while a recruiter scrolls through the page.
- The responsive rule stacks the footer on narrow screens.
- The focus outline keeps keyboard navigation visible.
- Select src/app/layout.tsx in the same file tree.
- Replace its starter content with the complete root layout in the double-check tab below.
✔️ I've updated the root layout
Your root layout now supplies the shared portfolio frame. The homepage content renders through the typed children slot.
ⓧ I'd like to double check the full code
- Replace the complete contents of src/app/layout.tsx with this file:
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "Your Name | Ecommerce Web Developer",
description:
"Portfolio of an ecommerce web developer focused on accessible storefronts, conversion-aware experiences, and maintainable frontend systems.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>
<header className="siteHeader">
<div className="siteHeaderInner">
<Link className="brand" href="/">
Your Name
</Link>
<nav className="siteNav" aria-label="Primary navigation">
<Link className="navLink" href="/">
Home
</Link>
<Link className="navLink" href="/work">
Work
</Link>
<a className="contactLink" href="mailto:you@example.com">
Contact
</a>
</nav>
</div>
</header>
{children}
<footer className="siteFooter">
<p>Your Name, Ecommerce Web Developer</p>
<p>Built with Next.js, Radix Primitives, CSS Modules, and GSAP.</p>
</footer>
</body>
</html>
);
}
What Does This Layout Do?
- The metadata object gives the homepage a descriptive browser title and summary.
- The Link components prepare client-side navigation between the homepage and the future work route.
- The global LayoutProps<"/"> helper types the root route and its child content.
- The header and footer wrap every route through children.
- Save src/app/globals.css.
- Save src/app/layout.tsx.
- Refresh http://localhost:3000 in your browser.
You should see a dark page framed by a sticky header and a footer. The starter homepage remains between them, which proves that the shared layout wraps the current route.
Shared Shell Missing?
Confirm that both files are inside src/app. Check that layout.tsx still imports ./globals.css.
Ask for help with diagnosing the shared layout.
Create the homepage content
A recruiter-focused homepage needs a clear value proposition before it lists tools. The content below leads with the customer journey and supports that claim with focused expertise.
The skills list uses a typed array so each label follows one reusable rendering pattern. This keeps the page easy to update without duplicating list markup.
- Select src/app/page.tsx in the ecommerce-portfolio file tree.
- Replace its starter content with the complete homepage in the double-check tab below.
✔️ I've added the homepage content
Your homepage now presents the specialty, expertise, skills, and experience story in a recruiter-friendly order.
ⓧ I'd like to double check the full code
- Replace the complete contents of src/app/page.tsx with this file:
import styles from "./page.module.css";
const skills = [
"Next.js",
"React",
"TypeScript",
"CSS Modules",
"Accessible UI",
"Commerce APIs",
"Performance",
"Conversion UX",
];
export default function HomePage() {
return (
<main className={styles.main}>
<section className={styles.hero} aria-labelledby="hero-title">
<div>
<p className={styles.eyebrow}>Ecommerce Web Developer</p>
<h1 id="hero-title" className={styles.heroTitle}>
I build storefronts that make buying feel effortless.
</h1>
<p className={styles.heroText}>
I turn product discovery, checkout, and post-purchase journeys into
fast, accessible experiences backed by maintainable frontend code.
</p>
<div className={styles.actions}>
<a className={styles.primaryAction} href="/work">
View case studies
</a>
<a className={styles.secondaryAction} href="mailto:you@example.com">
Start a conversation
</a>
</div>
</div>
<aside className={styles.heroPanel}>
<p className={styles.panelLabel}>What I bring</p>
<div className={styles.panelGrid}>
<div className={styles.panelCard}>
<span>01</span>
<strong>Storefront UX</strong>
<p>Clear journeys from landing page to checkout.</p>
</div>
<div className={styles.panelCard}>
<span>02</span>
<strong>Frontend systems</strong>
<p>Reusable components with typed, maintainable data.</p>
</div>
<div className={styles.panelCard}>
<span>03</span>
<strong>Business context</strong>
<p>Technical choices connected to customer outcomes.</p>
</div>
</div>
</aside>
</section>
<section className={styles.section} aria-labelledby="expertise-title">
<div className={styles.sectionHeader}>
<p className={styles.eyebrow}>Focused expertise</p>
<h2 id="expertise-title">Commerce experiences built around intent</h2>
</div>
<div className={styles.expertiseGrid}>
<article className={styles.expertiseCard}>
<h3>Product discovery</h3>
<p>
Search, navigation, merchandising, and product detail interfaces
that help customers make confident choices.
</p>
</article>
<article className={styles.expertiseCard}>
<h3>Checkout experience</h3>
<p>
Clear validation, resilient state, and accessible interaction
patterns that reduce avoidable purchase friction.
</p>
</article>
<article className={styles.expertiseCard}>
<h3>Frontend quality</h3>
<p>
Typed components, scoped styling, performance awareness, and
interfaces that remain understandable as products grow.
</p>
</article>
</div>
</section>
<section className={styles.section} aria-labelledby="skills-title">
<div className={styles.sectionHeader}>
<p className={styles.eyebrow}>Core toolkit</p>
<h2 id="skills-title">Skills aligned to modern ecommerce teams</h2>
</div>
<ul className={styles.skillList}>
{skills.map((skill) => (
<li key={skill}>{skill}</li>
))}
</ul>
</section>
<section className={styles.experience} aria-labelledby="experience-title">
<div>
<p className={styles.eyebrow}>Experience snapshot</p>
<h2 id="experience-title">Show ownership, not a task list</h2>
</div>
<div className={styles.experienceCopy}>
<p>
Replace this paragraph with two or three sentences about your role,
the customers you supported, and the ecommerce surfaces you owned.
</p>
<ul className={styles.experienceList}>
<li>State what you personally designed, built, or improved.</li>
<li>Connect a technical decision to a customer or business need.</li>
<li>Use only metrics or outcomes you can verify and explain.</li>
</ul>
<p className={styles.note}>
Content rule: every claim on this page should be defensible in an
interview.
</p>
</div>
</section>
</main>
);
}
What Does This Homepage Do?
- The hero states an ecommerce specialty and connects it to a customer outcome.
- The expertise cards translate broad frontend work into specific commerce journeys.
- The skills array generates consistent list items from one data source.
- The experience section prompts you to show personal ownership and defensible results.
- Save src/app/page.tsx.
- Refresh http://localhost:3000.
You should see the ecommerce hero, three expertise areas, a skills list, and an experience section. The content may look plain until the route-specific styles arrive.
Homepage Content Missing?
Check that page.tsx imports ./page.module.css. Confirm that every opening JSX tag has a matching closing tag.
Ask for help with diagnosing the homepage component.
Style and personalize the homepage
A CSS Module keeps homepage class names scoped to this route. The styles create the editorial layout without leaking route-specific rules into the shared stylesheet.
- Select src/app/page.module.css in the ecommerce-portfolio file tree.
- Replace its starter content with the complete homepage stylesheet in the double-check tab below.
✔️ I've styled the homepage
Your homepage now uses a responsive editorial grid with clear visual hierarchy and locally scoped styles.
ⓧ I'd like to double check the full code
- Replace the complete contents of src/app/page.module.css with this file:
.main {
width: min(calc(100% - 2rem), var(--max-width));
margin: 0 auto;
}
.hero {
min-height: calc(100vh - 72px);
display: grid;
grid-template-columns: 1.2fr 0.8fr;
align-items: center;
gap: clamp(2rem, 6vw, 5rem);
padding: 5rem 0;
}
.eyebrow {
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.heroTitle {
max-width: 760px;
margin-top: 1rem;
font-size: clamp(3rem, 8vw, 6.5rem);
line-height: 0.94;
letter-spacing: -0.06em;
}
.heroText {
max-width: 660px;
margin-top: 1.5rem;
color: var(--muted);
font-size: clamp(1rem, 2vw, 1.25rem);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 2rem;
}
.primaryAction,
.secondaryAction {
border-radius: 999px;
padding: 0.8rem 1.1rem;
font-weight: 800;
}
.primaryAction {
background: var(--accent);
color: var(--accent-dark);
}
.secondaryAction {
border: 1px solid var(--border);
}
.heroPanel,
.expertiseCard,
.experience {
border: 1px solid var(--border);
background: linear-gradient(145deg, var(--surface-strong), var(--surface));
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24);
}
.heroPanel {
border-radius: 1.5rem;
padding: 1.25rem;
}
.panelLabel {
color: var(--muted);
font-size: 0.85rem;
}
.panelGrid {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.panelCard {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.8rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.04);
padding: 1rem;
}
.panelCard span {
grid-row: span 2;
color: var(--accent);
font-weight: 800;
}
.panelCard p {
color: var(--muted);
font-size: 0.92rem;
}
.section {
padding: 5rem 0;
border-top: 1px solid var(--border);
}
.sectionHeader {
max-width: 720px;
margin-bottom: 2rem;
}
.sectionHeader h2,
.experience h2 {
margin-top: 0.5rem;
font-size: clamp(2rem, 5vw, 3.7rem);
line-height: 1.05;
letter-spacing: -0.04em;
}
.expertiseGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.expertiseCard {
min-height: 220px;
border-radius: 1.25rem;
padding: 1.4rem;
}
.expertiseCard h3 {
font-size: 1.3rem;
}
.expertiseCard p {
margin-top: 0.75rem;
color: var(--muted);
}
.skillList {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
list-style: none;
}
.skillList li {
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
padding: 0.65rem 0.9rem;
}
.experience {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 2rem;
margin: 2rem 0 5rem;
border-radius: 1.5rem;
padding: clamp(1.4rem, 5vw, 3rem);
}
.experienceCopy {
color: var(--muted);
}
.experienceList {
display: grid;
gap: 0.55rem;
margin: 1rem 0 1rem 1.2rem;
}
.note {
border-left: 3px solid var(--accent);
padding-left: 0.9rem;
color: var(--text);
}
@media (max-width: 800px) {
.main {
width: min(calc(100% - 1.25rem), var(--max-width));
}
.hero,
.experience {
grid-template-columns: 1fr;
}
.hero {
min-height: auto;
padding: 4rem 0;
}
.expertiseGrid {
grid-template-columns: 1fr;
}
}
What Does This Stylesheet Do?
- The hero uses a two-column grid for the value proposition and supporting strengths.
- The expertise cards and experience panel reuse the same surface treatment.
- The skill list wraps automatically as the available width changes.
- The mobile breakpoint converts multi-column sections into single-column layouts.
- Save src/app/page.module.css.
- Refresh http://localhost:3000.
The homepage should now have a large editorial headline, a two-column hero, styled expertise cards, skill pills, and a bordered experience panel. That is the first complete visual version of your portfolio.
Styles Not Appearing?
Confirm that the filename is exactly page.module.css. Check that page.tsx imports it as styles.
Ask for help with diagnosing the CSS Module.
The layout is ready for your real identity. Recruiters need claims that you can support with concrete examples during an interview.
- Personalize the shared identity values in src/app/layout.tsx by replacing Your Name and you@example.com with your real details.
- Save src/app/layout.tsx.
- Refresh the browser to confirm that your name appears in the header and footer.
Your contact link should now target your real email address. The shared shell is ready to follow visitors across the portfolio.
- Replace the example capability and experience copy in src/app/page.tsx with details you can defend in an interview.
- Save src/app/page.tsx.
Before you check the final result, consider which sections you expect to remain readable when the browser becomes narrow.
- Refresh http://localhost:3000.
- Narrow the browser window to test the mobile layout.
- Widen the browser window to restore the desktop layout.
You should see the hero and experience areas change from two columns to one. The expertise cards should also stack while the navigation, contact links, skills, and footer remain usable.
Your homepage now gives recruiters a clear first impression and a direct contact path. Next, you will add a dedicated work route and test whether short project summaries provide enough evidence.
Add the Work Route
Your homepage now gives recruiters a focused introduction to your ecommerce experience. The shared navigation already points toward a dedicated work area.
A separate route uses Next.js file-system routing to keep project evidence easy to scan. Short summaries create a useful first pass, but recruiters still need deeper proof of your decisions and results.
In this step, get ready to:
- Model two ecommerce projects with consistent evidence fields.
- Render both project summaries on a dedicated work route.
- Test the shared navigation to expose the missing case-study detail.
Model the project evidence
A TypeScript data model gives every project the same evidence structure. The cards initially show only the title, summary, and technologies while the remaining fields hold the deeper case-study material.
- In the Explorer sidebar in Visual Studio Code, right-click the src/app folder.
- Select New Folder.
- Enter work as the folder name.
- Right-click the new src/app/work folder.
- Select New File.
- Enter projects.ts as the file name.
- Define the evidence model and first project by adding this code to projects.ts:
export type Project = {
id: string;
title: string;
summary: string;
challenge: string;
contribution: string;
decisions: string[];
result: string;
technologies: string[];
};
export const projects: Project[] = [
{
id: "checkout-experience",
title: "Checkout Experience Redesign",
summary:
"A case-study template for explaining how you removed friction from a purchase journey.",
challenge:
"Replace this with the customer or business problem, the affected checkout stage, and the constraint that made the work difficult.",
contribution:
"Replace this with the interface, state management, validation, integration, or testing work that you personally owned.",
decisions: [
"Explain one technical trade-off and why it fit the checkout constraints.",
"Explain how you handled accessibility, failure states, or mobile behavior.",
],
result:
"Add one verified metric or an honest qualitative outcome. Do not invent conversion, revenue, or performance numbers.",
technologies: ["Next.js", "TypeScript", "Commerce API"],
},
];
What Does This Code Do?
- The Project type requires every case study to include the same evidence fields.
- The first object represents a checkout redesign with prompts for truthful project details.
- The technologies array supplies the tags that recruiters see during the first scan.
- Save projects.ts.
- Confirm that Visual Studio Code shows no TypeScript diagnostics in the file.
Seeing TypeScript Diagnostics?
Check that every property in the first project matches a field in Project. Confirm that the object ends with a comma before the array closes.
Ask for targeted help with fixing the project data model.
A second object proves that the same model can represent a different ecommerce challenge. Both entries remain separate from the interface that eventually displays them.
- In projects.ts, find the closing ]; line.
- Insert the second project directly above that closing line by adding this code:
{
id: "storefront-replatform",
title: "Storefront Replatform",
summary:
"A case-study template for showing how you modernized a storefront while protecting the customer journey.",
challenge:
"Replace this with the legacy limitation, growth requirement, performance issue, or editorial need that drove the project.",
contribution:
"Replace this with the components, routing, data integration, migration, or release work that you personally completed.",
decisions: [
"Describe why the chosen frontend architecture matched the product needs.",
"Describe how you reduced delivery risk during the migration or launch.",
],
result:
"Add a verified business, customer, delivery, or performance result, or clearly state a qualitative outcome when no reliable metric exists.",
technologies: ["React", "TypeScript", "CSS Modules"],
},
How Does the Second Project Fit?
- The object follows the exact structure enforced by Project.
- The content prompts distinguish personal contributions from wider team outcomes.
- The result prompt keeps unverified business metrics out of your portfolio.
- Save projects.ts.
- Confirm that the projects array contains two objects without TypeScript diagnostics.
Is the Second Object Underlined?
Check that the first object ends with a comma. Make sure the second object sits inside the projects array before its closing bracket.
Ask for help with checking the second project object.
✔️ Awesome, I've got everything!
Great. Double-check that projects.ts is saved with both project objects.
ⓧ I'd like to double check the full code
export type Project = {
id: string;
title: string;
summary: string;
challenge: string;
contribution: string;
decisions: string[];
result: string;
technologies: string[];
};
export const projects: Project[] = [
{
id: "checkout-experience",
title: "Checkout Experience Redesign",
summary:
"A case-study template for explaining how you removed friction from a purchase journey.",
challenge:
"Replace this with the customer or business problem, the affected checkout stage, and the constraint that made the work difficult.",
contribution:
"Replace this with the interface, state management, validation, integration, or testing work that you personally owned.",
decisions: [
"Explain one technical trade-off and why it fit the checkout constraints.",
"Explain how you handled accessibility, failure states, or mobile behavior.",
],
result:
"Add one verified metric or an honest qualitative outcome. Do not invent conversion, revenue, or performance numbers.",
technologies: ["Next.js", "TypeScript", "Commerce API"],
},
{
id: "storefront-replatform",
title: "Storefront Replatform",
summary:
"A case-study template for showing how you modernized a storefront while protecting the customer journey.",
challenge:
"Replace this with the legacy limitation, growth requirement, performance issue, or editorial need that drove the project.",
contribution:
"Replace this with the components, routing, data integration, migration, or release work that you personally completed.",
decisions: [
"Describe why the chosen frontend architecture matched the product needs.",
"Describe how you reduced delivery risk during the migration or launch.",
],
result:
"Add a verified business, customer, delivery, or performance result, or clearly state a qualitative outcome when no reliable metric exists.",
technologies: ["React", "TypeScript", "CSS Modules"],
},
];
Render the static work route
A folder containing page.tsx becomes a route in the App Router. Placing that file inside src/app/work exposes the page at /work.
- Right-click the src/app/work folder in the Explorer sidebar.
- Select New File.
- Enter work.module.css as the file name.
- Right-click the src/app/work folder again.
- Select New File.
- Enter page.tsx as the file name.
- Add the route imports and metadata to page.tsx with this code:
import type { Metadata } from "next";
import { projects } from "./projects";
import styles from "./work.module.css";
export const metadata: Metadata = {
title: "Selected Work | Your Name",
description:
"Selected ecommerce development case studies covering customer problems, technical decisions, personal contributions, and outcomes.",
};
What Does This Code Do?
- The data import gives the route access to the two typed project objects.
- The stylesheet import connects this page to locally scoped class names.
- The metadata gives the work route its own browser title and search description.
- Add the page component directly below the metadata by pasting this code:
export default function WorkPage() {
return (
<main className={styles.main}>
<header className={styles.intro}>
<p className={styles.eyebrow}>Selected work</p>
<h1>Case studies built around decisions and outcomes.</h1>
<p>
Replace the guided prompts below with your real projects. Lead with
the problem and your contribution, then use technology to explain how
you solved it.
</p>
</header>
<div className={styles.grid}>
{projects.map((project, index) => (
<article key={project.id} className={styles.card}>
<div className={styles.cardTop}>
<span className={styles.projectIndex}>0{index + 1}</span>
<ul className={styles.tags} aria-label="Technologies">
{project.technologies.map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
</div>
<h2>{project.title}</h2>
<p className={styles.summary}>{project.summary}</p>
</article>
))}
</div>
</main>
);
}
How Are the Cards Generated?
- The map() call creates one article for each object in projects.
- The project id gives each article a stable key.
- A nested map() call renders every technology as a separate tag.
- Only the summary fields appear during this first recruiter scan.
- Save page.tsx.
- Switch back to the browser tab running the local portfolio.
- Visit http://localhost:3000/work.
You should see the selected-work heading with two unstyled project summaries. This first route render proves that the data mapping works before presentation styles are added.
Does the Work Route Fail to Load?
Confirm that page.tsx, projects.ts, and work.module.css all sit inside src/app/work.
Ask for help with diagnosing the work route.
✔️ Awesome, I've got everything!
Your work route renders both project summaries from the shared data array.
ⓧ I'd like to double check the full code
import type { Metadata } from "next";
import { projects } from "./projects";
import styles from "./work.module.css";
export const metadata: Metadata = {
title: "Selected Work | Your Name",
description:
"Selected ecommerce development case studies covering customer problems, technical decisions, personal contributions, and outcomes.",
};
export default function WorkPage() {
return (
<main className={styles.main}>
<header className={styles.intro}>
<p className={styles.eyebrow}>Selected work</p>
<h1>Case studies built around decisions and outcomes.</h1>
<p>
Replace the guided prompts below with your real projects. Lead with
the problem and your contribution, then use technology to explain how
you solved it.
</p>
</header>
<div className={styles.grid}>
{projects.map((project, index) => (
<article key={project.id} className={styles.card}>
<div className={styles.cardTop}>
<span className={styles.projectIndex}>0{index + 1}</span>
<ul className={styles.tags} aria-label="Technologies">
{project.technologies.map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
</div>
<h2>{project.title}</h2>
<p className={styles.summary}>{project.summary}</p>
</article>
))}
</div>
</main>
);
}
Style and test the evidence gap
A CSS Module keeps work-route styles scoped to this page. The first rules establish the page width, introduction spacing, and visual label.
- Switch to work.module.css in Visual Studio Code.
- Add the page shell and introduction label with this code:
.main {
width: min(calc(100% - 2rem), var(--max-width));
min-height: calc(100vh - 72px);
margin: 0 auto;
padding: 5rem 0;
}
.intro {
max-width: 820px;
margin-bottom: 2.5rem;
}
.eyebrow {
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
What Do These Styles Control?
- The main class keeps the route aligned with the homepage content width.
- The intro class limits the opening copy to a readable line length.
- The eyebrow class creates the small accent label above the main heading.
- Save work.module.css.
- Return to the local work route in your browser.
You should see the content centered within the same width as the homepage. The selected-work label should now appear in the accent colour.
Are the Intro Styles Missing?
Check that page.tsx imports ./work.module.css. Confirm that the class names match main, intro, and eyebrow.
Ask for help with checking the work-page CSS import.
The next rules establish the editorial heading and two-column project layout. Muted supporting text keeps attention on the project titles.
- Add the heading, summary colour, and project grid below the existing styles:
.intro h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2.8rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.intro > p:last-child,
.summary {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
How Does the Layout Take Shape?
- The fluid heading size adapts between narrow and wide browser windows.
- The muted colour separates supporting copy from the stronger headings.
- The grid places the two project cards in equal columns.
- Save work.module.css.
- Return to the local work route.
You should see a larger editorial heading above two side-by-side project areas. The introductory paragraph and project summaries should use the muted text colour.
Do the Projects Stay in One Column?
Confirm that the wrapper around the mapped articles uses styles.grid. Check that the grid rule contains two equal columns.
Ask for help with diagnosing the project grid.
Each project now needs a distinct visual surface. A flexible card keeps the summary aligned while leaving room for more evidence later.
- Add the card surface and top-row styles below the grid rules:
.card {
min-height: 330px;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 1.5rem;
background: linear-gradient(145deg, var(--surface-strong), var(--surface));
padding: 1.4rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.2);
}
.cardTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.projectIndex {
color: var(--accent);
font-weight: 800;
}
What Defines Each Card?
- The flexible column gives every card a consistent structure.
- The border, gradient, and shadow separate each project from the page background.
- The top row places the accent project number opposite the technology list.
- Save work.module.css.
- Return to the local work route.
You should see two dark rounded cards with accent project numbers. Each card should have the same minimum height.
Do the Cards Still Look Like Plain Text?
Confirm that each mapped article uses styles.card. Check that cardTop matches the class on the number and technology wrapper.
Ask for help with fixing the static card styles.
Typography and compact tags complete the recruiter-facing scan. The title remains prominent while each technology stays readable without dominating the card.
- Add the card title, summary, and technology-tag styles below the existing card rules:
.card h2 {
margin-top: 2.5rem;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.05;
}
.summary {
margin-top: 0.9rem;
}
.tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.4rem;
list-style: none;
}
.tags li {
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.3rem 0.55rem;
color: var(--muted);
font-size: 0.76rem;
}
Why Use Compact Technology Tags?
- The larger title makes the project identity the card's main focal point.
- The summary spacing separates the evidence preview from the title.
- The wrapping tag list accommodates different technology counts without widening the card.
- Save work.module.css.
- Return to the local work route.
You should see prominent project titles with outlined technology pills in the top-right area of each card. The summaries should sit below their titles with consistent spacing.
Are the Technology Tags Unstyled?
Check that the technology wrapper is a list using styles.tags. Confirm that every technology is rendered inside an li element.
Ask for help with checking the technology-tag markup.
The desktop grid needs a mobile fallback so both cards remain readable on narrow screens. The page also uses tighter outer spacing on smaller devices.
- Add the mobile breakpoint at the bottom of work.module.css with this code:
@media (max-width: 720px) {
.main {
width: min(calc(100% - 1.25rem), var(--max-width));
padding: 3.5rem 0;
}
.grid {
grid-template-columns: 1fr;
}
.card {
min-height: 300px;
}
}
What Changes on Mobile?
- The page uses narrower horizontal spacing on small screens.
- The project grid collapses into one column.
- The reduced minimum height keeps each card proportionate to the smaller viewport.
- Save work.module.css.
- Resize the browser below 720px wide.
You should see the project cards stack into one column with comfortable spacing. Both titles, summaries, and technology lists should remain readable.
Do the Cards Stay Side by Side?
Confirm that the media query closes after the card rule. Check that the browser viewport is narrower than 720px.
Ask for help with diagnosing the mobile breakpoint.
✔️ Awesome, I've got everything!
Your work route now has its complete responsive static-card styling.
ⓧ I'd like to double check the full code
.main {
width: min(calc(100% - 2rem), var(--max-width));
min-height: calc(100vh - 72px);
margin: 0 auto;
padding: 5rem 0;
}
.intro {
max-width: 820px;
margin-bottom: 2.5rem;
}
.eyebrow {
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.intro h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2.8rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.intro > p:last-child,
.summary {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.card {
min-height: 330px;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 1.5rem;
background: linear-gradient(145deg, var(--surface-strong), var(--surface));
padding: 1.4rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.2);
}
.cardTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.projectIndex {
color: var(--accent);
font-weight: 800;
}
.card h2 {
margin-top: 2.5rem;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.05;
}
.summary {
margin-top: 0.9rem;
}
.tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.4rem;
list-style: none;
}
.tags li {
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.3rem 0.55rem;
color: var(--muted);
font-size: 0.76rem;
}
@media (max-width: 720px) {
.main {
width: min(calc(100% - 1.25rem), var(--max-width));
padding: 3.5rem 0;
}
.grid {
grid-template-columns: 1fr;
}
.card {
min-height: 300px;
}
}
Before you test the shared navigation, do you think either summary gives a recruiter enough evidence to inspect the challenge, your contribution, your decisions, and the result?
- Select Home in the shared navigation.
- Confirm that the address returns to http://localhost:3000.
- Select Work in the shared navigation.
- Confirm that the address changes to http://localhost:3000/work.
- Review the Checkout Experience Redesign card.
- Review the Storefront Replatform card.
The two summaries are easy to scan, but neither card provides a way to inspect the full challenge, contribution, decisions, or result. That visible evidence gap is the intended outcome of this static version.
You have the work route running through the shared navigation. Recruiters can now find your projects, while the missing depth is clear enough to solve next.
Your new route makes the evidence gap visible. Next, you will turn each summary into an accessible case study without crowding the initial scan.
Create Accessible Case Studies
Your /work route gives recruiters a quick overview of two projects. The static cards still hide the evidence behind each result.
A Radix Dialog gives each project a focused case study without crowding the page. Its modal behavior also manages keyboard focus and supports dismissal with the Escape key.
In this step, get ready to:
- Move the typed project cards into a Client Component.
- Present each case study inside an accessible dialog.
- Style the modal experience for desktop and mobile screens.
Create the project gallery
The gallery needs browser-side interaction because each button opens a modal. The "use client" directive lets this component use that interactive behavior.
- Select the src/app/work folder in the Visual Studio Code Explorer.
- Select New File in the Explorer toolbar.
- Enter ProjectGallery.tsx as the file name.
- Create the initial Client Component by pasting this code:
"use client";
import { Dialog } from "radix-ui";
import { projects } from "./projects";
import styles from "./work.module.css";
export function ProjectGallery() {
return (
<div id="project-grid" className={styles.grid}>
{projects.map((project, index) => (
<Dialog.Root key={project.id}>
<article className={styles.card} data-project-card>
<h2>{project.title}</h2>
<p className={styles.summary}>{project.summary}</p>
<Dialog.Trigger className={styles.trigger}>
Read case study
</Dialog.Trigger>
</article>
</Dialog.Root>
))}
</div>
);
}
What Does This Component Do?
- The "use client" directive marks the gallery as an interactive Client Component.
- The projects.map() call creates one card from each typed project object.
- Each Dialog.Root keeps the open state associated with the correct project.
- The Dialog.Trigger turns each case-study button into a modal control.
- Save ProjectGallery.tsx.
- Confirm that ProjectGallery.tsx appears inside src/app/work in the Explorer.
Cannot Save the Gallery?
Check that the file sits directly inside src/app/work. Confirm that its extension is .tsx.
Ask for help with creating the ProjectGallery Client Component.
The route still owns the page metadata and introduction. It can now delegate the reusable card interface to ProjectGallery.
- Return to src/app/work/page.tsx from the Visual Studio Code tab bar.
- Replace the file contents with this code:
import type { Metadata } from "next";
import { ProjectGallery } from "./ProjectGallery";
import styles from "./work.module.css";
export const metadata: Metadata = {
title: "Selected Work | Your Name",
description:
"Selected ecommerce development case studies covering customer problems, technical decisions, personal contributions, and outcomes.",
};
export default function WorkPage() {
return (
<main className={styles.main}>
<header className={styles.intro}>
<p className={styles.eyebrow}>Selected work</p>
<h1>Case studies built around decisions and outcomes.</h1>
<p>
Replace the guided prompts below with your real projects. Lead with
the problem and your contribution, then use technology to explain how
you solved it.
</p>
</header>
<ProjectGallery />
</main>
);
}
How Does the Route Change?
- The page imports ProjectGallery instead of reading the project data itself.
- The route keeps its metadata inside a Server Component.
- The <ProjectGallery /> element creates a clear boundary around the interactive interface.
- Save src/app/work/page.tsx.
- Return to http://localhost:3000/work in your browser.
- Refresh the route.
You should see the two project cards with a Read case study button on each card. The route now renders its cards through the new gallery component.
The Work Route Does Not Load?
Check that the import uses ./ProjectGallery. Confirm that the component name matches the exported ProjectGallery function.
Ask for help with fixing the ProjectGallery import.
✔️ Awesome, I've got everything!
Great. Save page.tsx before continuing.
ⓧ I'd like to double check the full code
import type { Metadata } from "next";
import { ProjectGallery } from "./ProjectGallery";
import styles from "./work.module.css";
export const metadata: Metadata = {
title: "Selected Work | Your Name",
description:
"Selected ecommerce development case studies covering customer problems, technical decisions, personal contributions, and outcomes.",
};
export default function WorkPage() {
return (
<main className={styles.main}>
<header className={styles.intro}>
<p className={styles.eyebrow}>Selected work</p>
<h1>Case studies built around decisions and outcomes.</h1>
<p>
Replace the guided prompts below with your real projects. Lead with
the problem and your contribution, then use technology to explain how
you solved it.
</p>
</header>
<ProjectGallery />
</main>
);
}
This reference keeps the route introduction and metadata beside the rendered gallery.
Add the case-study details
Each card needs enough context for a quick scan before the dialog opens. Its index and technology tags provide that context without repeating the full case study.
- Return to src/app/work/ProjectGallery.tsx.
- Find the opening <article className={styles.card} data-project-card> line.
- Add the project index and technology list directly below that line:
<div className={styles.cardTop}>
<span className={styles.projectIndex}>0{index + 1}</span>
<ul className={styles.tags} aria-label="Technologies">
{project.technologies.map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
</div>
What Does the Card Header Show?
- The index value numbers each card in its mapped order.
- The nested mapping creates one list item for each technology.
- The aria-label gives the list a concise accessible name.
- Save ProjectGallery.tsx.
- Refresh http://localhost:3000/work.
You should see a numbered header and technology tags at the top of each card. The typed project data still controls every displayed value.
Missing the Project Numbers or Tags?
Check that the new block sits inside the article element. Confirm that the outer mapping receives both project and index.
Ask for help with debugging the mapped card header.
The dialog portal places the modal content outside the card layout. The title and description give assistive technology the labels it needs for the open case study.
- Find the closing </article> element inside Dialog.Root.
- Add the dialog portal directly below that element:
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content className={styles.dialog}>
<p className={styles.dialogKicker}>Ecommerce case study</p>
<Dialog.Title className={styles.dialogTitle}>
{project.title}
</Dialog.Title>
<Dialog.Description className={styles.dialogDescription}>
{project.summary}
</Dialog.Description>
<Dialog.Close className={styles.close}>
Close case study
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
How Does the Dialog Work?
- The Dialog.Portal renders the modal outside the card layout.
- The Dialog.Overlay separates the open case study from the page behind it.
- The Dialog.Title identifies the modal with the current project title.
- The Dialog.Description connects the project summary to the modal.
- The Dialog.Close gives keyboard and pointer users an explicit dismissal control.
- Save ProjectGallery.tsx.
- Refresh the /work route.
- Select the first Read case study button.
You should see the matching project title and summary inside an open dialog. Select Close case study to return to the cards.
The Dialog Does Not Open?
Confirm that Dialog.Trigger and Dialog.Portal share the same Dialog.Root. Check the browser console for a misplaced closing tag.
Ask for help with repairing the Radix Dialog structure.
A recruiter needs the reasoning behind the project as well as its summary. The typed fields keep that evidence consistent across every case study.
- Find the closing </Dialog.Description> element.
- Add the detailed evidence grid directly below it:
<div className={styles.detailGrid}>
<section className={styles.detailBlock}>
<h3>Challenge</h3>
<p>{project.challenge}</p>
</section>
<section className={styles.detailBlock}>
<h3>My contribution</h3>
<p>{project.contribution}</p>
</section>
<section className={styles.detailBlock}>
<h3>Technical decisions</h3>
<ul className={styles.decisionList}>
{project.decisions.map((decision) => (
<li key={decision}>{decision}</li>
))}
</ul>
</section>
<section className={styles.result}>
<h3>Result</h3>
<p>{project.result}</p>
</section>
</div>
How Is the Evidence Organized?
- The Challenge section describes the problem and its constraints.
- The My contribution section separates your work from the wider team effort.
- The Technical decisions list exposes the reasoning behind your implementation.
- The Result section closes the story with evidence you can defend.
- Save ProjectGallery.tsx.
- Refresh the /work route.
- Open each case study.
You should see the correct Challenge, My contribution, Technical decisions, and Result content for each project. That is the evidence layer the static cards were missing.
Seeing the Wrong Project Details?
Keep the detail grid inside the same projects.map() callback as the card. Read every value from the current project object.
Ask for help with checking the mapped dialog data.
✔️ Awesome, I've got everything!
Your gallery now renders every typed project as a card with its own detailed dialog.
ⓧ I'd like to double check the full code
"use client";
import { Dialog } from "radix-ui";
import { projects } from "./projects";
import styles from "./work.module.css";
export function ProjectGallery() {
return (
<div id="project-grid" className={styles.grid}>
{projects.map((project, index) => (
<Dialog.Root key={project.id}>
<article className={styles.card} data-project-card>
<div className={styles.cardTop}>
<span className={styles.projectIndex}>0{index + 1}</span>
<ul className={styles.tags} aria-label="Technologies">
{project.technologies.map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
</div>
<h2>{project.title}</h2>
<p className={styles.summary}>{project.summary}</p>
<Dialog.Trigger className={styles.trigger}>
Read case study
</Dialog.Trigger>
</article>
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content className={styles.dialog}>
<p className={styles.dialogKicker}>Ecommerce case study</p>
<Dialog.Title className={styles.dialogTitle}>
{project.title}
</Dialog.Title>
<Dialog.Description className={styles.dialogDescription}>
{project.summary}
</Dialog.Description>
<div className={styles.detailGrid}>
<section className={styles.detailBlock}>
<h3>Challenge</h3>
<p>{project.challenge}</p>
</section>
<section className={styles.detailBlock}>
<h3>My contribution</h3>
<p>{project.contribution}</p>
</section>
<section className={styles.detailBlock}>
<h3>Technical decisions</h3>
<ul className={styles.decisionList}>
{project.decisions.map((decision) => (
<li key={decision}>{decision}</li>
))}
</ul>
</section>
<section className={styles.result}>
<h3>Result</h3>
<p>{project.result}</p>
</section>
</div>
<Dialog.Close className={styles.close}>
Close case study
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
))}
</div>
);
}
This reference shows the complete gallery before motion is added in the next step.
Style and test the dialogs
The dialog now has the correct accessible structure. Its CSS Modules styles will turn that structure into a focused modal experience on large and small screens.
- Return to src/app/work/work.module.css.
- Find the region that begins with the .eyebrow rule.
- Replace that region through the existing muted-text rule with this code:
.eyebrow,
.dialogKicker {
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.intro h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2.8rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.intro > p:last-child,
.summary,
.dialogDescription,
.detailBlock p,
.detailBlock li,
.result p {
color: var(--muted);
}
What Do the Shared Text Styles Do?
- The shared eyebrow rule gives the page label and dialog kicker the same visual language.
- The muted-text group keeps supporting evidence quieter than the project title.
- The existing responsive heading size remains unchanged.
- Save work.module.css.
- Open a case study in the browser.
You should see the Ecommerce case study label in the accent color. The summary and evidence copy should use the muted text color.
The Dialog Text Looks Unchanged?
Confirm that the combined selectors include .dialogKicker and .dialogDescription. Check that each class name matches ProjectGallery.tsx.
Ask for help with matching the dialog text classes.
- Find the closing brace of the .tags li rule.
- Add the dialog controls and overlay directly below it:
.trigger,
.close {
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--accent-dark);
padding: 0.75rem 1rem;
font-weight: 800;
cursor: pointer;
}
.trigger {
align-self: flex-start;
margin-top: auto;
}
.overlay {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(2, 8, 18, 0.78);
backdrop-filter: blur(8px);
}
What Do the Controls and Overlay Do?
- The shared control rule gives both buttons a consistent high-contrast appearance.
- The trigger alignment keeps the card button at the bottom of its card.
- The fixed overlay covers the viewport behind the active case study.
- Save work.module.css.
- Open a case study in the browser.
You should see accent-colored controls and a dark overlay across the page. The active project remains in front of the overlay.
Missing the Dark Overlay?
Check that .overlay includes position: fixed and inset: 0. Confirm that Dialog.Overlay uses styles.overlay.
Ask for help with fixing the dialog overlay.
- Find the closing brace of the .overlay rule.
- Add the modal panel and title styles directly below it:
.dialog {
position: fixed;
top: 50%;
left: 50%;
z-index: 50;
width: min(calc(100% - 2rem), 760px);
max-height: 88vh;
overflow-y: auto;
transform: translate(-50%, -50%);
border: 1px solid var(--border);
border-radius: 1.5rem;
background: var(--surface);
padding: clamp(1.25rem, 5vw, 2.5rem);
box-shadow: 0 30px 100px rgba(0, 0, 0, 0.55);
}
.dialogTitle {
margin-top: 0.5rem;
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1;
letter-spacing: -0.045em;
}
How Is the Modal Positioned?
- The fixed position centers the modal inside the viewport.
- The maximum height lets long case studies scroll inside the panel.
- The responsive width keeps the modal clear of the screen edges.
- The title scale makes the current project immediately identifiable.
- Save work.module.css.
- Open a case study in the browser.
You should see a centered dark panel with a large project title. A long case study should scroll inside the panel.
The Panel Is Not Centered?
Confirm that .dialog includes the top and left values. Check that its transform uses negative half-width offsets.
Ask for help with centering the dialog panel.
- Find the closing brace of the .dialogTitle rule.
- Add the description spacing and evidence grid directly below it:
.dialogDescription {
margin-top: 1rem;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin: 2rem 0;
}
Why Use a Detail Grid?
- The description margin separates the summary from the project title.
- The two-column grid groups related evidence without creating one long text column.
- The grid gap keeps each evidence section visually distinct.
- Save work.module.css.
- Open a case study on a wide browser window.
You should see the evidence arranged in two columns. The project summary should sit above the grid.
The Evidence Stays in One Column?
Check that .detailGrid uses display: grid. Confirm that the JSX applies styles.detailGrid to the surrounding element.
Ask for help with debugging the detail grid.
- Find the closing brace of the .detailGrid rule.
- Add the evidence-section and close-button styles directly below it:
.detailBlock,
.result {
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.detailBlock h3,
.result h3 {
margin-bottom: 0.5rem;
}
.decisionList {
display: grid;
gap: 0.45rem;
margin-left: 1rem;
}
.result {
border-color: var(--accent);
}
.close {
display: block;
margin-left: auto;
}
How Do the Detail Styles Help?
- The top borders divide the four parts of the case-study story.
- The accent border gives the result a stronger visual finish.
- The decision list keeps each technical choice separate.
- The close control aligns with the lower-right edge of the panel.
- Save work.module.css.
- Open a case study in the browser.
You should see divided evidence sections and an accent border above the Result section. The close button should align to the right.
The Detail Sections Look Unstructured?
Confirm that the Challenge and contribution sections use styles.detailBlock. Check that the Result section uses styles.result.
Ask for help with matching the detail section classes.
The evidence grid needs a single-column layout on narrow screens. The existing card grid follows the same responsive shift.
- Find the existing mobile media query at the bottom of work.module.css.
- Replace that media query with this responsive version:
@media (max-width: 720px) {
.main {
width: min(calc(100% - 1.25rem), var(--max-width));
padding: 3.5rem 0;
}
.grid,
.detailGrid {
grid-template-columns: 1fr;
}
.card {
min-height: 300px;
}
}
What Changes on Small Screens?
- The page width leaves a smaller margin on narrow screens.
- The project cards collapse into one column.
- The dialog evidence also collapses into one column.
- The shorter card height keeps the mobile scan compact.
- Save work.module.css.
- Resize the browser below 720px wide.
- Open a case study.
You should see one project card per row and one evidence section per row. The dialog should remain inside the viewport.
The Mobile Dialog Overflows?
Check that the media query includes both .grid and .detailGrid. Confirm that the dialog width still uses the viewport calculation from .dialog.
Ask for help with fixing the mobile dialog layout.
✔️ Awesome, I've got everything!
Your work-page styles now cover the cards and the complete modal experience.
ⓧ I'd like to double check the full code
.main {
width: min(calc(100% - 2rem), var(--max-width));
min-height: calc(100vh - 72px);
margin: 0 auto;
padding: 5rem 0;
}
.intro {
max-width: 820px;
margin-bottom: 2.5rem;
}
.eyebrow,
.dialogKicker {
color: var(--accent);
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.intro h1 {
margin: 0.75rem 0 1rem;
font-size: clamp(2.8rem, 7vw, 5.5rem);
line-height: 0.98;
letter-spacing: -0.055em;
}
.intro > p:last-child,
.summary,
.dialogDescription,
.detailBlock p,
.detailBlock li,
.result p {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.card {
min-height: 330px;
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 1.5rem;
background: linear-gradient(145deg, var(--surface-strong), var(--surface));
padding: 1.4rem;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.2);
}
.cardTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.projectIndex {
color: var(--accent);
font-weight: 800;
}
.card h2 {
margin-top: 2.5rem;
font-size: clamp(1.8rem, 4vw, 2.6rem);
line-height: 1.05;
}
.summary {
margin-top: 0.9rem;
}
.tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.4rem;
list-style: none;
}
.tags li {
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.3rem 0.55rem;
color: var(--muted);
font-size: 0.76rem;
}
.trigger,
.close {
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--accent-dark);
padding: 0.75rem 1rem;
font-weight: 800;
cursor: pointer;
}
.trigger {
align-self: flex-start;
margin-top: auto;
}
.overlay {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(2, 8, 18, 0.78);
backdrop-filter: blur(8px);
}
.dialog {
position: fixed;
top: 50%;
left: 50%;
z-index: 50;
width: min(calc(100% - 2rem), 760px);
max-height: 88vh;
overflow-y: auto;
transform: translate(-50%, -50%);
border: 1px solid var(--border);
border-radius: 1.5rem;
background: var(--surface);
padding: clamp(1.25rem, 5vw, 2.5rem);
box-shadow: 0 30px 100px rgba(0, 0, 0, 0.55);
}
.dialogTitle {
margin-top: 0.5rem;
font-size: clamp(2rem, 5vw, 3.5rem);
line-height: 1;
letter-spacing: -0.045em;
}
.dialogDescription {
margin-top: 1rem;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin: 2rem 0;
}
.detailBlock,
.result {
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.detailBlock h3,
.result h3 {
margin-bottom: 0.5rem;
}
.decisionList {
display: grid;
gap: 0.45rem;
margin-left: 1rem;
}
.result {
border-color: var(--accent);
}
.close {
display: block;
margin-left: auto;
}
@media (max-width: 720px) {
.main {
width: min(calc(100% - 1.25rem), var(--max-width));
padding: 3.5rem 0;
}
.grid,
.detailGrid {
grid-template-columns: 1fr;
}
.card {
min-height: 300px;
}
}
This reference combines the original card styles with the complete dialog and mobile rules.
Before you test the finished interaction, where do you expect keyboard focus to stay while a case study is open?
- Return to http://localhost:3000/work.
- Select the first Read case study button.
- Press Tab repeatedly.
- Press Escape.
- Open the second case study.
- Repeat the keyboard check.
You should see the correct titled case study open for each card. Focus should remain inside the modal until Escape closes it.
That completes the evidence experience. Recruiters can now scan the cards quickly and inspect the reasoning behind either project.
Your case studies now reveal detailed evidence through an accessible interaction. Next, you will add responsible motion and prepare the portfolio for deployment.
Animate and Deploy the Portfolio
The portfolio now gives recruiters the evidence behind each project. Restrained GSAP motion can guide their attention without distracting from that evidence.
This final step also proves the portfolio is ready outside your computer. You will test accessibility preferences before publishing a production deployment with Vercel.
In this step, get ready to:
- Move the homepage hero into an animated Client Component.
- Add reduced-motion-aware entrances to the project cards.
- Build the portfolio before deploying it to Vercel.
Animate the homepage hero
The useGSAP() hook scopes animation work to the React component lifecycle. Its automatic cleanup prevents duplicate animation effects.
Why Respect Reduced Motion?
The prefers-reduced-motion preference identifies visitors who have requested less non-essential movement. The hero remains fully visible when that preference is active.
- In the Visual Studio Code Explorer sidebar, create Hero.tsx inside src/app.
- Build the Client Component foundation by pasting this first chunk into Hero.tsx:
"use client";
import Link from "next/link";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import styles from "./page.module.css";
gsap.registerPlugin(useGSAP);
export function Hero() {
useGSAP(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
gsap.from("[data-hero-item]", {
opacity: 0,
y: 32,
duration: 0.8,
stagger: 0.12,
});
});
What Does This Code Do?
- The client directive lets the component use browser APIs such as window.matchMedia().
- The media query exits the animation hook when reduced motion is requested.
- The gsap.from() call fades each marked hero item upward from its starting position.
- Continue the component by pasting this chunk directly below the animation hook:
return (
<section className={styles.hero} aria-labelledby="hero-title">
<div>
<p className={styles.eyebrow} data-hero-item>
Ecommerce Web Developer
</p>
<h1 id="hero-title" className={styles.heroTitle} data-hero-item>
I build storefronts that make buying feel effortless.
</h1>
<p className={styles.heroText} data-hero-item>
I turn product discovery, checkout, and post-purchase journeys into
fast, accessible experiences backed by maintainable frontend code.
</p>
<div className={styles.actions} data-hero-item>
<Link className={styles.primaryAction} href="/work">
View case studies
</Link>
<a className={styles.secondaryAction} href="mailto:you@example.com">
Start a conversation
</a>
</div>
</div>
How Is the Hero Targeted?
Each major element carries the same data-hero-item attribute. The selector can animate those elements as one staggered sequence.
- Complete Hero.tsx by pasting this final chunk at the bottom:
<aside className={styles.heroPanel} data-hero-item>
<p className={styles.panelLabel}>What I bring</p>
<div className={styles.panelGrid}>
<div className={styles.panelCard}>
<span>01</span>
<strong>Storefront UX</strong>
<p>Clear journeys from landing page to checkout.</p>
</div>
<div className={styles.panelCard}>
<span>02</span>
<strong>Frontend systems</strong>
<p>Reusable components with typed, maintainable data.</p>
</div>
<div className={styles.panelCard}>
<span>03</span>
<strong>Business context</strong>
<p>Technical choices connected to customer outcomes.</p>
</div>
</div>
</aside>
</section>
);
}
What Completes the Component?
The side panel joins the same animation sequence through data-hero-item. The remaining markup preserves the responsive hero layout you already styled.
- Save Hero.tsx.
Seeing an Error in Hero.tsx?
Confirm that the three chunks appear in the same order. Check that the final closing brace remains at the bottom of the file.
Ask for help with checking the Hero component structure.
- In src/app/page.tsx, locate the existing stylesheet import shown below:
import styles from "./page.module.css";
What Are You Locating?
This import currently sits at the top of the static homepage. The new component import belongs directly above it.
- Replace that import with these two imports:
import { Hero } from "./Hero";
import styles from "./page.module.css";
What Changes Here?
The homepage can now render the exported Hero component. Its existing CSS Module remains available to the other sections.
- In src/app/page.tsx, find the hero section that starts with this line:
<section className={styles.hero} aria-labelledby="hero-title">
Which Section Is This?
This line begins the static hero markup. The section ends immediately before the focused expertise section.
- Delete the complete static hero section.
- Insert the animated component in the same location:
<Hero />
What Does This Render?
The homepage now delegates its hero to the Client Component. The remaining homepage sections stay server-rendered.
- Save src/app/page.tsx.
- Switch back to the browser from earlier.
- Refresh http://localhost:3000.
You should see the hero text appear in a short staggered sequence. The expertise section remains unchanged below it.
Hero Motion Is Missing?
Confirm that every animated element contains data-hero-item. Check that your browser is not currently emulating reduced motion.
Ask for help with debugging the hero entrance animation.
✔️ The animated hero works
Good progress. Your homepage now opens with motion that supports the content hierarchy.
ⓧ I want to check Hero.tsx
"use client";
import Link from "next/link";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import styles from "./page.module.css";
gsap.registerPlugin(useGSAP);
export function Hero() {
useGSAP(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
gsap.from("[data-hero-item]", {
opacity: 0,
y: 32,
duration: 0.8,
stagger: 0.12,
});
});
return (
<section className={styles.hero} aria-labelledby="hero-title">
<div>
<p className={styles.eyebrow} data-hero-item>
Ecommerce Web Developer
</p>
<h1 id="hero-title" className={styles.heroTitle} data-hero-item>
I build storefronts that make buying feel effortless.
</h1>
<p className={styles.heroText} data-hero-item>
I turn product discovery, checkout, and post-purchase journeys into
fast, accessible experiences backed by maintainable frontend code.
</p>
<div className={styles.actions} data-hero-item>
<Link className={styles.primaryAction} href="/work">
View case studies
</Link>
<a className={styles.secondaryAction} href="mailto:you@example.com">
Start a conversation
</a>
</div>
</div>
<aside className={styles.heroPanel} data-hero-item>
<p className={styles.panelLabel}>What I bring</p>
<div className={styles.panelGrid}>
<div className={styles.panelCard}>
<span>01</span>
<strong>Storefront UX</strong>
<p>Clear journeys from landing page to checkout.</p>
</div>
<div className={styles.panelCard}>
<span>02</span>
<strong>Frontend systems</strong>
<p>Reusable components with typed, maintainable data.</p>
</div>
<div className={styles.panelCard}>
<span>03</span>
<strong>Business context</strong>
<p>Technical choices connected to customer outcomes.</p>
</div>
</div>
</aside>
</section>
);
}
How to Use This Reference
Compare the imports first. Then compare the animation hook and JSX closing tags.
✔️ The homepage still looks complete
The extracted hero now works alongside the existing expertise and experience sections.
ⓧ I want to check page.tsx
import { Hero } from "./Hero";
import styles from "./page.module.css";
const skills = [
"Next.js",
"React",
"TypeScript",
"CSS Modules",
"Accessible UI",
"Commerce APIs",
"Performance",
"Conversion UX",
];
export default function HomePage() {
return (
<main className={styles.main}>
<Hero />
<section className={styles.section} aria-labelledby="expertise-title">
<div className={styles.sectionHeader}>
<p className={styles.eyebrow}>Focused expertise</p>
<h2 id="expertise-title">Commerce experiences built around intent</h2>
</div>
<div className={styles.expertiseGrid}>
<article className={styles.expertiseCard}>
<h3>Product discovery</h3>
<p>
Search, navigation, merchandising, and product detail interfaces
that help customers make confident choices.
</p>
</article>
<article className={styles.expertiseCard}>
<h3>Checkout experience</h3>
<p>
Clear validation, resilient state, and accessible interaction
patterns that reduce avoidable purchase friction.
</p>
</article>
<article className={styles.expertiseCard}>
<h3>Frontend quality</h3>
<p>
Typed components, scoped styling, performance awareness, and
interfaces that remain understandable as products grow.
</p>
</article>
</div>
</section>
<section className={styles.section} aria-labelledby="skills-title">
<div className={styles.sectionHeader}>
<p className={styles.eyebrow}>Core toolkit</p>
<h2 id="skills-title">Skills aligned to modern ecommerce teams</h2>
</div>
<ul className={styles.skillList}>
{skills.map((skill) => (
<li key={skill}>{skill}</li>
))}
</ul>
</section>
<section className={styles.experience} aria-labelledby="experience-title">
<div>
<p className={styles.eyebrow}>Experience snapshot</p>
<h2 id="experience-title">Show ownership, not a task list</h2>
</div>
<div className={styles.experienceCopy}>
<p>
Replace this paragraph with two or three sentences about your role,
the customers you supported, and the ecommerce surfaces you owned.
</p>
<ul className={styles.experienceList}>
<li>State what you personally designed, built, or improved.</li>
<li>Connect a technical decision to a customer or business need.</li>
<li>Use only metrics or outcomes you can verify and explain.</li>
</ul>
<p className={styles.note}>
Content rule: every claim on this page should be defensible in an
interview.
</p>
</div>
</section>
</main>
);
}
How to Use This Reference
Confirm that Hero is imported once. Check that <Hero /> appears before the expertise section.
Animate the project cards and test the build
The work page needs the same restrained visual rhythm. A shared data-project-card selector lets one animation cover every project generated from the typed array.
- In src/app/work/ProjectGallery.tsx, locate the current imports:
"use client";
import { Dialog } from "radix-ui";
import { projects } from "./projects";
import styles from "./work.module.css";
What Are You Locating?
These imports support the existing dialogs and typed project mapping. The animation dependencies belong beside them.
- Replace the import section with this animated setup:
"use client";
import { Dialog } from "radix-ui";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { projects } from "./projects";
import styles from "./work.module.css";
gsap.registerPlugin(useGSAP);
What Does This Setup Add?
The gallery now has access to GSAP and its React hook. Plugin registration makes the hook available before the component runs.
- Find the start of ProjectGallery shown below:
export function ProjectGallery() {
return (
Where Does the Hook Belong?
The animation hook belongs inside the component. It must run before the JSX is returned.
- Replace those lines with the reduced-motion-aware hook:
export function ProjectGallery() {
useGSAP(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
gsap.from("[data-project-card]", {
opacity: 0,
y: 24,
duration: 0.6,
stagger: 0.12,
});
});
return (
How Does the Card Reveal Work?
The hook checks the same motion preference as the hero. Each matching card starts slightly lower before settling into place.
- Find the project card opening tag:
<article className={styles.card}>
What Is Missing Here?
The card already has its scoped CSS class. It needs the shared data attribute used by the GSAP selector.
- Replace the opening tag with this version:
<article className={styles.card} data-project-card>
What Does the Attribute Do?
Every mapped project receives data-project-card automatically. A future third project joins the animation without gallery changes.
- Save src/app/work/ProjectGallery.tsx.
- Refresh http://localhost:3000/work.
You should see both project cards rise into view. Each case-study dialog should still open from its card.
Cards Stay Static?
Check that the selector uses data-project-card in both places. Confirm that plugin registration appears above ProjectGallery.
Ask for help with debugging the project card animation.
✔️ Both cards animate
The mapped gallery now reveals every project through one reusable animation path.
ⓧ I want to check ProjectGallery.tsx
"use client";
import { Dialog } from "radix-ui";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { projects } from "./projects";
import styles from "./work.module.css";
gsap.registerPlugin(useGSAP);
export function ProjectGallery() {
useGSAP(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
gsap.from("[data-project-card]", {
opacity: 0,
y: 24,
duration: 0.6,
stagger: 0.12,
});
});
return (
<div id="project-grid" className={styles.grid}>
{projects.map((project, index) => (
<Dialog.Root key={project.id}>
<article className={styles.card} data-project-card>
<div className={styles.cardTop}>
<span className={styles.projectIndex}>0{index + 1}</span>
<ul className={styles.tags} aria-label="Technologies">
{project.technologies.map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
</div>
<h2>{project.title}</h2>
<p className={styles.summary}>{project.summary}</p>
<Dialog.Trigger className={styles.trigger}>
Read case study
</Dialog.Trigger>
</article>
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content className={styles.dialog}>
<p className={styles.dialogKicker}>Ecommerce case study</p>
<Dialog.Title className={styles.dialogTitle}>
{project.title}
</Dialog.Title>
<Dialog.Description className={styles.dialogDescription}>
{project.summary}
</Dialog.Description>
<div className={styles.detailGrid}>
<section className={styles.detailBlock}>
<h3>Challenge</h3>
<p>{project.challenge}</p>
</section>
<section className={styles.detailBlock}>
<h3>My contribution</h3>
<p>{project.contribution}</p>
</section>
<section className={styles.detailBlock}>
<h3>Technical decisions</h3>
<ul className={styles.decisionList}>
{project.decisions.map((decision) => (
<li key={decision}>{decision}</li>
))}
</ul>
</section>
<section className={styles.result}>
<h3>Result</h3>
<p>{project.result}</p>
</section>
</div>
<Dialog.Close className={styles.close}>
Close case study
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
))}
</div>
);
}
How to Use This Reference
Compare the imports and animation hook first. Then check the data attribute without changing the dialog structure.
Before you run the production build, do you expect both application routes to compile successfully?
- Return to the Visual Studio Code terminal running the development server.
- Stop the development server by pressing Ctrl+C.
- Verify the production build by running this command:
npm run build
What Does This Command Prove?
The production build checks the application using deployment-oriented compilation. It also generates a route summary for the homepage and work page.
The command should complete successfully. The route summary should include / and /work.
Production Build Failed?
Start with the first reported file path. Compare that file with the full-code reference before rerunning the build.
Ask for help with diagnosing the production build.
- Restart the local development server by running this command:
npm run dev
What Happens Next?
The development server makes the finished local portfolio available at http://localhost:3000. Keep this terminal running during the motion checks.
Chrome DevTools can emulate the browser preference without changing your Windows settings. This gives you a quick comparison between normal motion and reduced motion.
- Open http://localhost:3000 in Chrome.
- Open Chrome DevTools.
- Press Ctrl+Shift+P to open the Command Menu.
- Type Rendering.
- Select Show Rendering.
- Select No emulation under Emulate CSS media feature prefers-reduced-motion.
- Refresh the homepage.
You should see the hero entrance sequence. The project cards should animate when you visit the work page.
- Select prefers-reduced-motion:reduce in the same Rendering control.
- Refresh the homepage.
- Select Work in the shared navigation.
- Refresh the work page.
The content should remain visible on both routes. The hero and project cards should skip their entrance movement.
Deploy the production portfolio
Authentication hands control to Vercel's browser flow. Keep your account credentials inside that flow.
- Return to the Visual Studio Code terminal.
- Stop the development server by pressing Ctrl+C.
- Start Vercel authentication by running this command:
npm run vercel:login
What Does This Command Do?
The package script runs the pinned Vercel login command. The browser authentication flow connects the local CLI to your Vercel account.
- Complete the authentication flow in the browser.
- Return to the terminal after Vercel confirms authentication.
The terminal should confirm that the CLI is authenticated. You can now create the production deployment.
Authentication Did Not Complete?
Keep the terminal session open while completing the browser flow. Retry the login command if the session expires.
Ask for help with troubleshooting Vercel CLI authentication.
The deployment command may ask project-linking questions. Keep the project name ecommerce-portfolio so the cloud resource matches your local folder.
- Keep your authenticated Vercel scope when prompted.
- Connect the current directory to a Vercel project when prompted.
- Enter ecommerce-portfolio when asked for the project name.
Before you deploy, do you think this command creates a preview deployment or a production deployment?
- Create the production deployment by running this command:
npm run deploy
What Does This Command Do?
The script runs Vercel with its production flag. A successful deployment prints the production URL to the terminal.
The first deployment may take a little longer while Vercel builds the application. Keep the terminal open until it prints the URL.
- Complete any remaining project-linking prompts.
- Record the printed production URL here: your Vercel production URL.
Deployment Did Not Finish?
Confirm that the login command completed in the same environment. Check the first deployment error before retrying.
Ask for help with diagnosing the Vercel deployment.
Before you test the live site, do you expect its routes and dialogs to behave exactly like the local build?
- Open your Vercel production URL in Chrome.
- Confirm that the recruiter-focused homepage loads.
- Select Work in the shared navigation.
- Open each Read case study button one at a time.
- Press Tab inside each open dialog.
- Press Escape to close each dialog.
- Select prefers-reduced-motion:reduce in the DevTools Rendering control.
- Refresh both production routes.
You should see the homepage and work route load from the production URL. Both dialogs should retain keyboard behavior while reduced motion keeps all content visible without entrance movement.
That is the production milestone complete. Your portfolio now combines ecommerce evidence with accessible motion and a shareable Vercel URL.
Secret mission
Scale the Typed Case Study System
Add a third real ecommerce case study through the existing typed data model. Prove that the reusable gallery creates its animated card and accessible dialog without duplicated UI code.
Clean Up Your Resources
Clean Up Your Resources
Your local Next.js project creates no ongoing charges. Your personal, non-commercial Vercel Hobby deployment costs $0 within included limits.
Cost warning
Vercel Hobby is for personal, non-commercial use. Keeping your personal portfolio deployed within its included limits does not add an ongoing charge.
A portfolio used for freelance or other commercial business should use an appropriate paid plan. Vercel Pro starts at $20/month.
Resources you used:
- The local ecommerce-portfolio folder with its source files, installed npm dependencies, and production build.
- The Vercel ecommerce-portfolio project with its production deployments.
Keep everything running
No action is needed. Choose this if you are still refining the site or want its production URL to remain live.
- Keep the Vercel ecommerce-portfolio project live so its production URL remains available.
- Keep the local ecommerce-portfolio folder for future edits.
- Use the existing deployment script whenever you publish another revision.
The production URL stays live. Your personal, non-commercial Hobby use remains at $0 within included limits.
Pause - I'll come back to this later
Pausing ends local development while leaving your files and cloud portfolio intact. This option keeps the project ready for a later update.
- Return to the Windows PowerShell terminal from earlier if the development server is running.
- Stop the server by pressing Ctrl+C.
- Keep the local ecommerce-portfolio folder unchanged.
- Leave the Vercel ecommerce-portfolio project deployed.
You will see the PowerShell prompt again. The existing production URL remains live.
Delete - I don't want to use this again
Deleting the Vercel project is permanent. Your local files remain untouched until you remove them separately.
- Return to the ecommerce-portfolio directory in Windows PowerShell.
- Start the Vercel removal by running:
npm run cleanup
What does this cleanup command do?
The cleanup script in package.json calls the pinned Vercel CLI removal action for the ecommerce-portfolio project.
This removes the project from your current Vercel scope. It also removes every deployment attached to that project.
- Enter the confirmation requested by Vercel.
- Wait for the removal command to finish.
- Visit the production URL from earlier.
You should no longer see the portfolio at that URL. This confirms that the Vercel project and its deployments are gone.
Removal not completing?
- Check that Windows PowerShell is inside ecommerce-portfolio if the cleanup script cannot be found.
- Repeat the Vercel login flow from earlier if the CLI reports an authentication problem.
- Help me troubleshoot this cleanup.
The cloud copy is now gone. Your local folder still contains the source files, installed dependencies, and production build.
- Stop any running development server by pressing Ctrl+C in Windows PowerShell.
- Close the ecommerce-portfolio workspace in Visual Studio Code.
- Use Windows file management to return to the parent directory containing ecommerce-portfolio.
- Delete the ecommerce-portfolio folder.
- Confirm that ecommerce-portfolio no longer appears in its parent directory.
That clears both copies. Neither the deployed portfolio nor the local project folder remains.
Nice Work!
Nice Work!
You made it! Your recruiter-ready ecommerce portfolio is live on Vercel. Recruiters can now scan your specialty before opening detailed evidence from your work.
You've learned how to:
- Shape a recruiter-first portfolio with a responsive homepage plus a dedicated /work route through the Next.js App Router.
- Model case-study evidence with TypeScript. Feed consistent project data into the reusable ProjectGallery UI without duplicated card markup.
- Present each project in an accessible Radix Dialog with managed focus plus Escape dismissal. Add GSAP entrance motion with a reduced-motion escape path. Validate the production build before publishing it through Vercel.
- Secret Mission: Scale the typed data system with a third real ecommerce case study. Keep ProjectGallery unchanged while the new animated card and keyboard-accessible dialog appear automatically.
Ready to quiz yourself? Let's see what stayed with you.