# Buttons inside card links that also open the page

Source: https://easeweb.dev/learn/card-link-buttons
Topics: Buttons and links > Buttons and links are never placed inside each other (WCAG 4.1.2)
The fix: Either move the wishlist button out of the link and place it over the photo with CSS, or make the product's name the link and stretch its box over the card with a pseudo-element, with the button above it. Stopping the click from reaching the link is not a fix: the button is still inside it.
Test with: keyboard, screen reader, mouse, touch
References: https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Understanding/parsing.html https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-a-element https://inclusive-components.design/cards/ https://dequeuniversity.com/rules/axe/4.13/nested-interactive

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 lists its mugs as a grid of cards: a photo, a name and a price. The whole card opens the product page, which makes a large, easy target, so the developer wrapped each card in one link. In the corner of the photo sits a heart, Add to wishlist, and it went inside the link with everything else.

A shopper presses the heart on the speckled mug. It fills, and then the product page opens. The press on the heart is also a press inside the link, and the browser follows the link once the heart has done its job: in the engines of Chrome, Firefox and Safari alike, whether the heart was clicked, tapped, or pressed with Enter or Space. To save a second mug, the shopper goes back to the list and finds their place again.

A screen reader user meets the card one stop at a time. The first stop is the link, and its name, as Chrome works it out, is "Speckled mug $24 Add to wishlist": the link claims the wishlist too. The next stop is the heart, a button of its own, inside the same link. Nothing they hear says which of the two a press will set off, and pressing the heart sets off both.

## Why it fails

Success Criterion 4.1.2 Name, Role, Value asks that the name and role of every control can be determined by code, so that assistive technology can tell people what each control is and what it does. Here the two controls are tangled. A link takes its name from everything inside it, so the heart's "Add to wishlist" becomes part of the link's name, and the link is announced as doing something it doesn't. The button is announced inside a link, so its role no longer settles what a press does, and in all three browser engines the press does what both of them do.

HTML doesn't allow this. An `a` element may not contain interactive content, such as a button, another link or a form field, and a `button` may not contain any either. The browser builds the page anyway, because HTML parsers repair what they can and leave the rest, but HTML gives a control inside a control no meaning. Browsers today behave alike, as described above, while what a screen reader makes of it is up to each screen reader. WCAG 2.2 removed 4.1.1 Parsing, which used to catch markup like this, and W3C's note on the removal says that many of the issues it caught now fail Info and Relationships or Name, Role, Value instead. A button inside a link is one of them, because of what it does to the names and roles above.

## Who is affected

The card works with a mouse in the sense that both things happen, so a quick test can pass it as working. The trouble shows on any phone or touch screen, where every tap on the heart leaves the list, and in a long list, where going back means scrolling down to the same place again.

## What automation and AI miss

axe's rule `nested-interactive` looks for focusable content inside controls whose children are read as part of them, such as a button. A link inside a button is reported. A button inside a link is not, because ARIA keeps a link's children as separate items, so the card in this guide passes axe. EaseWeb's audit lists the buttons, links and fields it finds inside links as a lead for a person to press. A code assistant asked to stop the heart opening the page may add `preventDefault()` and `stopPropagation()` to the heart's handler, as in the patch below: the page no longer opens, and the button stays inside the link.

## Before: a card wrapped in one link, the heart inside it

```html
<ul class="cards">
  <li class="card">
    <a href="/mugs/speckled" class="card-link">
      <img src="speckled-mug.webp" alt="">
      <h3>Speckled mug</h3>
      <p class="price">$24</p>
      <button type="button" class="wish" aria-label="Add to wishlist" aria-pressed="false">
        <svg aria-hidden="true">…</svg>
      </button>
    </a>
  </li>
  …
</ul>
```

```css
.card-link {
  position: relative;
  display: grid;
  gap: 8px;
  color: inherit;
  text-decoration: none;
}
.wish {
  position: absolute;
  top: 8px;
  right: 8px;
}
```

- The heart's words are part of the link's name, so the link is read as "Speckled mug $24 Add to wishlist".
- Pressing the heart, with a mouse, a finger, Enter or Space, saves the mug and then opens the product page.
- A screen reader is told about a button inside a link, which HTML doesn't allow, so what it says is up to each screen reader.

## After: the heart beside the link

```html
<ul class="cards">
  <li class="card">
    <a href="/mugs/speckled" class="card-link">
      <img src="speckled-mug.webp" alt="">
      <h3>Speckled mug</h3>
      <p class="price">$24</p>
    </a>
    <button type="button" class="wish" aria-label="Add to wishlist" aria-pressed="false">
      <svg aria-hidden="true">…</svg>
    </button>
  </li>
  …
</ul>
```

```css
.card {
  position: relative;
}
.card-link {
  display: grid;
  gap: 8px;
  color: inherit;
  text-decoration: none;
}
.wish {
  position: absolute;
  top: 8px;
  right: 8px;
}
```

1. Move the button out of the `a` and put it after the link, inside the card. The link now holds the photo, the name and the price, and its name is "Speckled mug $24".
2. Move `position: relative` from the link to the card, so the heart stays in the photo's corner. It looks exactly as it did.
3. Leave the button's handler as it is. Pressing the heart now presses the heart alone, and the list stays where it was.

