# Seat maps keyboards can't use and screen readers can't read

Source: https://easeweb.dev/learn/seat-maps
Topics: Complex widgets > Seat maps let people choose a seat with a keyboard and hear its row and number (WCAG 1.4.1, 2.1.1, 4.1.2)
The fix: Either make each seat a native checkbox drawn as a seat, named by its row, number and price, with taken seats disabled; or, for a large venue, put the seats in a grid with one Tab stop and the arrow keys, each seat a button with aria-pressed, and aria-disabled when taken. In both, draw a tick on chosen seats and a cross on taken ones, as well as their colours.
Test with: keyboard, screen reader, colour vision, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/ARIA/apg/patterns/grid/ https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/ https://www.w3.org/WAI/ARIA/apg/patterns/button/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A cinema's booking page shows the seats for one screening as a map: four rows, A to D, six seats in each, under a bar marked Screen. Free seats are green and taken seats red, and a click turns a free seat blue, chosen. A key above the map names the three colours, and a line says that rows A and B cost $10 and rows C and D $14. With a mouse, choosing two seats side by side in row C takes two clicks.

Put the mouse aside and press Tab. Focus goes from the top of the page straight to Continue, past every seat: no seat can be chosen. A screen reader reading the page hears "Screen", then "A", "B", "C" and "D", the row letters, and nothing between them, because the seats are empty divs. And someone who sees red and green as one colour sees a map of seats that look alike: row C's free seats 1, 2, 5 and 6 are the same shade as its taken seats 3 and 4.

The team then turns each seat into a button. Tab now stops on every seat, but a screen reader hears "button" on each of the 24, with nothing to say which row a seat is in, its number or its price. Taken seats are buttons too, and pressing one does nothing, with nothing said. When a seat is chosen, only its colour changes, so the screen reader says "button" again, as it did before.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything a mouse can do, a keyboard can do. Here the only way to choose a seat is to click a div, and a div takes no focus and answers no key, so no seat can be chosen without a pointer.

Success criterion 4.1.2 Name, Role, Value asks that every control has a name and a role, and that its state is exposed to assistive technology. A seat is a control: it can be chosen, and it is free, taken or chosen. The divs have no role and no name, so a screen reader passes over them as if nothing were there. The buttons have a role, but no text and no name. In both, the states are only the classes `taken` and `chosen` and their colours. Browsers build what assistive technology reads from elements and attributes, not from class names or background colours, so the state never reaches it.

Success criterion 1.4.1 Use of Color asks that colour is not the only way information is shown. Each seat has only its fill to say whether it is free, taken or chosen: no mark, shape or word. The key above the map has words, but they are matched to the seats by colour alone. The green, the red and the blue are within 1.46:1 of each other in lightness, the red and the blue only 1.11:1, so without their hues they look nearly the same, and to someone with the common red–green colour vision deficiencies, the green and the red look nearly the same.

## Who is affected

Cinemas, theatres, concert halls, stadiums, airlines and trains put a map like this in the middle of buying a ticket. When the map is the only way to choose, a seat that can't be chosen is a ticket that can't be bought. Some sites offer a Best available button beside the map, but it chooses for people: they can't ask for an aisle seat, a seat beside the person they came with, or the row they always sit in.

## What automation and AI miss

The empty divs have no role, so axe has nothing to check in the first map, and its colour contrast rule looks at text, not at what a fill colour means. axe does report the empty buttons of the second map, as `button-name`: each has no text and no name. It can't tell that a button is a seat, or that its classes say it is taken or chosen while its markup says nothing.

The audit looks for seats by their class names, `seat` and its common variants, in divs, spans and SVG shapes. A seat map where Tab reaches no seat, nor anything else in the map, is reported, and so is one whose seats can be reached but whose class says taken or chosen while the seat has no `disabled`, `aria-disabled`, `checked`, `aria-pressed`, `aria-checked` or `aria-selected` to say so. Every seat map that Tab reaches goes to review, so a person checks that each name gives the row and the number, and that the states show without colour, and so does a `canvas` whose id or class names it a seat map. A map drawn from shapes with no telling class names is not found, and no check can tell whether a seat's name makes sense to a listener.

Widely copied seat booking tutorials are built the first way, an empty div per seat and one click listener on the map. An AI assistant asked to make one accessible can change the divs to buttons and stop there, which is the second map, or give every seat the same name, such as `aria-label="Seat"`.

## Before: seats as divs that take a click

