# Buttons announced as links that Space can't press

Source: https://easeweb.dev/learn/links-as-buttons
Topics: Buttons and links > Links go somewhere, buttons do something (WCAG 4.1.2)
The fix: Either build the control as a button with type="button" and keep the pill's look, or, where the link has to stay, give it role="button" and a key handler that presses it on Space. role="button" on its own is not enough: Space still scrolls the page.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/ARIA/apg/patterns/button/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/button_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 product page shows a linen tote that has sold out. Under the price is a dark green pill, "Email me when it's back". Pressing it opens a small form for an email address, over the page. It is drawn as a button, and it does a button's job: it opens something here, and goes nowhere.

It is built as a link. A keyboard user tabs to the pill and presses Space, the key that presses a button, and the page scrolls down a screen. The pill scrolls out of view, and no form opens. Enter opens it. A screen reader user hears "Email me when it's back, link". A link takes you to another page, so they expect to leave the product, and when they look through the page's buttons for a way to be told about the tote, the pill isn't there: it is listed with the links.

## Why it fails

Success Criterion 4.1.2 Name, Role, Value asks that the role of every control can be determined by code, so that assistive technology can say what it is. An `a` element with an `href` is a link to the browser, and that is the role it hands to a screen reader. The click handler that opens the form doesn't change the role, and neither does a class that draws a pill. So the role on record is link, while the control does a button's job. The ARIA Authoring Practices put it plainly: the actions buttons perform are "distinctly different from the function of a link", and both the appearance and the role of a widget should match the function it provides.

The keys follow the role. A browser turns Enter on a link into a click, but Space on a link is the key that scrolls the page, so the handler never runs from Space. A native button runs its click handler for both keys. `href="#"` is there only to make the link focusable and to stop the page reloading, and it adds its own surprises: the menu item "Open link in new tab" opens a copy of the same page, and if the script is slow to load, the press jumps to the top of the page.

## Who is affected

The pill works with a mouse and with Enter, so a quick test passes it. The gap shows only to people who rely on what the control is called, or on the key a button answers to.

## What automation and AI miss

Rule-based scanners such as axe see a link with a name and an address, and report nothing: a link that opens a form is still a valid link. What the control does only shows when someone presses it. EaseWeb's audit lists links that point nowhere (`href="#"`, `"#!"`, `""` or a `javascript:` URL) as a lead for a person to press, but a link to a real address whose script opens a form instead looks just as fine. A quick fix a code assistant may suggest, adding `role="button"`, changes what is announced and leaves Space scrolling the page.

## Before: a link that points nowhere, drawn as a pill

```html
<p class="stock">Sold out</p>
<a href="#" class="pill notify">
  Email me when it's back
</a>
<dialog id="notify" aria-labelledby="notify-title">
  …
</dialog>
```

```css
.pill {
  display: inline-block;
  padding: 10px 20px;
  border-radius: 999px;
  background: #1f4d3f;
  color: #fff;
  font-weight: bold;
  text-decoration: none;
}
```

```js
const notify = document.querySelector('.notify');
notify.addEventListener('click', (event) => {
  event.preventDefault();
  document.getElementById('notify').showModal();
});
```

The `a` element makes the pill a link, and the script makes it act as a button. A screen reader reports the link, and Space, which presses buttons, scrolls the page instead. The same goes for `href="javascript:void(0)"` and for a link whose address is real but whose script always cancels it.

## After: a button

```html
<p class="stock">Sold out</p>
<button type="button" class="pill notify">
  Email me when it's back
</button>
<dialog id="notify" aria-labelledby="notify-title">
  …
</dialog>
```

```css
.pill {
  display: inline-block;
  padding: 10px 20px;
  border: 0;
  border-radius: 999px;
  background: #1f4d3f;
  color: #fff;
  font: inherit;
  font-weight: bold;
  cursor: pointer;
}
```

```js
const notify = document.querySelector('.notify');
notify.addEventListener('click', () => {
  document.getElementById('notify').showModal();
});
```

A button is announced as a button, and the browser runs its click handler for Enter and for Space, so no key handling is needed and the page never scrolls. `type="button"` keeps it from sending a form it sits in. The CSS takes away the browser's border and grey face and sets the font, so the pill looks as it did.

## After: role="button" and Space, when the link has to stay

```html
<p class="stock">Sold out</p>
<a href="#" role="button" class="pill notify">
  Email me when it's back
</a>
<dialog id="notify" aria-labelledby="notify-title">
  …
</dialog>
```

```css
.pill {
  display: inline-block;
  padding: 10px 20px;
  border-radius: 999px;
  background: #1f4d3f;
  color: #fff;
  font-weight: bold;
  text-decoration: none;
}
```

```js
const notify = document.querySelector('.notify');
notify.addEventListener('click', (event) => {
  event.preventDefault();
  document.getElementById('notify').showModal();
});
// Space presses a button when it is let go, and must not scroll the page.
notify.addEventListener('keydown', (event) => {
  if (event.key === ' ') event.preventDefault();
});
notify.addEventListener('keyup', (event) => {
  if (event.key === ' ') notify.click();
});
```

`role="button"` makes a screen reader announce a button, and the two key handlers make Space press it as it presses a native button, on release, without scrolling. Enter already clicks a link. This is for markup you can add to but not change, such as a theme or a plugin that renders the link. The browser still treats the element as a link in its own menus, so "Open link in new tab" and dragging it still offer the same page.

## The patch that is not a fix

```html
<a href="#" role="button" class="pill notify">
  Email me when it's back
</a>
```

`role="button"` makes a screen reader say "button", which mends the role. But ARIA changes only what is announced: the element is still a link to the browser, and Space still scrolls the page. A screen reader user is now told it is a button, and the key every button answers to does nothing. The second fix above finishes the job by hand; a native button needs none of it.

## Which option to use

Both pass. Use the button: it does the same job with less code and nothing to give up, since the browser handles both keys and the role, and there is no fake address to explain. Keep the link with `role="button"` and the Space handler only where the element can't be changed, and test its keys again whenever the theme or plugin updates.

## Implementation decisions

The look and the role go together. A button can look like a link where the design needs it, but an underlined word is what links usually look like, so it suggests to sighted users that it goes somewhere. That is why the Authoring Practices suggest adjusting the visual design to match what the control does. A pill or a button shape tells everyone it acts.

Shared components are where this usually starts. A `Button` component that renders an `a` when it is given `href`, and a `button` otherwise, keeps the two apart; one that always renders an `a`, or is passed `href="#"` to make it focusable, makes every action a link. A router's link component used for an action has the same problem.

A control that changes something on the server, such as adding to a basket or subscribing, also belongs in a button rather than a link to an address with the action in it: a link can be prefetched, bookmarked or shared, and following it again repeats the action. Put it in a form that is sent with a submit button, or call the server from the button's click handler.

What happens after the press is a separate job. The native `dialog` opened with `showModal()` moves focus into the form and back to the button when it closes; see [modals that leave keyboard focus behind them](/learn/modal-focus-management) for dialogs built another way.

## Verify the fix

1. With a screen reader, Tab to the control and confirm it is announced as a button, with its visible words.
2. Press Enter, close the form, then press Space: each opens the form once, and the page doesn't scroll.
3. Open the screen reader's list of buttons and confirm the control is in it, and not in the list of links.
4. Press every other control on the page that opens, changes or sends something, and repeat the first three steps for each.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared button or link component changes.

## Limits

This guide covers a link that does a button's job. The reverse, a button that only takes people to another page, is the same topic seen from the other side, and is meant for a later guide: it is announced as a button, and it can't be opened in a new tab or found in the list of links. For a div or span with a click handler and no role at all, see [div buttons that can't be reached with Tab](/learn/native-buttons). Links that filter or sort the page in place belong to the topic on links that update the page, and a button with no words to [icon-only buttons with no accessible name](/learn/icon-buttons). Test the complete journey with your target assistive technology.

---

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