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 once.

The brief

Build a product detail page for a shop: gallery, description, specifications, price, an add to cart form and reviews.

Requirements

  • A gallery with a main image and thumbnails that swap it.
  • Specifications as a description list.
  • An add to cart form sending a product identifier, not a price.
  • Stock status conveyed by more than colour.
  • Reviews as nested article elements.
  • Product structured data with real values.
  • Breadcrumb navigation.

The structure

<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/kitchen">Kitchen</a></li>
    <li><a href="/kitchen/kettles">Kettles</a></li>
    <li><a href="/products/kettle-2l" aria-current="page">Two litre kettle</a></li>
  </ol>
</nav>

<main id="main-content" tabindex="-1">
  <article class="product">
    <h1>Two litre electric kettle</h1>

    <section class="gallery" aria-label="Product images">
      <img id="main-image"
           src="/images/kettle-800.jpg"
           srcset="/images/kettle-400.jpg 400w, /images/kettle-800.jpg 800w, /images/kettle-1600.jpg 1600w"
           sizes="(max-width: 50rem) 100vw, 30rem"
           alt="A stainless steel two litre kettle, viewed from the front"
           width="1600" height="1600"
           fetchpriority="high">

      <ul class="thumbs" role="list">
        <li>
          <button type="button"
                  data-full="/images/kettle-800.jpg"
                  data-alt="A stainless steel two litre kettle, viewed from the front"
                  aria-pressed="true">
            <img src="/images/kettle-thumb.jpg" alt="Front view" width="80" height="80">
          </button>
        </li>
        <li>
          <button type="button"
                  data-full="/images/kettle-lid-800.jpg"
                  data-alt="The kettle with the lid open, showing the water level marks"
                  aria-pressed="false">
            <img src="/images/kettle-lid-thumb.jpg" alt="Lid open" width="80" height="80">
          </button>
        </li>
      </ul>
    </section>

    <section class="buy">
      <p class="price">
        <span class="visually-hidden">Price:</span>
        <s>Rs 2,999</s>
        <strong>Rs 2,499</strong>
        <span class="saving">Save Rs 500</span>
      </p>

      <p class="stock stock--in">
        <svg aria-hidden="true" width="16" height="16"><use href="#icon-tick"/></svg>
        In stock, dispatched within two working days
      </p>

      <form action="/cart/add" method="post">
        <input type="hidden" name="product_id" value="kettle-2l">
        <input type="hidden" name="csrf_token" value="...">

        <p>
          <label for="quantity">Quantity</label>
          <input type="number" id="quantity" name="quantity"
                 value="1" min="1" max="10" step="1">
        </p>

        <button type="submit">Add to cart</button>
      </form>
    </section>

    <section>
      <h2>Description</h2>
      <p>Boils two litres in ninety seconds. Concealed element, so it is easy to clean.</p>
    </section>

    <section>
      <h2>Specifications</h2>
      <dl class="spec">
        <div><dt>Capacity</dt><dd>2 litres</dd></div>
        <div><dt>Power</dt><dd>1500 watts</dd></div>
        <div><dt>Material</dt><dd>Stainless steel</dd></div>
        <div><dt>Warranty</dt><dd>Two years</dd></div>
      </dl>
    </section>

    <section>
      <h2>Reviews</h2>
      <p>Rated 4.4 out of 5 from 87 reviews.</p>

      <article class="review">
        <h3>Fast and quiet</h3>
        <p>
          <span class="visually-hidden">Rated</span> 5 out of 5 ·
          Arun · <time datetime="2026-07-14">14 July 2026</time>
        </p>
        <p>Boils faster than the one it replaced and does not rattle.</p>
      </article>
    </section>
  </article>
</main>

The decisions that matter

The form sends an identifier, not a price

<!-- dangerous: the reader can edit this to 1 -->
<input type="hidden" name="price" value="2499">

<!-- safe: the server looks up the price -->
<input type="hidden" name="product_id" value="kettle-2l">

Everything in the page source is editable in DevTools. Never send a value the server will trust.

Stock status is not colour alone

The status has an icon, text and a colour. A reader who cannot distinguish green from red still gets the message from two other channels.

The price is announced sensibly

A visually hidden Price: label and an s element on the old price mean a screen reader announces the sale clearly rather than reading two numbers with no relationship.

The thumbnails are buttons

Swapping the main image is an action, not navigation, so the control is a button. aria-pressed marks which view is current.

const main = document.getElementById("main-image");
const thumbs = document.querySelector(".thumbs");

thumbs.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-full]");
  if (!button) return;

  main.src = button.dataset.full;
  main.alt = button.dataset.alt;
  main.removeAttribute("srcset");

  thumbs.querySelectorAll("button").forEach((b) => {
    b.setAttribute("aria-pressed", String(b === button));
  });
});

Note that the alt text changes with the image. A gallery that swaps the picture and leaves the description behind is worse than no description at all.

Structured data

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Two litre electric kettle",
  "image": [
    "https://example.com/images/kettle-1600.jpg",
    "https://example.com/images/kettle-lid-800.jpg"
  ],
  "description": "Boils two litres in ninety seconds. Concealed element.",
  "sku": "KET-2L-001",
  "brand": { "@type": "Brand", "name": "Riverside" },
  "offers": {
    "@type": "Offer",
    "url": "https://example.com/products/kettle-2l",
    "priceCurrency": "INR",
    "price": "2499",
    "availability": "https://schema.org/InStock",
    "priceValidUntil": "2026-12-31"
  },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.4",
    "reviewCount": "87"
  }
}
</script>

Every value here must match what is visible on the page. Invented ratings are a policy violation with a real penalty attached.

Checking your work

  1. Validate.
  2. Edit the hidden field in DevTools and confirm your server would reject it.
  3. Tab through the gallery: are the thumbnails reachable and is the current one announced?
  4. Emulate colour blindness in the Rendering panel; is the stock status still clear?
  5. Test the Product structured data.
  6. Check which image variant is fetched at three widths.

Extensions

  • Add a size or colour selector using radio buttons in a fieldset.
  • Add a delivery estimate form using a postcode field.
  • Add a review form with proper validation.
  • Add a lightbox for the gallery, built on dialog.
  • Add a comparison table against two similar products.

Practice

  1. Build the page and confirm the add to cart form sends only an identifier.
  2. Make the gallery keyboard operable with correct aria-pressed.
  3. Add Product structured data matching the visible values exactly.
  4. Check the stock indicator under a colour blindness emulation.

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 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
HTML

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

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.