LEARNING OBJECTIVES ⌵
- Understand the mechanics of transforming tabular 2D structures into vertical card stacks using CSS media queries.
- Implement pseudo-element content injection with
td::before { content: attr(data-label); }to retain field context on mobile. - Apply accessible visual hiding techniques (
clip-path: inset(50%)orposition: absolute) to remove<thead>visually without breaking screen readers. - Address the WebKit/Blink accessibility tree degradation bug where
display: blockstrips native table semantics.
📖 The Mental Model & Story (Intuitive Foundation)
Imagine a medical patient chart in an emergency room. On the doctor's wide desktop monitor, the chart is displayed as an expansive horizontal spreadsheet with columns for Time, Heart Rate, Blood Pressure, Medication, and Nurse Notes.
Desktop Spreadsheet (Horizontal Matrix):
+----------+------------+----------------+------------+---------------------+
| Time | Heart Rate | Blood Pressure | Medication | Nurse Notes |
+----------+------------+----------------+------------+---------------------+
| 08:00 AM | 72 bpm | 120/80 mmHg | Saline IV | Stable post-op |
| 09:30 AM | 88 bpm | 135/85 mmHg | Morphine 5mg| Pain reported |
+----------+------------+----------------+------------+---------------------+
Mobile Transformation -> Stacked Patient Cards (Vertical Units):
+------------------------------------------+
| RECORD #1 (08:00 AM) |
| • Time: 08:00 AM |
| • Heart Rate: 72 bpm |
| • Blood Pressure: 120/80 mmHg |
| • Medication: Saline IV |
| • Notes: Stable post-op |
+------------------------------------------+
+------------------------------------------+
| RECORD #2 (09:30 AM) |
| • Time: 09:30 AM |
| • Heart Rate: 88 bpm |
| • Blood Pressure: 135/85 mmHg |
| • Medication: Morphine 5mg |
| • Notes: Pain reported |
+------------------------------------------+
When a triage nurse walks the floor with a handheld smartphone, they don't want to pan horizontally across 5 columns. Instead, each row (<tr>) is transformed into an isolated, beautifully padded index card, and each cell (<td>) becomes a discrete vertical key-value line item. The column header labels are dynamically stamped in front of each value using CSS.
Technical Deep Dive & Specifications
The Pure CSS Stacked-Card Transformation Algorithm
To transform a table into vertical cards below a specific viewport breakpoint (e.g., @media (max-width: 768px)), we systematically change the layout display properties of all structural table tags:
+-------------------------------------------------------------------------------+
| DOM Node | Desktop Default Display | Mobile Transformation Display |
+-------------------------------------------------------------------------------+
| <table> | display: table | display: block |
| <thead> | display: table-header-group | display: none (or sr-only) |
| <tbody> | display: table-row-group | display: block |
| <tr> | display: table-row | display: block (Card Box) |
| <td> | display: table-cell | display: flex / block (Row) |
+-------------------------------------------------------------------------------+
The attr(data-label) Pseudo-Element Pattern
Because <thead> is hidden on mobile screens, data cells lose their visual column context. To fix this, we attach custom HTML5 data attributes (data-label="...") to each <td>, and retrieve them via CSS using the attr() function inside a ::before pseudo-element:
<!-- HTML -->
<td data-label="Blood Pressure">120/80 mmHg</td>
/* CSS */
@media (max-width: 768px) {
td {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
}
td::before {
content: attr(data-label);
font-weight: 700;
color: #475569;
text-align: left;
margin-right: 12px;
}
}
Rendered Mobile Cell Box:
+------------------------------------------------------------------+
| [::before pseudo-element] | [Native Text Node] |
| "Blood Pressure" | "120/80 mmHg" |
| (content: attr(data-label)) | |
+------------------------------------------------------------------+
The Accessibility Tree Stripping Hazard & Fix
Historically, browser layout engines (notably Safari WebKit and Google Chrome Blink) tied accessibility tree roles directly to CSS display modes. When you set display: block or display: flex on a <table>, <tr>, or <td>, the engine would strip role="table" and role="cell", degrading the table into generic <div> blocks. Screen readers would announce "list of text" instead of "Table: 5 columns, 3 rows".
To safeguard accessibility across all browsers, we attach explicit ARIA tabular roles whenever non-table CSS display transformations are applied:
<!-- Robust Accessible Markup with Fallback ARIA Roles -->
<table role="table">
<thead role="rowgroup">
<tr role="row">
<th role="columnheader">Metric</th>
<th role="columnheader">Value</th>
</tr>
</thead>
<tbody role="rowgroup">
<tr role="row">
<td role="cell" data-label="Metric">Latency</td>
<td role="cell" data-label="Value">14ms</td>
</tr>
</tbody>
</table>
Visually Hiding <thead> Without Breaking Screen Readers
Never use display: none on <thead> if you rely on standard table navigation for screen reader users on desktop. Instead, use an accessible screen-reader-only utility class:
@media (max-width: 768px) {
.responsive-card-table thead {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
border: 0;
white-space: nowrap;
}
}
💻 Interactive Code Playground
Starter Code
Line-by-Line Code Breakdown
- Lines 55–60: Below $768\text{px}$,
table,tbody,tr, andtdare assigneddisplay: block, stripping the native rigid column alignment and allowing each element to stack vertically. - Lines 63–73: The
theadelement is not removed withdisplay: none(which could affect certain screen reader modes), but is visually clipped to $1\text{px} \times 1\text{px}$ off-screen usingclip-path: inset(50%). - Lines 76–83: Each
<tr>receivesmargin-bottom: 16px; border-radius: 8px; box-shadow: ...;converting the row into a clean, modern card container. - Lines 85–92: Each
<td>is styled asdisplay: flex; justify-content: space-between; align-items: center;. This creates a two-column key/value row within the card. - Lines 100–109:
td::beforereadsattr(data-label)from the HTML attribute and prints the uppercase label on the left side of the card line. - Lines 120–152: Every
<td>tag is explicitly annotated withdata-label="..."and ARIA roles (role="table",role="rowgroup",role="row",role="cell") to maintain structural semantics.
Expected Browser Render Output
- Desktop Screens ($> 768\text{px}$): A standard dark-header enterprise data table.
- Mobile Screens ($\le 768\text{px}$): The table collapses into 3 floating white cards. Each card displays 5 key-value lines with grey uppercase labels on the left and bold values/status badges aligned to the right.
🏋️ Hands-On Exercise
🎯 The Challenge: Refactor an Employee Directory Table to Responsive Cards
Instructions:
- Populate each
<td>in the starter code with matchingdata-labelattributes. - In the media query, convert the table structure to cards below
640px. - Add a highlight style to the first
<td>in each card so the employee's name acts as a prominent card header.
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Mismatched
data-labelstrings: Hardcodingdata-label="Department"on the wrong<td>creates confusing misinformation (e.g., displaying the email address with a "Department" label). Generatedata-labeldynamically via your templating engine (React, Vue, Jinja, or Blade). - Forgetting to style long content: In
display: flex; justify-content: space-between, a very long value (like an address or URL) can crush the::beforepseudo-element. Always setmin-widthorflex-shrink: 0ontd::before.
💡 Pro Tips
- Automate
data-labelInjection via JavaScript (if HTML is static): If rendering legacy HTML withoutdata-label, inject them once on DOM load:document.querySelectorAll('.card-table tbody tr').forEach(row => { const headers = Array.from(row.closest('table').querySelectorAll('thead th')).map(th => th.textContent); row.querySelectorAll('td').forEach((td, i) => td.setAttribute('data-label', headers[i] || '')); }); - Avoid Table Structure on Non-Tabular Data: If data is always cards on both mobile and desktop (e.g., product listings), use semantic
<ul role="list">and<li>with CSS Grid instead of a<table>transformed via CSS.
📌 Key Takeaways
- The stacked-card layout converts 2D table rows into standalone vertical card components on narrow viewports.
display: blockapplied totable,tbody,tr, andtdbreaks rigid column alignment for responsive reflow.- Pseudo-element
td::before { content: attr(data-label); }restores lost column context on mobile. - Explicit ARIA roles (
role="table",role="row",role="cell") ensure screen readers preserve semantic relationships even when CSS alters display types. - Visually hiding
<thead>withclip-path: inset(50%)preserves desktop table accessibility while decluttering mobile screens. - --