Build a Personal Developer Portfolio

Build a responsive portfolio site and deploy it live with GitHub Pages.

Introduction

30 Second Summary

When a hiring manager looks at candidates for a frontend role, the first thing they check is a live portfolio. It proves you can actually build what your resume claims.

In this project, you will build a personal developer portfolio website from scratch using vanilla HTML, CSS, and JavaScript. You will deploy it live to the internet so anyone can visit your site from a public URL.

What You'll Build

A responsive portfolio website with a dark/light theme toggle, interactive project cards, mobile navigation, and a contact form with client-side validation.

By the end of this project, you'll have:

  • A responsive multi-section portfolio page with a hero, about, projects grid, and contact form that works on any screen size.
  • A dark/light theme toggle that remembers the visitor's preference using localStorage.
  • A live deployment on GitHub Pages with a shareable URL you can put on your resume.
  • Secret Mission: Add smooth scroll-reveal animations and a typing effect to the hero section.

Are there any prerequisites?

You should have basic familiarity with HTML and CSS syntax. You do not need prior JavaScript experience. This project walks you through every line of code.

Set Up Your Project and Repository

Your portfolio needs a home online before you start building it. A Git repository on GitHub gives you version control and a deployment target for GitHub Pages.

In this step, you'll create a GitHub repository, clone it to your machine, and set up the file structure your portfolio needs.

In this step, get ready to:
  • Create a GitHub repository for your portfolio.
  • Clone the repository and set up the project files.
Create a new repository on GitHub

You need a remote repository on GitHub that will hold your portfolio code and serve as the source for GitHub Pages deployment.

  • In your browser, go to GitHub and click the + icon in the top-right corner.
  • Select New repository.
  • Enter portfolio in the Repository name field.
  • Keep the repository set to Public.
  • Leave all checkboxes unchecked (do not initialize with a README, .gitignore, or license).
  • Click Create repository.

Why leave the repository empty?

Initializing with a README creates a commit on GitHub that your local clone would need to reconcile. Starting empty means your local copy and the remote are perfectly in sync from the start.

Clone the repository and create project files

Now that the repository exists on GitHub, you need a local copy on your machine to work with.

  • In VS Code, click Terminal in the top menu bar.
  • Click New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/portfolio.git

What does this command do?

The git clone command downloads a copy of the remote repository to your machine. It creates a folder named portfolio on your Desktop with the Git history already connected to GitHub.

Seeing an authentication error?

Make sure the repository URL matches your GitHub username exactly. Double-check for typos in the URL.

If GitHub asks for credentials, you may need to set up a personal access token or SSH key. Check your GitHub authentication settings.

Help me fix a git clone authentication error.

  • Navigate into the project folder by running this command:
cd portfolio
  • Create the four project files by running this command:
touch index.html style.css script.js .nojekyll

What is the .nojekyll file?

GitHub Pages processes sites with Jekyll by default. The .nojekyll file tells GitHub Pages to skip that processing. Since your portfolio is plain HTML, CSS, and JavaScript, you do not need Jekyll.

  • Confirm the files exist by running:
ls -a

You should see index.html, style.css, script.js, and .nojekyll in the output.

Now open this folder in VS Code so you can see the files in the explorer.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the portfolio folder on your Desktop.
  • Click Open.

Workspace trust dialog

VS Code may ask if you trust the authors of files in this folder. Confirm that you trust it since this is your own project.

VS Code reloads with the portfolio folder as your workspace. You should see all four files in the left sidebar.

  • Open a new terminal by clicking Terminal in the top menu bar, then clicking New Terminal.
  • Verify your setup by running this command:
git status

You should see four untracked files listed: index.html, style.css, script.js, and .nojekyll. This confirms Git is tracking the folder and sees the new files you created.

Not seeing untracked files?

If the terminal says "not a git repository", your terminal is not inside the portfolio folder. Make sure you opened the correct folder in VS Code using File > Open Folder.

If you see no files listed at all, re-run the touch command from earlier inside the portfolio folder.

Help me fix my git status output in the portfolio project.

Your project structure is ready. Next up, you'll build the HTML skeleton that gives your portfolio its content and structure.

Build the HTML Structure

Your project files are ready and your Git repository is connected. Now it's time to give your portfolio a skeleton.

Every website starts with HTML that defines the page's structure and content. In this step, you'll write the full HTML for your portfolio including navigation, content sections, a project grid, and a contact form.

In this step, get ready to:
  • Write the HTML document skeleton with meta tags and a theme-persistence script.
  • Build the page sections: header, hero, about, projects, and contact.
  • Add a footer with social links and verify all sections render in the browser.
Write the document skeleton

The document skeleton contains everything the browser needs before it renders visible content. This includes character encoding, the viewport setting for mobile devices, and a small script that prevents a flash of the wrong theme on load.

  • In VS Code, open index.html from the Explorer sidebar.
  • Replace the empty file with the following document skeleton:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dev Portfolio</title>
  <!-- Read saved theme before first paint to prevent flash -->
  <script>
    (function () {
      var t = localStorage.getItem('theme');
      if (t === 'dark' || t === 'light') {
        document.documentElement.setAttribute('data-theme', t);
      }
    })();
  </script>
  <link rel="stylesheet" href="style.css">
</head>
<body>

  <!-- Load main script after HTML is parsed -->
  <script src="script.js" defer></script>
</body>
</html>

What does this code do?

The <meta name="viewport"> tag tells mobile browsers to use the device's actual width instead of a zoomed-out desktop view.

The inline <script> in the head runs immediately, before the page paints. It checks localStorage for a saved theme preference and applies it to the root element. This prevents a flash of the wrong color scheme when the page loads.

The defer attribute on the bottom <script> tag tells the browser to run script.js only after all the HTML has finished parsing. This guarantees every element exists before the script tries to access it.

  • Save index.html with Cmd+S (macOS) or Ctrl+S (Windows).
  • Open the file in your browser by double-clicking index.html in Finder (macOS) or File Explorer (Windows). You should see a blank white page with no errors in the browser tab titled "Dev Portfolio".

See a blank tab with no title?

Make sure you saved the file after pasting the code. Check that the <title> tag is inside <head> and not inside <body>.

help me debug my HTML skeleton

Build the page sections

