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.

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-pressed accurate 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.

<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

  1. Validate both pages.
  2. Tab through the grid: one stop per card, focus ring visible around the card.
  3. Use the filter with the keyboard and confirm the count is announced.
  4. Check the Network panel at three widths to confirm which image variant was fetched.
  5. Run Lighthouse; the hero should be the largest paint element.
  6. Test the breadcrumb structured data.

Extensions

  • Make the filter update the URL so a filtered view can be shared.
  • Add CreativeWork structured data to each case study.
  • Add previous and next links between case studies.
  • Add a lightbox for images, built on dialog.

Practice

  1. Build the grid and confirm one tab stop per card.
  2. Add the filter and test it with a screen reader.
  3. Write one full case study page with breadcrumbs and structured data.
  4. Measure the page at Fast 3G and reduce the largest three resources.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →
HTML

Project: A Product Page

An image gallery, a specification list, a quantity form and structured data. The page where hidden fields, tables and forms all have to be right at on...

Read more
HTML

Project: A Resume Page

A document with real structure: dated entries, an experience timeline, skills grouped by category, and a print stylesheet that produces a usable PDF.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.