Skip to content
academia.sh

Lesson 07 / 25

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.

Contents

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.

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close