Now you'll fill the body with the visible parts of the portfolio. Each section uses semantic HTML elements that describe their purpose to browsers and screen readers.

  • In index.html, find the empty space between the opening <body> tag and the <script src="script.js" defer></script> line.
  • Add the header section by pasting this code in that space:
  <header class="header">
    <div class="header-container">
      <a href="#" class="logo">Portfolio</a>
      <!-- Hamburger button for mobile navigation -->
      <button class="hamburger" id="hamburger" aria-label="Open navigation" aria-expanded="false">
        <span class="hamburger-line"></span>
        <span class="hamburger-line"></span>
        <span class="hamburger-line"></span>
      </button>
      <!-- Navigation links -->
      <nav class="nav" id="nav">
        <a href="#about" class="nav-link">About</a>
        <a href="#projects" class="nav-link">Projects</a>
        <a href="#contact" class="nav-link">Contact</a>
      </nav>
      <!-- Theme toggle button -->
      <button class="theme-toggle" id="theme-toggle" aria-label="Switch to dark mode">Light</button>
    </div>
  </header>

What does this code do?

  • The <header> element wraps the entire top bar. Inside, a logo link, a hamburger button (for mobile), navigation links, and a theme toggle button are laid out in a flex container.
  • The aria-label and aria-expanded attributes make the buttons accessible to screen readers. JavaScript will update these later.
  • Each nav link's href points to a section ID (#about, #projects, #contact). The browser will scroll to those sections when clicked.
  • Save index.html and refresh your browser.
  • You should see the text "Portfolio", "About", "Projects", "Contact", and "Light" appearing as plain links and text at the top of the page.

Don't see the nav text?

Make sure you pasted the header code between the <body> opening tag and the <script> tag. If you accidentally placed it inside <head>, it won't render.

help me fix my header placement

  • Below the closing </header> tag, add a <main> element containing the hero and about sections:
  <main>
    <!-- Hero section - first thing visitors see -->
    <section class="hero" id="hero">
      <h1 class="hero-title">Hi, I'm <span class="accent">Your Name</span></h1>
      <p class="hero-subtitle" id="hero-subtitle">I build things for the web.</p>
      <a href="#projects" class="cta-button">See My Work</a>
    </section>

    <!-- About section -->
    <section class="section" id="about">
      <div class="container">
        <h2 class="section-title">About Me</h2>
        <p class="section-text">I'm a frontend developer passionate about creating clean, responsive, and user-friendly websites. I love turning ideas into reality with code and I'm always learning new technologies to stay current.</p>
        <p class="section-text">When I'm not coding, you can find me exploring open source projects, reading tech blogs, or working on side projects to sharpen my skills.</p>
      </div>
    </section>
  </main>

What does this code do?

  • The <main> element groups the primary page content (everything except header and footer). This helps assistive technology identify the core content area.
  • The hero section is the landing area visitors see first. It has a heading with an accent-colored name, a subtitle, and a call-to-action link.
  • The about section wraps its content in a div.container for max-width centering when CSS is applied later.
  • Save index.html and refresh your browser.
  • You should see a large "Hi, I'm Your Name" heading, the subtitle "I build things for the web.", a "See My Work" link, and the About Me paragraphs below.

Missing the hero or about text?

Check that you pasted the code after the </header> closing tag, not inside the header. Also confirm the </main> closing tag is present at the end of this block.

help me debug my main content sections

  • In index.html, find the closing </section> tag for the about section (the one with id="about"). Below it, but still inside <main> (above the </main> closing tag), add the projects section:
    <!-- Projects section with card grid -->
    <section class="section" id="projects">
      <div class="container">
        <h2 class="section-title">Projects</h2>
        <div class="projects-grid">
          <div class="project-card">
            <h3 class="project-title">Weather Dashboard</h3>
            <p class="project-desc">A responsive weather app that fetches real-time data from a public API and displays forecasts with clean visualizations.</p>
            <div class="project-tags">
              <span class="tag">HTML</span>
              <span class="tag">CSS</span>
              <span class="tag">JavaScript</span>
            </div>
          </div>
          <div class="project-card">
            <h3 class="project-title">Task Manager</h3>
            <p class="project-desc">A drag-and-drop task board with local storage persistence, filters, and keyboard accessibility.</p>
            <div class="project-tags">
              <span class="tag">JavaScript</span>
              <span class="tag">CSS Grid</span>
              <span class="tag">LocalStorage</span>
            </div>
          </div>
          <div class="project-card">
            <h3 class="project-title">Recipe Finder</h3>
            <p class="project-desc">Search and save your favorite recipes from an external API. Features bookmarking and responsive card layout.</p>
            <div class="project-tags">
              <span class="tag">API Integration</span>
              <span class="tag">Flexbox</span>
              <span class="tag">ES6</span>
            </div>
          </div>
        </div>
      </div>
    </section>

What does this code do?

  • The projects-grid div wraps three project cards. CSS will turn this into a responsive grid later using Flexbox.
  • Each project-card contains a title, a short description, and technology tags. These are placeholder projects you can swap out with your own work later.
  • The span.tag elements display which technologies each project uses. They'll become styled badges once CSS is added.
  • Save index.html and refresh your browser.
  • You should see three project titles (Weather Dashboard, Task Manager, Recipe Finder) with descriptions and technology names below the About section.

Projects section not appearing?