```html
<h2>Choose your seats</h2>
<p>Rows A–B $10 · Rows C–D $14</p>
<ul class="legend">
  <li>
    <span class="key"></span>
    Free
  </li>
  <li>
    <span class="key chosen"></span>
    Chosen
  </li>
  <li>
    <span class="key taken"></span>
    Taken
  </li>
</ul>
<div class="seat-map">
  <div class="screen">Screen</div>
  …
  <div class="row">
    <span class="row-name">C</span>
    <div class="seat"></div>
    <div class="seat"></div>
    <div class="seat taken"></div>
    <div class="seat taken"></div>
    <div class="seat"></div>
    <div class="seat"></div>
  </div>
  …
</div>
<button type="button" class="continue">Continue</button>
```

```css
.seat-map .row {
  display: flex;
  gap: 8px;
}
/* A seat, and its key in the legend */
.seat,
.key {
  display: inline-block;
  inline-size: 28px;
  block-size: 24px;
  padding: 0;
  border: 2px solid #2f9e44;
  border-radius: 8px 8px 3px 3px;
  background: #2f9e44;
}
.seat {
  cursor: pointer;
}
.seat.chosen,
.key.chosen {
  border-color: #1971c2;
  background: #1971c2;
}
.seat.taken,
.key.taken {
  border-color: #e03131;
  background: #e03131;
}
```

```js
document.querySelector('.seat-map').addEventListener('click', (event) => {
  const seat = event.target.closest('.seat');
  if (seat && !seat.classList.contains('taken')) {
    seat.classList.toggle('chosen');
  }
});
```

- The seats answer only `click` and take no focus, so Tab goes past the map and no seat can be chosen from the keyboard.
- Each seat is an empty div, so a screen reader reads the row letters and nothing between them.
- Free, taken and chosen are classes, so nothing tells assistive technology which is which.
- On screen, the fill colour is the only difference between them.

## Before: the same seats as buttons

```html
<h2>Choose your seats</h2>
<p>Rows A–B $10 · Rows C–D $14</p>
<ul class="legend">
  <li>
    <span class="key"></span>
    Free
  </li>
  <li>
    <span class="key chosen"></span>
    Chosen
  </li>
  <li>
    <span class="key taken"></span>
    Taken
  </li>
</ul>
<div class="seat-map">
  <div class="screen">Screen</div>
  …
  <div class="row">
    <span class="row-name">C</span>
    <button type="button" class="seat"></button>
    <button type="button" class="seat"></button>
    <button type="button" class="seat taken"></button>
    <button type="button" class="seat taken"></button>
    <button type="button" class="seat"></button>
    <button type="button" class="seat"></button>
  </div>
  …
</div>
<button type="button" class="continue">Continue</button>
```

```css
.seat-map .row {
  display: flex;
  gap: 8px;
}
/* A seat, and its key in the legend */
.seat,
.key {
  display: inline-block;
  inline-size: 28px;
  block-size: 24px;
  padding: 0;
  border: 2px solid #2f9e44;
  border-radius: 8px 8px 3px 3px;
  background: #2f9e44;
}
.seat {
  cursor: pointer;
}
.seat.chosen,
.key.chosen {
  border-color: #1971c2;
  background: #1971c2;
}
.seat.taken,
.key.taken {
  border-color: #e03131;
  background: #e03131;
}
```

```js
document.querySelector('.seat-map').addEventListener('click', (event) => {
  const seat = event.target.closest('.seat');
  if (seat && !seat.classList.contains('taken')) {
    seat.classList.toggle('chosen');
  }
});
```

The CSS and the script are the ones above; only the elements have changed. Tab and Enter now work, but:

- Every seat is announced only as "button", with no row, number or price.
- A taken seat is a button like the others, and pressing it does nothing, with nothing said.
- Choosing a seat changes only its class and colour, so it is announced just as it was.
- On screen, the fill colour is still the only difference.

## After: a checkbox for each seat

