Skip to content
Chapter 17Lesson 6

data-*, aria-*, and the table decision

HTML and JSX attributes chosen by who reads them: data-* for your scripts, aria-* for assistive tech, and a real table when the data is tabular.

Picture one screen from Acme, the invoicing app you’ve marked up all chapter: an audit log. Each row records something that happened to an invoice and ends in a wordless trash icon that deletes it; a button above toggles newest-first or oldest-first.

Four ordinary pieces of that screen each hide a decision. The icon-only delete button has to know which invoice it belongs to. The sort toggle has a direction that only the visible arrow records. The log is a grid of rows and columns. And a screen reader user, who never sees the trash glyph, still needs to know the button deletes an invoice.

Each is markup you write for a reader who isn’t the person looking at the screen: your own JavaScript, a screen reader, or the browser’s layout and accessibility engine. So keep asking one question: who reads this attribute? There are three answers, and a tool for each.

Who reads this markup?
Your scripts the JavaScript you wrote, your tests, your analytics data-*
Assistive tech screen readers and other tools that read the page aloud or in braille aria-*
The browser engine layout, and the accessibility tree it builds from your tags <table> and its parts
Same screen, three audiences. Pick the tool by the audience.

We’ll cover the daily-use part of each, in that order, plus the judgment to pick.

Custom attributes your scripts read: data-*

Section titled “Custom attributes your scripts read: data-*”

Any attribute whose name starts with data- is valid HTML. The browser stores it on the element and hands it back to you, but never renders it, styles off it, or gives it meaning. That last point is the whole reason the tool exists: a data-invoice-id means whatever your code decides. Unlike href or type, where behavior is baked in, data-* is a private channel from your markup to your own code, with a guarantee that no one but you reads it.

You read data-* through the dataset object from chapter 14: every element exposes one holding its data-* attributes. The one rule is the spelling. You write the attribute in kebab-case and read it back in camelCase, so data-invoice-id becomes dataset.invoiceId; the DOM converts both directions for you.

In JSX, data-* and aria-* pass straight through unchanged. Elsewhere JSX renames things (class to className, for to htmlFor), but these keep their plain-HTML spelling, so what you write and what the browser receives are identical. Hover the two highlighted attributes to see the round trip.

<li data-invoice-id={invoice.id} data-status={invoice.status}>
{invoice.summary}
</li>

What comes back is always a string. The DOM stores attributes as text, so data-count={5} round-trips as dataset.count === "5", not 5, and you parse it yourself.

The model is simple, so the real skill is knowing when to use data-* rather than invent your own attribute. Each use below belongs to a later chapter; for now, learn to recognize the set.

The load-bearing use is event delegation, from chapter 14. Instead of one click handler per row (fifty rows, fifty listeners), you attach one listener to the container and let clicks bubble up. When it fires you ask which element the click started on and what should happen, and data-* carries both answers.

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);
}
}

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

  • Test selectors. Tools like Playwright can find an element by a data-testid you add for the purpose. Treat it as the fallback: a good test first finds things the way a user would, by the button labelled Delete invoice, and reaches for data-testid only when there’s no accessible handle.
  • Analytics hooks. A delegated listener can read a data-event-name off whatever was clicked and fire a tracking event, instead of wiring analytics into every handler by hand.
  • Tailwind state variants. Tailwind can style off a data-* attribute: data-[state=open]:rotate-180 rotates a chevron when its element carries data-state="open". shadcn and Radix components set data-state all over their output for exactly this.

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 never carries meaning for a screen reader (that’s the next section, and a costly mix-up). Second, the data- prefix is mandatory: a bare <div rowid="42"> is not valid HTML and won’t appear in dataset at all, so the round trip quietly breaks.

data-* was mechanical: learn the prefix, learn the camelCase rule, done. ARIA is not. The attribute names are easy; 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”

No ARIA is better than bad ARIA. A wrong attribute doesn’t fail loudly like a syntax error; it quietly lies to a screen reader, and the only person who finds out is the user you were trying to help.

Semantic elements populate the accessibility tree for free: a <button> enters as a button, a <nav> as a navigation landmark, an <input> wired to a <label> as a named field. ARIA exists for two jobs. It fills gaps the native element can’t express, a state, name, or relationship the HTML has no built-in way to convey. And it can override an element’s defaults, which is where it goes wrong: overriding a correct default replaces something true with something you now have to keep true by hand.