Make sure you placed this code inside <main> (after the about section's </section> tag but before </main>). If you pasted it after </main>, it will still show but won't be semantically correct.

help me position my projects section correctly

  • Below the projects section you just added, still inside <main> (above the </main> tag), add the contact section:
    <!-- Contact section with form -->
    <section class="section" id="contact">
      <div class="container">
        <h2 class="section-title">Contact</h2>
        <form class="contact-form" id="contact-form" novalidate>
          <div class="form-group">
            <label for="name">Name</label>
            <input type="text" id="name" name="name" required>
            <span class="error-message" id="name-error"></span>
          </div>
          <div class="form-group">
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required>
            <span class="error-message" id="email-error"></span>
          </div>
          <div class="form-group">
            <label for="message">Message</label>
            <textarea id="message" name="message" rows="5" required></textarea>
            <span class="error-message" id="message-error"></span>
          </div>
          <button type="submit" class="submit-button">Send Message</button>
          <p class="success-message" id="success-message"></p>
        </form>
      </div>
    </section>

What does this code do?

  • The novalidate attribute on the form disables the browser's built-in validation. This lets you write custom JavaScript validation with better error messages in a later step.
  • Each form-group pairs a <label> with its input and an empty span.error-message that JavaScript will fill when validation fails.
  • The for attribute on each label matches the id of its input. Clicking the label focuses the input, improving usability and accessibility.
  • Save index.html and refresh your browser.
  • You should see form fields for Name, Email, and Message with a "Send Message" button below the projects section.

Form fields not showing?

Check that the contact section is placed before the </main> closing tag. Also verify that each <input> tag is self-closing (no </input> needed) and the <textarea> has a proper closing </textarea> tag.

help me debug my contact form HTML

Add the footer and verify the full page

The footer sits outside <main> because it's supplementary content, not the primary page material. It holds social links and a copyright line.

  • In index.html, find the closing </main> tag. Below it (but above the <script src="script.js" defer></script> line), add the footer:
  <footer class="footer">
    <div class="container">
      <div class="footer-links">
        <a href="https://github.com" target="_blank" rel="noopener noreferrer">GitHub</a>
        <a href="https://linkedin.com" target="_blank" rel="noopener noreferrer">LinkedIn</a>
      </div>
      <p class="footer-copy">2026 Your Name. All rights reserved.</p>
    </div>
  </footer>

What does this code do?

  • The target="_blank" attribute opens social links in a new tab so visitors don't leave your portfolio.
  • The rel="noopener noreferrer" attribute is a security measure. It prevents the linked page from accessing your page's window.opener object.
  • These are placeholder URLs. You'll replace them with your real GitHub and LinkedIn profile links later.
  • Save index.html and refresh your browser.
  • Scroll to the bottom of the page. You should see "GitHub" and "LinkedIn" links followed by the copyright text.
  • Click the "About" link at the top of the page. The browser should scroll down to the About Me heading. Test "Projects" and "Contact" links the same way.

Navigation links not scrolling?

Make sure each section has the correct id attribute matching the nav link's href value. For example, the nav link href="#about" must match id="about" on the section. IDs are case-sensitive.

help me fix my navigation anchor links

✔️ Awesome, I've got everything!

Great. Double check you've saved index.html and that all nav links scroll to their matching sections.

ⓧ I'd like to double check the full code

Here is the complete index.html file at this point. Compare yours against it:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dev Portfolio</title>
  <script>
    (function () {
      var t = localStorage.getItem('theme');
      if (t === 'dark' || t === 'light') {
        document.documentElement.setAttribute('data-theme', t);
      }
    })();
  </script>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header class="header">
    <div class="header-container">
      <a href="#" class="logo">Portfolio</a>
      <button class="hamburger" id="hamburger" aria-label="Open navigation" aria-expanded="false">
        <span class="hamburger-line"></span>
        <span class="hamburger-line"></span>
        <span class="hamburger-line"></span>
      </button>
      <nav class="nav" id="nav">
        <a href="#about" class="nav-link">About</a>
        <a href="#projects" class="nav-link">Projects</a>
        <a href="#contact" class="nav-link">Contact</a>
      </nav>
      <button class="theme-toggle" id="theme-toggle" aria-label="Switch to dark mode">Light</button>
    </div>
  </header>

  <main>
    <section class="hero" id="hero">
      <h1 class="hero-title">Hi, I'm <span class="accent">Your Name</span></h1>
      <p class="hero-subtitle" id="hero-subtitle">I build things for the web.</p>
      <a href="#projects" class="cta-button">See My Work</a>
    </section>

    <section class="section" id="about">
      <div class="container">
        <h2 class="section-title">About Me</h2>
        <p class="section-text">I'm a frontend developer passionate about creating clean, responsive, and user-friendly websites. I love turning ideas into reality with code and I'm always learning new technologies to stay current.</p>
        <p class="section-text">When I'm not coding, you can find me exploring open source projects, reading tech blogs, or working on side projects to sharpen my skills.</p>
      </div>
    </section>

    <section class="section" id="projects">
      <div class="container">
        <h2 class="section-title">Projects</h2>
        <div class="projects-grid">
          <div class="project-card">
            <h3 class="project-title">Weather Dashboard</h3>
            <p class="project-desc">A responsive weather app that fetches real-time data from a public API and displays forecasts with clean visualizations.</p>
            <div class="project-tags">
              <span class="tag">HTML</span>
              <span class="tag">CSS</span>
              <span class="tag">JavaScript</span>
            </div>
          </div>
          <div class="project-card">
            <h3 class="project-title">Task Manager</h3>
            <p class="project-desc">A drag-and-drop task board with local storage persistence, filters, and keyboard accessibility.</p>
            <div class="project-tags">
              <span class="tag">JavaScript</span>
              <span class="tag">CSS Grid</span>
              <span class="tag">LocalStorage</span>
            </div>
          </div>
          <div class="project-card">
            <h3 class="project-title">Recipe Finder</h3>
            <p class="project-desc">Search and save your favorite recipes from an external API. Features bookmarking and responsive card layout.</p>
            <div class="project-tags">
              <span class="tag">API Integration</span>
              <span class="tag">Flexbox</span>
              <span class="tag">ES6</span>
            </div>
          </div>
        </div>
      </div>
    </section>

    <section class="section" id="contact">
      <div class="container">
        <h2 class="section-title">Contact</h2>
        <form class="contact-form" id="contact-form" novalidate>
          <div class="form-group">
            <label for="name">Name</label>
            <input type="text" id="name" name="name" required>
            <span class="error-message" id="name-error"></span>
          </div>
          <div class="form-group">
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required>
            <span class="error-message" id="email-error"></span>
          </div>
          <div class="form-group">
            <label for="message">Message</label>
            <textarea id="message" name="message" rows="5" required></textarea>
            <span class="error-message" id="message-error"></span>
          </div>
          <button type="submit" class="submit-button">Send Message</button>
          <p class="success-message" id="success-message"></p>
        </form>
      </div>
    </section>
  </main>

  <footer class="footer">
    <div class="container">
      <div class="footer-links">
        <a href="https://github.com" target="_blank" rel="noopener noreferrer">GitHub</a>
        <a href="https://linkedin.com" target="_blank" rel="noopener noreferrer">LinkedIn</a>
      </div>
      <p class="footer-copy">2026 Your Name. All rights reserved.</p>
    </div>
  </footer>

  <script src="script.js" defer></script>
</body>
</html>

Your HTML structure is complete. Every section is in place with proper IDs, semantic elements, and accessible attributes. Next up, you'll bring this raw structure to life with responsive CSS styling.

Style with Responsive CSS

Your HTML structure is complete and every section is visible in the browser. Right now it looks like a plain wall of text with no visual hierarchy.

In this step, you will transform that raw HTML into a polished, professional layout using CSS custom properties for theming, Flexbox for layout, and a responsive breakpoint so the site works on mobile screens.

In this step, get ready to:
  • Set up CSS custom properties for dark and light theming.
  • Style the page layout with Flexbox and hover effects.
  • Add a responsive breakpoint for mobile devices.
Set up CSS custom properties and base styles

CSS custom properties let you define colors once and reuse them everywhere. By putting all your color values in variables, switching between a light and dark theme becomes a single selector swap.

  • In VS Code, click on style.css in the Explorer sidebar to open it.
  • Paste the following code into the empty file:
:root {
  --bg: #ffffff;
  --text: #1a1a2e;
  --accent: #4361ee;
  --surface: #f8f9fa;
  --border: #e0e0e0;
  --text-muted: #6c757d;
}

:root[data-theme="dark"] {
  --bg: #121220;
  --text: #e8e8f0;
  --accent: #6c8cff;
  --surface: #1c1c30;
  --border: #2d2d44;
  --text-muted: #9a9ab0;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #121220;
    --text: #e8e8f0;
    --accent: #6c8cff;
    --surface: #1c1c30;
    --border: #2d2d44;
    --text-muted: #9a9ab0;
  }
}

