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.
HTML Structure
CompleteWhy 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>withid: Universal support. Theidattribute enables anchor navigation from nav links. Works since HTML 4.min-height: 80vh: Supported universally. On iOS Safari,vhunits include the URL bar — the hero will be taller than expected on initial load. Use80dvh(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-shadowon hover: Works universally. Unlikebox-shadowtransitions, the:hoverstate is instantaneous (no transition) so there's no performance concern.- Form input
:focuswithbox-shadow: The green focus ring usesbox-shadow: 0 0 0 3px rgba(0,212,170,0.1). Supported in all modern browsers. Firefox usesoutlinefor native focus — override withoutline: nonewhen 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. Theidon each<section>must match exactly — case-sensitive, no spaces. - Project card semantics: Cards are
<div>elements with:hovereffects. If cards are clickable (linking to project pages), use<a>instead or addrole="link"withtabindex="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#666on#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
tabindexmanipulation.
Common pitfalls
- Missing
idon sections: Nav links scroll to#projectsbut if the<section>doesn't haveid="projects", clicking the link does nothing. Double-check everyhrefmatches anid. - Nav link hash conflicts with URL parameters: If the page URL already has a hash (e.g.,
#contactfrom 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
ulwithgap: 2remcan overflow on narrow viewports. The fix:overflow-x: autoon 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 aactionURL 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 to3rem 1.25remat mobile breakpoints.
Key concepts
<section id="...">— Semantic section containers with anchor targets. One per content blockmin-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 patternrepeat(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.
<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">🤖</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>© 2026 Alex Chen</p>
</footer>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);
}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.Styling & Layout
CompleteWhy 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
::afterpseudo-element transitions: The underline nav effect uses::afterwithwidthtransition. Supported universally. Thebottom: -2pxpositioning 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 JSscrollIntoView()polyfill.position: stickyin header: Works in all modern browsers. Fails if any parent hasoverflow: 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,100vhincludes the URL bar, making the hero taller on initial load. Use100dvhas 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#aboutand 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 torgba(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. Addscroll-margin-top: 80pxto each section to offset the scroll position below the header. - Underline animation offset at different font sizes. The
::afterelement usesbottom: -2pxrelative to the link. If the link hasline-heightgreater than the height of the text, the underline may appear too low or too high. Usebottom: 0withtransform: 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 withmax-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 hasoverflow: hidden. Useoverflow: visibleon 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: 0brings it into view. But if the user clicks the skip link and then tabs away, the skip link stays attop: 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. Needsz-indexto stay above sections::afterunderline animation — Hover effect withwidth: 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 hoverscroll-margin-top: 80px— Anchor offset for sticky header. Without it, sections hide behind the navskip-link—position: absolute; top: -100%→top: 0on:focus. WCAG 2.4.1 requirementcalc(100vh - 64px)— Hero height accounting for sticky header height@media (max-width: 768px)— Tablet breakpoint.480pxfor small phones
Next up
Step 3 adds filterable project cards with tag-based filtering, animated transitions, and project detail links.
<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>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;
}::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%.Projects Grid
CompleteWhy 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 todata-*attributes.card.dataset.tagsreadsdata-tags. Works universally in modern browsers.String.prototype.includes(): Supported since Chrome 41, Firefox 40, Safari 9, IE 12 (Edge). For IE11 support, useindexOf()instead.NodeList.forEach(): Supported since Chrome 51, Firefox 50, Safari 10. IE11 doesn't supportNodeList.forEach— useArray.from()or aforloop for broader compatibility.display: nonein CSS Grid: When a grid item hasdisplay: 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: noneare removed from the accessibility tree and tab order — correct behavior. Elements withvisibility: hiddenare 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>withonclickwould requirerole="button"andtabindex="0"to match native button behavior. - Active filter contrast: The active button uses
#00d4aa(accent) on#fffbackground — 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 withcard.dataset.tags.split(',').includes(filter). This matches entire tag names only. - Whitespace in data-tags.
data-tags="react, typescript"(with spaces) will failincludes("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: nonedoesn't animate. For a smooth filter transition, use a briefopacity+transformanimation before settingdisplay: none(viasetTimeoutor 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 viacard.dataset.tagsfilter()+includes()— Filter logic. Usesplit(',')for exact tag matchingdisplay: none— Removes elements from layout AND accessibility tree. Correct for filtersclassList.toggle('hidden', condition)— Conditional class toggle. Second argument sets boolean stateforEach()with arrow functions — Iterating over NodeLists. Polyfill for IE11aria-live="polite"— Announce filter results to screen readersdataset API— Read/writedata-*attributes as camelCase properties
Next up
Step 5 adds dark mode, theme toggle, and final polish.
<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-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;
}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 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.Contact Form & Skills
CompleteWhy 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: widthon bars: Supported universally. The0.8s easetiming is smooth without being too slow — shorter bars (60%) finish faster than longer ones (90%) because the distance is less.novalidateform attribute: Supported universally. Prevents native validation popups across all browsers.:focus-visiblevs:focus: The form uses:focuswith 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, useif (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 viaaria-describedby— a common accessibility mistake. For screen reader support, addaria-describedby="nameError"to each input androle="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#fffbackground. This meets WCAG AA for normal text (4.5:1 is required) but only just. Consider#777for better readability. - Bar color contrast. The bar fill uses
#00d4aagradient — 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. Addborder-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 = 0before 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 UXblurevent — Validate when user leaves a field. Immediate feedback without intrusioninputevent — Re-validate as user types (after first blur). Clears error state in real timeclassList.toggle()with boolean — Conditional CSS class toggle for error/valid statesIntersectionObserver— Scroll-triggered animation. Async, non-blocking, auto-disconnecttransition: width 0.8s ease— CSS-animated progress bar. GPU-composited, minimal JSdata-widthattribute — Store numeric values in HTML for JS to read viadatasete.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.
<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-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;
}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');
}
});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);setFieldState helper keeps validation DRY — it handles three states (error, valid, untouched) and sets error messages dynamically.Dark Mode & Polish
CompleteWhy 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), usematchMedia.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 usesscrollbar-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 readsprefers-color-schemeand 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: #e6edf3on--bg-card: #1a1f2bis ~10:1 contrast.--text-muted: #8b949eis ~5:1. The accent#00d4aaon 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 setsdata-themebefore 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: reducemedia 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 setsdata-themebefore 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.setItemcan 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
matchMediain 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 addobserver.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
fillcolors, they'd need to be converted tocurrentColoror 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:rootpropertiesprefers-color-scheme: dark— OS-level dark mode detection. Used on first visit onlymatchMedia().addEventListener('change')— Live OS preference change detectionlocalStorage.setItem/getItem— Persist user theme choice across sessionsprefers-reduced-motion: reduce— Disable animations for vestibular disorders. WCAG 2.3.3::selection— Selected text styling. Themed to accent color
: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 */
}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');
}
});@media (prefers-reduced-motion:
reduce) {
*, *::before, *::after {
transition-duration: 0.01ms
!important;
animation-duration: 0.01ms
!important;
}
}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);
}--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.