Product
Wireless Mechanical Keyboard
75% layout, hot-swappable switches, 4000 mAh battery. Backordered until next month.
// pattern
Cards are everywhere — product grids, blog post lists, dashboard tiles. They look simple but hide one of the trickiest accessibility traps: the "whole-card click target" easily breaks when you nest interactive elements. Here's how to build them right.
// 01 · live demo
Three correct card patterns followed by two anti-patterns.
Article
A breakdown of when native semantics already do the job, and when ARIA is genuinely needed.
Article
How to move focus on route changes without disorienting screen reader users.
Article
WCAG ratios are a floor, not a ceiling. Real-world contrast considerations for buttons, links, and icons.
Each card has one link — wrapped in the heading. The ::after overlay extends the click target to the full card. One tab stop, one accessible name.
Product
75% layout, hot-swappable switches, 4000 mAh battery. Backordered until next month.
No ::after overlay — each control has its own hit area. The title link goes to the product page; the buttons trigger separate actions. Three tab stops, three clear names.
Guide
When to reach for aria-live, when role="alert" works better, and how to debug announcements that never fire.
The body text contains its own link. Making the whole card clickable would steal clicks from inline links — give the user distinct hit areas instead.
<a href="/post/123" class="card">
<h3>Article Title</h3>
<p>Body text with an <a href="/author">author link</a>.</p>
</a>
Nesting <a> inside <a> is invalid HTML5. Browsers split or relocate the inner link unpredictably; screen readers may announce the wrong destination or skip the inner link entirely.
<div class="card" onclick="location='/post/123'">
<h3>Article Title</h3>
<p>Body text...</p>
</div>
No keyboard support, no role, no accessible name, no link semantics. Screen readers announce nothing useful; keyboard users can't reach it; middle-click and "open in new tab" don't work.
// 02 · the code
All three patterns are JS-free. The single-link card works with one heading-wrapped link plus a CSS pseudo-element overlay.
<!-- One link, wrapped in the heading.
The card itself is just an <article>. -->
<article class="card">
<p class="card__eyebrow">Article</p>
<h3 class="card__title">
<a href="/posts/native-html-beats-aria/">
Why Native HTML Beats ARIA Most of the Time
</a>
</h3>
<p class="card__body">
A breakdown of when native semantics already do the
job, and when ARIA is genuinely needed.
</p>
</article>
<!-- Title link is the primary navigation.
Each button is its own control with its own name.
No ::after overlay. -->
<article class="card card--actions">
<p class="card__eyebrow">Product</p>
<h3 class="card__title">
<a href="/products/keyboard/">Wireless Mechanical Keyboard</a>
</h3>
<p class="card__body">75% layout, hot-swappable switches.</p>
<div class="card__actions">
<button type="button"
aria-label="Add Wireless Mechanical Keyboard to favorites">
<svg aria-hidden="true"><!-- heart --></svg>
<span>Favorite</span>
</button>
<button type="button"
aria-label="Quick view of Wireless Mechanical Keyboard">
<svg aria-hidden="true"><!-- eye --></svg>
<span>Quick view</span>
</button>
</div>
</article>
/* Card sets the positioning context */
.card {
position: relative;
padding: 1rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
}
/* The link's ::after expands to fill the card.
This is a virtual click target — no extra DOM,
no extra tab stop, no nested interactive element. */
.card .card__title a::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
}
/* Anything that needs to stay clickable on top of
the overlay (action buttons, inline links) needs
position: relative + z-index. */
.card__actions,
.card__body a {
position: relative;
z-index: 1;
}
/* Hover the card visually when the pointer is anywhere
over it — works because ::after covers everything. */
.card:hover {
border-color: var(--color-primary);
}
/* Focus ring on the whole card when the inner link
has keyboard focus. :has() is supported in all
modern browsers (2023+). */
.card:has(a:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-color: var(--color-primary);
}
/* Suppress the link's own focus ring — we render
it on the card instead. */
.card .card__title a:focus-visible {
outline: none;
}
/* Fallback for browsers without :has() —
focus ring on the link itself. */
@supports not selector(:has(*)) {
.card .card__title a:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
}
::after overlay sits above the body text but does not block selection in modern browsers — the overlay is empty, so click-and-drag passes through to the text below. If you've layered other content with z-index, double-check selection still works on touch and desktop.
// 03 · why these decisions
The HTML5 spec explicitly forbids <a> inside <a> — links are categorized as "interactive content" and cannot contain other interactive content. When browsers parse nested links, they silently split or rearrange them, so what you wrote isn't what gets rendered. Screen readers report inconsistent link targets: VoiceOver may announce both, NVDA may flatten them, and the inner link can become unreachable.
::after to expand the click target?The pseudo-element gives you a single tab stop, a single accessible name (the heading text), and a single announcement when a screen reader reaches the card. Compare that to wrapping the whole card in a link — which forces all the body text to be read as part of the link's accessible name, making it long and confusing. Or to JavaScript click handlers on the card, which break middle-click, "open in new tab", and right-click context menus.
Screen reader users navigate by heading. With a proper <h2> or <h3> per card, pressing the H key in NVDA or rotor-navigating in VoiceOver gives a scannable list of every card on the page. Without headings, users have to tab through every link or read the page linearly. Heading hierarchy must be logical — a list of cards inside an <h2> section uses <h3> per card.
The rule is simple: a link takes the user somewhere (a new URL); a button performs an in-page action. The card title link navigates to the detail page. The "Add to favorites" control is a button — it doesn't change the URL, it triggers an action. Mixing them up confuses keyboard users who expect Enter on a link to navigate, and Space on a button to act.
The link's accessible name is the title text — the most descriptive label possible. If you put the link in a generic "Read more" element instead, screen reader users hearing the link list outside any context get "Read more, Read more, Read more, Read more." Screen readers do offer a "links list" navigation mode that strips surrounding context entirely. Title-as-link gives every link a unique, meaningful name (SC 2.4.4).
WCAG 2.2 added SC 2.5.8 (Level AA): every interactive control must be at least 24×24 CSS pixels, or have equivalent spacing. Card action buttons (favorite, quick view, dismiss) are easy to under-size because they're "secondary." Pad them with min-width/min-height of at least 24px — preferably 44px for comfortable touch use. The exception is inline links inside flowing text, which are excluded from 2.5.8.
Users routinely select card text to copy or look up — make sure the ::after overlay doesn't trap clicks at the expense of selection. In practice, an empty ::after with no pointer-events override allows native text selection on desktop because text selection happens during mousedown/mousemove on the text layer. If you ever set pointer-events: auto on the overlay above the text, test selection on every input device. The inverse — pointer-events: none on ::after — disables the overlay-as-click-target entirely.
// 04 · keyboard interaction
| Key | Action |
|---|---|
| Tab | Moves focus to the card's title link, then through any internal action buttons or inline links in document order. |
| Shift + Tab | Moves focus backward through the same elements. |
| Enter | Activates the focused link (navigates) or button (triggers its action). |
| Space | Activates the focused button. (Does not activate links — links use Enter only.) |
| Arrow keys | Not used. A grid of cards is a sequence of links, not a composite widget. If you're building a card-based grid widget (rare), you'd implement role="grid" with arrow-key navigation — that's a different pattern. |
// 05 · wcag 2.2 success criteria
This pattern satisfies the following WCAG 2.2 success criteria:
<div>s.
// 06 · screen reader behavior
Reading the card linearly, a screen reader announces something like: "Article. Heading level 3, Why Native HTML Beats ARIA Most of the Time, link. A breakdown of when native semantics already do the job…". Tabbing into the card jumps straight to the link and announces only "Why Native HTML Beats ARIA Most of the Time, link". Both modes give a clear, unique name.
Tabbing through the product card produces three announcements in order: "Wireless Mechanical Keyboard, link", "Add Wireless Mechanical Keyboard to favorites, button", "Quick view of Wireless Mechanical Keyboard, button". Each control names what it acts on, so even users navigating the buttons list understand context.
What happens depends on the browser and screen reader. VoiceOver on Safari may announce both the outer and inner link, but only one is reachable. NVDA on Chrome may treat the parser-relocated outer link as containing nothing. JAWS may announce the inner link's target as the entire wrapper's destination. None of these are reliable — the bug is that the markup itself is invalid.
A page with 12 cards, each titled with an <h3>, is navigable as a heading list. NVDA users press H 12 times to scan; VoiceOver users open the rotor and pick the heading. The same page without headings forces 12 tab stops or full linear reading. The heading is what makes the card list scannable.
// 07 · common mistakes
<a> inside <a>
Invalid HTML5 — links cannot contain other interactive elements. Browsers split or reposition the inner link in unpredictable ways, and screen readers report the wrong destination. Use the ::after overlay technique instead.
::after for whole-card click and you drop a button into the body, clicks on the button still fire — but they can also bubble up and trigger the card link's navigation. Give interactive children position: relative and a z-index above the overlay, and call event.stopPropagation() if needed for click handlers.
<div> with a click handler instead of a link
<div onclick="…"> has no role, no keyboard support, no accessible name, and breaks middle-click and "open in new tab." Screen reader users can't reach it; keyboard users can't activate it. Use a real <a>, even if you have to style away the underline.
Read more <span class="visually-hidden">about Native HTML Beats ARIA</span>. Violates SC 2.4.4.
:hover but do nothing on :focus-visible are invisible to keyboard users. Use :has(a:focus-visible) or a fallback focus ring on the link to make focus state visible. Required by SC 2.4.7.
// 08 · when to make the whole card clickable
The "whole-card click" pattern works when the card has one clear destination. It breaks when there are competing interactive elements.
| Make the whole card clickable | Don't |
|---|---|
| The card has a single primary action (open the article, view the product page). | The card has multiple actions of similar weight (favorite + share + quick view). |
| The card links to a detail page or full content destination. | The card body contains its own inline links (author name, tags, related items). |
| There are no other interactive elements inside the card. | The card is a complex grouping with several distinct CTAs at equal hierarchy. |
| The user's mental model is "this card is one thing I can click." | The user's mental model is "this card holds several things I can do." |
::after overlay. If it takes two or more verbs ("favorite, share, view"), give each control its own hit area and skip the overlay.
// 09 · native html vs. aria
Cards are a CSS pattern, not a widget pattern — there is no role="card". Use semantic HTML and let CSS do the layout work.
| Need | Native HTML | ARIA alternative |
|---|---|---|
| Card container | <article> for self-contained content; <li> when in a list of cards |
None needed — there is no "card" role |
| Card title | <h2>/<h3> matching the section hierarchy |
role="heading" aria-level="3" only when you can't use a real heading |
| Whole-card link | <a> in the heading + CSS ::after overlay |
None — never wrap the card in a link |
| Card with multiple actions | Real <button> for actions; <a> for navigation |
role="button" on a <div> only as a last resort with tabindex="0" and key handlers |
| Grid of cards | <ul> with one <li> per card; CSS grid for layout |
role="grid" only for spreadsheet/data-grid widgets — not a visual grid of cards |