*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: var(--bg);
  color: var(--text);
  line-height: 1.6;
  transition: background-color 0.3s, color 0.3s;
}

a {
  color: var(--accent);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

.container {
  max-width: 900px;
  margin: 0 auto;
  padding: 0 20px;
}

What does this code do?

The :root selector defines six color variables for light mode. Any element that uses var(--bg) or var(--text) automatically gets the right color for the current theme.

The :root[data-theme="dark"] selector overrides those same variables when the user manually picks dark mode. The @media (prefers-color-scheme: dark) block handles users who have dark mode set at the operating system level but have not made a manual choice yet.

The universal selector reset removes default browser margins and padding. The body rule sets the base font and applies the theme variables, with a transition so color changes animate smoothly.

Why CSS custom properties instead of hardcoded colors?

Hardcoding hex values everywhere means you would need to find-and-replace dozens of lines to switch themes. Custom properties centralize your palette in one place. When you change a variable, every element using it updates automatically.

  • Save style.css.
  • Open index.html in your browser (right-click the file in VS Code's Explorer and select Open with Live Server, or double-click the file in your file manager). You should see a clean white background with dark text and blue accent-colored links.

Still seeing unstyled text?

Make sure style.css is saved and sits in the same folder as index.html. The <link rel="stylesheet" href="style.css"> tag in your HTML must match the filename exactly (case-sensitive).

If your browser shows a cached version, do a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).

Still stuck? Why isn't my CSS loading in the browser?

Style the header and hero section

The header needs to stay visible as the user scrolls, and the hero section should grab attention with large centered typography. Both use Flexbox for alignment.

  • Add the following code below the existing styles in style.css:
/* Header */
.header {
  position: sticky;
  top: 0;
  background-color: var(--bg);
  border-bottom: 1px solid var(--border);
  z-index: 100;
  transition: background-color 0.3s;
}

.header-container {
  max-width: 900px;
  margin: 0 auto;
  padding: 16px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.logo {
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--text);
}

.logo:hover {
  text-decoration: none;
}

.nav {
  display: flex;
  gap: 24px;
}

.nav-link {
  color: var(--text);
  font-size: 0.95rem;
}

.nav-link:hover {
  color: var(--accent);
  text-decoration: none;
}

.hamburger {
  display: none;
  flex-direction: column;
  gap: 5px;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
}

.hamburger-line {
  display: block;
  width: 24px;
  height: 2px;
  background-color: var(--text);
  transition: transform 0.3s;
}

.theme-toggle {
  background-color: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  padding: 6px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.85rem;
}

.theme-toggle:hover {
  border-color: var(--accent);
}

/* Hero */
.hero {
  text-align: center;
  padding: 100px 20px 80px;
}

.hero-title {
  font-size: 2.5rem;
  margin-bottom: 16px;
  line-height: 1.2;
}

.accent {
  color: var(--accent);
}

.hero-subtitle {
  font-size: 1.25rem;
  color: var(--text-muted);
  margin-bottom: 32px;
}

.cta-button {
  display: inline-block;
  background-color: var(--accent);
  color: #ffffff;
  padding: 12px 28px;
  border-radius: 6px;
  font-weight: 600;
  transition: opacity 0.2s;
}

.cta-button:hover {
  opacity: 0.9;
  text-decoration: none;
}

What does this code do?

The .header uses position: sticky so it stays anchored to the top of the viewport as the user scrolls. The .header-container uses Flexbox with justify-content: space-between to spread the logo, nav links, and theme toggle across the bar.

The .hamburger button is hidden on desktop with display: none. It only appears on mobile (you will add that rule later). The hero section centers everything with text-align: center and uses generous padding to give it room to breathe.

  • Save style.css and refresh your browser.
  • You should see a sticky navigation bar at the top with "Portfolio" on the left and navigation links on the right. Below it, the hero heading should be large and centered with a blue "See My Work" button.

Header not sticking to the top?

Check that position: sticky and top: 0 are both present on .header. Both are required for sticky positioning to work.

Also confirm you did not accidentally nest .header inside another rule block. Each selector should be at the top level of the file.

Need more help? Why isn't my header staying at the top when I scroll?

Style the content sections, projects grid, and form

The remaining sections follow the same pattern. Each section gets consistent padding, the projects grid uses Flexbox to arrange cards side by side, and the contact form uses clean input styling.

  • Add the following code below the hero styles in style.css:
/* Sections */
.section {
  padding: 80px 20px;
}

.section:nth-child(even) {
  background-color: var(--surface);
}

.section-title {
  font-size: 1.75rem;
  margin-bottom: 24px;
  text-align: center;
}

.section-text {
  color: var(--text-muted);
  margin-bottom: 16px;
  max-width: 700px;
  margin-left: auto;
  margin-right: auto;
}

/* Projects Grid */
.projects-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  margin-top: 24px;
}

.project-card {
  flex: 1 1 260px;
  background-color: var(--bg);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 24px;
  transition: transform 0.2s, box-shadow 0.2s;
}

.project-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}

.project-title {
  font-size: 1.1rem;
  margin-bottom: 8px;
}

.project-desc {
  color: var(--text-muted);
  font-size: 0.9rem;
  margin-bottom: 16px;
}

.project-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.tag {
  background-color: var(--surface);
  color: var(--text-muted);
  padding: 4px 10px;
  border-radius: 4px;
  font-size: 0.8rem;
}

/* Contact Form */
.contact-form {
  max-width: 500px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 6px;
  font-weight: 500;
  font-size: 0.9rem;
}

.form-group input,
.form-group textarea {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background-color: var(--bg);
  color: var(--text);
  font-size: 1rem;
  font-family: inherit;
  transition: border-color 0.2s;
}

