Anatomy of a product card — from 87 to 38 elements, 0 divs
Table of contents
Press Tab
Open the developer tools on any e-commerce site. Find a product card, trainers, t-shirt, saucepan, doesn't matter. Expand the DOM.
Visually, everything is in place. An image, a "Promo" badge, a little heart for favourites, a title, a crossed-out price, a sale price. The client is happy. The sighted user notices nothing.
Now count the elements. On the one I'm dissecting today: 87. Including 27 <div>s. Three levels of wrappers around an image. Two <div>s to contain a button. An empty <div> that "reserves space". Another one that draws a border on hover.
Put down the mouse. Press Tab. Try to scroll through the carousel images.
You can't.
This article takes that card, a real one, extracted from a production site, and rebuilds it from scratch. From 87 to 38 elements. From 27 divs to zero. Every defect identified, every fix explained, every choice sourced.
Try it yourself: Both versions of the card, original and rebuilt, are available side by side on the demo page. Open it, press Tab, and compare.
What the card hides
The silent carousel
The carousel has 5 images. But nothing tells you that. No visible indicator, no counter, no dots. The only way to discover the other photos is to hover over the card with your mouse, previous/next buttons appear. They're not hidden in CSS, they don't exist in the DOM. JavaScript creates them on hover, and destroys them when the mouse leaves. No mouse, no buttons, no navigation, not even the information that there's something to navigate.
For a screen reader, it's the same silence. The carousel has no aria-roledescription, no aria-live, no aria-label. The user hears one image. Just one. They don't know there are four more.
The double link
The card has two links to the product page: one on the image, one on the title. The second has tabindex="-1", it's out of the keyboard tab order. But a screen reader in browse mode still encounters it. Two identical links, same destination, same implicit text. Noise.
And the second link wraps a <header>, a landmark. A navigation waypoint inside an interactive element. Semantically, it's like putting a road sign in the boot of a car.
The phantom prices
Both price lines have an aria-label="" attribute, empty. According to the ARIA specification, the paragraph role is "Name From: prohibited", aria-label should have no effect. JAWS and NVDA ignore it. But VoiceOver on macOS reads it, and TalkBack on Android goes further: it replaces the text content with the label value. An empty aria-label="" on TalkBack means prices that vanish.
And the crossed-out price? "€114.95" is visually struck through with text-decoration: line-through in CSS. The surrounding text ("Reference price") gives a clue, but in the HTML, the strikethrough itself is a styled <span>, it has no semantic equivalent. A screen reader reads the text without announcing it's struck through. The visual meaning of the strikethrough (this price is no longer valid) relies entirely on CSS.
The whimsical structure
The carousel dots use <i class="dot-indicator">, an alternative emphasis element repurposed as visual decoration. And the carousel area is a <section> without a heading. The HTML spec requires a heading inside a <section>. Without one, it creates an anonymous landmark, a nameless waypoint, noise for the screen reader.
The rebuild
Structure
The original card stacks <article>, <section>, <footer>, <header>, and 27 <div>s. Each wrapper adds a node to the DOM without adding meaning. The rebuilt version flattens everything:
<article class="card">
<header class="card-media" role="group"
aria-roledescription="carousel" aria-label="Product images">
<!-- slides, prev/next buttons, badge -->
</header>
<button type="button" aria-label="Add to favourites"><!-- wishlist --></button>
<button type="button" aria-haspopup="dialog"><!-- sponsored --></button>
<h3><a href="/product/example"><!-- title --></a></h3>
<p><!-- sale price --></p>
<p><!-- reference price --></p>
</article><article>: The card is self-contained content. It makes sense on its own, in a grid, in a feed, in a search result. That's the definition of <article>.
<header>: No HTML element perfectly matches "carousel container". <figure> would be tempting, no landmark, no heading required, but the spec defines it as "self-contained content, referenced from the main flow". Product images aren't an ancillary illustration, they are the product. <section> creates a parasitic landmark. <aside> too, and the images aren't "tangentially related". <header> is the most defensible: the spec defines it as "a group of introductory or navigational aids". The carousel with its prev/next buttons is a navigational aid through the product images. Inside an <article>, <header> doesn't have the banner role. HTML AAM maps it to sectionheader, a recent role (ARIA 1.3) that doesn't create a parasitic landmark. The explicit role="group" satisfies the APG requirement. The carousel container must encompass both slides and controls, and aria-roledescription overrides the announced role anyway.
No more <footer>: The title and prices aren't footer information. They're the card's main content. They live directly inside the <article>.
No more <div>: Every element has a role. CSS applies directly to semantic elements, no wrappers needed.
The carousel that speaks
The original carousel has no controls. The rebuilt one does, and they're accessible by keyboard, mouse, and screen reader.
<header class="card-media" role="group"
aria-roledescription="carousel" aria-label="Product images">
<ol aria-live="polite">
<li role="group" aria-roledescription="slide" aria-label="1 of 5">
<img alt="White shoes with textured details and flat sole."
width="300" height="433" loading="eager"
src="...">
</li>
</ol>
<button type="button" aria-label="Previous image">‹</button>
<button type="button" aria-label="Next image">›</button>
</header>aria-roledescription="carousel": The screen reader announces "carousel, Product images". The user immediately knows what they're dealing with. The attribute overrides the announced role, whether the element is a <header>, a <div> or anything else, the user hears "carousel".
aria-roledescription="slide" on each <li>: Each image is announced as "slide, 1 of 5". The user knows how many images remain, the information completely missing from the original.
aria-live="polite": When the carousel changes slide, the screen reader announces the new image without interrupting what it was reading. Without this attribute, the change is silent. The APG recommends polite precisely for user-driven carousels: the user clicked, they need feedback. For an auto-rotating carousel, it's the opposite: aria-live="off" to avoid interrupting the user with each unsolicited change.
The prev/next buttons: They exist in the HTML. Always. Not injected by JavaScript, not created on hover. They're visually hidden by default (opacity: 0) and appear on hover and on focus-visible. The aria-labels make the buttons comprehensible for screen readers, and WCAG criteria are satisfied. But for a voice control user (Dragon, Voice Control), a button without visible text remains an obstacle: you have to guess what to say. In the compact context of a product card, that's an accepted compromise, not a complete solution:
.carousel-prev,
.carousel-next {
opacity: 0;
transition: opacity var(--duration);
}
.card-media:hover .carousel-prev,
.card-media:hover .carousel-next,
.carousel-prev:focus-visible,
.carousel-next:focus-visible {
opacity: 1;
}A keyboard user tabbing through the card reaches the buttons, they become visible on focus. The difference from the original: the buttons are always in the DOM, always reachable. CSS controls their visibility, not JavaScript their existence.
The dots: Position indicators are purely decorative, no HTML elements needed. A ::after pseudo-element on the container does the job:
.card-media::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 0.25rem;
z-index: 5;
background: var(--color-dot-inactive);
background-image: linear-gradient(var(--color-dot-active), var(--color-dot-active));
background-size: calc(100% / var(--total, 5)) 100%;
background-position: calc(var(--current, 0) * 100% / var(--total, 5)) 0;
background-repeat: no-repeat;
}JavaScript sets two custom properties on the container: --total (number of slides) and --current (active index). The fallbacks (5 and 0) ensure correct rendering before JavaScript has initialised the variables, unlike design tokens, these values are dynamic. CSS calculates the size and position of the active segment. A single JS operation per slide change, zero HTML elements for decoration. The W3C APG recommends prev/next buttons as sufficient controls. Interactive dots are a possible addition, not a requirement.
The single link
The original card has two links to the product page, one on the image, one on the title. The second has tabindex="-1" to remove it from the keyboard tab order, but a screen reader in browse mode still encounters it. Two identical links, same destination: noise.
The rebuilt version has a single link, inside the <h3>:
<h3 class="card-title">
<a class="info-link" href="/product/example">
<span class="brand-name">Brand</span>
<span class="product-name">Model XYZ</span>
</a>
</h3><h3><a>: A link inside a heading is valid HTML. Headings accept phrasing content, and <a> is phrasing content. It's also the pattern recommended by Adrian Roselli for clickable cards. The link is inside the title, not wrapped around the entire card.
Why not an <a> around the entire <article>? Because a screen reader would read the entire link content in one block. Image, badge, title, price, sponsored text in a single 25-second announcement. The user can't navigate within it, nor reach the wishlist or sponsored buttons.
The stretched click area: The link only covers the title, but you want the entire card to be clickable. A pseudo-element does the job:
.info-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
}The ::after covers the entire card. Interactive buttons (wishlist, sponsored, carousel) stay above with a higher z-index, they're clickable on top of the stretched link. The result: clicking anywhere on the card leads to the product page, except on buttons which have their own action.
Text selection: The ::after at z-index: 1 intercepts mouse events across the entire card. Without precaution, the user can no longer select prices to copy them. The fix: position: relative; z-index: 2 on text elements that should remain selectable. They "pierce" the stretched link layer, become mouse-accessible again, and clicking on them no longer triggers navigation.
Readable prices
The original card has two price problems. The first is a phantom attribute: both <p> elements carry an aria-label="". According to the ARIA specification, the paragraph role is "Name From: prohibited", aria-label should have no effect. JAWS and NVDA indeed ignore it. But TalkBack replaces the text content with the label value, and an empty label makes the prices vanish. VoiceOver on macOS reads it too. An invalid attribute that should do nothing, but breaks the experience on mobile Android.
The solution: remove the attribute.
The second problem is subtle. The crossed-out price, "€114.95", is visually struck through with text-decoration: line-through in CSS. The surrounding text ("Reference price") provides context, but a screen reader doesn't see the strikethrough. It reads the text without announcing it's struck through. The visual meaning of the strikethrough relies entirely on CSS.
The solution: <s>. The HTML element <s> means "this content is no longer accurate". The browser strikes it through visually, and the text "Reference price" provides the necessary context for all users. Why <s> and not <del>? Because the reference price hasn't been "deleted from the document", it's displayed deliberately for comparison. It's simply no longer the current price, which is exactly what <s> expresses.
<p class="price-sale">from €85.95</p>
<p class="price-ref">Reference price: <s>€114.95</s> Up to -25%</p>Two <p> elements, one <s>, zero <span>s. But the design requires two colours, grey for the reference price, red for the discount. Without a <span>, how do you target each part?
What follows is the most technical part of the article. If you're not comfortable with CSS gradients, keep the essential: you can colourise text without <span> using background-clip: text. The conic-gradient detail is a bonus.
With a conic-gradient.
.price-ref {
background: conic-gradient(from 0deg at var(--gradient-center) var(--gradient-top), var(--color-sale) 270deg, var(--color-muted) 270deg);
background-clip: text;
color: transparent;
}
.price-ref s {
text-decoration-color: var(--color-muted);
}The gradient centre is placed at 23ch, the width of 23 characters in the current font, where the grey text ends. The upper-left quadrant of the gradient is grey, everything else is red. Why a conic-gradient rather than a linear-gradient? Because a linear gradient is applied to the element's box. If the text wraps, the second line starts again from the left of the gradient. It would be grey instead of red. The conic-gradient radiates from a point: everything below and to the right of the centre is red, including subsequent lines.
The text-decoration-color on the <s> forces the strikethrough colour, without it, color: transparent would make the strikethrough line invisible.
In forced-colors: The gradient doesn't work in Windows high contrast mode, used for low vision, but also for eye strain or light sensitivity. The text would be invisible. A fallback is mandatory:
@media (forced-colors: active) {
.price-ref { background: none; color: ActiveText; }
.price-ref s { color: GrayText; text-decoration-color: GrayText; }
}ActiveText and GrayText are system colour keywords, they adapt to the user's chosen theme. You lose the grey/red split, but the text stays readable and the <s> remains visually distinct thanks to its muted colour. To go further on forced-colors and high contrast modes, see Colors lie.
The result
| Before | After | |
|---|---|---|
| Elements | 87 + 2 in JS | 38 |
<div>s | 27 | 0 |
| Links to product page | 2 | 1 |
| Carousel controls by keyboard | 0 | 2 |
| Prices announced by screen reader | potentially 0 | 2 |
| Slides announced | 0 / 5 | 5 / 5 |
To see the difference in action, open the side-by-side demo.
Same card. Same design. Same functionality. Less than half the elements, 0 divs, and a keyboard user who can finally see all 5 images.
The overall criteria
Each fix in this anatomy addresses one or more WCAG success criteria:
- 1.1.1 Non-text Content (A) — Every image has a text alternative. The original had a good
alt, we keep it. - 1.3.1 Info and Relationships (A) — Information, structure, and relationships conveyed through presentation are programmatically determinable. The
<article>→<h3>hierarchy replaces the untitled<section>and misused<header>. - 2.1.1 Keyboard (A) — All functionality is operable through a keyboard. The carousel buttons created by JavaScript on hover are not. Those in the rebuilt version are in the HTML.
- 2.4.7 Focus Visible (AA) — The carousel, wishlist and sponsored buttons all have a
focus-visiblewith sufficient contrast outline. - 4.1.2 Name, Role, Value (A) — All components have accessible names (
aria-label), roles (aria-roledescription), and states (aria-live). Valid HTML replaces the<section>without heading, the<i>repurposed as decoration, and the<header>inside a link.
Five criteria, eight defects fixed, zero visual compromise. Each fix makes the card compliant without changing what the user sees on screen.