Project: A Portfolio Page
A grid of project cards with a filter, responsive images and a case study page. The project where card markup, image performance and clickable regions all come together.
-
HTML Basics
- What is HTML: The Structure Layer of Every Web Page
- HTML Document Structure: DOCTYPE, html, head and body
- Elements, Tags and Attributes: The Vocabulary of HTML
- HTML Comments: Notes That Ship With Your Code
- Block Level and Inline Elements
- Writing and Running Your First HTML Page
- How a Browser Turns Markup Into a Page
- Text and Formatting
- Links and Navigation
- Images and Media
- Lists
- Tables
-
Forms
- Form Structure: form, action and method
- Input Types: Text, Email, Number, Date and the Rest
- Labels: The Most Important Element in a Form
- Checkboxes, Radio Buttons and Grouping
- select, option, optgroup and datalist
- textarea, File Uploads and Hidden Fields
- Buttons: submit, reset and button
- Built In Form Validation
- GET or POST: What Happens When a Form Is Submitted
- Semantic HTML
- HTML5 Features
- Head and Metadata
- HTML with CSS
- HTML with JavaScript
- Accessibility
-
HTML SEO
- How Google Works: Crawling, Indexing and Ranking
- SEO Friendly HTML Structure
- Titles and Descriptions That Earn Clicks
- Headings and Content Structure for Search
- Internal Linking and Anchor Text
- robots.txt and XML Sitemaps
- Canonical URLs and Duplicate Content
- Structured Data and JSON-LD
- Image SEO
- Core Web Vitals and Mobile Friendliness
- DevTools and Debugging
- Editor Productivity
- HTML Best Practices
- HTML Projects
- Advanced Projects
- Practice and Exams
The brief
Build a portfolio: a landing page with a filterable grid of work, and one case study page in full.
Requirements
- A responsive card grid with no media query in the grid itself.
- Each card is one link and one tab stop.
- Responsive images with
srcset, dimensions and lazy loading below the fold. - A working filter that keeps
aria-pressedaccurate and announces the result count. - One full case study page with a proper article structure.
- Breadcrumb navigation on the case study, with structured data.
The card grid
<main id="main-content" tabindex="-1">
<h1>Selected work</h1>
<div class="filters">
<h2 id="filter-heading">Filter by type</h2>
<div role="group" aria-labelledby="filter-heading">
<button type="button" data-filter="all" aria-pressed="true">All</button>
<button type="button" data-filter="web" aria-pressed="false">Websites</button>
<button type="button" data-filter="app" aria-pressed="false">Applications</button>
<button type="button" data-filter="print" aria-pressed="false">Print</button>
</div>
<p id="result-count" aria-live="polite"></p>
</div>
<ul class="grid" role="list">
<li data-category="web">
<article class="card">
<img src="images/admissions-800.jpg"
srcset="images/admissions-400.jpg 400w, images/admissions-800.jpg 800w"
sizes="(max-width: 50rem) 100vw, 28rem"
alt=""
width="800" height="600"
loading="lazy" decoding="async">
<div class="card-body">
<h3><a href="/work/admissions-portal">Admissions portal rebuild</a></h3>
<p>A public sector application form, rebuilt to work on a slow connection.</p>
<p class="card-meta"><time datetime="2025-11">November 2025</time> · Website</p>
</div>
</article>
</li>
<!-- more cards -->
</ul>
</main>Two decisions worth explaining
The image has empty alt text. The heading immediately beside it already names the project; describing the screenshot as well would make a screen reader announce the same thing twice.
The link is inside the heading. It gives the link meaningful text and a place in the heading outline, and the stretched pseudo element below makes the whole card clickable without adding a second tab stop.
The stylesheet
.grid {
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1.5rem;
}
.card {
position: relative;
display: grid;
grid-template-rows: auto 1fr; /* image, then body fills the height */
border: 1px solid #cbd5e1;
border-radius: 10px;
overflow: hidden;
height: 100%;
}
.card img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; }
.card-body { padding: 1rem; }
.card h3 { margin: 0 0 0.5rem; }
/* one link, whole card clickable */
.card h3 a::after {
content: "";
position: absolute;
inset: 0;
}
.card:has(a:focus-visible) {
outline: 3px solid #1e3a8a;
outline-offset: 2px;
}repeat(auto-fit, minmax(18rem, 1fr)) is a responsive grid with no media query: columns are added as the space allows and removed as it narrows.
The :has rule puts the focus ring around the whole card rather than around the invisible stretched link, which is what a keyboard user needs to see.
The filter
const filters = document.querySelector(".filters");
const items = [...document.querySelectorAll(".grid > li")];
const count = document.getElementById("result-count");
function applyFilter(value) {
let shown = 0;
items.forEach((item) => {
const show = value === "all" || item.dataset.category === value;
item.hidden = !show;
if (show) shown += 1;
});
filters.querySelectorAll("button").forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.filter === value));
});
count.textContent = `Showing ${shown} of ${items.length} projects`;
}
filters.addEventListener("click", (event) => {
const button = event.target.closest("button[data-filter]");
if (button) applyFilter(button.dataset.filter);
});
applyFilter("all");Three things make this accessible rather than merely functional: hidden removes filtered items from the tab order, aria-pressed tells a screen reader which filter is active, and the live region announces the new count without moving focus.
The case study page
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/work">Work</a></li>
<li><a href="/work/admissions-portal" aria-current="page">Admissions portal</a></li>
</ol>
</nav>
<main id="main-content" tabindex="-1">
<article>
<header>
<h1>Admissions portal rebuild</h1>
<p><time datetime="2025-11">November 2025</time> · Riverside College</p>
</header>
<figure>
<img src="images/admissions-hero-1600.jpg"
srcset="images/admissions-hero-800.jpg 800w, images/admissions-hero-1600.jpg 1600w"
sizes="100vw"
alt="The rebuilt admissions form on a phone, showing one question per screen"
width="1600" height="900"
fetchpriority="high">
<figcaption>The rebuilt form, one question per screen.</figcaption>
</figure>
<h2>The problem</h2>
<p>...</p>
<h2>What we changed</h2>
<ol>
<li>Split a thirty field form into six steps.</li>
<li>Replaced a custom date picker with a native input.</li>
</ol>
<h2>Results</h2>
<dl class="results">
<div><dt>Page weight</dt><dd>Down 60 percent</dd></div>
<div><dt>Completion rate</dt><dd>Up from 41 to 78 percent</dd></div>
</dl>
<footer>
<p><a href="/work">Back to all work</a></p>
</footer>
</article>
</main>The hero image is fetchpriority="high" and not lazy loaded - it is the largest thing on the first screen and therefore the measured element.
Breadcrumb structured data
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://example.com/" },
{ "@type": "ListItem", "position": 2, "name": "Work", "item": "https://example.com/work" },
{ "@type": "ListItem", "position": 3, "name": "Admissions portal", "item": "https://example.com/work/admissions-portal" }
]
}
</script>Checking your work
- Validate both pages.
- Tab through the grid: one stop per card, focus ring visible around the card.
- Use the filter with the keyboard and confirm the count is announced.
- Check the Network panel at three widths to confirm which image variant was fetched.
- Run Lighthouse; the hero should be the largest paint element.
- Test the breadcrumb structured data.
Extensions
- Make the filter update the URL so a filtered view can be shared.
- Add
CreativeWorkstructured data to each case study. - Add previous and next links between case studies.
- Add a lightbox for images, built on
dialog.
Practice
- Build the grid and confirm one tab stop per card.
- Add the filter and test it with a screen reader.
- Write one full case study page with breadcrumbs and structured data.
- Measure the page at Fast 3G and reduce the largest three resources.