.form-group input:focus,
.form-group textarea:focus {
  outline: none;
  border-color: var(--accent);
}

.error-message {
  color: #e63946;
  font-size: 0.8rem;
  margin-top: 4px;
  display: block;
  min-height: 1.2em;
}

.submit-button {
  width: 100%;
  padding: 12px;
  background-color: var(--accent);
  color: #ffffff;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.2s;
}

.submit-button:hover {
  opacity: 0.9;
}

.success-message {
  color: #2a9d8f;
  text-align: center;
  margin-top: 16px;
  font-weight: 500;
}

/* Footer */
.footer {
  padding: 40px 20px;
  text-align: center;
  border-top: 1px solid var(--border);
}

.footer-links {
  display: flex;
  justify-content: center;
  gap: 24px;
  margin-bottom: 16px;
}

.footer-copy {
  color: var(--text-muted);
  font-size: 0.85rem;
}

What does this code do?

  • The .projects-grid uses display: flex with flex-wrap: wrap so cards sit side by side on wide screens and wrap onto the next row when space runs out.
  • Each .project-card has a hover effect that lifts it slightly (translateY(-4px)) and adds a soft shadow, giving a tactile feel.
  • The form inputs use border-color: var(--accent) on focus to give clear visual feedback when a field is active.
  • The .section:nth-child(even) rule alternates the background between white and the surface color so sections feel visually distinct.
  • Save style.css and refresh your browser.
  • You should see three project cards arranged in a row, each with a border and rounded corners. Hover over a card to see it lift up with a shadow. The contact form should have neatly spaced input fields with a full-width blue submit button.

Project cards stacked vertically on a wide screen?

Check that .projects-grid has display: flex and flex-wrap: wrap. Without display: flex, the cards default to block layout and stack.

Also make sure each .project-card has flex: 1 1 260px. The 260px sets the minimum card width so they only wrap when the container is too narrow.

Need more help? Why are my flexbox cards stacking instead of sitting side by side?

Add responsive breakpoints for mobile

On screens narrower than 768px, the horizontal navigation needs to collapse into a hidden menu (controlled by JavaScript in the next step). The project cards should stack vertically, and font sizes should shrink so everything fits comfortably.

  • Add the following responsive rules at the bottom of style.css:
/* Responsive */
@media (max-width: 768px) {
  .hamburger {
    display: flex;
  }

  .nav {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background-color: var(--bg);
    border-bottom: 1px solid var(--border);
    flex-direction: column;
    padding: 20px;
    gap: 16px;
  }

  .nav.open {
    display: flex;
  }

  .hero-title {
    font-size: 1.75rem;
  }

  .hero-subtitle {
    font-size: 1rem;
  }

  .projects-grid {
    flex-direction: column;
  }

  .project-card {
    flex: 1 1 auto;
  }
}

What does this code do?

Everything inside the @media (max-width: 768px) block only applies when the browser window is 768 pixels wide or narrower (roughly tablet size and below).

  • The .hamburger button switches from display: none to display: flex so it becomes visible.
  • The .nav is hidden by default. Adding the .open class (via JavaScript) reveals it as a vertical dropdown.
  • The .projects-grid switches to flex-direction: column so cards stack into a single column on small screens.

Why 768px as the breakpoint?

768px is a widely used threshold that captures most tablets in portrait orientation and all phones. Screens wider than 768px get the full desktop layout with side-by-side cards and an inline nav bar.

  • Save style.css.
  • Refresh your browser and drag the browser window narrower. At around 768px wide, the navigation links should disappear and a three-line hamburger icon should appear in the header. The project cards should stack into a single column.

Hamburger icon not appearing on narrow screens?

Make sure the @media (max-width: 768px) block is at the very bottom of your CSS file. If it appears above the .hamburger { display: none; } rule, the desktop rule wins because it comes later.

Also check that the media query is not accidentally nested inside another selector's curly braces.

Still stuck? Why isn't my hamburger menu showing on mobile screen widths?

✔️ Awesome, I've got everything!

Great. Double check you have saved style.css and all the sections look styled in your browser.

ⓧ I'd like to double check the full code

Here is the complete style.css file. Compare yours against it:

:root {
  --bg: #ffffff;
  --text: #1a1a2e;
  --accent: #4361ee;
  --surface: #f8f9fa;
  --border: #e0e0e0;
  --text-muted: #6c757d;
}

:root[data-theme="dark"] {
  --bg: #121220;
  --text: #e8e8f0;
  --accent: #6c8cff;
  --surface: #1c1c30;
  --border: #2d2d44;
  --text-muted: #9a9ab0;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #121220;
    --text: #e8e8f0;
    --accent: #6c8cff;
    --surface: #1c1c30;
    --border: #2d2d44;
    --text-muted: #9a9ab0;
  }
}

*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background-color: var(--bg);
  color: var(--text);
  line-height: 1.6;
  transition: background-color 0.3s, color 0.3s;
}

a {
  color: var(--accent);
  text-decoration: none;
}

a:hover {
  text-decoration: underline;
}

.container {
  max-width: 900px;
  margin: 0 auto;
  padding: 0 20px;
}

/* Header */
.header {
  position: sticky;
  top: 0;
  background-color: var(--bg);
  border-bottom: 1px solid var(--border);
  z-index: 100;
  transition: background-color 0.3s;
}

.header-container {
  max-width: 900px;
  margin: 0 auto;
  padding: 16px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.logo {
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--text);
}

.logo:hover {
  text-decoration: none;
}

.nav {
  display: flex;
  gap: 24px;
}

.nav-link {
  color: var(--text);
  font-size: 0.95rem;
}

.nav-link:hover {
  color: var(--accent);
  text-decoration: none;
}

.hamburger {
  display: none;
  flex-direction: column;
  gap: 5px;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
}

.hamburger-line {
  display: block;
  width: 24px;
  height: 2px;
  background-color: var(--text);
  transition: transform 0.3s;
}

.theme-toggle {
  background-color: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  padding: 6px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.85rem;
}

.theme-toggle:hover {
  border-color: var(--accent);
}

/* Hero */
.hero {
  text-align: center;
  padding: 100px 20px 80px;
}

.hero-title {
  font-size: 2.5rem;
  margin-bottom: 16px;
  line-height: 1.2;
}

.accent {
  color: var(--accent);
}

.hero-subtitle {
  font-size: 1.25rem;
  color: var(--text-muted);
  margin-bottom: 32px;
}

.cta-button {
  display: inline-block;
  background-color: var(--accent);
  color: #ffffff;
  padding: 12px 28px;
  border-radius: 6px;
  font-weight: 600;
  transition: opacity 0.2s;
}

