Accessible buttons and links start with one decision developers get wrong constantly: which element to reach for. A <button> does something on the current page (submit, open a dialog, toggle a menu). An <a href> navigates somewhere. Pick the wrong one and you break expectations for keyboard users, screen reader users, and browser features everyone relies on, like open-in-new-tab and the back button. Get it right and the platform hands you focus, keyboard activation, and the correct announced role for free.
This guide covers the four things that cause the most real-world failures: choosing button vs link, giving icon-only controls an accessible name, why a <div> dressed as a button is a trap, and the keyboard behaviour you inherit (or have to rebuild). It maps to WCAG 2.2 Level A and AA, the baseline referenced by EN 301 549 and enforced by the European Accessibility Act from 28 June 2025. Want to find these problems on your own site first? Run a free scan with AccessScan.
Buttons vs links: the one rule that settles most arguments
Ask one question: does activating this control change the URL? If the answer is yes, it is a link. If it performs an action and the page stays put, it is a button. "Add to cart", "Open filters", "Play video", and "Submit" are buttons. "View product", "Go to checkout", and "Read the docs" are links.
This is not pedantry. The two elements behave differently in ways users depend on. A link responds to Enter; a button responds to both Enter and Space. A link exposes a context menu with "Open in new tab" and "Copy link address"; a button does not, and should not, because there is no destination to copy. Screen readers announce "link" or "button" so the user knows whether they are about to move or to act.
The common anti-pattern is the link with href="#" or href="javascript:void(0)" wired to a click handler that does something on the page. It announces as a link, so users expect navigation, then nothing navigates, and middle-clicking opens a useless blank tab. If it acts on the page, use <button type="button">. If it goes somewhere, use a real <a href> with a real URL, even in a single-page app where the router intercepts the click.
- Use `<button>` for: submit, toggles, opening modals and menus, anything driven by JavaScript on the current page.
- Use `<a href>` for: any navigation, including in-page anchors and client-side routes. The
hrefmust be a usable URL. - Set `type` explicitly. A
<button>inside a<form>defaults totype="submit". Usetype="button"for anything that is not submitting the form, or you will trigger accidental submissions.
Never use a div as a button
A <div onclick> or <span onclick> looks identical to a real button once you style it. It is not. A generic element has no role, is not in the tab order, and does not respond to the keyboard. To a screen reader it is silent or announced as plain text, and a keyboard-only user cannot reach or fire it at all. This is a direct failure of WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A).
Developers who know this sometimes try to retrofit a div into a button, and it is instructive to see how much you have to bolt on to match what <button> gives you out of the box:
<div role="button" tabindex="0" @keydown="..."> plus a handler that fires on both Enter and Space, plus aria-disabled handling, plus aria-pressed if it toggles. You are reimplementing the browser, badly, and you still lose form participation and the high-contrast-mode styling real buttons get.
The fix is almost always to delete the wrapper and use <button type="button">. Reset the default styling with appearance: none; background: none; border: none; and style from there. You keep the role, the tab stop, Enter and Space activation, the disabled semantics, and focusability, all without writing a line of JavaScript. The same logic applies to React, Vue, and every framework: render a real <button> or <a>, not a clickable <div>. When you genuinely cannot use a native element, you take on the full custom-widget keyboard contract described below.
Accessible names for icon-only controls
An icon button with no text, a hamburger menu, a close X, a search magnifier, has no accessible name. Screen readers announce it as just "button", which tells the user nothing. WCAG 2.4.4 Link Purpose (In Context) (Level A) and 4.1.2 Name, Role, Value (Level A) require that every control communicates what it does.
Give it a name with aria-label, and make sure the visible icon itself is hidden from assistive tech so it is not announced twice or as a meaningless graphic:
<button type="button" aria-label="Close dialog"><svg aria-hidden="true" focusable="false">...</svg></button>
Two details people miss. First, aria-hidden="true" and focusable="false" on the inline <svg> stop the icon from being announced and stop it grabbing a tab stop in older browsers. Second, if there is visible text next to the icon, do not add a redundant aria-label, let the visible text be the name, because a mismatch between visible label and accessible name can also fail 2.5.3 Label in Name (Level A).
- Name the action, not the icon. "Search", not "magnifying glass". "Menu", not "three lines".
- Mind the target size. WCAG 2.2 added 2.5.8 Target Size (Minimum) (Level AA): interactive targets should be at least 24 by 24 CSS pixels (with spacing and other exceptions). Icon buttons are the most frequent offender, give them padding.
- Tooltips are not enough. A
titleattribute is unreliable for screen readers and invisible to touch and keyboard users until hover, so it is not a substitute for a proper name. If you do add a tooltip, it must obey 1.4.13 Content on Hover or Focus (Level AA).
Keyboard behaviour you inherit, and what you must rebuild
The strongest argument for native elements is the keyboard behaviour you get without any code. A <button> is automatically in the tab order, shows a focus ring, and fires its click handler on both Enter and Space. An <a href> is in the tab order, shows a focus ring, and activates on Enter. None of this exists on a <div> until you write it.
When you must build a custom composite control, a tab list, a menu button, a toggle, you take on the matching keyboard contract from the ARIA Authoring Practices Guide. A menu button typically opens on Enter, Space, and Down Arrow; arrow keys move between items; Escape closes it and returns focus to the trigger. Skipping any of these is a 2.1.1 Keyboard failure even if the mouse works perfectly.
Whatever you build, focus must stay visible. Do not write the blanket *:focus { outline: none; } reset, a common audit failure, it strips the indicator keyboard users rely on. Use :focus-visible so the ring appears for keyboard interaction but not mouse clicks, and give it a 3:1 contrast ratio against its background, the threshold WCAG 1.4.11 Non-text Contrast (Level AA) sets for UI components. You can verify those values with our contrast checker.
- Tab and Shift+Tab reach native buttons and links automatically; custom widgets need
tabindex="0"on the focusable element. - Enter activates links and buttons; Space activates buttons (and scrolls the page if focus is not on a control, so prevent default carefully in custom widgets).
- Disabled state: a native
<button disabled>is removed from the tab order and ignores clicks for free. If you usearia-disabled="true"to keep it focusable, you must block the action in your handler yourself.
A quick audit you can run today
You do not need special tooling to catch the worst offenders. Put the mouse aside and Tab through your page. Every interactive thing should receive focus, show a visible ring, and activate with Enter (and Space for buttons). Anything you can click but cannot reach with Tab is almost certainly a <div> masquerading as a control.
Then turn on a screen reader, VoiceOver on macOS or NVDA on Windows, and listen as you move through your buttons and links. Each should announce a meaningful name and the correct role. "Button" with no name, or "link" on something that does not navigate, are the two failures this guide is built to eliminate. For a structured pass across the whole site, a free AccessScan will surface missing names, empty links, and unlabeled controls at scale.