So ask in order:

  1. Is there a semantic element that already conveys this? Use it, and you’re done.
  2. If not, is there an aria-* attribute that adds the missing name, state, or relationship? Add the minimum.
  3. Changing what an element fundamentally is with a role is the last resort, and in 2026 rarely right.

Most beginner ARIA mistakes skip step 1: putting role and aria-* on a <div> to rebuild a button that <button> would have handed over complete.

The attributes you’ll reach for most weeks

Section titled “The attributes you’ll reach for most weeks”

Naming a control with no visible text. The trash button in your audit log is just an icon, so a screen reader has nothing to announce. Give it a name:

<button aria-label="Delete invoice" onClick={() => deleteInvoice(invoice.id)}>
<TrashIcon />
</button>

aria-label supplies the accessible name as a literal string, and overrides any visible text. That’s the trap: on a button reading “Save”, aria-label="Submit" makes the screen reader say “Submit” while the eye reads “Save”. Only label controls with no text.

When the name 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, so changing the heading text changes the accessible name with it. When a visible label exists, prefer this over aria-label.

Describing an element. Where aria-labelledby supplies the name, aria-describedby attaches secondary text: a longer explanation, hint, or 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, which you’ll wire fully, alongside aria-invalid and the alert role, when you reach forms and validation.

State the eye can see but the tree can’t. Whether a nav item is active, a panel open, or a toggle pressed is invisible to a screen reader unless you say so.

  • aria-current="page" marks the one nav link pointing at the current page: the highlighted item in the Acme sidebar. (Other values like step or date cover wizards and calendars.)
  • aria-expanded plus aria-controls belong on a disclosure . aria-expanded="true|false" announces open/closed, and aria-controls="panel-id" names the panel it governs.
  • aria-pressed="true|false" marks a toggle button, like the sort-direction toggle from the top of this lesson: it tells the tree the button is a two-state switch and which state it’s in. (For a checkbox or link, use those elements instead.)
<button aria-pressed={isDescending} onClick={toggleSort}>
Sort
</button>

Hiding the decorative. aria-hidden="true" removes an element from the tree. The textbook case is a decorative icon next to visible text, like a chevron beside “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>

That settles the two icon cases. Icon with text? aria-hidden the icon; the text is already the name. Icon alone? aria-label the button; the icon can’t speak for itself.