```html
<h2>Choose your seats</h2>
<p>Rows A–B $10 · Rows C–D $14</p>
<ul class="legend">
  <li>
    <span class="key"></span>
    Free
  </li>
  <li>
    <span class="key chosen"></span>
    Chosen
  </li>
  <li>
    <span class="key taken"></span>
    Taken
  </li>
</ul>
<div class="seat-map">
  <div class="screen">Screen</div>
  …
  <div class="row">
    <span class="row-name">C</span>
    <label class="seat">
      <input type="checkbox" name="seats" value="C1">
      <span class="visually-hidden">Row C, seat 1, $14</span>
    </label>
    <label class="seat">
      <input type="checkbox" name="seats" value="C2">
      <span class="visually-hidden">Row C, seat 2, $14</span>
    </label>
    <label class="seat">
      <input type="checkbox" name="seats" value="C3" disabled>
      <span class="visually-hidden">Row C, seat 3, $14</span>
    </label>
    <label class="seat">
      <input type="checkbox" name="seats" value="C4" disabled>
      <span class="visually-hidden">Row C, seat 4, $14</span>
    </label>
    <label class="seat">
      <input type="checkbox" name="seats" value="C5">
      <span class="visually-hidden">Row C, seat 5, $14</span>
    </label>
    <label class="seat">
      <input type="checkbox" name="seats" value="C6">
      <span class="visually-hidden">Row C, seat 6, $14</span>
    </label>
  </div>
  …
</div>
<button type="button" class="continue">Continue</button>
```

```css
.seat-map .row {
  display: flex;
  gap: 8px;
}
/* A seat, drawn on its checkbox, and its key in the legend */
.seat input,
.key {
  display: inline-grid;
  place-content: center;
  inline-size: 28px;
  block-size: 24px;
  padding: 0;
  border: 2px solid #2f9e44;
  border-radius: 8px 8px 3px 3px;
  background: #2f9e44;
}
.seat input {
  appearance: none;
  margin: 0;
  cursor: pointer;
}
/* Chosen: the fill and a tick, from the checkbox's own state */
.seat input:checked,
.key.chosen {
  border-color: #1971c2;
  background: #1971c2;
}
.seat input:checked::before,
.key.chosen::before {
  content: "";
  inline-size: 6px;
  block-size: 11px;
  border: solid #fff;
  border-width: 0 3px 3px 0;
  rotate: 45deg;
}
/* Taken: the fill and a cross of two borders, which a contrast theme keeps */
.seat input:disabled,
.key.taken {
  border-color: #e03131;
  background: #e03131;
  cursor: default;
}
.seat input:disabled::before,
.seat input:disabled::after,
.key.taken::before,
.key.taken::after {
  content: "";
  grid-area: 1 / 1;
  block-size: 14px;
  border-left: 3px solid #fff;
  rotate: 45deg;
}
.seat input:disabled::after,
.key.taken::after {
  rotate: -45deg;
}
.seat input:focus-visible {
  outline: 3px solid #1a1a1a;
  outline-offset: 2px;
}
```

1. Make each seat a checkbox inside a label, with hidden text that gives its row, number and price. Tab stops on it, Space chooses it, and a screen reader says "Row C, seat 5, $14, checkbox, not checked".
2. Disable the checkbox of a taken seat. Tab passes it, and a screen reader reading through the map announces it as unavailable or dimmed.
3. Draw the seat on the checkbox itself with `appearance: none`, and its colour from `:checked` and `:disabled`, so the look can't drift from what is announced.
4. Add a tick to a chosen seat and a cross to a taken one, in the seats and in the key, drawn with borders so that they also show in a Windows contrast theme.
5. Delete the script. The checkbox keeps its own state, and inside a form, the values of the chosen seats are sent with no code.

This fixes both Befores and passes 1.4.1, 2.1.1 and 4.1.2. Every free seat is a Tab stop, in reading order, row by row.

## After: a grid of seats with the arrow keys

```html
<h2>Choose your seats</h2>
<p>Rows A–B $10 · Rows C–D $14</p>
<ul class="legend">
  <li>
    <span class="key"></span>
    Free
  </li>
  <li>
    <span class="key chosen"></span>
    Chosen
  </li>
  <li>
    <span class="key taken"></span>
    Taken
  </li>
</ul>
<div class="seat-map">
  <div class="screen">Screen</div>
  <table role="grid" aria-label="Seats">
    …
    <tr>
      <th scope="row">C</th>
      <td>
        <button type="button" class="seat" aria-pressed="false">
          <span class="visually-hidden">Row C, seat 1, $14</span>
        </button>
      </td>
      <td>
        <button type="button" class="seat" aria-pressed="false">
          <span class="visually-hidden">Row C, seat 2, $14</span>
        </button>
      </td>
      <td>
        <button type="button" class="seat" aria-pressed="false" aria-disabled="true">
          <span class="visually-hidden">Row C, seat 3, $14</span>
        </button>
      </td>
      <td>
        <button type="button" class="seat" aria-pressed="false" aria-disabled="true">
          <span class="visually-hidden">Row C, seat 4, $14</span>
        </button>
      </td>
      <td>
        <button type="button" class="seat" aria-pressed="false">
          <span class="visually-hidden">Row C, seat 5, $14</span>
        </button>
      </td>
      <td>
        <button type="button" class="seat" aria-pressed="false">
          <span class="visually-hidden">Row C, seat 6, $14</span>
        </button>
      </td>
    </tr>
    …
  </table>
</div>
<button type="button" class="continue">Continue</button>
```

