---
title: Cards
source: 'https://academia.sh/en/courses/accessible-patterns/cards'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:51+00:00'
license: 'CC BY-SA 4.0'
---

# Cards

That a card is a grouping layout rather than a component; computing the three clickability patterns by tab stop count, accessible name, and nested placement.

The previous topic specified individual controls one at a time, and each control's
boundary was its own box. That boundary blurs in the results list's card view. A card
carries an image, a title, an author, a year, a status badge, and two controls, and the
design decision is that the **entire** card is clickable.

The request is reasonable: when the user clicks anywhere on the card, they should land
on the record detail page. But the card also holds a button, and two controls cannot
nest inside each other. This lesson specifies the card and compares three solutions to
the clickability problem by counting.

## A Card Is a Layout, Not a Component

What the card solves is presenting **information of different kinds** together as a
single scannable unit: an image, a title, two lines of metadata, and a status. The
measure for the visual grouping is the common region principle from the Fundamentals of
Interface Design course; a surface reads its contents as a single unit.

Where the card is not used follows from this too: when the information is homogeneous
and comparable, the right control is a table, not a card. If forty records need to be
compared by publication year, a card view puts every value at a different vertical
position and makes the comparison impossible.

## The Native Element First

The card has no element counterpart, because it is not a control. Its markup rests on
two decisions.

The first is that the card list is written as a **list**. The `ul` and `li` elements
report the list role and the item count to the tree; a user entering the list learns how
many records there are. In a grid built from a stack of `div` elements, that count
exists nowhere.

The second is that the card contains a **heading element**. The heading places the
record on the outline and lets a user navigating from the heading list skip through
forty records without reading each one individually. If the card is a unit that can be
distributed on its own, it is wrapped in an `article` element and tied to the heading
with `aria-labelledby`, becoming a named region in the tree.

## Three Solutions to the Clickability Problem

**A. The whole card is a link.** The image, the title, and the summary are all inside
the link. So is the button inside it.

**B. Only the title is a link, and the surface spreads to match it.** A pseudo-element
covers the whole card with the link; the button is raised above that overlay in the
presentation layer.

**C. Each part is its own link.** The image, the title, and a "Detail" link all go to
the same target.

