// pattern

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

html intermediate wcag-2.2-aa

// 01 · live demo

Live Demo

Three correct card patterns followed by two anti-patterns.

1. Single-link card (recommended for one primary destination):

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.

2. Card with a primary link plus secondary actions:

Product

Wireless Mechanical Keyboard

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.

3. Layout card with multiple links (don't fake whole-card click):

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.

Bad example: nested links

Don't
<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.

Bad example: clickable div

Don't
<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

The Code

All three patterns are JS-free. The single-link card works with one heading-wrapped link plus a CSS pseudo-element overlay.

HTML — Single-link card
<!-- 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>
HTML — Card with multiple actions
<!-- 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>
CSS — ::after overlay technique
/* 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;
}
CSS — Hover and focus states for the whole card
/* 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;
  }
}
Text selection still works The ::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

Why These Decisions

Why no nested links?

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.

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

Why a real heading inside the card?

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.

Link vs. button inside cards

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.

Why the heading should contain the link?

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

Why touch target size matters even for tiny buttons

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.

Click targets and text selection

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

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.
One stop per card A single-link card should be one tab stop. If pressing Tab three times to get past a card, you've added too many interactive elements. Audit by tabbing through the page and counting stops.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 1.3.1 Info and Relationships Level A — Each card uses a real heading, so screen reader users can navigate the list of cards by heading.
  • 2.1.1 Keyboard Level A — Card links and buttons are real interactive elements; all are keyboard-operable without scripts.
  • 2.4.4 Link Purpose (In Context) Level A — The card title is the link text, so each link has a meaningful accessible name even out of context.
  • 2.4.7 Focus Visible Level AA — A visible focus ring renders on the card or the focused control whenever a keyboard user tabs in.
  • 2.5.8 Target Size (Minimum) Level AA — Action buttons inside cards meet the 24×24px minimum target.
  • 4.1.2 Name, Role, Value Level A — Every interactive element in the card is a real link or button with a name; no fake controls on <div>s.

// 06 · screen reader behavior

Screen Reader Behavior

Single-link card with a heading

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.

Card with multiple actions

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.

Nested-link card (broken)

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.

Card list with proper headings

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

Common Mistakes

Nesting <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.
Putting a button inside a clickable card without z-index handling If the card uses ::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.
Using a <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.
Generic "Read more" link without the title in the name "Read more, Read more, Read more" in a links list is useless. Either put the link inside the heading so the title is the accessible name, or add visually-hidden context: Read more <span class="visually-hidden">about Native HTML Beats ARIA</span>. Violates SC 2.4.4.
Whole-card hover styles with no keyboard equivalent Cards that change border, shadow, or background on :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.
Tiny action buttons that fail SC 2.5.8 A 16×16 heart icon with no padding is a common failure. Pad action buttons to at least 24×24 CSS pixels (preferably 44×44 for touch comfort). The visible icon can stay small; the button's hit area is what counts.

// 08 · when to make the whole card clickable

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."
Rule of thumb If you can describe the card with one verb ("read this article"), use a single-link card with the ::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

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