.cta-button:hover {
  opacity: 0.9;
  text-decoration: none;
}

/* Sections */
.section {
  padding: 80px 20px;
}

.section:nth-child(even) {
  background-color: var(--surface);
}

.section-title {
  font-size: 1.75rem;
  margin-bottom: 24px;
  text-align: center;
}

.section-text {
  color: var(--text-muted);
  margin-bottom: 16px;
  max-width: 700px;
  margin-left: auto;
  margin-right: auto;
}

/* Projects Grid */
.projects-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  margin-top: 24px;
}

.project-card {
  flex: 1 1 260px;
  background-color: var(--bg);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 24px;
  transition: transform 0.2s, box-shadow 0.2s;
}

.project-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}

.project-title {
  font-size: 1.1rem;
  margin-bottom: 8px;
}

.project-desc {
  color: var(--text-muted);
  font-size: 0.9rem;
  margin-bottom: 16px;
}

.project-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.tag {
  background-color: var(--surface);
  color: var(--text-muted);
  padding: 4px 10px;
  border-radius: 4px;
  font-size: 0.8rem;
}

/* Contact Form */
.contact-form {
  max-width: 500px;
  margin: 0 auto;
}

.form-group {
  margin-bottom: 20px;
}

.form-group label {
  display: block;
  margin-bottom: 6px;
  font-weight: 500;
  font-size: 0.9rem;
}

.form-group input,
.form-group textarea {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background-color: var(--bg);
  color: var(--text);
  font-size: 1rem;
  font-family: inherit;
  transition: border-color 0.2s;
}

.form-group input:focus,
.form-group textarea:focus {
  outline: none;
  border-color: var(--accent);
}

.error-message {
  color: #e63946;
  font-size: 0.8rem;
  margin-top: 4px;
  display: block;
  min-height: 1.2em;
}

.submit-button {
  width: 100%;
  padding: 12px;
  background-color: var(--accent);
  color: #ffffff;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.2s;
}

.submit-button:hover {
  opacity: 0.9;
}

.success-message {
  color: #2a9d8f;
  text-align: center;
  margin-top: 16px;
  font-weight: 500;
}

/* Footer */
.footer {
  padding: 40px 20px;
  text-align: center;
  border-top: 1px solid var(--border);
}

.footer-links {
  display: flex;
  justify-content: center;
  gap: 24px;
  margin-bottom: 16px;
}

.footer-copy {
  color: var(--text-muted);
  font-size: 0.85rem;
}

/* Responsive */
@media (max-width: 768px) {
  .hamburger {
    display: flex;
  }

  .nav {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background-color: var(--bg);
    border-bottom: 1px solid var(--border);
    flex-direction: column;
    padding: 20px;
    gap: 16px;
  }

  .nav.open {
    display: flex;
  }

  .hero-title {
    font-size: 1.75rem;
  }

  .hero-subtitle {
    font-size: 1rem;
  }

  .projects-grid {
    flex-direction: column;
  }

  .project-card {
    flex: 1 1 auto;
  }
}

Your portfolio is fully styled and responsive. Next up, you will add JavaScript to make the theme toggle, mobile navigation, and contact form actually work.

Add JavaScript Interactivity

Your portfolio looks polished, but right now it is completely static. Clicking the theme toggle does nothing, the hamburger button on mobile is decorative, and the contact form submits to nowhere.

In this step you will bring the page to life with DOM manipulation. Every feature you add here (persisting user preferences, toggling UI state, validating input) is a pattern you will use daily in frontend work.

In this step, get ready to:
  • Implement a dark/light theme toggle that remembers the user's choice.
  • Wire up the mobile hamburger navigation.
  • Add client-side validation to the contact form.
Implement the theme toggle

The CSS custom properties you set up in the last step already define two colour palettes. Now you need JavaScript to swap between them and save the user's choice to localStorage so it persists across page refreshes.

  • In VS Code, open script.js (it should be empty).
  • Paste the following code to create the theme toggle feature:
(function () {
  // Theme Toggle
  const themeToggle = document.getElementById('theme-toggle');
  const root = document.documentElement;

  function getPreferredTheme() {
    const stored = localStorage.getItem('theme');
    if (stored) return stored;
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  }

  function applyTheme(theme) {
    root.setAttribute('data-theme', theme);
    themeToggle.textContent = theme === 'dark' ? 'Dark' : 'Light';
    themeToggle.setAttribute(
      'aria-label',
      theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
    );
  }

  applyTheme(getPreferredTheme());

  themeToggle.addEventListener('click', function () {
    const current = root.getAttribute('data-theme') ||
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    const next = current === 'dark' ? 'light' : 'dark';
    localStorage.setItem('theme', next);
    applyTheme(next);
  });

  window.matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', function () {
      if (!localStorage.getItem('theme')) {
        applyTheme(getPreferredTheme());
      }
    });

What does this code do?

Everything is wrapped in an IIFE (Immediately Invoked Function Expression) so your variables stay private and don't leak into the global scope.

  • getPreferredTheme() checks localStorage first, then falls back to the operating system's colour scheme preference.
  • applyTheme() sets the data-theme attribute on the root element, which triggers the CSS variable swap you built earlier. It also updates the button text and its aria-label for accessibility.
  • The click listener reads the current theme, flips it, saves the new value to localStorage, and applies it.
  • The matchMedia change listener re-applies the system preference if the user hasn't made a manual choice yet.
  • Save script.js.
  • Refresh your browser. Click the theme toggle button in the header. The entire page should switch between light and dark colours.
  • Refresh the page again. Your theme choice should persist (the page loads in the theme you last selected).

Theme not switching?

  • Make sure the button in your HTML has id="theme-toggle". The JavaScript uses that exact ID to find the button.
  • Open the browser console (F12 then the Console tab) and look for error messages. A typo in the file often shows up as a red error here.
  • Confirm that script.js is linked at the bottom of your HTML with <script src="script.js" defer></script>.

Why isn't my theme toggle working even though I copied the code?

Implement the mobile navigation

