1990s Personal Site — Header navigation, hero section, about me, projects grid, skills groups, contact form, and footer. Every developer needs a portfolio — it's your resume, showcase, and personal brand all in one page. Inspired by freeCodeCamp's Responsive Web Design certification and modern portfolio patterns from Brittany Chiang and Josh Comeau.

01

HTML Structure

Complete

Why learn this?

A portfolio page is the most personal project a developer builds. Unlike a docs page or landing page, a portfolio has many distinct sections — hero, about, projects, skills, contact — each with its own layout pattern. Mastering the semantic HTML structure behind these sections (header, section, footer, nav) is the foundation for every multi-section site on the web. Portfolio sites from designers like Brittany Chiang and developers like Josh Comeau all use the same structural patterns: a full-viewport hero with a strong tagline, a project grid that scales from 1 to 3 columns, and a contact form that feels approachable.

Design decisions & tradeoffs

Semantic sections vs divs. Each major page region uses a semantic element: <header> for the top nav, <section> with id for each content block, <footer> for the bottom. This creates a clear document outline that screen readers, search engines, and future developers can navigate. The alternative — generic <div> containers — would require role="region" and aria-label to achieve the same semantics. Using native elements is always less code and more accessible.

Hero layout vs full landing hero. The portfolio hero uses min-height: 80vh instead of 100vh like the Product Landing hero. This is intentional — portfolio pages are scrollable by design (visitors expect to scroll through sections), and an 80vh hero shows a clear "below the fold" call-to-action without dominating the viewport. The alternative — a shorter hero with just a name and subtitle — works for minimalist portfolios but risks looking sparse.

Section max-width and padding. Each section is constrained to max-width: 1100px with padding: 5rem 2rem (80px top/bottom, 32px sides). The 1100px width is slightly wider than the 800px content width used in the Doc Page — portfolio sections need more horizontal space for grids (projects, skills) to breathe. The 5rem vertical padding creates clear visual separation between sections, which is critical for a single-page site where sections stack vertically.

Project card pattern. Each project card has an icon, title, description, and tag list. This is the most common portfolio card pattern — it's used by Dribbble, Behance, and personal portfolio templates. The :hover effect uses box-shadow instead of transform: translateY — a subtle glow rather than a lift. The tradeoff: box-shadow is less performant than transforms for animation (triggers repaint) but the effect is more appropriate for a portfolio (like a photo coming into focus).

Contact form placement. The contact form is the last section before the footer — it's the natural "end" of the portfolio journey. After seeing the hero, reading the about, browsing projects, and scanning skills, the visitor is ready to reach out. The form uses a simple 3-field layout (name, email, message) with a submit button. Keeping it simple reduces friction — studies show that forms with more than 5 fields have significantly lower conversion rates.

Browser compatibility

  • <section> with id: Universal support. The id attribute enables anchor navigation from nav links. Works since HTML 4.
  • min-height: 80vh: Supported universally. On iOS Safari, vh units include the URL bar — the hero will be taller than expected on initial load. Use 80dvh (iOS 15.4+) for dynamic viewport.
  • repeat(auto-fit, minmax(320px, 1fr)): CSS Grid with auto-wrapping. Supported since 2017 (Chrome 57, Firefox 52, Safari 10.3). Falls back to single-column on older browsers.
  • box-shadow on hover: Works universally. Unlike box-shadow transitions, the :hover state is instantaneous (no transition) so there's no performance concern.
  • Form input :focus with box-shadow: The green focus ring uses box-shadow: 0 0 0 3px rgba(0,212,170,0.1). Supported in all modern browsers. Firefox uses outline for native focus — override with outline: none when using custom focus styles.