```css
.seat-map table {
  border-spacing: 8px 4px;
}
.seat-map th {
  font-weight: normal;
}
/* A seat, and its key in the legend */
.seat,
.key {
  display: inline-grid;
  place-content: center;
  inline-size: 28px;
  block-size: 24px;
  padding: 0;
  border: 2px solid #2f9e44;
  border-radius: 8px 8px 3px 3px;
  background: #2f9e44;
}
.seat {
  cursor: pointer;
}
/* Chosen: the fill and a tick, from aria-pressed */
.seat[aria-pressed="true"],
.key.chosen {
  border-color: #1971c2;
  background: #1971c2;
}
.seat[aria-pressed="true"]::before,
.key.chosen::before {
  content: "";
  inline-size: 6px;
  block-size: 11px;
  border: solid #fff;
  border-width: 0 3px 3px 0;
  rotate: 45deg;
}
/* Taken: the fill and a cross of two borders, from aria-disabled */
.seat[aria-disabled="true"],
.key.taken {
  border-color: #e03131;
  background: #e03131;
  cursor: default;
}
.seat[aria-disabled="true"]::before,
.seat[aria-disabled="true"]::after,
.key.taken::before,
.key.taken::after {
  content: "";
  grid-area: 1 / 1;
  block-size: 14px;
  border-left: 3px solid #fff;
  rotate: 45deg;
}
.seat[aria-disabled="true"]::after,
.key.taken::after {
  rotate: -45deg;
}
.seat:focus-visible {
  outline: 3px solid #1a1a1a;
  outline-offset: 2px;
}
```

```js
const grid = document.querySelector('.seat-map [role="grid"]');
const rows = [...grid.rows].map((row) => [...row.querySelectorAll('.seat')]);
const seats = rows.flat();
// One Tab stop: the seat last moved to, and the first seat to begin with.
function moveTo(seat, focus = true) {
  for (const other of seats) other.tabIndex = other === seat ? 0 : -1;
  if (focus) seat.focus();
}
moveTo(seats[0], false);
grid.addEventListener('click', (event) => {
  const seat = event.target.closest('.seat');
  if (!seat) return;
  moveTo(seat);
  // A taken seat stays in the grid, so the arrow keys pass over it, but it can't be chosen.
  if (seat.getAttribute('aria-disabled') === 'true') return;
  const chosen = seat.getAttribute('aria-pressed') === 'true';
  seat.setAttribute('aria-pressed', String(!chosen));
});
grid.addEventListener('keydown', (event) => {
  const seat = event.target.closest('.seat');
  if (!seat) return;
  const r = rows.findIndex((row) => row.includes(seat));
  const c = rows[r].indexOf(seat);
  const next = {
    ArrowRight: rows[r][c + 1],
    ArrowLeft: rows[r][c - 1],
    ArrowDown: rows[r + 1]?.[c],
    ArrowUp: rows[r - 1]?.[c],
    Home: rows[r][0],
    End: rows[r].at(-1)
  };
  if (!(event.key in next)) return;
  event.preventDefault();
  if (next[event.key]) moveTo(next[event.key]);
});
```

1. Put the rows in a table with `role="grid"` and a name, and each row's letter in a row header.
2. Make each seat a button with hidden text that gives its row, number and price, and `aria-pressed` for whether it is chosen. Enter and Space press it, and a screen reader says "Row C, seat 5, $14, toggle button, not pressed".
3. Give a taken seat `aria-disabled="true"`, and have the script refuse it. It stays in the grid, so the arrow keys move across it as across the room, and a screen reader announces it as unavailable or dimmed.
4. Make the grid one Tab stop: `moveTo()` gives `tabindex="0"` to the seat last moved to, and `-1` to the rest, so one Tab enters the map and the next leaves it.
5. Move with the arrow keys as the ARIA Authoring Practices grid describes: Left and Right along a row, Up and Down to the same seat in the next row, Home and End to the ends of the row.
6. Draw the colours, the tick and the cross from `aria-pressed` and `aria-disabled`, not from classes.

This fixes both Befores and passes 1.4.1, 2.1.1 and 4.1.2, but only as far as the script does: every key has to work, and the Tab stop has to follow the focus. Screen readers handle grids differently, and a row header is not always read as focus moves into a new row, which is why each seat's name gives its row too. Test it with each screen reader you support.