The trap: never put aria-hidden="true" on a focusable or interactive element. You’d create a control a keyboard user can still Tab to but that’s erased from the tree, so the screen reader has nothing to announce when they land on it. 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, since step 1 almost always hands you a better starting element. The one family that earns its place is live regions, covered next. (You might see role="dialog" in the wild, but even that loses to the native <dialog> element and 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”

Naming this one for recognition; the depth comes in a later lesson. Everything above is static markup, present when the page loads. But a web app constantly produces content after load: a “Saved” toast slides in, a validation message appears, a result count updates. A sighted user sees these; a screen reader stays silent, because 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 once the user is idle, without interrupting. Use it for toasts and status messages.
  • aria-live="assertive" interrupts immediately. Reserve it for genuinely critical messages.
  • role="alert" is shorthand for an assertive live region, and the standard reach for an inline form error.

The trap: the region must already exist in the DOM before the content drops in. A region the browser has been watching announces the change; one that appears alongside its content gives the browser nothing to compare against, so the announcement is missed. You’ll wire these up later; for now, recognize the term and the trap.

A slice of the Acme invoices toolbar that earns several of these at once. Walk it one step at a time.

<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 already links, the <button>s already buttons. The 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, so we name it. This is the case held back from “Buttons, links, and lists”: 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, since the page has more than one and each needs a distinguishing name, and mark which filter link is the current page.

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

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 stops 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 with aria-describedby. That’s form-validation wiring for later, and it’d hang off the <input>, but it’s the same “point one element at another.”

1 / 1

ARIA is a thin layer on top of markup that’s already mostly correct. If you’re adding a lot of it, step back: you’ve probably skipped step 1 and are rebuilding something an element would have given you.

You’ve met both families written for a non-sighted reader: data-* for your scripts, aria-* for assistive tech. The expensive mistake is crossing them. 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-* My own scripts read this
aria-* Assistive tech reads this
data-testid
aria-label
The row id a delegated click handler needs
The accessible name of an icon-only button
data-state driving a Tailwind variant
aria-current for the active nav link
An analytics event name
aria-describedby pointing at a field’s error

Which of these are genuine ARIA bugs — markup that would mislead or trap a screen reader user? Select all that apply.

A button reading Export carries aria-label="Download CSV".
An icon-only download button — no text inside — carries aria-label="Download CSV".
A <a href="/help"> that the user can still Tab to carries aria-hidden="true".
A small spinner glyph rendered beside the word Saving… carries aria-hidden="true".

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, where keys, data-*, and ARIA all converge. The decision of whether to use it at all carries most of the value.

Don’t reach for <table> on a hunch, and don’t avoid it because it feels old-fashioned, building real tabular data out of <div>s and losing the structure the element gives the accessibility tree. Use a positive test. Tabular data is rows and columns of related records, each cell indexed by (row, column): every row is the same kind of thing, every column the same attribute across the rows.

A sharper check is the transpose : would swapping rows and columns give a different but still meaningful view of the same data? A billing breakdown reads sensibly either way; a page layout or a list of cards becomes nonsense. If the transpose is meaningful, it’s a table.

On the Acme surface the yes cases are grids of like records: the audit log, an invoice’s line items, a billing breakdown, a metrics grid. Each no case has a better element:

  • A page or section layout: CSS grid, coming soon.
  • A list of cards, one product or teammate per tile: a <ul> and a grid, from earlier in the chapter.
  • A form’s two-column fields: a <form> plus CSS grid.

A table is for data you’d compare across rows, never for positioning boxes on the page. The decision tree walks that judgment in order.

Should this be a <table>?

Once the decision says “table”, a 2026 SaaS reaches for one shape that folds in every thread this chapter has pulled:

  • <table> is the container.
  • <caption> is the table’s name, announced first so the screen reader user knows what they’ve landed in. It goes directly inside <table>, before everything else.
  • <thead> holds the header row, each column header a <th scope="col">.
  • <tbody> holds the data rows. The cell that identifies each row, the invoice number, is a <th scope="row">; the rest are plain <td>.
  • <tfoot> is the footer row, where an invoice’s “Total” lives.

(You may also meet multiple <tbody> sections and <colgroup>/<col> for grouping and styling columns. Recognize them; you won’t need them today.)

The accessibility hinges on one attribute: scope . scope="col" and scope="row" wire each data cell to its headers, so a screen reader reads “Amount: $200” instead of a bare “$200”. Without it, the table is a grid of disconnected numbers to anyone not looking at it; with it, plus the <caption>, the screen reader announces the table on entry (“table, four columns, fifty rows”), names it, and ties every value to its header.

The walkthrough below builds the table from a .map, marking where each 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. No aria-label on the table, because the caption already names it. 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>

The rows come from a .map. key={invoice.id} lets React’s reconciler track each row by identity: the keys rule from “JSX as property syntax”, stable and 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 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 the delegation hook: one listener on the table can find which invoice a click hit. The numeric Amount column gets text-right, since numbers read better right-aligned. Keep the two attributes separate: data-invoice-id is the delegation hook, while the HTML id is reserved for unique page targets like links and labels.

<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 empty-cell convention: render an explicit em-dash for a missing value, so “no amount” reads as deliberate rather than a hole the screen reader skips silently.

1 / 1

A six-column table won’t fit a phone. The safe default is to let it scroll sideways, wrapping it in a horizontally scrollable container so off-screen columns slide in on swipe:

<div className="overflow-x-auto">
<table>{/* … */}</table>
</div>

For long tables, pin the <thead> with sticky top-0. For complex ones, swap to a card-per-row layout below a phone breakpoint using Tailwind’s responsive variants, which you’ll meet later.

It’s tempting to force a vertical stack by setting display: block on <tr> and <td>. Don’t: the moment a row stops being laid out as a table row, the browser drops it from the accessibility tree. The “table, fifty rows” announcement and every header-to-cell association scope bought you go silent, while the table still looks fine to the eye.

The starter renders an Acme invoice table that’s half right; fix its markup into the shape from the walkthrough.

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.

Preview
    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, so no aria-label is needed. The scope="col" and scope="row" headers wire each data cell to its headers, so a screen reader reads “Amount: $200” instead of a bare “$200”. The key={invoice.id} tracks each row by identity, and data-invoice-id={invoice.id} is the delegation hook one table-level listener reads on a click. The key never reaches the DOM, so the checks can’t see it, but it’s still required.

    Before reaching for a custom attribute, ask who reads it. Your own scripts mean data-*, assistive tech means aria-*, and the browser’s layout-and-accessibility engine means a real <table> when the data is a grid of records.

    The canonical references for the three surfaces in this lesson, worth a bookmark for the parts the lesson left out: