# Filter links that don't say which is chosen or what changed

Source: https://easeweb.dev/learn/filter-links
Topics: Buttons and links > Links that update the page in place announce the change (WCAG 4.1.2)
The fix: Mark the chosen filter link with aria-current="page" and draw its look from that attribute. Then either keep the update in place and write what is now shown into a status region that is in the page from the start, or let the link load its own page, whose title says what changed.
Test with: screen reader, keyboard, forced colors
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/TR/wai-aria-1.2/#aria-current https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-current https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role

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 shop's tableware page lists six products under a row of pill-shaped links: All, Mugs, Plates and Bowls. Each pill is a real link with its own address, so it can be opened in a new tab or shared. A script stops the page from loading: pressing Mugs fetches the mugs, redraws the list below, changes the address to `?category=mugs`, and fills the Mugs pill in black. A sighted visitor sees the pill fill and the list shrink to two mugs. A screen reader user hears "Mugs, link", presses Enter, and hears nothing. No new page loads, so there is no new page title to read, and the script says nothing either. Moving back onto the pill, they hear "Mugs, link" again, exactly as before, and the same for every other pill. They can't tell that the list changed, or which filter is in force, without reading down through the products.

## Why it fails

Each pill is a control people use to choose a filter, and the filled one shows a state: this is the filter in force. Success Criterion 4.1.2 Name, Role, Value asks that states people can set are available to assistive technology, and that changes to them are reported to it. Here the state is a class, `is-active`, that only the stylesheet reads, so assistive technology has no state to report before the press or after it. The script also does what a page load did, swapping the content and the address, but leaves out what a page load gives for free: something that is read. The link says it will take people somewhere, and when it changes the page in place instead, nothing comes back to say where they are now.

## Who is affected

Filter rows like this sit above product grids, article lists and search results. On a phone they are often one strip that scrolls sideways, with the list that changes below the edge of the screen, so anyone looking at the strip sees only the pill fill.

## What automation and AI miss

A scan reads four valid links with names and addresses, and passes them. It doesn't press them, so it never sees the page change in place, and a class called `is-active`, `selected` or `current` means nothing to it. EaseWeb's audit reports the chosen pill when its address is the page being shown and it looks unlike the other links in its row, but nothing in its code marks it as chosen. It sends each row of filter links to review, since only a press shows what is said. AI assistants asked to fix filter links can reach for `aria-selected` or `role="tab"`, which belong to tabs and listboxes. Some turn the links into buttons, which loses the addresses people open in new tabs and share. Others put `aria-live` on the product list, which reads out every product after each press, or nothing when the list is replaced.

## Before: filter links redrawn in place, marked by a class

```html
<title>Mugs | Tableware</title>
…
<ul class="filters">
  <li>
    <a href="/shop">All</a>
  </li>
  <li>
    <a href="/shop?category=mugs" class="is-active">Mugs</a>
  </li>
  <li>
    <a href="/shop?category=plates">Plates</a>
  </li>
  <li>
    <a href="/shop?category=bowls">Bowls</a>
  </li>
</ul>
<ul id="products">…</ul>
```

```css
.filters a {
  border: 1px solid #1d1d1f;
  border-radius: 999px;
  padding: 0.25em 0.9em;
}
.filters a.is-active {
  background: #1d1d1f;
  color: #fff;
}
```

```js
const filters = document.querySelector('.filters');
const products = document.querySelector('#products');

filters.addEventListener('click', async (event) => {
  const link = event.target.closest('a');
  if (!link) return;
  event.preventDefault();
  products.innerHTML = await loadProducts(link.href);
  history.pushState(null, '', link.href);
  filters.querySelector('.is-active').classList.remove('is-active');
  link.classList.add('is-active');
});
```

- The `is-active` class fills the Mugs pill and does nothing else: a screen reader reads "Mugs, link", the same as the other three.
- The script stops the page load and redraws the list in place, so nothing is read after the press: no new page title, and no message.

