DEV Community

Cover image for Accessible Web Apps: A Div Is Not a Button
Filip Peralov
Filip Peralov

Posted on • Originally published at peralov.hashnode.dev

Accessible Web Apps: A Div Is Not a Button

Modern frontend frameworks give us structure to build complex, scalable web platforms. But if you look under the hood of many enterprise applications, you will find hundreds of clickable <div> elements.

Relying on the most generic layout container for core user interactions is more than lazy - it breaks the web for users who rely on assistive technologies, worsens native browser performance, and introduces technical debt.

The examples used in this article are using Angular, but the same principle applies in any other frontend framework or plain html.


Ban Clickable Divs

I have been guilty of abusing click events on <div> elements just like anybody else, but after fixing hundreds of WCAG issues as a result of this pattern, I have learned my lesson.

❌ Looks like a button but breaks accessibility
<div 
  class="btn-primary"
  (click)="saveChanges()">
  Save Changes
</div>
Enter fullscreen mode Exit fullscreen mode

Attaching click events to non-interactive tags like <div>, <span>, <p> , <h1>, breaks core browser accessibility and forces you to reinvent the wheel with messy fixes.

Don't reinvent the button

If you were forced to make a generic <div> match the accessibility and functionality of a native <button>, you cannot just use a simple (click) binding. You would have to manually implement all the native browser logic yourself:

<!-- ❌ The over-engineered, high-maintenance "accessible" div -->
<div 
  class="btn-primary" 
  tabindex="0" 
  role="button"
  (click)="saveChanges()"
  (keydown.enter)="$event.preventDefault(); saveChanges()"
  (keydown.space)="$event.preventDefault(); saveChanges()"
  [class.disabled]="isSaving"
  [attr.aria-disabled]="isSaving">
  Save Changes
</div>
Enter fullscreen mode Exit fullscreen mode

On top of writing extra html just to handle keyboard tracking and basic screen reader roles, you have to write additional code in your component controller and custom css styles because a generic <div> doesn't natively understand how to be interactive, focused, or disabled.

Use a button

For actions that change data or trigger a state, use a <button>. As one of the base HTML elements the button serves a much greater purpose than just being a styled box on the screen.

It brings a lot of pre-built behaviours. It maps click events to appropriate keyboard shortcuts, handles automatic form submission, prevents accidental text highlighting and can be easily disabled.

<!-- βœ… 100% accessible, focusable, and semantic out of the box -->
<button 
  type="button" 
  class="btn-primary" 
  (click)="saveChanges()" 
  [disabled]="isSaving">
  Save Changes
</button>
Enter fullscreen mode Exit fullscreen mode

By simply choosing a native <button> element paired with Angular's native property binding [disabled]="isSaving", the browser takes care of all this automatically.

Avoid: The clickable table row 🫩

Another place where this anti-pattern frequently appears into dashboards is data grids. Developers may attach a (click) binding to an entire table row (<tr>) to toggle an expanded view, open a sidebar, or navigate to a detail page.

<!-- ❌ Is it a row or a button? -->
<tr (click)="toggleRow(row.id)">
  <td>{{ row.name }}</td>
  <td>{{ row.status }}</td>
  <td><button (click)="deleteRow(row.id)">Delete</button></td>
</tr>
Enter fullscreen mode Exit fullscreen mode

A table row has an inherent structural role of "row". The element roles get conflicted. Is this a row in a data grid, or is it a button?

To make this row function remotely close to an accessible control for keyboard or screen reader users, you can't just use a simple template binding. You are forced to manually inject lots of boilerplate directly onto the structural table tag:

<!-- ❌ The over-engineered, high-maintenance "accessible" table row -->
<tr 
  class="custom-row-button" 
  tabindex="0" 
  role="button"
  [attr.aria-expanded]="row.expanded"
  [attr.aria-label]="(row.expanded ? 'Collapse' : 'Expand') + row.name"
  (click)="toggleRow(row.id)"
  (keydown.enter)="$event.preventDefault(); toggleRow(row.id)"
  (keydown.space)="$event.preventDefault(); toggleRow(row.id)">
  <td>{{ row.expanded ? '-' : '+' }}</td>
  <td>{{ row.name }}</td>
  <td>{{ row.status }}</td>
  <td>
    <!-- Event interception to stop row triggers -->
    <button 
      type="button" 
      class="btn-danger"
      (click)="$event.stopPropagation(); deleteRow(row.id)">
      Delete
    </button>
  </td>