This fixes the Before and passes 4.1.2. The heart comes after the link in the source, so Tab reaches the link first and then the heart, the same order as before.

## After: the name as the link, stretched over the card

```html
<ul class="cards">
  <li class="card">
    <img src="speckled-mug.webp" alt="">
    <h3>
      <a href="/mugs/speckled" class="card-link">Speckled mug</a>
    </h3>
    <p class="price">$24</p>
    <button type="button" class="wish" aria-label="Add to wishlist" aria-pressed="false">
      <svg aria-hidden="true">…</svg>
    </button>
  </li>
  …
</ul>
```

```css
.card {
  position: relative;
  display: grid;
  gap: 8px;
}
.card-link {
  color: inherit;
  text-decoration: none;
}
.card-link::after {
  content: "";
  position: absolute;
  inset: 0;
}
.card-link:focus-visible {
  outline: none;
}
.card-link:focus-visible::after {
  outline: 3px solid #1a4fd0;
  outline-offset: 3px;
}
.wish {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 1;
}
```

1. Make the product's name the only thing in the link, inside its heading. The link's name is "Speckled mug", and the photo, price and heart sit beside it.
2. Give the link an `::after` box that covers the whole card, positioned against the card. A click or tap anywhere on the card lands on that box, which belongs to the link, so the whole card still opens the product page.
3. Draw the focus ring on that box instead of on the name, so a keyboard user sees the whole card ringed, as before.
4. Give the heart `z-index: 1`, so it sits above the link's box and takes its own clicks.

This fixes the Before and passes 4.1.2. The link's box covers the card's text too, so the name and price can't be selected with a mouse, and any other link or button added to the card needs a `z-index` of its own to be pressed.

## The patch that is not a fix

```js
wish.addEventListener('click', (event) => {
  event.preventDefault();
  event.stopPropagation();
  …
});
```

`preventDefault()` stops the browser following the link, and `stopPropagation()` keeps the click from reaching a router's own handler on it, so pressing the heart no longer opens the page. Everything else stays. The button is still inside the link, so the link is still named "Speckled mug $24 Add to wishlist", the markup still breaks HTML's rule, and what a screen reader makes of a button inside a link is still left to each screen reader. It hides the one symptom a sighted mouse user would notice.

## Which option to use

Both pass. Moving the heart out of the link is the smaller change: the card keeps one link around its photo, name and price, the CSS only moves `position: relative`, and the text can still be selected. Its link reads everything inside it, which is fine for a name and a price, but grows long when a card also shows a rating, colours and a line of description. Choose the stretched title link for those cards, or when a card has more than one control of its own: the link's name is the product's name alone, and each control sits beside it.

## Implementation decisions

The same nesting comes from components as often as from hand-written markup. A router's link component wrapped around a design system's `Button` puts a button inside a link: two Tab stops for one action, and a press that runs both. Render one element instead. Most libraries offer a way to do that, such as passing the link component to the button through an `as`, `component` or `asChild` prop, so one element carries the link's address and the button's look. A card component whose root is a link, or a button such as a clickable card area, has the same problem as soon as anything clickable goes inside it; keep that component's actions in a part of the card outside the clickable area.

The heart's `aria-pressed` says whether the mug is saved, and its `aria-label` keeps one name in both states, so a screen reader reads it as a toggle button and says whether it is pressed. For why that pattern works, and its alternatives, see [toggle buttons that don't say whether they are pressed](/learn/toggle-buttons).

Each card's heart is named "Add to wishlist", the same on every card. A screen reader user meets it straight after the product's link, so its place tells them which mug it saves, but in a list of the page's buttons all the hearts read alike. Adding the product's name, such as "Add Speckled mug to wishlist", makes each one stand alone in that list.

A whole card that opens a page is a convenience, not a requirement: a link on the name alone, with the rest of the card plain, also passes and needs no positioning at all. When the whole card is a target, keep its link's focus ring around the card, as both fixes do, so a keyboard user sees what Enter will open.

## Verify the fix

1. In the page's markup or the browser's inspector, check that no link contains a button, link, field or element with a tabindex, and that no button contains a link or anything focusable.
2. With a screen reader, Tab to a card's link and confirm it is read with the product's words only, then Tab once more and confirm the heart is read separately as a toggle button named Add to wishlist.
3. Press the heart with a mouse, then with Enter, then with Space, and on a touch screen tap it: each time it fills or empties, and the list and its address stay as they were.
4. Repeat steps 1 to 3 for every kind of card on the site, such as search results, related products and the basket, and for any other component with a control inside a link or a button.

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

## Limits

This guide covers a button placed inside a card's link. The reverse, a link inside a button, is the form axe reports, and is fixed the same way, by putting the two side by side. A link laid over a banner or card with no words of its own is a link with no name: see [banner and image links with no name](/learn/empty-links). Cards that each end in the same link text are covered in ["Read more" links that all say the same thing](/learn/read-more), card actions that show only under the mouse in [card actions that only appear on hover](/learn/hover-actions), and a link used to do a button's job in [buttons announced as links that Space can't press](/learn/links-as-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)
