Hooks for scripts, signals for assistive tech, and the table decision
A guide to the HTML and JSX attributes you write for non-visual readers, data-* for your scripts, aria-* for assistive technology, and the accessible table element, chosen by asking who actually consumes each one.
Picture one screen from Acme, the invoicing app you’ve been building markup for across this chapter: an audit log. Each row records something that happened to an invoice. At the end of every row sits a small trash icon to delete it. Above the log, a button toggles whether the rows are sorted newest-first or oldest-first.
Look at four ordinary-seeming pieces of that screen, because each one hides a decision.
The delete button at the end of a row is a single icon with no words, and a click on it has to know which invoice it belongs to. The sort toggle has a direction, but right now the only thing that records that direction is the arrow you can see. The audit log is a grid of rows and columns. And a screen reader user, who never sees the trash glyph, still needs to be told that the button deletes an invoice.
All four pieces share one trait, and it is the thread that runs through this whole lesson: each is markup you write for a reader who isn’t the person looking at the screen. Sometimes that reader is your own JavaScript. Sometimes it’s a screen reader. Sometimes it’s the browser’s layout and accessibility engine. Throughout this chapter, every element, prop, and attribute has been a decision read by someone you’ll never watch use it. This lesson sorts those decisions by which reader they’re for.
That gives you one organizing question to keep asking as we go: who reads this attribute? There are three answers, and a tool for each.
We’ll cover the part of each tool you’ll reach for daily, in that order, plus the judgment to pick the right one. Two of these come from promises made earlier. When you learned buttons and links, the icon-only button’s accessible name was set aside for this lesson. When you learned landmarks and headings, “ARIA basics” was promised here too. This is where they land.
Custom attributes your scripts read: data-*
Section titled “Custom attributes your scripts read: data-*”We’ll start with the most mechanical of the three tools. data-* is the simplest to learn, and once it’s in place it sets up the audit-log table at the end.
The model
Section titled “The model”Any attribute whose name starts with data- is valid HTML. The browser stores it on the element and hands it back to you, but it never renders it, never styles off it by default, and never assigns it any meaning. That last point is the whole reason the tool exists: a data-invoice-id means whatever your code decides it means and nothing more.
So data-* is a private channel from your markup to your own code. The browser stays out of it because the channel belongs to you. Compare that to every other attribute you’ve met, like href, type, scope, and name, where the browser has opinions and behavior baked in. data-* is the deliberate opposite: a place to store structured information on an element, with a guarantee that no one but you is reading it.
Reading it back
Section titled “Reading it back”You met the reader for this in chapter 14, when you walked the DOM: every element exposes a dataset object holding its data-* attributes. You don’t need to re-learn the DOM traversal, only the one translation rule that trips everyone up the first time.
You write the attribute in kebab-case in your markup, and dataset reads it back in camelCase: data-invoice-id becomes dataset.invoiceId. The DOM does that conversion for you automatically in both directions, so you never write the camelCase form in your markup and never write the kebab form in dataset.
In JSX specifically, data-* is one of the two attribute families that pass straight through unchanged. Everywhere else, JSX renames things: class becomes className, for becomes htmlFor. But data-* and aria-* keep their kebab-case spelling exactly as you’d write them in plain HTML, so the JSX you write and the attribute the browser receives are identical here.
The clearest way to see the round trip is to put the JSX and the reader side by side on one element. Hover the two highlighted attributes below.
<li data-invoice-id={invoice.id} data-status={invoice.status}> {invoice.summary}</li>One more thing about what comes back: it’s always a string. The DOM stores attributes as text, so data-count={5} round-trips as dataset.count === "5", not the number 5. Write <div data-count={5}> and you’ll read "5" back and have to parse it yourself. This is the same numbers-are-strings rule you saw for attributes generally, and data-* is no exception.
Where you’ll actually reach for it
Section titled “Where you’ll actually reach for it”The model is simple, so the real skill is knowing when to reach for data-* instead of inventing your own attribute. Here is a tour of where it shows up. You don’t need the depth on any of these yet, since each one belongs to a later chapter, but seeing the whole set is what turns data-* from a curiosity into a tool you recognize on sight.
The first and most important use is event delegation, which you met in chapter 14. Instead of attaching a click handler to every row in a list (fifty rows, fifty listeners), you attach one listener to the container and let the click bubble up to it. When it fires, you ask two questions: which element did this actually start on, and what should happen? data-* carries the answer.
function handleListClick(event: MouseEvent) { const button = (event.target as HTMLElement).closest('[data-action]'); if (!button) return;
const row = button.closest('[data-invoice-id]'); const invoiceId = row?.getAttribute('data-invoice-id');
if (button.getAttribute('data-action') === 'delete' && invoiceId) { deleteInvoice(invoiceId); }}One listener reads closest('[data-action]') to find what was clicked, walks up to the row’s data-invoice-id to find which record, and acts. The data-* attributes are the routing table. This is the main reason you’ll see data-* on list rows in real SaaS code.
The rest of the tour is quicker:
- Test selectors. Playwright and other end-to-end tools can find an element by a
data-testidyou put there for exactly that purpose. One caveat is worth learning now, because it’s the most common over-reach: adata-testidis the fallback, not the default. A good test first tries to find things the way a user would, by “the button labelled Delete invoice” or “the field labelled Email”, and reaches for adata-testidonly when there’s no accessible handle to grab. You’ll see why when you reach testing. For now, the rule is just to avoid addingdata-testidto everything out of habit. - Analytics hooks. A single delegated listener can read a
data-event-nameoff whatever was clicked and fire a tracking event, instead of wiring analytics calls into every handler by hand. - Tailwind state variants. This is the one to recognize rather than reach for. Tailwind can style an element conditionally off a
data-*attribute:data-[state=open]:rotate-180rotates a chevron when its element carriesdata-state="open". When you start using shadcn and Radix components, you’ll noticedata-stateattributes all over their output, and this is why. The component changes adata-stateattribute, and the CSS reacts to it.
The reflexes
Section titled “The reflexes”Two things to carry forward. First, data-* carries hooks for code: an id, an action name, a state flag your scripts or styles read. It never carries text the user is meant to see (that’s a text node), and it never carries meaning for a screen reader (that’s the next section’s job, and a frequent, costly mix-up). Second, the data- prefix is mandatory. An invented bare attribute like <div rowid="42"> is not valid HTML and won’t show up in dataset at all, so the round trip quietly breaks. The prefix is the price of the private channel.
Signals for assistive tech: aria-*
Section titled “Signals for assistive tech: aria-*”Now the consumer changes, and so does the kind of thinking the tool demands. data-* was mechanical: you learn the prefix, learn the camelCase rule, and that’s it. ARIA is not mechanical. The attribute names are easy, but knowing when not to use them is the entire skill.
The first rule: reach for the element first
Section titled “The first rule: reach for the element first”Here is the rule every experienced developer has internalized: no ARIA is better than bad ARIA. A wrong ARIA attribute doesn’t fail loudly the way a syntax error does. It quietly lies to a screen reader, and the only person who finds out is the user you were trying to help.
To see why the rule holds, recall the accessibility tree from a few lessons back. The browser builds it from your markup, and semantic elements populate it for free. A <button> enters the tree already announced as a button. A <nav> enters as a navigation landmark. An <input> wired to a <label> enters as a named field. You didn’t add a single ARIA attribute to get any of that; the element did the work.
So what is ARIA for? Two things. It fills gaps the native element genuinely can’t express, such as a state, a name, or a relationship the HTML has no built-in way to convey. And it can override an element’s defaults, which is exactly where it goes wrong, because overriding a correct default replaces something true with something you now have to keep true by hand.
That gives you the order to ask questions in, and you’ll apply it for the rest of this section:
- Is there a semantic element that already conveys this? Use it, and you’re done.
- If not, is there an
aria-*attribute that adds the missing name, state, or relationship? Add the minimum. - Changing what an element fundamentally is with a
roleis the last resort, and in 2026 it’s rarely the right call, because you almost always have a better element to start from.
Most beginner ARIA mistakes trace back to skipping step 1: putting role and aria-* onto a <div> to rebuild a button that <button> would have handed you complete. Learn the order before the attributes, and you’ve learned the hard part.
The attributes you’ll reach for most weeks
Section titled “The attributes you’ll reach for most weeks”These are grouped by the job they do, because that’s how you’ll recall them: you won’t think “I need aria-label”, you’ll think “this control has no visible text”. Each comes with the place on the Acme screen where it earns its keep.
Naming a control that has no visible text. This is the home of that deferred icon-only button. Your trash button in the audit log is just an icon. It’s visually obvious, but a screen reader has nothing to announce because there’s no text inside it. Give it a name:
<button aria-label="Delete invoice" onClick={() => deleteInvoice(invoice.id)}> <TrashIcon /></button>aria-label takes a literal string and supplies the accessible name. The thing to watch out for lives right here: aria-label overrides any visible text. On a button that already reads “Save”, adding aria-label="Submit" means the screen reader says “Submit” while the eye reads “Save”, which is two sources of truth drifting apart. So the rule is narrow: only label controls with no text. If there’s visible text, the element is already named.
When the name you want already exists as another element on the page, point at it by id instead of retyping it:
<section aria-labelledby="invoices-heading"> <h2 id="invoices-heading">Invoices</h2> {/* … */}</section>aria-labelledby names an element by reference: the section borrows its name from the heading it already shows. That keeps one source of truth, since changing the heading text changes the accessible name with it. When a visible label exists, prefer this over aria-label.
Pointing one element at another for description. Where aria-labelledby supplies the name, aria-describedby attaches secondary text: the longer explanation, the hint, or the error message.
<input aria-describedby="email-hint" /><p id="email-hint">We'll only use this for billing receipts.</p>The most common use is a form field pointing at its error message, and you’ll wire that fully, alongside aria-invalid and the alert role, when you reach forms and validation in depth. (Remember the sign-in form a couple of lessons back deliberately rendered no error feedback yet; this is the piece that was being held back.) For now, treat aria-describedby as how a field says “this other element describes me.”
Telling the tree about a state the eye can already see. Visual state, like which nav item is active, whether a panel is open, or whether a toggle is pressed, is invisible to a screen reader unless you say so.
aria-current="page"marks the one nav link that points at the page you’re already on: the highlighted item in the Acme sidebar. (It takes other values too, likestep,date, or a baretrue, for wizards, calendars, and the like.)aria-expandedplusaria-controlsbelong on a disclosure , a control that shows and hides a panel.aria-expanded="true|false"announces the open/closed state, andaria-controls="panel-id"names the panel it governs.aria-pressed="true|false"is for a toggle button: the sort-direction toggle from the top of this lesson, or a bold button, or a favorite star. It tells the tree the button is a two-state switch and which state it’s in. (A checkbox or a link is a different thing. Use those elements when you mean them;aria-pressedspecifically says “this button is currently on or off”.)
<button aria-pressed={isDescending} onClick={toggleSort}> Sort</button>Hiding the decorative. Sometimes the most accessible thing you can do is remove something from the tree, and aria-hidden="true" does that. The textbook case is a decorative icon sitting next to visible text, like a chevron beside the word “Filters”. The eye wants the glyph, but the screen reader would announce it as noise on top of the word. Hide the icon, keep the text:
<button aria-pressed={isDescending} onClick={toggleSort}> Sort <ChevronIcon aria-hidden="true" /></button>Now the two cases for an icon line up cleanly. Icon with text? aria-hidden the icon, because the text is already the name. Icon alone? aria-label the button, because the icon can’t speak for itself.
The key thing to watch out for: never put aria-hidden="true" on a focusable or interactive element. Doing so creates an element you can still reach by pressing Tab but that has been erased from the tree the screen reader navigates. A keyboard user lands on the control, and the screen reader has nothing to announce. This ships as a real bug more often than almost anything else in ARIA.
Changing what an element is. The role attribute overrides an element’s built-in identity. In 2026 you reach for it rarely, because step 1 of the rule almost always hands you a better starting element. The one family of roles that genuinely earns its place is for live regions, covered next. (You might also see role="dialog" in the wild, and even that loses to the native <dialog> element and to Radix’s primitives, which you’ll meet later.)
Announcing things the user didn’t trigger: live regions
Section titled “Announcing things the user didn’t trigger: live regions”There is one more idea to name here so you recognize it later, with the full depth saved for a future lesson. Everything above is about static markup, meaning what’s on the page when it loads. But a SaaS app constantly produces content after load that the user never triggered: a “Saved” toast slides in, an inline validation message appears, a search result count updates. To a sighted user these are obvious. To a screen reader, by default, they’re silent, because the user’s focus is elsewhere and nothing told the tree to speak.
A live region is the fix. You mark a region to announce its own changes:
aria-live="polite"announces when the user is idle and doesn’t interrupt. Use it for toasts and status messages.aria-live="assertive"interrupts immediately. Reserve it for genuinely critical messages.role="alert"is the experienced developer’s shorthand for an assertive live region, and it’s the standard reach for an inline form error.
The one thing to watch out for: the region has to already exist in the DOM before the content drops into it. A live region the browser has been watching announces the change. A region that appears at the same moment as its content gives the browser nothing to compare against, so the announcement is missed. You’ll wire live regions properly later in the course. For now, it’s enough to recognize the term and the trap.
See it on one fragment
Section titled “See it on one fragment”Here’s a small slice of the Acme invoices toolbar that earns several of these at once. Walk it one step at a time, starting with how much is already announced correctly with no ARIA at all. That’s rule 1 made visible.
<nav aria-label="Invoice filters"> <a href="/invoices" aria-current="page">All</a> <a href="/invoices?status=paid">Paid</a> <a href="/invoices?status=overdue">Overdue</a>
<button aria-pressed={isDescending} onClick={toggleSort}> Sort <ChevronIcon aria-hidden="true" /> </button>
<button aria-label="Delete invoice" onClick={() => deleteInvoice(invoice.id)}> <TrashIcon /> </button></nav>Before a single aria-* attribute, the <nav> is already a navigation landmark, the <a>s are already links, and the <button>s are already buttons. The accessibility tree got all of that for free. Everything we add now is a thin patch on top.
<nav aria-label="Invoice filters"> <a href="/invoices" aria-current="page">All</a> <a href="/invoices?status=paid">Paid</a> <a href="/invoices?status=overdue">Overdue</a>
<button aria-pressed={isDescending} onClick={toggleSort}> Sort <ChevronIcon aria-hidden="true" /> </button>
<button aria-label="Delete invoice" onClick={() => deleteInvoice(invoice.id)}> <TrashIcon /> </button></nav>The icon-only delete button has no text inside it, so we give it a name. This is the case held back from the buttons-and-links lesson: the icon is alone, so the button itself gets the label.
<nav aria-label="Invoice filters"> <a href="/invoices" aria-current="page">All</a> <a href="/invoices?status=paid">Paid</a> <a href="/invoices?status=overdue">Overdue</a>
<button aria-pressed={isDescending} onClick={toggleSort}> Sort <ChevronIcon aria-hidden="true" /> </button>
<button aria-label="Delete invoice" onClick={() => deleteInvoice(invoice.id)}> <TrashIcon /> </button></nav>Name the nav region, since the page has more than one <nav> and each needs a distinguishing name, and mark which filter link is the page you’re already on.
<nav aria-label="Invoice filters"> <a href="/invoices" aria-current="page">All</a> <a href="/invoices?status=paid">Paid</a> <a href="/invoices?status=overdue">Overdue</a>
<button aria-pressed={isDescending} onClick={toggleSort}> Sort <ChevronIcon aria-hidden="true" /> </button>
<button aria-label="Delete invoice" onClick={() => deleteInvoice(invoice.id)}> <TrashIcon /> </button></nav>This step is worth reading closely. aria-pressed tells the tree the Sort button is a toggle and which way it’s set. The chevron is decorative and sits beside the word “Sort”, so aria-hidden keeps the screen reader from reading the glyph on top of the word. The chevron isn’t focusable, so hiding it is safe.
<nav aria-label="Invoice filters"> <a href="/invoices" aria-current="page">All</a> <a href="/invoices?status=paid">Paid</a> <a href="/invoices?status=overdue">Overdue</a>
<button aria-pressed={isDescending} onClick={toggleSort}> Sort <ChevronIcon aria-hidden="true" /> </button>
<button aria-label="Delete invoice" onClick={() => deleteInvoice(invoice.id)}> <TrashIcon /> </button></nav>One piece is deliberately absent: a real form field would also point at its error message with aria-describedby. That’s form-validation wiring for later, and it’d hang off the <input> rather than this toolbar, but it’s the same family of “point one element at another.”
The shape to take away: ARIA is a thin layer of additions on top of markup that was already mostly correct. If you find yourself adding a lot of it, step back. You’ve probably skipped step 1 and are rebuilding something an element would have given you.
Sort the two consumers
Section titled “Sort the two consumers”You’ve now met both attribute families written for a reader who isn’t the sighted user: data-* for your scripts, aria-* for assistive tech. The most expensive beginner mistake is crossing them, by using aria-* as a scripting hook or data-* to convey meaning to a screen reader. Drag each chip into the family that consumes it.
Sort each attribute by who actually reads it — your own code, or assistive technology? Drag each item into the bucket it belongs to, then press Check.
data-testidaria-labeldata-state driving a Tailwind variantaria-current for the active nav linkaria-describedby pointing at a field’s errorTwo specific traps are worth pinning down before we move on, because both ship as real, hard-to-notice bugs.
Which of these are genuine ARIA bugs — markup that would mislead or trap a screen reader user? Select all that apply.
aria-label="Download CSV".aria-label="Download CSV".<a href="/help"> that the user can still Tab to carries aria-hidden="true".aria-hidden="true".aria-label for controls with no text. And hiding a still-focusable link leaves a control a keyboard user can land on but the screen reader won’t announce — the classic unreachable trap. The other two are textbook-correct: an icon-only button has nothing to announce until you name it, and a decorative glyph sitting next to a word that already labels the state is exactly what aria-hidden is for.When the data is genuinely tabular: <table>
Section titled “When the data is genuinely tabular: <table>”The third reader is the browser’s own layout-and-accessibility engine, and the third tool is the <table> element family. This is also where the chapter’s threads converge: keys, data-*, and ARIA all show up in one artifact. But before any tag, there’s a decision, and getting the decision right is most of the value.
Is this actually a table?
Section titled “Is this actually a table?”<table> has two failure modes, and they pull in opposite directions. The old one, fading but not gone, is using a table to lay out a page, because decades ago that was the only way to get columns. The modern one is the overcorrection: building real tabular data out of a pile of <div>s because tables felt old-fashioned, and losing every bit of structure the element would have given the accessibility tree.
So you need a positive test, not a hunch. The test for tabular data is this: rows and columns of related records, where every position is indexed by (row, column). Every row is the same kind of thing. Every column is the same attribute measured across all the rows. If that’s true, it’s a table.
Here’s a sharper version of the test, the transpose check: would swapping the rows and columns, putting attributes down the side and records across the top, produce a different but still meaningful view of the same data? For a genuine table, yes: a billing breakdown reads sensibly either way. For a page layout or a list of cards, transposing it is nonsense. If the transpose is meaningful, you have tabular data.
On the Acme surface, the clear yes cases are the ones you’d expect: the audit log, an invoice’s line items, a billing breakdown, a metrics grid, all grids of like records. The no cases each have a better element, and naming them is the fastest way to keep <table> in its lane:
- Laying out a page or a section? That’s CSS grid, which you’ll meet soon. (This is the 1990s table-for-layout reflex; leave it behind.)
- A list of cards, one product or teammate or notification per tile? That’s a
<ul>and a grid, the list elements from earlier in the chapter. - A form’s two-column field arrangement? That’s a
<form>plus CSS grid.
The principle under all of it: a table is for data you’d compare across rows, never for positioning boxes on the page. The decision tree below walks that judgment once, in the order you’d actually ask the questions.
This is genuinely tabular. Reach for the table element family, and the next section builds the exact accessible shape a 2026 SaaS ships.
You’re positioning boxes, not comparing records across rows. That’s a layout job for CSS grid, coming up soon, not a table.
One thing per item is a list. Use the list elements from earlier in the chapter and lay them out with a grid.
The canonical accessible invoice table
Section titled “The canonical accessible invoice table”Once the decision says “table”, there’s one shape a 2026 SaaS reaches for, and it folds in every thread this chapter has been pulling. Learn the anatomy as a labeled structure:
<table>is the container.<caption>is the table’s name. It’s announced first, before the data, so the screen reader user knows what they’ve landed in. It goes directly inside<table>, before everything else.<thead>holds the header row. Inside it, each column header is a<th scope="col">.<tbody>holds the data rows. In each row, the cell that identifies the row, the invoice number, is a<th scope="row">, and the rest are plain<td>.<tfoot>is the footer row. It’s rare in a dashboard but common on an actual invoice, where it’s where the “Total” row lives.
(You may also run into multiple <tbody> sections and <colgroup>/<col> for grouping and styling columns. Recognize them; you won’t need them today.)
The accessibility hinges almost entirely on one attribute: scope . It does real work here, not decoration. scope="col" and scope="row" are what wire each data cell to its headers, so a screen reader reads a cell as “Amount: $200” instead of a bare “$200” with no idea which column it came from. Without scope, the table is a grid of disconnected numbers to anyone not looking at it. With it, and with the <caption> for the name, the screen reader announces the table on entry (“table, four columns, fifty rows”), names it, and ties every value to its header.
Now watch the chapter’s threads land in the same element. The walkthrough below builds the complete table from a .map, and each step calls out where an earlier idea pays off.
<table> <caption>Recent invoices</caption> <thead> <tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" className="text-right">Amount</th> </tr> </thead> <tbody> {invoices.map((invoice) => ( <tr key={invoice.id} data-invoice-id={invoice.id}> <th scope="row">{invoice.number}</th> <td>{invoice.client}</td> <td>{invoice.status}</td> <td className="text-right">{invoice.amount ?? '—'}</td> </tr> ))} </tbody></table>The <caption> is the table’s accessible name, announced first so the user knows what they’ve landed in. There’s no aria-label on the table, because the caption already names it (element first). Each <th scope="col"> in the <thead> labels its column.
<table> <caption>Recent invoices</caption> <thead> <tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" className="text-right">Amount</th> </tr> </thead> <tbody> {invoices.map((invoice) => ( <tr key={invoice.id} data-invoice-id={invoice.id}> <th scope="row">{invoice.number}</th> <td>{invoice.client}</td> <td>{invoice.status}</td> <td className="text-right">{invoice.amount ?? '—'}</td> </tr> ))} </tbody></table>Here the rows come from a .map over the data. key={invoice.id} lets React’s reconciler track each row by identity. That’s the keys rule from the JSX lesson: stable, tied to the data, never the array index.
<table> <caption>Recent invoices</caption> <thead> <tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" className="text-right">Amount</th> </tr> </thead> <tbody> {invoices.map((invoice) => ( <tr key={invoice.id} data-invoice-id={invoice.id}> <th scope="row">{invoice.number}</th> <td>{invoice.client}</td> <td>{invoice.status}</td> <td className="text-right">{invoice.amount ?? '—'}</td> </tr> ))} </tbody></table>The invoice number is the cell that identifies the row, so it’s a <th scope="row">, not a <td>. That’s what lets a screen reader say “row INV-1029” as it reads across.
<table> <caption>Recent invoices</caption> <thead> <tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" className="text-right">Amount</th> </tr> </thead> <tbody> {invoices.map((invoice) => ( <tr key={invoice.id} data-invoice-id={invoice.id}> <th scope="row">{invoice.number}</th> <td>{invoice.client}</td> <td>{invoice.status}</td> <td className="text-right">{invoice.amount ?? '—'}</td> </tr> ))} </tbody></table>data-invoice-id is this lesson’s delegation hook: one listener on the table can find which invoice a click hit. The numeric Amount column gets text-right (one deliberate Tailwind utility, since numbers read better right-aligned). Note the split of duties: data-invoice-id is the delegation hook, while the HTML id attribute is reserved for unique page identifiers like link and label targets. Don’t conflate the two.
<table> <caption>Recent invoices</caption> <thead> <tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Status</th> <th scope="col" className="text-right">Amount</th> </tr> </thead> <tbody> {invoices.map((invoice) => ( <tr key={invoice.id} data-invoice-id={invoice.id}> <th scope="row">{invoice.number}</th> <td>{invoice.client}</td> <td>{invoice.status}</td> <td className="text-right">{invoice.amount ?? '—'}</td> </tr> ))} </tbody></table>This is the empty-cell convention. When a value is missing, render an explicit em-dash character rather than leaving the cell blank, so “no amount” reads as a deliberate value rather than a hole the screen reader skips silently.
Keeping tables usable on small screens
Section titled “Keeping tables usable on small screens”A table with six columns does not fit a phone, and this is exactly where beginners reach for a fix that quietly undoes the accessibility you just built. The rest of this section covers the safe approaches and the one move to avoid.
The safe default is to let the table scroll sideways. Wrap it in a horizontally scrollable container, and the columns that don’t fit slide into view on swipe:
<div className="overflow-x-auto"> <table>{/* … */}</table></div>(That overflow-x-auto is one deliberate Tailwind utility, and it’s what makes the wrapper scroll.) For longer tables, the <thead> can stay pinned to the top while the body scrolls underneath with sticky top-0. And for genuinely complex tables, the more involved option is to switch to a card-per-row layout below a phone-sized breakpoint, one card per record instead of a cramped row, using Tailwind’s responsive variants, which you’ll meet later.
Here’s the move to avoid, and the reason. To force a table into a vertical stack on mobile, it’s tempting to set display: block on the <tr> and <td> elements. Don’t. The moment a table row stops being laid out as a table row, the browser stops treating it as one in the accessibility tree: the “table, fifty rows” announcement vanishes, and so does every header-to-cell association scope bought you. You’d have made the table look fine for the eye and gone completely silent for the other reader. That’s the whole spine of this lesson in one bug: the layout you give the eye must never cut off the reader who can’t see it.
Build it: the accessible invoice table
Section titled “Build it: the accessible invoice table”Now produce the canonical shape yourself. The starter below has an Acme invoice table that’s half right: the table renders, but the accessibility scaffolding is missing or wrong. Your job is to fix it into the shape from the walkthrough. The grade is on the markup, not the look, so there’s no preview, only the checks.
This invoice table renders but isn't accessible yet. Fix it into the canonical shape: add a <caption> naming the table; make each header cell a <th scope="col"> instead of a <td>; make each row's invoice-number cell a <th scope="row">; key each row on invoice.id; and add a data-invoice-id delegation hook to each row.
Reveal the accessible table
export function App() { return ( <table> <caption>Recent invoices</caption> <thead> <tr> <th scope="col">Invoice</th> <th scope="col">Client</th> <th scope="col">Amount</th> </tr> </thead> <tbody> {invoices.map((invoice) => ( <tr key={invoice.id} data-invoice-id={invoice.id}> <th scope="row">{invoice.number}</th> <td>{invoice.client}</td> <td>{invoice.amount}</td> </tr> ))} </tbody> </table> );}The <caption> gives the table its accessible name, announced on entry, with no aria-label needed because the caption already names it (element first). Swapping the <thead> cells to <th scope="col"> and each row’s first cell to <th scope="row"> is what wires every data cell to its headers, so a screen reader reads “Amount: $200” instead of a bare “$200”. The key={invoice.id} lets React’s reconciler track each row by identity, and data-invoice-id={invoice.id} is the delegation hook, so one listener on the table can read which invoice a click hit. Note that the key isn’t visible in the rendered DOM, so the checks can’t see it directly. It’s still required, and it’s the one piece the automated tests trust you to add.
That artifact integrates three chapters’ worth of decisions in one place: stable keys from the JSX lesson, this lesson’s data-* delegation hook, and table accessibility, all on the same handful of rows. If you can produce it from memory, you’ve got the chapter’s HTML-semantics arc.
Where this leaves you
Section titled “Where this leaves you”Three surfaces come down to one question. When you reach for a custom attribute now, the first thing to ask isn’t what’s it called, it’s who reads it. If it’s your own scripts, it’s data-* with the kebab-to-camel dataset rule. If it’s assistive tech, it’s aria-*: element first, the minimum patch second, and role almost never. If it’s the browser’s layout-and-accessibility engine, the question comes before any tag: is the data genuinely a grid of records? If it is, a <caption>, <th scope>, keys, and a data-* hook make it the shape a 2026 SaaS ships. The deeper ARIA surface, live regions in full, and form-error wiring all come back later in the course, and you’ve already installed the reflexes they build on.
External resources
Section titled “External resources”The canonical references for the three surfaces in this lesson, worth a bookmark for when you need the parts the lesson left out:
MDN's data-* guide: the dataset round trip, plus CSS attribute selectors that style off the same attribute.
MDN's ARIA overview: element-first, the states and properties you'll reach for, and why bad ARIA outranks none.
MDN walks scope, caption, and the thead/tbody/tfoot structure that wires every cell to its header.
W3C WAI's step-by-step build of accessible tables, from one-header grids up to the complex multi-header cases.