```js
// card.mjs — tab stop count, accessible name, and nesting in card patterns

const CARD = {
  title: "The Language of Structures",
  author: "T. Ashworth",
  year: "2019",
  status: "On the shelf",
  summary: "An introductory handbook on structural analysis.",
};

// Node: { element, attrs, children } — text nodes stand as plain strings
const interactive = (d) =>
  (d.element === "a" && d.attrs?.href !== undefined) || d.element === "button";

function focusStops(d, path = []) {
  if (typeof d === "string") return [];
  const here = interactive(d) ? [[...path, d.element + (d.attrs?.href ? "[href]" : "")].join(" > ")] : [];
  const below = (d.children ?? []).flatMap((c) => focusStops(c, interactive(d) ? [...path, d.element] : path));
  return [...here, ...below];
}

function text(d) {
  if (typeof d === "string") return d;
  const a = d.attrs ?? {};
  if (a["aria-hidden"]) return "";
  if (d.element === "img") return a.alt ?? "";
  return (d.children ?? []).map(text).join(" ").replace(/\s+/g, " ").trim();
}

// Accessible name of an interactive element: aria-label > content text
function name(d) {
  const a = d.attrs ?? {};
  if (a["aria-label"]) return a["aria-label"];
  return text(d);
}

// Count of nested interactive elements (a button inside a link)
function nested(d, inside = false) {
  if (typeof d === "string") return 0;
  const here = inside && interactive(d) ? 1 : 0;
  return here + (d.children ?? []).reduce(
    (t, c) => t + nested(c, inside || interactive(d)), 0);
}

const body = () => [
  { element: "img", attrs: { alt: "" } },
  { element: "h3", children: [CARD.title] },
  { element: "p", children: [CARD.author + ", " + CARD.year] },
  { element: "p", children: [CARD.summary] },
  { element: "span", children: [CARD.status] },
];

// A: the whole card is one link; a button also sits inside it
const A = {
  element: "article",
  children: [
    { element: "a", attrs: { href: "/record/K-118" },
      children: [...body(), { element: "button", children: ["Borrow"] }] },
  ],
};

// B: only the title is a link; the button is a sibling; the card surface spreads over the link
const B = {
  element: "article",
  children: [
    { element: "img", attrs: { alt: "" } },
    { element: "h3", children: [{ element: "a", attrs: { href: "/record/K-118" }, children: [CARD.title] }] },
    { element: "p", children: [CARD.author + ", " + CARD.year] },
    { element: "p", children: [CARD.summary] },
    { element: "span", children: [CARD.status] },
    { element: "button", children: ["Borrow"] },
  ],
};

// C: the image, the title, and "Detail" are each a separate link
const C = {
  element: "article",
  children: [
    { element: "a", attrs: { href: "/record/K-118" }, children: [{ element: "img", attrs: { alt: "" } }] },
    { element: "h3", children: [{ element: "a", attrs: { href: "/record/K-118" }, children: [CARD.title] }] },
    { element: "p", children: [CARD.author + ", " + CARD.year] },
    { element: "span", children: [CARD.status] },
    { element: "a", attrs: { href: "/record/K-118" }, children: ["Detail"] },
    { element: "button", children: ["Borrow"] },
  ],
};

const PATTERNS = [
  ["A: whole card is a link", A],
  ["B: title link + spreading surface", B],
  ["C: each part a separate link", C],
];

const CARDS_PER_PAGE = 40;

console.log("pattern".padEnd(36) + "stops".padEnd(8) + "in 40 cards".padEnd(13) +
  "nested".padEnd(8) + "first stop's name (chars)");
for (const [title, k] of PATTERNS) {
  const stops = focusStops(k);
  const first = (k.children ?? []).flatMap(function find(d) {
    if (typeof d === "string") return [];
    return interactive(d) ? [d] : (d.children ?? []).flatMap(find);
  })[0];
  const n = name(first);
  console.log(title.padEnd(36) + String(stops.length).padEnd(8) +
    String(stops.length * CARDS_PER_PAGE).padEnd(13) +
    String(nested(k)).padEnd(8) + `"${n.slice(0, 34)}${n.length > 34 ? "…" : ""}" (${n.length})`);
}

console.log("\ntab stops:");
for (const [title, k] of PATTERNS) {
  console.log("  " + title);
  for (const d of focusStops(k)) console.log("    " + d);
}

console.log("\nrule audit:");
let findings = 0;
for (const [title, k] of PATTERNS) {
  const stops = focusStops(k);
  const elements = (k.children ?? []).flatMap(function find(d) {
    if (typeof d === "string") return [];
    return interactive(d) ? [d, ...(d.children ?? []).flatMap(find)] : (d.children ?? []).flatMap(find);
  });
  const names = elements.map(name);
  const b = [];
  if (nested(k) > 0) b.push(`interactive element inside another interactive element (${nested(k)})`);
  for (const n of names) {
    if (n === "") b.push("nameless stop: image with empty alternative text as its only content");
    else if (n.length > 60) b.push(`accessible name is ${n.length} characters: unreadable in a link list`);
  }
  const targets = new Map();
  for (const o of elements) {
    const h = o.attrs?.href;
    if (!h) continue;
    if (!targets.has(h)) targets.set(h, new Set());
    targets.get(h).add(name(o));
  }
  for (const [h, set] of targets) {
    if (set.size > 1) b.push(`${set.size} links to the same target (${h}) with different names`);
  }
  if (stops.length * CARDS_PER_PAGE > 100)
    b.push(`${stops.length * CARDS_PER_PAGE} stops in 40 cards: the list cannot be crossed with the tab key`);
  for (const x of b) { findings++; console.log("  " + title.padEnd(36) + x); }
}
console.log(`\n${PATTERNS.length} patterns, ${findings} findings`);
```

```
pattern                             stops   in 40 cards  nested  first stop's name (chars)
A: whole card is a link             2       80           1       "The Language of Structures T. Ashw…" (113)
B: title link + spreading surface   2       80           0       "The Language of Structures" (26)
C: each part a separate link        4       160          0       "" (0)

tab stops:
  A: whole card is a link
    a[href]
    a > button
  B: title link + spreading surface
    a[href]
    button
  C: each part a separate link
    a[href]
    a[href]
    a[href]
    button

rule audit:
  A: whole card is a link             interactive element inside another interactive element (1)
  A: whole card is a link             accessible name is 113 characters: unreadable in a link list
  C: each part a separate link        nameless stop: image with empty alternative text as its only content
  C: each part a separate link        3 links to the same target (/record/K-118) with different names
  C: each part a separate link        160 stops in 40 cards: the list cannot be crossed with the tab key

3 patterns, 5 findings
```