</tr>
Enter fullscreen mode Exit fullscreen mode

When a screen reader user navigates to this row, the browser attempts to read every single data cell inside the row back-to-back as one single, continuous, chaotic button description.

Event Bubbling Issues

Placing a real <button> inside an already clickable <tr> creates a severe JavaScript event propagation bug. Because of standard DOM event bubbling, clicking that inner "Delete" button will trigger your deleteRow() logic, and then bubble up and trigger toggleRow() on the parent row. To stop this, you are forced to do $event.stopPropagation() somewhere in the code.

Sometimes the UX is the problem

Instead of writing heavy JavaScript or complex TypeScript decorators to patch a flawed UI layout, bring the problem to your design and product teams.

Show them how making a giant container or a table row clickable introduces severe user experience barriers for screen readers and mobile touch devices. Propose a design change: add an explicit, native <button> or a clear text link inside the layout to cleanly isolate the action.

Keep your data grid structure pure and preserve standard HTML table layout trees. Place an explicit, accessible <button> inside the very first cell to handle the expansion behaviour.

<!-- βœ… Pure table semantics with native controls -->
<tr>
  <td>
    <button 
      type="button" 
      class="btn-toggle"
      (click)="toggleRow(row.id)" 
      [attr.aria-expanded]="row.expanded"
      [attr.aria-label]="(row.expanded ? 'Collapse' : 'Expand') + row.name"
      {{ row.expanded ? '-' : '+' }}
    </button>
  </td>
  <td>{{ row.name }}</td>
  <td>{{ row.status }}</td>
  <td>
    <button 
      type="button" 
      class="btn-danger" 
      (click)="deleteRow(row.id)">
      Delete
    </button>
  </td>
</tr>
Enter fullscreen mode Exit fullscreen mode

Can we add an additional click on the container ?

Can we maybe attach an (click) event to a <tr> or <div> only as a visual shortcut for mouse and mobile users, as long there is a dedicated button for the same action? In theory if we don’t add role="button" or tabindex="0", the accessibility tree ignores the container, while mouse users get a larger, touch-friendly click target.

<!-- ❌ Hidden visual trap -->
<div 
  class="custom-card" 
  (click)="viewProduct(product.id)">
    <img 
      [src]="product.imageUrl"
      [alt]="product.imageDescription">
    <h3>{{ product.name }}</h3>
    <p>{{ product.description }}</p>
    <button
      type="button" 
      class="btn-fav"
      [attr.aria-label]="'View ' + product.name"
      (click)="$event.stopPropagation(); viewProduct(product.id)">View</button>
</div>
Enter fullscreen mode Exit fullscreen mode

But this pattern introduces a hidden trap for low-vision users.

Consider a user who has partial vision and uses the mouse to explore the app but relies on a screen reader for the content bellow the cursor. When they hover over your clickable custom-card <div>, the mouse tells them the area is interactive, but the screen reader treats it as regular div. If they click it, the layout suddenly changes without warning.

Final Thoughts

Like many frontend developers, I viewed semantic HTML in web apps as an afterthought and accessibility as a post-development checklist. It took a client demanding accessibility compliance to finally force it onto my radar.

But after spending a year fixing an inaccessible web application, I realised something critical: building accessible software is a fundamental developer responsibility, not an optional feature

Engineering accessible apps starts by respecting the very building blocks of the browser. Choosing a native <button> over a lazy <div> wrapper isn't a minor stylistic preference - it is a foundational architectural choice. It means letting the browser handle user interaction, keyboard navigation, and assistive technologies out of the box instead of reinventing the wheel.

Top comments (0)