## After: mark the chosen link, and say what changed

```html
<title>Mugs | Tableware</title>
…
<ul class="filters">
  <li>
    <a href="/shop">All</a>
  </li>
  <li>
    <a href="/shop?category=mugs" aria-current="page">Mugs</a>
  </li>
  <li>
    <a href="/shop?category=plates">Plates</a>
  </li>
  <li>
    <a href="/shop?category=bowls">Bowls</a>
  </li>
</ul>
<p id="filter-status" role="status" class="visually-hidden"></p>
<ul id="products">…</ul>
```

```css
.filters a {
  border: 1px solid #1d1d1f;
  border-radius: 999px;
  padding: 0.25em 0.9em;
}
.filters a[aria-current="page"] {
  background: #1d1d1f;
  color: #fff;
}
```

```js
const filters = document.querySelector('.filters');
const products = document.querySelector('#products');
const status = document.querySelector('#filter-status');

filters.addEventListener('click', async (event) => {
  const link = event.target.closest('a');
  if (!link) return;
  event.preventDefault();
  if (link.getAttribute('aria-current') === 'page') return;
  products.innerHTML = await loadProducts(link.href);
  history.pushState(null, '', link.href);
  filters.querySelector('[aria-current]').removeAttribute('aria-current');
  link.setAttribute('aria-current', 'page');
  status.textContent = `Now showing: ${link.textContent}`;
});
```

1. Replace the class with `aria-current="page"` in the markup the server sends. The link's address is the page being shown, and a screen reader reads it as the current page, with something like "Mugs, current page, link".
2. Draw the filled look from `[aria-current="page"]`, so the pill that looks chosen and the one that is announced are always the same.
3. Put an empty `role="status"` region under the filter row, hidden visually. It is in the page from the start and outside the list that is redrawn, so writing into it is a change the browser reports.
4. Once the new list is in, move `aria-current` to the pressed link and write "Now showing: Mugs" into the region. The screen reader finishes what it is saying, then reads it, and focus stays on the pill, ready for the next filter.
5. Do nothing when the pressed link is already the current one, so the list isn't fetched again for the filter in force.

This fixes the Before and passes 4.1.2: the chosen filter is marked in code, and the change is reported as it happens. The mark is what 4.1.2 asks for, but whether a screen reader speaks a change to `aria-current` on the link that has focus is not something tested here, so the message says it in words.

## After: let the link load its page

```html
<title>Mugs | Tableware</title>
…
<ul class="filters">
  <li>
    <a href="/shop">All</a>
  </li>
  <li>
    <a href="/shop?category=mugs" aria-current="page">Mugs</a>
  </li>
  <li>
    <a href="/shop?category=plates">Plates</a>
  </li>
  <li>
    <a href="/shop?category=bowls">Bowls</a>
  </li>
</ul>
<ul id="products">…</ul>
```

```css
.filters a {
  border: 1px solid #1d1d1f;
  border-radius: 999px;
  padding: 0.25em 0.9em;
}
.filters a[aria-current="page"] {
  background: #1d1d1f;
  color: #fff;
}
```

```js
// No click handler: each filter link loads its own page.
```

1. Remove the click handler. Each pill then loads its address, as any link does, and the server already sends the filtered list and a title for it, "Mugs | Tableware", which a screen reader reads as the page loads.
2. Mark the chosen link with `aria-current="page"` in the markup the server sends for each filter.
3. Draw the filled look from `[aria-current="page"]`.

This fixes the Before and passes 4.1.2. The new title says what the page now shows, and when the person reaches the filter row, the chosen pill is announced as the current page. Focus starts again at the top of the page, so people move back down to the list with Tab or by heading.

## Which option to use

Both mark the chosen filter and tell people what changed. The update in place keeps the visitor's place, with focus on the filter row, so they can try Plates straight after Mugs; it costs a status region and a handler that has to keep the mark, the message and the list in step. The page load needs no script for the update and nothing to keep in step, because the browser and the screen reader do what they do for any link. It costs a page load each time, and focus and scrolling start again at the top. Let the link load its page when the filters are rarely pressed or the update script is there only for speed; keep the update in place when people try several filters in a row.