On narrow screens the CSS hides the navigation links and shows a hamburger icon. You need JavaScript to toggle the .open class that makes the nav visible, and to close it again when a link is tapped.

  • In script.js, add the following code directly below the theme toggle's matchMedia change listener:
  // Mobile Navigation
  const hamburger = document.getElementById('hamburger');
  const nav = document.getElementById('nav');

  hamburger.addEventListener('click', function () {
    const isOpen = nav.classList.toggle('open');
    hamburger.setAttribute('aria-expanded', String(isOpen));
    hamburger.setAttribute(
      'aria-label',
      isOpen ? 'Close navigation' : 'Open navigation'
    );
  });

  var navLinks = nav.querySelectorAll('.nav-link');
  navLinks.forEach(function (link) {
    link.addEventListener('click', function () {
      nav.classList.remove('open');
      hamburger.setAttribute('aria-expanded', 'false');
      hamburger.setAttribute('aria-label', 'Open navigation');
    });
  });

What does this code do?

  • nav.classList.toggle('open') adds the .open class if it is missing, or removes it if it is already there. Your CSS rule .nav.open { display: flex; } makes the links visible.
  • aria-expanded and aria-label update so screen readers announce the correct state to users who rely on assistive technology.
  • The navLinks.forEach loop closes the menu automatically when a visitor taps a link. Without this, the menu would stay open and cover the content.
  • Save script.js.
  • Resize your browser window to below 768px wide (or open DevTools and toggle the device toolbar with Cmd+Shift+M on macOS or Ctrl+Shift+M on Windows).
  • Click the three-line hamburger icon. The nav links should slide into view. Click any link and the menu should close.

Hamburger not showing?

  • The hamburger button only appears at 768px or narrower. Make sure your viewport is small enough.
  • Check that your HTML has id="hamburger" on the button and id="nav" on the nav element.

Help me figure out why my hamburger menu is not toggling the navigation.

Implement contact form validation

The HTML form has a novalidate attribute, which disables the browser's built-in validation popups. That means you are fully responsible for checking the inputs and showing helpful error messages yourself.

  • In script.js, add the following code below the mobile navigation section, just before the closing })();:
  // Contact Form Validation
  const form = document.getElementById('contact-form');
  const nameInput = document.getElementById('name');
  const emailInput = document.getElementById('email');
  const messageInput = document.getElementById('message');
  const nameError = document.getElementById('name-error');
  const emailError = document.getElementById('email-error');
  const messageError = document.getElementById('message-error');
  const successMessage = document.getElementById('success-message');

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var isValid = true;

    nameError.textContent = '';
    emailError.textContent = '';
    messageError.textContent = '';
    successMessage.textContent = '';

    if (nameInput.value.trim() === '') {
      nameError.textContent = 'Name is required.';
      isValid = false;
    }

    var emailValue = emailInput.value.trim();
    var atIndex = emailValue.indexOf('@');
    if (atIndex < 1 || emailValue.indexOf('.', atIndex) === -1) {
      emailError.textContent = 'Please enter a valid email address.';
      isValid = false;
    }

    if (messageInput.value.trim().length < 10) {
      messageError.textContent = 'Message must be at least 10 characters.';
      isValid = false;
    }

    if (isValid) {
      successMessage.textContent = 'Message sent successfully!';
      form.reset();
    }
  });
})();

What does this code do?

  • e.preventDefault() stops the browser from actually submitting the form (which would reload the page). This lets your JavaScript handle everything.
  • Each check clears previous errors first, validates one field, and writes a specific message into the corresponding <span> error element if the check fails.
  • The email check looks for an @ symbol at position 1 or later, then a . after the @. This is a lightweight check, not a full RFC-compliant email regex.
  • If all checks pass, form.reset() clears the fields and a green success message appears.
  • Save script.js and refresh your browser.
  • Scroll to the Contact section and click Send Message with all fields empty. You should see red error messages below each field.
  • Fill in a valid name, a valid email (with an @ and a dot), and a message of at least 10 characters. Click Send Message again. The green text "Message sent successfully!" should appear and the fields should clear.

Not seeing error messages?

  • Open the browser console and check for JavaScript errors. A missing comma or bracket in the code above can stop the entire script from running.
  • Make sure the closing })(); appears exactly once at the very end of script.js. This closes the IIFE you opened at the top.
  • Confirm that each error span in your HTML has the matching ID (name-error, email-error, message-error).

Help me debug why my contact form validation is not working.

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete script.js file. Compare yours against it line by line.

(function () {
  // Theme Toggle
  const themeToggle = document.getElementById('theme-toggle');
  const root = document.documentElement;

  function getPreferredTheme() {
    const stored = localStorage.getItem('theme');
    if (stored) return stored;
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light';
  }

  function applyTheme(theme) {
    root.setAttribute('data-theme', theme);
    themeToggle.textContent = theme === 'dark' ? 'Dark' : 'Light';
    themeToggle.setAttribute(
      'aria-label',
      theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
    );
  }

  applyTheme(getPreferredTheme());

  themeToggle.addEventListener('click', function () {
    const current = root.getAttribute('data-theme') ||
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    const next = current === 'dark' ? 'light' : 'dark';
    localStorage.setItem('theme', next);
    applyTheme(next);
  });

  window.matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', function () {
      if (!localStorage.getItem('theme')) {
        applyTheme(getPreferredTheme());
      }
    });

  // Mobile Navigation
  const hamburger = document.getElementById('hamburger');
  const nav = document.getElementById('nav');

  hamburger.addEventListener('click', function () {
    const isOpen = nav.classList.toggle('open');
    hamburger.setAttribute('aria-expanded', String(isOpen));
    hamburger.setAttribute(
      'aria-label',
      isOpen ? 'Close navigation' : 'Open navigation'
    );
  });

  var navLinks = nav.querySelectorAll('.nav-link');
  navLinks.forEach(function (link) {
    link.addEventListener('click', function () {
      nav.classList.remove('open');
      hamburger.setAttribute('aria-expanded', 'false');
      hamburger.setAttribute('aria-label', 'Open navigation');
    });
  });

  // Contact Form Validation
  const form = document.getElementById('contact-form');
  const nameInput = document.getElementById('name');
  const emailInput = document.getElementById('email');
  const messageInput = document.getElementById('message');
  const nameError = document.getElementById('name-error');
  const emailError = document.getElementById('email-error');
  const messageError = document.getElementById('message-error');
  const successMessage = document.getElementById('success-message');

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var isValid = true;

    nameError.textContent = '';
    emailError.textContent = '';
    messageError.textContent = '';
    successMessage.textContent = '';

    if (nameInput.value.trim() === '') {
      nameError.textContent = 'Name is required.';
      isValid = false;
    }

    var emailValue = emailInput.value.trim();
    var atIndex = emailValue.indexOf('@');
    if (atIndex < 1 || emailValue.indexOf('.', atIndex) === -1) {
      emailError.textContent = 'Please enter a valid email address.';
      isValid = false;
    }

    if (messageInput.value.trim().length < 10) {
      messageError.textContent = 'Message must be at least 10 characters.';
      isValid = false;
    }

    if (isValid) {
      successMessage.textContent = 'Message sent successfully!';
      form.reset();
    }
  });
})();