## Reading the Findings

**Pattern A breaks two separate rules.** The button inside the link sits in the tree as
a second interactive element inside a first one; this placement is undefined, and how
implementations behave is unpredictable. The second finding is measurable: the link's
name runs to a hundred and thirteen characters. When a user opens the link list, every
row is a paragraph long and the list becomes unscannable.

**Pattern C falls on the count.** Four stops per card, times forty cards, comes to a
hundred and sixty stops. A keyboard user crossing the list needs a hundred and sixty tab
presses. The same pattern produces two more findings: the link made of an image with
empty alternative text ends up nameless, and three links to the same target carry three
different names.

**Pattern B comes out clean on the audit.** Two stops per card, one short link name, no
nested placement. Its cost sits in the presentation layer and has to be written into the
specification: the spreading surface makes the card's text unselectable with the mouse.
The fix is having the overlay capture pointer events only over the empty area.

The tab stop list shows one more detail. In patterns B and C, the button sits at the
card's own level; in pattern A, it sits on the `a > button` path — inside the link. The
path is how nesting is detected without looking at the markup.

## Keyboard Contract

The card carries **no** keyboard contract of its own. A card does not take focus, does
not listen for keys, and is invisible in the tab order. The contracts that apply are the
ones for the link and the button inside it.

This is a decision of the specification. Writing `tabindex` on the card and attaching a
click listener raises the tab stop count by one per card and produces a focusable shell.
That shell has no role, its name is unclear, and its response to the Enter key has to be
hand-written.

If the card list is long, the tab stop count is lowered by a separate decision: a skip
link is placed before the list, giving the user a way past it. Hiding the card's
secondary controls and showing them only on hover does not lower this count, because a
hidden control becomes unreachable from the keyboard.

## Measurable Constraints

**2.4.7 Focus Visible.** A presentation decision that clips overflowing content at the
card's edge also clips the focus ring of a control near that edge. The indicator still
exists; it is just invisible. Catching it takes a keyboard pass.

**1.4.11 Non-text Contrast.** The boundary separating a card from the page surface has to
meet the 3:1 measure if it carries information. If cards are separated only by a shadow,
and the shadow is low-contrast, the group boundary cannot be seen.

**1.4.1 Use of Color.** The "On the shelf," "On loan," and "Reserved" status badges
cannot be distinguished by color alone; every badge carries its own text.

**1.4.10 Reflow.** The card grid must not require two-directional scrolling once reduced
to a 320 CSS pixel width. Fixed-width card boxes remove this criterion outright.

**2.5.8 Target Size.** The card's secondary controls — the flag and share buttons — have
to meet the 24-pixel measure or the spacing exception.

## Common Mistake

**Leaving the card without a heading.** Bold-styled text is not a heading. Catching it
means counting the heading levels in the card list: if there are forty cards, the
outline must hold forty headings.

**Naming the image after the record.** If the card's image carries alternative text
while the title already sits right next to it, the same information is announced twice.
The decision: the cover image inside a card is presentational and its alternative text
is empty.

**Not writing the card list as a list.** Catching it means comparing the tree's item
count against the number of cards on screen; if there is no count, there is no
structure.

## Summary

- A card is not a control but a grouping layout that presents information of different
  kinds as a single unit; homogeneous, comparable data uses a table instead.
- The card list is written with list elements, and every card carries a heading element;
  the two report the item count and the outline to the tree.
- Making the whole card a link produces a nested interactive element and stretches the
  link's name past a readable length.
- Making each part its own link raises the tab stop count to four per card; in a list of
  forty cards, that comes to a hundred and sixty stops.
- The pattern where only the title is a link and the surface spreads to match it comes
  out clean on all three measures; its cost is text selectability, and that is solved by
  the overlay's event capture area.
- The card carries no keyboard contract of its own; giving the card focus produces a
  roleless, nameless shell.

## Next Step

The card was the sum of the controls inside it; it carried no behavior on its own. On the
record detail page, that changes: the citation, the available copies, and the borrowing
history are three separate sections, and only one is shown at a time. This is the first
pattern where the container itself carries a behavior — it decides which section is
visible. The next lesson specifies the tab pattern: how tab headings and panels are tied
together, why the roving tabindex is mandatory here, and the decision between automatic
and manual activation of the selection.