## Implementation decisions

**A link or a button.** A filter with its own address, one people can open in a new tab, bookmark or share, is a link and stays one. A filter that changes the list with no address of its own is a button: give it `aria-pressed`, as in [toggle buttons that don't say whether they are pressed](/learn/toggle-buttons). See [buttons announced as links that Space can't press](/learn/links-as-buttons) for how the two roles differ.

**Which value of aria-current.** Use `page`: the link's address is the page being shown, and the filter row is a set of pages. `aria-current="true"` marks the current item in a set too, and a screen reader reads it as "current". Don't use `aria-selected`, which belongs to tabs, options, and the rows and cells of a grid, or `role="tab"`, which would make the row a tab list with keys of its own.

**Say what is shown, not the whole list.** The message names the filter in force. To say how many products are left as well, put the count in the same message, written once per press; see [search and filter results that update without a word](/learn/search-results). Never put `aria-live` on the product list.

**Keep the region out of what is redrawn.** Render the status region in the page layout, or in a part of the filter component that stays mounted, never inside the list or a block that is replaced along with it. A region created along with its message is often not announced.

**Sort links.** A row such as "Sort: Newest · Price, low to high · Price, high to low" is the same pattern: `aria-current="page"` on the link for the order in force, and in place, a message such as "Now sorted by price, low to high". Sorting by a table's column header is a different pattern, with `aria-sort` on the header, covered by the sortable tables topic.

**What a link that stops itself must keep.** A handler that calls `preventDefault()` on every click also stops Ctrl and Cmd clicks, so the filter no longer opens in a new tab. Leave clicks with a modifier key to the browser. Redraw the list on `popstate`, too, so Back returns to the earlier filter with the mark moved to match. Neither is a 4.1.2 failure, but both are what people lose when a link stops acting like one.

**Contrast themes.** A Windows contrast theme replaces the black fill with the page colour, so the chosen pill looks like the rest. Give it a mark the theme keeps, such as a thicker border, or fill it with `Highlight` and `HighlightText` under `@media (forced-colors: active)`; see [high contrast mode hiding focus, icons and selections](/learn/forced-colors).

## Verify the fix

1. Inspect the DOM: exactly one link in the row has `aria-current="page"`, the one whose address is shown; the filled look comes from that attribute; with the update in place, the `role="status"` region is in the page before the first press, empty, and outside the list that is redrawn.
2. With a screen reader, Tab along the filter row: the chosen filter is read as the current page, and the others are not.
3. Press another filter with Enter: in place, "Now showing:" and the filter's name is read once, and focus stays on the pill; with a page load, the new title is read.
4. Move back onto the row: the new filter is read as the current page and the old one no longer is, and pressing the current filter again changes nothing.
5. Turn on a Windows contrast theme: the chosen filter still looks different from the others.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the filter component, the list template or the page layout changes.

## Limits

Live regions behave differently across screen readers and browsers, and a polite message can be dropped if people keep pressing. This guide covers one row of links that each choose one filter. Filters people combine, several ticked at once, are usually checkboxes, and announcing their results is covered in [search and filter results that update without a word](/learn/search-results). A link that replaces the whole view of a single-page app is covered in [single-page app route changes that are never announced](/learn/route-changes), and the current page in a site's menu in [navigation that marks the current page only by its look](/learn/current-page). Test your real filters, with their sorting and pagination, with the screen readers your users use.

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

`aria-current="page"`: Marks the link to the page you are on, in a menu, breadcrumbs or pagination.

| Screen reader | Browser and system | Tab to it | Down Arrow |
| --- | --- | --- | --- |
| 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="status"`: A polite live region: its changes wait until the screen reader has finished what it is saying.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| 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 |

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)