Your portfolio now responds to user actions. It remembers theme preferences, adapts navigation for mobile, and catches form errors before they get sent. Next up, you will push this to GitHub and make it live on the internet.

Deploy to GitHub Pages

Your portfolio has a complete HTML structure, polished responsive CSS, and interactive JavaScript features. Everything works locally, but nobody else can see it yet.

In this step, you will push your code to GitHub and enable GitHub Pages to host your site at a public URL. Anyone with the link can visit your portfolio from any device.

In this step, get ready to:
  • Commit and push all project files to your GitHub repository.
  • Enable GitHub Pages to deploy your site from the main branch.
  • Test the live site on desktop and mobile to confirm all features work.
Commit and push your code to GitHub

Git tracks changes to your code in snapshots called commits. You need to stage your files, create a commit, and then push that commit to your remote repository on GitHub.

  • Stage, commit, and push all four project files by running these commands in your terminal:

GitHub will ask you to sign in

When you run the push command, GitHub needs to verify your identity. A browser window may open asking you to sign in to your GitHub account. Follow the prompts to authorize access.

If you see a terminal prompt instead, enter your GitHub username and a personal access token (not your password) when asked.

git add .
git commit -m "Build portfolio site"
git push origin main

What do these commands do?

  • git add . stages all files in the current folder, telling Git to include them in the next commit.
  • git commit -m "Build portfolio site" creates a snapshot of those staged files with a descriptive message.
  • git push origin main uploads your commit to the remote repository on GitHub.
  • Open your repository on GitHub in a browser.
  • You should see all four files (index.html, style.css, script.js, .nojekyll) listed in the repository with the commit message "Build portfolio site".

Push failed or permission denied?

  • Make sure you are inside the portfolio folder when running the commands. Run pwd to confirm your location.
  • If you see a permission error, make sure you authenticated with the same GitHub account that owns the repository.
  • If the push is rejected because the remote contains work you do not have locally, re-clone the repository and copy your files back in.

Why is my git push being rejected or denied?

Enable GitHub Pages in your repository settings

GitHub Pages is a free hosting service that serves static websites directly from a GitHub repository. You need to tell GitHub which branch and folder to publish from.

  • On your repository page on GitHub, click Settings in the top navigation bar.
  • In the left sidebar under Code and automation, click Pages.
  • Under Build and deployment > Source, select Deploy from a branch.
  • Under Branch, select main from the branch dropdown.
  • Keep the folder set to / (root).
  • Click Save.

Why the .nojekyll file matters

GitHub Pages processes sites with Jekyll by default. The empty .nojekyll file you created earlier tells GitHub to skip that processing and serve your files exactly as they are.

  • After clicking Save, you should see a confirmation message on the Pages settings page indicating that your site is being built.

Don't see the Pages option?

  • Make sure the repository is set to Public. GitHub Pages requires a public repository on free accounts.
  • Make sure you pushed your files successfully in the previous substep. The branch dropdown will be empty if no branches exist on the remote.

Why can't I see the GitHub Pages settings?

Test your live site

GitHub Pages takes a moment to build and deploy your files. Once it finishes, your portfolio is accessible to anyone on the internet.

  • Wait 1-2 minutes for the deployment to complete.
  • Open a new browser tab and visit your GitHub Pages URL:
https://[[GITHUB_USERNAME="yourusername"]].github.io/portfolio/

How do I find my URL?

Your GitHub Pages URL follows the format https://yourusername.github.io/portfolio/ where yourusername is your GitHub username. You can also find the exact URL on the Pages settings page once the deployment finishes.

Deployments can take up to 10 minutes in some cases. If you see a 404 page, wait a bit longer and refresh.

  • Click the theme toggle button. The page should switch between light and dark themes.
  • Resize the browser window to a narrow width. The navigation should disappear and a hamburger icon should appear.
  • Click the hamburger icon. The navigation links should slide into view.
  • Scroll down to the contact form. Submit the form with empty fields and confirm that error messages appear.
  • Try visiting the same URL on your phone to see the responsive layout in action.

Seeing a 404 page?

  • Double-check that your repository contains a file named exactly index.html (not Index.html or index.HTML). The filename is case-sensitive.
  • Make sure the trailing slash is included in the URL. Visit /portfolio/ not /portfolio.
  • If you just enabled Pages, wait a few more minutes. Deployments can take up to 10 minutes.

My GitHub Pages site shows a 404 error. How do I fix it?

Secret mission

Add Scroll Animations and a Typing Effect

Your portfolio works, but it feels static. Professional sites use subtle animations to guide the eye and create a sense of polish. In this mission, you'll add scroll-reveal animations that fade sections in as the user scrolls, plus a typing effect that makes your hero subtitle appear character by character.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and deploys to GitHub Pages for free, so there are no ongoing costs.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local portfolio project folder on your Desktop.
  • GitHub repository portfolio (public).
  • GitHub Pages site serving your portfolio.

Keep everything running

No action needed. Choose this if you're still actively building or want your portfolio live at its GitHub Pages URL.

  • Your site stays live and accessible to anyone with the link.
  • Your local files remain on your Desktop for future edits.
  • Git global config (user.name and user.email) stays set for future Git projects.

Pause - I'll come back to this later

Your GitHub Pages site and repository cost nothing to keep online. The only action is closing VS Code to free up memory.

  • Close the portfolio project in VS Code.
  • Your site stays live on GitHub Pages. When you're ready to resume, reopen the folder in VS Code and pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This takes down your live site and removes all local files.

  • Delete the local project folder by running this command in your terminal:
rm -rf ~/Desktop/portfolio
  • On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\portfolio
  • Delete the GitHub repository (this also removes the GitHub Pages site):
  • Go to your portfolio repository on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

You just built and deployed a complete developer portfolio from scratch using vanilla HTML, CSS, and JavaScript.

You've learned how to:

  • Build a responsive portfolio website with semantic HTML, CSS custom properties, Flexbox layout, and a dark/light theme toggle that persists with localStorage.
  • Add JavaScript interactivity including mobile hamburger navigation, client-side form validation with inline error messages, and system preference detection with matchMedia.
  • Deploy a live site to GitHub Pages with a shareable public URL using a clean Git workflow of staging, committing, and pushing.
  • Secret Mission: Added scroll-reveal animations using the Intersection Observer API and a typing effect with setInterval.

Ready to quiz yourself?