Accessibility details

  • Skip-to-content link: Missing from this step. Visitors tabbing through the page start at the nav links and must tab through 4 links before reaching content. Add a skip link as the first focusable element (<a href="#about" class="skip-link">).
  • Nav link targets: Nav links use href="#about" etc. to scroll to sections. This is the standard single-page navigation pattern. The id on each <section> must match exactly — case-sensitive, no spaces.
  • Project card semantics: Cards are <div> elements with :hover effects. If cards are clickable (linking to project pages), use <a> instead or add role="link" with tabindex="0".
  • Form labels: Each input has an explicit <label for="id">. This is WCAG 3.3.2 compliance — screen readers announce the label when the input is focused.
  • Color contrast: The hero uses dark text (#1a1a2e) on a light background (#f8f9fa) — ratio ~14:1, exceeding AAA. Project card descriptions use #666 on #fff — ratio ~5.1:1, meets AA.
  • Focus order: The single-page layout means focus order follows DOM order (header → hero → about → projects → skills → contact → footer). This is the natural reading order and requires no tabindex manipulation.

Common pitfalls

  • Missing id on sections: Nav links scroll to #projects but if the <section> doesn't have id="projects", clicking the link does nothing. Double-check every href matches an id.
  • Nav link hash conflicts with URL parameters: If the page URL already has a hash (e.g., #contact from a shared link), the nav link click must still work. Using <a href="#about"> is the standard pattern and handles this correctly.
  • Horizontal scroll on small screens: The nav ul with gap: 2rem can overflow on narrow viewports. The fix: overflow-x: auto on the nav container or switch to a hamburger menu at mobile breakpoints.
  • Form submission reloads the page: Without event.preventDefault() (added in Step 4), clicking submit reloads the page and loses the user's input. A plain HTML form without JS handling needs a action URL to go anywhere.
  • Section padding on mobile: padding: 5rem 2rem (80px) looks balanced on desktop but can leave too much empty space on mobile. Reduce to 3rem 1.25rem at mobile breakpoints.

Key concepts

  • <section id="..."> — Semantic section containers with anchor targets. One per content block
  • min-height: 80vh — Full-viewport hero that signals "scroll down" (vs 100vh which says "this is everything")
  • max-width: 1100px; margin: 0 auto; padding: 5rem 2rem — Centered section container pattern
  • repeat(auto-fit, minmax(320px, 1fr)) — Responsive project grid. Cards wrap from 3→2→1 columns
  • <label for="..."> — Explicit label association. Required for WCAG compliance
  • <ul> for nav links — List-based navigation is semantically correct and screen-reader friendly

Next up

Step 2 adds full styling: color scheme, typography polish, section backgrounds, hover effects, and responsive breakpoints.

Portfolio structure ▶ Run
<header>
  <nav>
    <div class="logo">
      Alex<span>Dev</span>
    </div>
    <ul>
      <li><a href="#about">
        About</a></li>
      <li><a href="#projects">
        Projects</a></li>
      <li><a href="#contact">
        Contact</a></li>
    </ul>
  </nav>
</header>

<section class="hero">
  <h1>Hi, I'm <span>Alex Chen</span></h1>
  <p class="tagline">
    Full-stack developer building
    with AI.</p>
  <a href="#projects"
    class="cta">My Work</a>
</section>

<section id="about">
  <h2>About Me</h2>
  <div class="about-content">
    <p>I'm a developer based
      in San Francisco...</p>
  </div>
</section>

<section id="projects">
  <h2>Projects</h2>
  <div class="project-grid">
    <div class="project-card">
      <div class="icon">&#129302;</div>
      <h3>AgentForge</h3>
      <p>AI agent framework</p>
      <div class="tags">
        <span>TypeScript</span>
      </div>
    </div>
  </div>
</section>

<section id="contact">
  <h2>Get in Touch</h2>
  <form>
    <label for="name">Name</label>
    <input type="text"
      id="name" required>
    <button>Send</button>
  </form>
</section>

<footer>
  <p>&copy; 2026 Alex Chen</p>
</footer>
Layout CSS
section {
  max-width: 1100px;
  margin: 0 auto;
  padding: 5rem 2rem;
}
.hero {
  min-height: 80vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.project-grid {
  display: grid;
  grid-template-columns:
    repeat(auto-fit,
    minmax(320px, 1fr));
  gap: 1.5rem;
}
.project-card {
  background: #fff;
  border: 1px solid #e2e6ea;
  border-radius: 12px;
  padding: 1.5rem;
  transition: box-shadow 0.2s;
}
.project-card:hover {
  box-shadow:
    0 4px 20px rgba(0,0,0,0.06);
}
.skills-list {
  display: grid;
  grid-template-columns:
    repeat(auto-fit,
    minmax(200px, 1fr));
  gap: 1.5rem;
}
.contact-form input,
.contact-form textarea {
  width: 100%;
  padding: 0.7em 0.9em;
  border: 1px solid #d0d5dd;
  border-radius: 8px;
  margin-bottom: 1rem;
}
.contact-form input:focus {
  border-color: #00d4aa;
  box-shadow:
    0 0 0 3px rgba(0,212,170,0.1);
}
📐 Section spacing: The padding: 5rem 2rem pattern (80px top/bottom, 32px sides) is the standard spacing for multi-section pages. At mobile widths, reduce to 3rem 1.25rem to avoid excessive whitespace. For visual section alternation, alternate #f8f9fa and #fff backgrounds between sections.
02

Styling & Layout

Complete

Why learn this?

A portfolio page needs to feel personal and polished — raw HTML structure isn't enough. Styling decisions (color scheme, typography, spacing, hover effects) are what turn a generic layout into a reflection of the developer. This step teaches the CSS patterns that professional portfolio sites use: sticky header with underline nav animation, alternating section backgrounds for visual rhythm, refined card hover states, and responsive breakpoints that adapt the layout to any screen size.

Design decisions & tradeoffs

Sticky header vs static header. The header uses position: sticky; top: 0 to stay visible while scrolling through sections. This is the standard pattern for single-page portfolios — it gives users persistent access to navigation without requiring a "back to top" button. The tradeoff: the sticky header occupies 64px of vertical space on mobile, which can feel cramped on small screens. The alternative — a static header that scrolls away — works better for portfolios with a single, immersive hero section.

Underline nav hover effect. Nav links use ::after with a width: 0 → 100% transition on hover. This is the classic "underline slide" effect used by Stripe, Linear, and most modern marketing sites. The transition is GPU-composited (only animating width, which triggers composite) and takes 200ms — fast enough to feel responsive, slow enough to be visible. The alternative — text-decoration: underline with transition — is simpler but doesn't animate from left to right, which feels less polished.

Alternating section backgrounds. Sections alternate between the page background (#f8f9fa) and white (#fff). This creates visual rhythm — the eye can distinguish between sections without relying on padding alone. The pattern uses a wrapper div (.section-alt) around alternating sections, keeping the max-width constraint inside while allowing the background color to bleed edge-to-edge. The alternative — alternating background colors within the same container — requires more complex CSS with negative margins or nested containers.

Card hover: lift vs shadow. Project cards use transform: translateY(-4px) combined with box-shadow and border-color changes. This triple-signal hover (lift + glow + color) is more interactive than the shadow-only approach from Step 1. The lift effect is created with transform (GPU-composited, no layout shift), and the border color change to #00d4aa ties the hover state to the brand color. The tradeoff: transform on hover can cause cards to overlap when they're in a tight grid — add padding to the grid to prevent clipping.

Skip link for accessibility. A skip-to-content link is the first focusable element on the page, hidden by default (top: -100%) and revealed on keyboard focus. This is a WCAG 2.4.1 requirement — users navigating by keyboard should be able to skip repetitive navigation (the header links) and jump directly to the main content. The skip link uses the brand color to be visually consistent with the rest of the page.

Social links in footer. The footer includes social links (GitHub, LinkedIn, Twitter) — a standard portfolio convention that gives visitors a way to connect outside the contact form. The links use rgba() for subtle coloring and transition to the brand accent on hover. The alternative — placing social links in the header — is more visible but adds clutter to the primary navigation.

Browser compatibility

  • ::after pseudo-element transitions: The underline nav effect uses ::after with width transition. Supported universally. The bottom: -2px positioning may shift if the nav link has different line-height across browsers — test in Firefox and Safari.
  • scroll-behavior: smooth: Supported since Chrome 61, Firefox 36, Safari 15.4. Safari was the late adopter — before 15.4, anchor links jumped instantly. For older Safari, add a JS scrollIntoView() polyfill.
  • position: sticky in header: Works in all modern browsers. Fails if any parent has overflow: hidden. The header's parent (body) must not have overflow restrictions.
  • calc(100vh - 64px): The hero height subtracts the header height. Supported universally. On iOS Safari, 100vh includes the URL bar, making the hero taller on initial load. Use 100dvh as a progressive enhancement (iOS 15.4+).
  • Inter font: The font stack uses 'Inter', -apple-system, ... — prefers Inter if available, falls back to system fonts. Inter is a popular open-source font for portfolios (used by Vercel, Linear, Tailwind). If Inter isn't loaded (no @import in this demo), the system font fallback is identical to Step 1.

Accessibility details

  • Skip link behavior: The skip link (.skip-link) appears on keyboard focus. When clicked, it scrolls to #about and focuses that section. Without a skip link, keyboard users tab through every nav link before reaching content — 4 unnecessary stops on this page.
  • Underline animation and reduced motion: The nav link underline animation (width: 0 → 100%) should be disabled for users who prefer reduced motion. Wrap it in @media (prefers-reduced-motion: reduce) { nav a::after { transition: none; } }.
  • Card hover vs focus: The card lift effect only triggers on :hover. Keyboard users tabbing through cards won't see it. Add .project-card:focus-within { transform: translateY(-4px); } or apply the same effect on :focus-visible.
  • Color-only section distinction: Alternating section backgrounds use #f8f9fa (light gray) vs #fff (white). The contrast ratio between these is ~1.1:1 — insufficient to distinguish by color alone. Users with low vision may not perceive the alternation. Use a subtle border or divider as a secondary signal.
  • Footer link contrast: Social links use color: rgba(255,255,255,0.4) on the dark footer background. This is approximately 3.5:1 contrast — barely meeting WCAG AA for normal text (4.5:1). Consider lightening to rgba(255,255,255,0.6) for better readability.

Common pitfalls

  • Sticky header + anchor offset. When clicking a nav link (#projects), the browser scrolls so the target section is at the very top — behind the 64px sticky header. Add scroll-margin-top: 80px to each section to offset the scroll position below the header.
  • Underline animation offset at different font sizes. The ::after element uses bottom: -2px relative to the link. If the link has line-height greater than the height of the text, the underline may appear too low or too high. Use bottom: 0 with transform: translateY(4px) for consistent positioning.
  • Section background bleed on wide screens. The alternating background uses a wrapper div (.section-alt) with no max-width, containing an inner div with max-width: 1100px. On ultrawide screens, the background color extends to page edges, but the inner content stays centered. If the wrapper is accidentally omitted, the background color will be confined to 1100px, creating a visible box.
  • Card lift clipping in grid. transform: translateY(-4px) on hover can cause the card to be clipped if the grid has overflow: hidden. Use overflow: visible on the grid or add extra padding to accommodate the lift.
  • Skip link visible on click. The skip link uses position: absolute; top: -100% to hide it off-screen. When focused, top: 0 brings it into view. But if the user clicks the skip link and then tabs away, the skip link stays at top: 0 (still focused) and covers the top of the page content. Add a blur listener to re-hide it.

Key concepts

  • position: sticky; top: 0 — Sticky header. Needs z-index to stay above sections
  • ::after underline animation — Hover effect with width: 0 → 100% transition. GPU-composited
  • Alternating section backgrounds — Wrapper div for edge-to-edge color, inner div for content max-width
  • transform: translateY(-4px) + box-shadow + border-color — Triple-signal card hover
  • scroll-margin-top: 80px — Anchor offset for sticky header. Without it, sections hide behind the nav
  • skip-linkposition: absolute; top: -100%top: 0 on :focus. WCAG 2.4.1 requirement
  • calc(100vh - 64px) — Hero height accounting for sticky header height
  • @media (max-width: 768px) — Tablet breakpoint. 480px for small phones

Next up

Step 3 adds filterable project cards with tag-based filtering, animated transitions, and project detail links.

Styling additions ▶ Run
<header>
  <div class="inner">
    <a href="#" class="logo">
      Alex<span>Dev</span>
    </a>
    <nav>
      <ul>
        <li><a href="#about">
          About</a></li>
        <li><a href="#projects">
          Projects</a></li>
      </ul>
    </nav>
  </div>
</header>

<!-- Alternating sections -->
<div class="section-alt">
  <div class="section-alt-wrap">
    ...
  </div>
</div>
Refined CSS
header {
  position: sticky;
  top: 0;
  z-index: 100;
}
header .inner {
  max-width: 1100px;
  margin: 0 auto;
  padding: 0 2rem;
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 64px;
}
nav a {
  position: relative;
  color: rgba(255,255,255,0.6);
  transition: color 0.2s;
}
nav a::after {
  content: '';
  position: absolute;
  bottom: -2px;
  left: 0;
  width: 0;
  height: 2px;
  background: #00d4aa;
  transition: width 0.2s;
}
nav a:hover { color: #fff; }
nav a:hover::after { width: 100%; }

.section-alt {
  background: #fff;
}
.section-alt-wrap {
  max-width: 1100px;
  margin: 0 auto;
  padding: 5rem 2rem;
}

.project-card {
  transition:
    transform 0.2s,
    box-shadow 0.2s,
    border-color 0.2s;
}
.project-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 32px
    rgba(0,0,0,0.06);
  border-color: #00d4aa;
}
🎨 Underline trick: The ::after underline animation works with position: relative on the parent and position: absolute on the pseudo-element. The left: 0 + width: 0 → 100% creates a left-to-right slide. For a center-out animation, use left: 50%; transform: translateX(-50%); width: 0 → 100%.
03

Projects Grid

Complete

Why learn this?

A filterable project grid is one of the most common interactive UI patterns on the web — used by portfolio sites, e-commerce category pages, job boards, and documentation filters. The core mechanic (click a button → show/hide cards based on data attributes) teaches JavaScript event handling, DOM manipulation, and the dataset API. This pattern also introduces a key frontend architecture decision: how to store metadata about elements for runtime filtering.

Design decisions & tradeoffs

data-* attributes vs class-based filtering. Each project card stores its technologies as comma-separated values in data-tags="react,typescript,node". The filter function checks card.dataset.tags.includes(filter) to decide visibility. This approach is declarative — the metadata lives in the HTML, not in a JavaScript array. The alternative — adding/removing CSS classes per filter category (e.g., .filter-react) — requires N classes for N tags and doesn't scale beyond a few categories. Data attributes scale to any number of tags and keep the JS logic simple.

display: none vs visibility: hidden for hiding. The filter uses display: none (via a .hidden class) to hide non-matching cards. display: none removes the element from the layout flow, which causes the grid to reflow and collapse empty space — this is the correct behavior for a filter (you want the remaining cards to fill the gaps). The alternative — visibility: hidden — preserves layout space but leaves blank card-sized holes in the grid, which looks broken. The tradeoff: display: none triggers repaint on every filter change, but for a grid of 6 cards, this is imperceptible.

Filter button active state. The active filter button gets a solid brand-color background (#00d4aa) while inactive buttons have an outlined style. This uses both color AND weight (bold text) to indicate the active state — accessible by default. The alternative — underline-only or border-only active indicators — would fail WCAG 1.4.1 for color-only distinction.

Event delegation vs individual listeners. Each filter button gets its own addEventListener. This is fine for 6 buttons but doesn't scale to 50+ filters. The alternative — event delegation on the parent container with e.target.closest('.filter-btn') — handles dynamically added buttons without re-binding listeners. For this portfolio, individual listeners are simpler and more readable.

Card metadata storage. Tags are stored in data-tags as a flat comma-separated string ("react,typescript,node"). The includes() check works because the filter values match individual tag names. This approach has a subtle edge case: filtering for "react" would also match a tag called "react-native" since includes("react") matches substrings. For production, use a more precise match (tags.split(',').includes(filter)).

Browser compatibility

  • HTMLElement.dataset: Supported since Chrome 7, Firefox 6, Safari 5.1, IE 11. Provides camelCase access to data-* attributes. card.dataset.tags reads data-tags. Works universally in modern browsers.
  • String.prototype.includes(): Supported since Chrome 41, Firefox 40, Safari 9, IE 12 (Edge). For IE11 support, use indexOf() instead.
  • NodeList.forEach(): Supported since Chrome 51, Firefox 50, Safari 10. IE11 doesn't support NodeList.forEach — use Array.from() or a for loop for broader compatibility.
  • display: none in CSS Grid: When a grid item has display: none, the grid re-calculates column layout. This works in all grid-supporting browsers. The remaining items expand to fill available space.

Accessibility details

  • Filter results not announced: When a filter hides cards, screen readers don't know anything changed. Add aria-live="polite" to a status element that announces "Showing N of M projects" after each filter change. Without this, screen reader users have no way to know the filter worked.
  • Focus management after filter: After clicking a filter button, focus stays on the button. If all visible cards disappear (empty filter result), the user's next tab lands on the next focusable element after the grid — potentially skipping over content. Add focus management to move to the first visible card or a "no results" message.
  • Hidden cards still in tab order: Elements with display: none are removed from the accessibility tree and tab order — correct behavior. Elements with visibility: hidden are hidden from screen readers but remain in tab order (bad — keyboard users can tab to invisible elements).
  • Filter button semantics: Using <button> for filter toggles ensures they're keyboard accessible by default (enter/space to activate). Using <div> with onclick would require role="button" and tabindex="0" to match native button behavior.
  • Active filter contrast: The active button uses #00d4aa (accent) on #fff background — contrast ratio ~1.7:1 for the background. But the text is #0a0a1a (near-black), which is ~13:1 on the accent color. The important contrast is between the text and the button background, not between the button and page background.

Common pitfalls

  • Substring matching in includes(). "react,typescript".includes("react") matches correctly, but "react-native,node".includes("react") also matches because "react" is a substring of "react-native". Fix: split and check with card.dataset.tags.split(',').includes(filter). This matches entire tag names only.
  • Whitespace in data-tags. data-tags="react, typescript" (with spaces) will fail includes("typescript") because the stored value is "react, typescript" (with space). Use no spaces: "react,typescript", or normalize with .trim() and .replace(/\s/g, '').
  • No results state. If a filter matches zero cards (e.g., filtering for a tag no project has), the grid shows empty. Add a "No projects match this filter" message that appears when visibleCards.length === 0. This prevents user confusion.
  • Grid reflow animation. Cards appear/disappear instantly because display: none doesn't animate. For a smooth filter transition, use a brief opacity + transform animation before setting display: none (via setTimeout or CSS @starting-style).
  • Filter button and card count mismatch. If a new project is added to the HTML but no filter tag exists for it, that project is always hidden. Keep the filter tags in sync with the actual data tags on cards.

Key concepts

  • data-tags="react,typescript" — Custom data attribute for card metadata. Read via card.dataset.tags
  • filter() + includes() — Filter logic. Use split(',') for exact tag matching
  • display: none — Removes elements from layout AND accessibility tree. Correct for filters
  • classList.toggle('hidden', condition) — Conditional class toggle. Second argument sets boolean state
  • forEach() with arrow functions — Iterating over NodeLists. Polyfill for IE11
  • aria-live="polite" — Announce filter results to screen readers
  • dataset API — Read/write data-* attributes as camelCase properties

Next up

Step 5 adds dark mode, theme toggle, and final polish.

Filter buttons ▶ Run
<div class="filter-bar">
  <button class="filter-btn active"
    data-filter="all">All</button>
  <button class="filter-btn"
    data-filter="react">React</button>
  <button class="filter-btn"
    data-filter="typescript">
    TypeScript</button>
  <button class="filter-btn"
    data-filter="ai">AI</button>
</div>

<div class="project-grid">
  <div class="project-card"
    data-tags="react,typescript">
    ...
  </div>
</div>
Filter CSS
.filter-bar {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 2rem;
}
.filter-btn {
  padding: 0.45em 1em;
  border: 1px solid #d0d5dd;
  background: #fff;
  border-radius: 100px;
  font-size: 0.82rem;
  color: #555;
  cursor: pointer;
  transition: all 0.2s;
}
.filter-btn:hover {
  border-color: #00d4aa;
  color: #00d4aa;
}
.filter-btn.active {
  background: #00d4aa;
  color: #0a0a1a;
  border-color: #00d4aa;
  font-weight: 600;
}
.project-card.hidden {
  display: none;
}
Filter JS
const filterBtns =
  document.querySelectorAll('.filter-btn');
const cards =
  document.querySelectorAll('.project-card');

filterBtns.forEach(btn => {
  btn.addEventListener('click', () => {
    // Update active button
    filterBtns.forEach(b => b
      .classList.remove('active'));
    btn.classList.add('active');

    const filter = btn.dataset.filter;
    const visible = [];

    cards.forEach(card => {
      if (filter === 'all') {
        card.classList.remove('hidden');
        visible.push(card);
      } else {
        const tags = card.dataset.tags;
        const match = tags
          .split(',')
          .includes(filter);
        card.classList
          .toggle('hidden', !match);
        if (match) visible.push(card);
      }
    });

    // Announce result count
    const status = document
      .getElementById('filterStatus');
    status.textContent =
      `Showing ${visible.length}
       of ${cards.length} projects`;
  });
});
🏷️ data-tags gotcha: Always split data-tags values before using includes(). A data-tags="react" card matches a "react" filter correctly, but a card with data-tags="react-native" also matches "react" if you don't split first. Use card.dataset.tags.split(',').includes(filter) for exact matching.
04

Contact Form & Skills

Complete

Why learn this?

Two of the most common JavaScript interactions on the web are form validation and animated progress. Form validation is the gatekeeper between users and your backend — it catches typos, missing fields, and malformed data before they become server errors. Progress bars are a core UX pattern for skill displays, loading states, and data visualization. This step teaches client-side validation patterns (real-time feedback, field states, error messages) and CSS-animated progress bars with IntersectionObserver for scroll-triggered animation.

Design decisions & tradeoffs

Real-time validation vs validate on submit. The form validates on blur (when the user leaves a field) and clears errors on input (once the user starts fixing it). This is the most user-friendly pattern — the user gets immediate feedback without aggressive pop-in. The tradeoff: it requires more JavaScript (3 event listeners per field instead of 1). The alternative — validate only on submit — is simpler but gives a worse UX where all errors appear at once with no guidance on which field to fix first.

Field states: error vs valid vs default. Each input has three visual states controlled by CSS classes: .error (red border + red shadow), .valid (green border), and default (gray border). This is the standard pattern used by Tailwind UI, shadcn/ui, and most component libraries. The error state uses both border color and a visible error message — dual-signal accessibility (not color-only). The valid state uses a subtle green border that signals success without being distracting.

novalidate attribute. The form uses novalidate to disable the browser's built-in HTML5 validation (required, type="email" popups). This gives full control over the validation UX — custom error messages, custom styling, and no native tooltips. The tradeoff: without novalidate, the browser handles required-field checks for free, but the native popup style can't be styled and the placement varies by browser (Chrome shows a tooltip, Safari shows inline text, Firefox blocks submission).

CSS transition for bar animation vs JS animation. Skill progress bars use transition: width 0.8s ease — a single CSS declaration. When JS sets style.width, the bar animates smoothly from 0 to the target width. The alternative — JS-driven animation with requestAnimationFrame — gives frame-level control but requires significantly more code. A CSS transition is smoother (GPU-composited) and more performant for simple width animations.

IntersectionObserver for scroll-triggered animation. Bars animate when the skills section scrolls into view. IntersectionObserver is the modern standard for scroll detection — it's async, non-blocking, and more efficient than scroll event listeners. The threshold of 0.3 (30% visible) ensures the bars aren't triggered by a brief scroll-through. The observer disconnects after the first animation to prevent re-triggers. The alternative — scroll event with getBoundingClientRect — would require throttling and manual visibility math.

data-width attribute for bar percentages. Each skill's percentage is stored in data-width="85" on the bar-fill element. This keeps the numeric value in the HTML (easy to update, no JS array) while the JS reads it via dataset.width to set style.width. The alternative — hardcoding the width in CSS (.skill-bar:nth-child(1) .bar-fill { width: 90%; }) — creates maintenance overhead when skills change.

Progress bar accessibility. Each bar has a visible percentage label (90%) next to the skill name. This serves as the accessible text — no aria-valuenow or role="progressbar" needed because the value is already presented as text. For screen reader users, the label-reading order (name → percentage) conveys both the skill and the proficiency level. Adding role="progressbar" would require aria-valuenow, aria-valuemin, and aria-valuemax — more markup for no additional value since the percentage text already serves the purpose.

Browser compatibility

  • IntersectionObserver: Supported since Chrome 51, Firefox 55, Safari 12.1. For older browsers, the bars remain at width 0 (degraded but not broken). Add a fallback that sets widths immediately if the API isn't available.
  • transition: width on bars: Supported universally. The 0.8s ease timing is smooth without being too slow — shorter bars (60%) finish faster than longer ones (90%) because the distance is less.
  • novalidate form attribute: Supported universally. Prevents native validation popups across all browsers.
  • :focus-visible vs :focus: The form uses :focus with a custom box-shadow. Safari 10+ supports this. For keyboard-only focus outlines, :focus-visible (Safari 15.4+) provides better UX — mouse clicks don't show the ring, only keyboard tabbing does.
  • element.classList: Supported since Chrome 8, Firefox 3.6, Safari 5.1. classList.toggle(className, force) with the second boolean argument is newer (Chrome 65+, Safari 12.1+). For broader support, use if (condition) { el.classList.add('visible') } else { el.classList.remove('visible') }.

Accessibility details

  • Error message association. Error messages are <div> elements placed immediately after each input. They're not linked via aria-describedby — a common accessibility mistake. For screen reader support, add aria-describedby="nameError" to each input and role="alert" on the error div so errors are announced when they appear.
  • Color-only error indicators. The error state uses a red border (#e74c3c) combined with visible error text below the input. This meets WCAG 1.4.1 (use of color) because the error text provides a non-color signal. The valid state uses green border — adding a checkmark icon would improve this further.
  • Progress bar text contrast. Skill percentages use color: #888 — approximately 4.2:1 on #fff background. This meets WCAG AA for normal text (4.5:1 is required) but only just. Consider #777 for better readability.
  • Bar color contrast. The bar fill uses #00d4aa gradient — approximately 1.7:1 against the track background #e8ecf0. Color alone distinguishes the fill from the track, but low-vision users may not perceive the difference. Add border-right: 1px solid rgba(0,0,0,0.1) to each bar fill for shape-based distinction.
  • Form reset clears validation state. On successful submission, the form resets and all validation classes are removed. This prevents stale visual states (green "valid" borders on an empty form).
  • Focus after submission. After the success message appears, focus moves to the success heading. Without this, keyboard users would be disoriented — the form is gone but focus stays where the submit button was.

Common pitfalls

  • RegEx email validation is too strict or too loose. /^[^\s@]+@[^\s@]+\.[^\s@]+$/ catches most typos but rejects valid emails like [email protected] (the plus and multiple dots are valid). A better approach: check for the presence of @ and a dot after it, then let the server handle strict validation. Don't reject emails that might be valid.
  • Bar animation fires off-screen. Without IntersectionObserver, bars animate on page load — users who scroll down to skills see empty bars (animation already finished). Always pair scroll-triggered animations with IntersectionObserver or a scroll-listener.
  • form.reset() doesn't clear validation classes. Calling form.reset() clears input values but doesn't remove CSS classes or error messages. Always explicitly reset validation state alongside form.reset().
  • Multiple form submissions. A user can click submit multiple times before the validation runs, sending N duplicate form submissions. Disable the submit button immediately on click (submitBtn.disabled = true) and re-enable only if validation fails. Or use a loading state to prevent double-submit.
  • Bar transition doesn't reset on re-observe. If IntersectionObserver fires again (e.g., the user scrolls away and back), the bars don't re-animate because they're already at their target width. Reset style.width = 0 before re-animating if re-trigger is desired.
  • Email field accepts whitespace. " [email protected] " passes most regex checks because the regex matches the inner portion. Always call .trim() on input values before validation to prevent leading/trailing space issues.

Key concepts

  • novalidate — Disable browser native validation. Full control over error UX
  • blur event — Validate when user leaves a field. Immediate feedback without intrusion
  • input event — Re-validate as user types (after first blur). Clears error state in real time
  • classList.toggle() with boolean — Conditional CSS class toggle for error/valid states
  • IntersectionObserver — Scroll-triggered animation. Async, non-blocking, auto-disconnect
  • transition: width 0.8s ease — CSS-animated progress bar. GPU-composited, minimal JS
  • data-width attribute — Store numeric values in HTML for JS to read via dataset
  • e.preventDefault() — Intercept form submission. Prevents page reload

Next up

Step 5 adds dark mode with a CSS theme toggle, prefers-color-scheme detection, LocalStorage persistence, and final visual polish. This is the final step — after this, the portfolio is complete.

Skill bar HTML ▶ Run
<div class="skill-bar">
  <div class="skill-label">
    <span class="skill-name">
      React / Next.js</span>
    <span class="skill-pct">90%</span>
  </div>
  <div class="bar-track">
    <div class="bar-fill"
      data-width="90"></div>
  </div>
</div>
Bar CSS
.bar-track {
  height: 8px;
  background: #e8ecf0;
  border-radius: 100px;
  overflow: hidden;
}
.bar-fill {
  height: 100%;
  background: linear-gradient(
    90deg,
    #00d4aa,
    #00b894
  );
  border-radius: 100px;
  width: 0;
  transition: width 0.8s ease;
}
Form validation JS
const validateName = () => {
  const valid = nameInput.value.trim()
    .length >= 2;
  setFieldState(nameInput, nameError,
    valid, 'Name must be 2+ chars.');
  return valid;
}

nameInput
  .addEventListener('blur', validateName);
nameInput
  .addEventListener('input', () => {
    if (nameInput.classList
      .contains('error'))
      validateName();
});

form.addEventListener('submit', (e) => {
  e.preventDefault();
  const nameValid = validateName();
  const emailValid = validateEmail();
  const msgValid = validateMessage();

  if (nameValid && emailValid
    && msgValid) {
    form.reset();
    form.style.display = 'none';
    formSuccess
      .classList.add('visible');
  }
});
Progress bar animation
const section =
  document.getElementById('skills');
let animated = false;
const observer =
  new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting
        && !animated) {
        animated = true;
        document
          .querySelectorAll('.bar-fill')
          .forEach(bar => {
            bar.style.width =
              bar.dataset.width + '%';
          });
        observer.disconnect();
      }
    });
  }, { threshold: 0.3 });
observer.observe(section);
🧪 Validation flow: Blur validates on field exit (catches user focus leaving). Input re-validates in real time once the field has been touched (the "dirty" pattern). This two-phase approach gives immediate feedback without annoying the user before they've finished typing. The setFieldState helper keeps validation DRY — it handles three states (error, valid, untouched) and sets error messages dynamically.
05

Dark Mode & Polish

Complete

Why learn this?

Dark mode is one of the most requested features for any modern web application — it's no longer optional. GitHub, Vercel, Tailwind, and every major framework ship dark mode as a default feature. This step teaches CSS custom properties (the foundation of any theme system), the prefers-color-scheme media query for OS-level dark mode detection, LocalStorage for persisting user preference, and the theme toggle pattern. It also covers accessibility details like prefers-reduced-motion and scrollbar styling.

Design decisions & tradeoffs

CSS custom properties vs CSS-in-JS theming. The entire theme system uses CSS custom properties (--bg-body, --text-primary, etc.) defined in :root (light mode) and overridden in [data-theme="dark"]. When the data-theme attribute on <html> changes, every property updates instantly. This approach is zero-dependency, works with any CSS framework, and doesn't need a JavaScript runtime to compute styles. The alternative — CSS-in-JS with a ThemeProvider — requires a library (styled-components, Emotion) and adds bundle size for something CSS already handles natively.

Granularity of custom properties. The theme defines ~30 custom properties covering backgrounds (body, header, cards, alt sections, tracks, tags), text (primary, secondary, muted, header, nav, footer), borders (default, card, error, valid), accents, shadows, and utility colors. This is more properties than the minimum needed (you could get away with 10-15), but the extra granularity means individual components can be themed independently — a design system pattern used by shadcn/ui, Tailwind UI, and Radix. The tradeoff: more properties means more maintenance when adding new components.

data-theme attribute on <html> vs CSS class on <body>. The theme is stored as data-theme="dark" on <html>. This is the standard pattern used by Bootstrap, shadcn/ui, and most CSS frameworks — it's more specific than a class on <body> and can be targeted by CSS selectors with higher specificity. The alternative — <body class="dark"> — is equally valid but less conventional. The data-* attribute approach is more semantic (it's metadata, not a visual class) and keeps the selector namespace clean.

prefers-color-scheme vs localStorage priority. On first visit, the site respects the OS preference (prefers-color-scheme: dark). Once the user toggles manually, the choice is saved to localStorage and takes priority over the OS preference on subsequent visits. This is the standard UX pattern — respect the OS default until the user overrides it, then respect the override. The edge case handled here: if the user hasn't saved a preference and the OS dark mode changes live (e.g., macOS auto-dark at sunset), the site updates in real time via matchMedia.addEventListener('change', ...).

Transition on all themable properties vs selective. The body applies a blanket transition: background-color 0.3s, color 0.3s, border-color 0.3s on the body and key containers. This creates a smooth fade when toggling themes. The tradeoff: transitions on every themed element can cause a cascade of animations on toggle — each component animates its colors independently, creating a staggered effect. An alternative — disabling transitions during theme switch with a class (.no-transition that is removed after toggle) — gives instant switching. The staggered animation is a visual cue that reinforces the toggle action and is generally preferred by users.

reduced-motion media query. The styles respect prefers-reduced-motion: reduce by disabling all transitions and animations. This is a WCAG 2.3.3 requirement — users with vestibular disorders should be able to disable motion. The selector *, *::before, *::after { transition-duration: 0.01ms !important; } is the standard pattern (using 0.01ms instead of 0s to avoid a Firefox bug where 0s doesn't override all animations).

Scrollbar styling. Custom scrollbar styles via ::-webkit-scrollbar pseudo-elements. This is a visual polish touch that makes the portfolio feel cohesive — the scrollbar track matches --bg-body and the thumb matches --border-light. The tradeoff: scrollbar styling only works in WebKit-based browsers (Chrome, Edge, Safari). Firefox uses scrollbar-color and scrollbar-width properties instead. The dark mode version adjusts scrollbar colors automatically via custom properties.

Browser compatibility

  • CSS Custom Properties (CSS Variables): Supported since Chrome 49, Firefox 31, Safari 9.3. Works universally in modern browsers. var(--foo) with fallback (var(--foo, #000)) is also supported.
  • prefers-color-scheme: Supported since Chrome 76, Firefox 67, Safari 12.1, Edge 79. On older browsers, the site defaults to light mode (no dark option).
  • matchMedia.addEventListener('change'): Supported since Chrome 45, Firefox 52, Safari 14. Listens for OS dark mode changes in real time. For older Safari (9-13), use matchMedia.addListener() as a fallback.
  • prefers-reduced-motion: Supported since Chrome 74, Firefox 63, Safari 10.3. On browsers without support, all animations and transitions run normally — no breakage, just no motion preference detection.
  • ::-webkit-scrollbar: WebKit-only (Chrome, Edge, Safari). Firefox uses scrollbar-width: thin; scrollbar-color: ... — the demo doesn't include this for brevity, but a production site should add both.
  • localStorage: Supported since IE 8, Chrome 4, Firefox 3.5, Safari 4. Universal. If localStorage is disabled (private mode in some browsers), the fallback reads prefers-color-scheme and defaults to light mode.
  • [data-theme] attribute selector:: Supported universally. CSS attribute selectors work in all browsers including IE 6+.

Accessibility details

  • Theme toggle accessibility. The toggle button has aria-label="Toggle dark mode" and dynamically updates the label to reflect the current state (Switch to light mode / Switch to dark mode). The button uses a native <button> element so it's keyboard accessible by default (Enter/Space to activate). The icon changes from 🌙 to ☀️ — these emoji are accessible text, not decorative images.
  • Color contrast in dark mode. Dark mode colors are carefully tuned: --text-primary: #e6edf3 on --bg-card: #1a1f2b is ~10:1 contrast. --text-muted: #8b949e is ~5:1. The accent #00d4aa on dark backgrounds is ~4.5:1 — meeting AA for normal text. All colors were tuned using GitHub's dark theme as a reference.
  • No flash of unstyled theme (FOUHT). The theme is applied via JS after DOM ready, which means users briefly see the light theme before JS runs. For production, inline a critical <script> in the <head> that reads localStorage immediately and sets data-theme before paint — preventing the flash. This is called the "critical theme script" pattern used by Next.js, Astro, and Gatsby.
  • Reduced motion. The prefers-reduced-motion: reduce media query disables all transitions and animations. This affects: theme toggle transitions (color changes), bar fill animations (width), card hover effects (translate/shadow), and nav underline (width change). Users who prefer reduced motion get instant state changes with no transitional delay.
  • Selection color. ::selection { background: var(--accent); color: var(--accent-text); } — selected text uses the accent color in both themes. In dark mode, this creates a bright green selection on dark backgrounds — high contrast and visually distinctive.

Common pitfalls

  • Flash of light mode before JS runs. The most common dark mode bug — user opens the site, sees light mode for 200ms, then it switches to dark. Fix: inline a blocking <script> in <head> that reads localStorage and sets data-theme before any CSS renders. This is called the "critical theme script" and is used by all major frameworks.
  • Hardcoded colors in third-party widgets. If the portfolio includes a third-party embed (maps, tweets, codepens), those elements won't respond to data-theme — they have their own hardcoded styles. Handle these with CSS overrides or by loading dark variants.
  • Transition flicker on page load. If the body has transition: background-color 0.3s, and the theme is applied after DOM ready, the background animates from light to dark on every page load. Fix: remove transitions on page load with a class, or apply the theme before the first paint (see critical theme script above).
  • localStorage quota exceeded. The theme preference is a tiny string. Not a real concern, but localStorage.setItem can throw if storage is full or disabled. Wrap in a try/catch to prevent JS errors from breaking the toggle.
  • prefers-color-scheme change event memory leak. Adding an event listener on matchMedia in a component that mounts/unmounts (e.g., a React component) can leak listeners. In vanilla JS, the listener lives for the page lifetime — fine for a portfolio, but add observer.disconnect() in cleanup if used in a framework with lifecycle methods.
  • SVG and image colors in dark mode. Project icons are emoji (not SVGs), so they work in both themes. If the portfolio used SVGs with hardcoded fill colors, they'd need to be converted to currentColor or themed via CSS.

Key concepts

  • --custom-property — CSS custom properties. Defined in :root, overridden in [data-theme="dark"]
  • var(--property-name) — Read a custom property. Optional fallback: var(--foo, #000)
  • [data-theme="dark"] — Attribute selector for dark mode. Overrides :root properties
  • prefers-color-scheme: dark — OS-level dark mode detection. Used on first visit only
  • matchMedia().addEventListener('change') — Live OS preference change detection
  • localStorage.setItem/getItem — Persist user theme choice across sessions
  • prefers-reduced-motion: reduce — Disable animations for vestibular disorders. WCAG 2.3.3
  • ::selection — Selected text styling. Themed to accent color
Theme variables ▶ Run
:root {
  --bg-body: #f8f9fa;
  --text-primary: #1a1a2e;
  --accent: #00d4aa;
  --shadow-card:
    0 12px 32px rgba(0,0,0,0.06);
  /* ... 30+ properties */
}
[data-theme="dark"] {
  --bg-body: #0d1117;
  --text-primary: #e6edf3;
  --accent: #00d4aa;
  --shadow-card:
    0 12px 32px rgba(0,0,0,0.3);
  /* ... 30+ properties */
}
Theme toggle JS
const setTheme = (theme) => {
  document.documentElement
    .setAttribute('data-theme', theme);
  toggleBtn.textContent =
    theme === 'dark' ? '☀️' : '🌙';
  toggleBtn.setAttribute(
    'aria-label',
    theme === 'dark'
      ? 'Switch to light mode'
      : 'Switch to dark mode');
  localStorage.setItem(
    'portfolio-theme', theme);
};

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

setTheme(getPreferredTheme());

toggleBtn.addEventListener('click', () => {
  const current = document
    .documentElement
    .getAttribute('data-theme');
  setTheme(current === 'dark'
    ? 'light' : 'dark');
});

// Live OS preference change
window.matchMedia(
  '(prefers-color-scheme: dark)'
).addEventListener('change', (e) => {
  if (!localStorage
    .getItem('portfolio-theme')) {
    setTheme(e.matches
      ? 'dark' : 'light');
  }
});
Reduced motion
@media (prefers-reduced-motion:
  reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms
      !important;
    animation-duration: 0.01ms
      !important;
  }
}
Used throughout CSS
body {
  color: var(--text-primary);
  background: var(--bg-body);
  transition:
    background-color 0.3s ease,
    color 0.3s ease,
    border-color 0.3s ease;
}
.project-card {
  background: var(--bg-card);
  border-color: var(--border-card);
  transition: all 0.3s ease,
    var(--transition-bg);
}
🌗 Theme architecture: The entire theme system is driven by ~30 CSS custom properties organized into categories: --bg-* (backgrounds), --text-* (text colors), --border-* (borders), --accent* (brand), --shadow-* (shadows), and --transition-* (timing). Every hardcoded color from previous steps has been replaced with a variable reference. Adding a new theme (e.g., "high contrast") would require only another [data-theme="high-contrast"] { ... } block — zero CSS changes to any component rules. This is the same architecture powering shadcn/ui's multi-theme system.
Model
DeepSeek V4 Flash
Total Tokens
~24.6K
Est. Cost
~$0.05
Steps
5 / 5
Output tokens measured from demo files × 0.28 tok/byte. Input estimated from session context. DeepSeek V4 Flash pricing. Updated as each step completes.

✅ Portfolio Complete!

All 5 steps are built. The portfolio demonstrates: semantic HTML sections, sticky header with underline nav, responsive grid layout, filterable project cards, animated skill bars, form validation with real-time feedback, dark mode with theme toggle, and accessibility patterns throughout. Next project: a Technical Documentation page — teaching sidebar layout, smooth scrolling, and code syntax highlighting.

06
Technical Documentation PageSidebar layout · Smooth scrolling · Code syntax highlighting