## Which option to use

Both pass. For a small room, such as this cinema screen, use the checkboxes: no script and nothing to break, and inside a form the chosen seats are sent even before any JavaScript has loaded. People move through the free seats with Tab, row by row, as through any form. For a large venue, such as a theatre, an aircraft or a section of a stadium, where Tab through hundreds of seats would take minutes and people expect to move to the row behind, use the grid. It costs a script that has to handle every key, and a test with each screen reader, since support for grids varies.

## Implementation decisions

A seat's name is read on its own, as it takes focus, so "Seat 5" or "5" says too little once focus has moved down a row: give the row in every name. Add what changes the choice after the number, such as "aisle", "restricted view", "wheelchair space" or "extra legroom", and the price only where prices differ. The same goes for a seat that holds no seat at all, such as a space for a wheelchair: name it for what it is.

If the seats are already buttons, as in the second Before, the smallest change keeps them: give each the hidden text, `aria-pressed`, and `disabled` on taken seats, and draw the states from those attributes. That is the checkbox fix with a button in its place: every free seat a Tab stop, and a screen reader saying "toggle button" rather than "checkbox".

A large venue often opens with a map of its sections, then the seats of the one chosen. Each section needs the same treatment as a seat: a control that the keyboard reaches, named with its name and price range, and its "sold out" in words, not only in grey. A map that can be zoomed and dragged needs buttons for both, as [pinch, drag and tilt gestures with no button](/learn/gestures) explains. A seat map drawn on a `canvas` gives a screen reader nothing at all: draw the seats as elements over it, or offer a list of the free seats, row by row, beside it.

The seats themselves need a 3:1 contrast with what is around them, as [low-contrast form fields, icons and focus rings](/learn/non-text-contrast) explains; here the green is 3.45:1 on white, and the red and blue more. The marks are white on the red and the blue, at 4.51:1 and 5.02:1. Drawn with borders, the tick and the cross also show in a Windows contrast theme, which removes the fill colours; a mark drawn with a background gradient would disappear there. For other places where colour alone carries a state, such as order statuses, see [status dots told apart by colour alone](/learn/status-colours).

Many sites show the seats chosen, and their total, beside Continue: "Row C, seats 5 and 6, $28". That line helps everyone check the choice before paying. A screen reader hears it only when it reaches it, unless it is announced, as [add to cart buttons that change a badge and say nothing](/learn/action-confirmations) explains.

## Verify the fix

1. Put the mouse aside. Tab into the map, choose two free seats side by side in row C, and go on to Continue. In a grid, move between rows with the arrow keys, and check that one Tab leaves the map.
2. With a screen reader, move through the seats: each one says its row, its number and its price.
3. Choose a seat and listen: it is announced as checked or pressed. Move to a taken seat: it is announced as unavailable, and pressing it changes nothing.
4. View the map in greyscale and through a red–green simulation: free, taken and chosen seats can still be told apart, by the tick and the cross.
5. Turn on a Windows contrast theme: the outline of every seat, the tick, the cross and the focus ring all still show.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the seat map, its component or its library changes.

## Limits

This is a starting pattern, not a guarantee for every application. The grid's script is a minimal sketch: it has no Page Up and Page Down, and no way to jump to a row by its letter, which a large venue may need. NVDA has been reported to leave focus mode when the arrow keys move to a link in a grid cell, after which the arrows move its reading cursor instead; if it does the same with these buttons, NVDA users need Insert + Space to get the arrow keys back, so test the grid with NVDA in particular. A venue drawn on a street map, such as a list of cinemas, is covered in [maps that are the only way to find a location](/learn/interactive-maps). A grid whose cells are edited, rather than chosen, is covered in [editable data grids a keyboard can't move through or edit](/learn/editable-grids), and checkboxes drawn by hand in a form in [custom checkboxes that keyboards and screen readers skip](/learn/custom-checkboxes). Test the complete journey, from choosing a showing to paying, with your target assistive technology.

## What screen readers do (tested by hand by EaseWeb)

`aria-pressed`: Makes a button a toggle that is on or off.

| Screen reader | Browser and system | Press it | Tab to it |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`role="grid", a button in each cell`: A grid with one Tab stop whose cells each hold a button, such as the seats of a seat map, moved through by the page’s script with the arrow keys. A grid is a widget in ARIA, so the page, not the screen reader, is meant to handle the arrow keys inside it.

| Screen reader | Browser and system | Arrow keys between cells |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
