NJ
Buttons vs. Links: Choosing the Right Element | Accessible HTML Guide
Developer typing code on a laptop in front of two monitors showing codeAccessibility, Front-End Development, Vue

Buttons vs. Links: Choosing the Right Element

Nerando Johnson•
#accessibility#a11y#semantic-html#html#vue#nuxt#wcag#front-end

18 min read

Loading...

Start here

Man at a desk leans over his closed laptop and says 'Alright.' (PBS)

The short answer: a link takes you somewhere. A button does something.

So essentially, it depends on what you want to achieve. Think of a building. A link is a door: you walk through it and end up in a different room (a different page or place). A button is a light switch: you stay in the same room, but something in it changes (a menu opens, a form is sent, an item is deleted).

They can look identical on screen. Underneath, the browser, the keyboard and tools for disabled users treat them very differently. Choosing the wrong one usually doesn’t break anything you can see, which is why it is such a common mistake made.

Side-by-side illustration: a link is a door (takes you somewhere new, announced as 'About us, link'), a button is a light switch (changes something here, announced as 'Save, button'). Below, two identical-looking dark buttons labelled About us and Save, one a link and one a button. Figure 1. Door vs. light switch. Two elements can look identical while the browser and assistive tech treat them completely differently.

How to read this guide

  • New to this concept? Read the main part of each section. It uses plain language and small examples. You can skip anything labelled Going deeper.
  • Already shipping UI? Each section ends with a Going deeper part: the edge cases, specs and trade-offs worth knowing as an intermediate developer. The last two topic sections, on concerns beyond accessibility and on component libraries, are mostly for you.
Going deeper sections (7)

Words you’ll see

TermWhat it means in plain English
HTML elementA building block of a web page, written as a tag like <button> or <a>.
Semantic HTMLUsing the element that means the right thing, not just one that looks right. A <button> means “I do something”; a <div> means nothing at all.
Screen readerSoftware that reads the page out loud for people who are blind or have low vision. It announces each element’s type, like “Save, button”.
FocusWhich element the keyboard is currently “on”. Pressing Tab moves focus to the next interactive element.
Accessible nameThe words a screen reader says for an element. Usually its visible text.
ARIAExtra HTML attributes (aria-…) that describe things HTML can’t express on its own, like whether a menu is open.
hrefThe address a link points to. Without it, <a> isn’t really a link.
WCAGThe Web Content Accessibility Guidelines: the international standard for accessible websites.

The core rule

Ask yourself one question: after I click this, did I go somewhere (navigation), or did something happen ( action)?

  • Somewhere new (another page, another part of this page, a file) → use a link: <a href="/about">About us</a>
  • Something happened here (form sent, menu opened, item saved) → use a button: <button type="button">Save</button>

A quick test: would it make sense to open this in a new tab or bookmark it? If yes, it’s a link. You can’t bookmark “Save”.

Decision flowchart. Start: after I click this, am I somewhere new, or did something happen right here? Somewhere new leads to a new-tab tiebreaker and then Link (a href or NuxtLink to). Right here leads to: does it send data, open/close, toggle, save or delete? Then Button with an explicit type, even if it navigates afterwards like Save and continue. Figure 2. The core rule as a flowchart. Readers who skim will often stop here, so it carries the whole article in one picture.

What you get for free by choosing correctly

What is happensLinkButton
Screen reader says“About us, link”“Save, button”
Activate with the keyboardEnterEnter or Space
Right-click menuOpen in new tab, copy linkNothing special
Browser Back buttonTakes you backNot involved
Works if JavaScript failsYesOnly inside a form

You don’t have to write any code for these. The browser should give them to you, as long as you use the correct element.

Going deeper: why the browser treats them differently

  • Implicit ARIA roles. <a href> maps to role="link" and <button> to role="button" in the accessibility tree. Remove the href and an <a> has no role (it’s “generic”) and drops out of the tab order.
  • Activation behavior. The HTML spec gives each element its own activation behavior. Links navigate while buttons run their form-owner behavior (submit/reset/etc). That’s why Space activates buttons but scrolls the page on a link.
  • Navigation is a browser feature, not your code. A real link participates in history, :visited, prefetch, link-hover status text, modifier-key clicks and “open in new tab”. A click handler that calls location.href = … or router.push() reimplements one sliver of that.
  • The mental model screen reader users rely on. Users pick their keyboard command based on the announced role. Mismatches cost them time even when the action still technically works.

Types of buttons

All buttons “do something” or perform an action, but they do different kinds of things. Here are the ones you’ll use the most:

TypeEveryday exampleWhat it does
ActionSave, Copy, RetryDoes one thing, once.
SubmitSign in, Place orderSends a form.
ToggleMute, Bold, FavoriteSwitches something on or off.
Show/hide“Show more”, accordion headersReveals or hides content.
MenuThe ⋯ “more options” buttonOpens a list of actions.
Dialog“Edit profile”Opens a pop-up window/modal.
Icon-only× close, ▶ playSame as the others, but shows only an icon.
DestructiveDelete, RemoveDoes something that’s hard to undo.

Grid of eight real button examples: Action (Copy), Submit (Place order), Toggle (Mute, aria-pressed true), Show/hide (Show more, aria-expanded false), Menu (three dots, aria-haspopup menu), Dialog (Edit profile), Icon-only (close X with aria-label Close), Destructive (Delete invoice). Each shows the key attribute and what a screen reader announces. Figure 3. Every one of these is a <button>. The attributes change what a screen reader announces.

Common habits that prevent most button bugs

  • Always write the type. A button inside a form submits the form by default. Write type="button" unless you really mean type="submit".
  • Give icon-only buttons a name. A screen reader can’t describe a picture of an ×. Add hidden text or aria-label="Close".
  • Say what will happen. “Delete invoice” beats “OK”. Don’t rely on a branding colors to signal button purpose.

Looks are not types

“Primary”, “secondary”, “ghost” and “text” are just styles. A link can wear a primary-button style and still be a link. Choose the element by behavior, then pick the style.

Going deeper: types, states and disabling

  • HTML type values. submit (the default inside a <form>), button (no default behavior) and reset (clears the form, and is rarely worth the risk of lost work). A button outside a form can still submit one via the form="form-id" attribute.
  • State lives in ARIA.
    • Toggle: aria-pressed="true|false", with a constant label (“Mute”, not “Mute”/“Unmute”). Changing both the label and the state double-signals and confuses users.
    • Disclosure: aria-expanded plus aria-controls pointing at the region.
    • Menu button: aria-haspopup="menu" + aria-expanded, and the menu itself must implement arrow-key navigation per the ARIA Authoring Practices menu pattern.
    • Dialog trigger: move focus into the dialog on open, trap it there, and return it to the trigger on close. Native <dialog> with showModal() handles most of this.
  • disabled vs aria-disabled. Native disabled removes the button from the tab order and blocks events, so keyboard and screen reader users may never discover it or learn why it’s off. aria-disabled="true" keeps it focusable and announced; you block activation in code and can explain the reason. For form submit buttons, many teams prefer leaving them enabled and showing validation errors instead.
  • Loading states. Keep the button’s width stable, set aria-busy="true" or announce progress via an aria-live region, and guard against double submission.
  • Split buttons are two buttons (the action and the menu trigger), each needing its own accessible name.

Every link needs an href, the address it goes to. The rest is about where it goes.

KindExampleBeginner tip
Page on your site<a href="/settings">Settings</a>The most common kind
Section of this page<a href="#pricing">Pricing</a>Jumps down to the element with id="pricing"
Skip link<a href="#main">Skip to content</a>Lets keyboard users jump past the menu; put it first on the page
Another website<a href="https://vuejs.org">Vue docs</a>Let people know they’re leaving your site
New tab<a href="…" target="_blank">Use rarely, and say so (“opens in new tab”)
File download<a href="/report.pdf" download>Mention type and size: “Report (PDF, 2 MB)”
Email / phone<a href="mailto:help@example.com">Show the address or number too, so people can copy it

Screen reader users often jump through a page link by link, hearing only the link text. Imagine hearing “click here, click here, read more”. So writing descriptive link text like “Read more about cleaning schedules” instead of “Read more” or “Click here”, link the words that describe the destination.

Two mock screen reader link lists. Left, vague: click here, read more, read more, here, download. Right, descriptive: Pricing plans, Read more about cleaning schedules, Vue docs (opens in new tab), Skip to content, Report (PDF, 2 MB). Figure 4. Link text heard out of context, the way screen reader users often browse a page.

  • Router links. <RouterLink> and <NuxtLink> render a real <a href> and intercept the click for client-side navigation. You keep every native link behavior and get SPA speed. NuxtLink also decides internal vs external for you and prefetches route chunks.
  • Current page. Mark the active nav item with aria-current="page" (router links can add this automatically). Don’t rely on bold or color alone.
  • In-page anchors and focus. Browsers move scroll position, and modern ones move the focus starting point, to the target. If the target is a non-interactive element and focus doesn’t follow in your setup, add tabindex="-1" to it. With sticky headers, set scroll-margin-top so the target isn’t hidden.
  • target="_blank" and rel. Modern browsers imply rel="noopener" for _blank, which blocks the new page from controlling yours via window.opener. Add noreferrer if you don’t want to leak the referring URL. Announce the new tab in text or with an icon that has a text alternative (WCAG 3.2.5 is AAA, but the courtesy matters).
  • download only works for same-origin URLs (or blob:/data:); cross-origin it falls back to normal navigation. The server’s Content-Disposition header can override it.
  • Visible label vs aria-label. If you add aria-label, it replaces the visible text for screen readers. Voice-control users speak what they see, so the accessible name must contain the visible words (WCAG 2.5.3, Label in Name). Visually hidden extra text is usually safer than aria-label on links.

Accessibility

Accessibility boils down to ensuring everyone can use your site however they use a computer. For buttons and links, the people most affected are:

  • Keyboard users, who press Tab, Enter and Space instead of using a mouse.
  • Screen reader users, who hear the page instead of seeing it.
  • Voice-control users, who say “click Save” out loud.
  • People with low vision or shaky hands, who need good contrast and big enough targets.

The good news: if you use real <button> and <a href> elements, most of this works automatically.

The beginner checklist

  • You can reach it with the Tab key.
  • You can see where focus is (an outline or ring appears).
  • It works with Enter (and Space, for buttons).
  • It has text a screen reader can say, even if only an icon is visible.
  • Links look like links (underlines help a lot), not just a different color.
  • It’s big enough to tap easily.

Try it now on any page you’ve built: put the mouse away and press Tab.

Animated demo: pressing Tab moves a blue focus ring from the About us link to the Save button, then jumps straight to Export, skipping a Delete control built from a div, which gets outlined in red dashes. Figure 5 (animated). The Tab test in action. Focus jumps from Save to Export because the Delete <div> isn’t in the tab order.

Going deeper: the specs behind the checklist

WCAG 2.2 criterionLevelWhat it requires here
2.1.1 KeyboardAEvery action reachable and operable by keyboard
2.4.7 Focus VisibleAAA visible focus indicator. Use :focus-visible instead of removing outline
2.4.11 Focus Not Obscured (Minimum)AASticky headers/footers can’t fully hide the focused control
2.4.4 Link Purpose (In Context)ALink text plus its context explains where it goes
1.4.1 Use of ColorAInline links need a non-color cue (or 3:1 contrast with body text plus a cue on hover/focus)
1.4.3 / 1.4.11 ContrastAAText 4.5:1; control boundaries and focus rings 3:1
2.5.3 Label in NameAAccessible name contains the visible label
2.5.8 Target Size (Minimum)AAAt least 24×24 CSS px, or enough spacing around smaller targets
4.1.2 Name, Role, ValueACorrect role, a name, and exposed state (pressed, expanded)
  • How the accessible name is computed. In order of precedence: aria-labelledby, then aria-label, then the element’s content (text, plus alt on images inside it), then title as a last resort. Know this order when debugging “why does it say that?” in the browser’s accessibility tree panel.
  • The first rule of ARIA: don’t use ARIA if a native element does the job. role="button" on a <div> changes the announcement but not the behavior: you then owe tabindex="0", Enter and Space handling, and disabled handling. Use ARIA for state (aria-pressed, aria-expanded, aria-current), not to turn one element into another.
  • Don’t nest interactive elements. A <button> inside an <a> (or vice versa) is invalid HTML and announces unpredictably across screen readers.
  • Test with real tools. Keyboard only, then VoiceOver (macOS/iOS) or NVDA (Windows), plus an automated checker like axe. Automated tools catch roughly a third of issues; the role/behavior mismatch is often one they miss.

Common mistakes (and the fix)

Mistake 1: a clickable div. It looks fine with a mouse, but the keyboard can’t reach it and a screen reader doesn’t know it’s clickable.

<!-- ✗ Before -->
<div class="btn" @click="save">Save</div>

<!-- ✓ After -->
<button type="button" class="btn" @click="save">Save</button>

Mistake 2: a fake link that’s really a button. It’s announced as a link, adds # to the URL, and Space scrolls the page instead of activating it.

<!-- ✗ Before -->
<a href="#" @click.prevent="openMenu">Options</a>

<!-- ✓ After -->
<button type="button" @click="openMenu">Options</button>

Animated demo: with focus on a fake link Options (a href='#'), pressing Space scrolls the page and no menu opens. With focus on a real button Options, pressing Space opens a menu with Rename, Duplicate and Archive and the page stays put. Figure 6 (animated). Mistake 2, live. Space scrolls the page on a fake link but opens the menu on a real button.

Mistake 3: a button that’s really a link. It goes to a new page, but you can’t open it in a new tab, copy it, or rely on the Back button.

<!-- ✗ Before -->
<button @click="router.push('/reports')">Reports</button>

<!-- ✓ After -->
<NuxtLink to="/reports" class="btn">Reports</NuxtLink>

Mistake 4: a button inside a link (or a link inside a button). Pick one element and style it however you like.

Mistake 5: a form button with no type. Clicking “Cancel” submits the form, because submit is the default.

Going deeper: the gray areas

  • A “Sign up” call-to-action styled as a big button. If it goes to /signup, it’s a link with button styling. That’s correct. Style follows design; element follows behavior.
  • Submit, then redirect. “Save and continue” sends data and then navigates. The user’s action is the submit, so it’s a button. The navigation is a consequence.
  • Tabs. If each tab swaps a panel in place, it’s the ARIA tabs widget (tablist/tab/tabpanel roles, arrow-key navigation). If each “tab” is its own URL, it’s a <nav> of links with aria-current="page".
  • Pagination, sorting and filters. If they update the URL (and they usually should, so results are shareable and Back works), they’re links. If they’re purely local UI state, they’re buttons.
  • Whole-card click targets. Don’t wrap the entire card in <a>: the link name becomes the whole card’s text, and nested buttons become invalid. Put the link on the heading and stretch its hit area with a ::after pseudo-element positioned over the card.
  • Logout and other state changes. Logging out changes server state, so a form POST behind a button is more correct than a GET link. Prefetchers and crawlers can follow links without a human clicking.

Comparison of two article cards. Left: the whole card wrapped in one link with a nested Save button, announced as the entire card text followed by 'link', and invalid HTML. Right: the link is only on the heading, with an ::after pseudo-element stretched over the card so the whole card is clickable, announced as 'Weekly cleaning schedules, link'; the Save button sits above the overlay. Figure 7. The whole-card click target, done two ways.

Beyond accessibility

Choosing correctly also affects search engines, security and speed. In short: real links help Google find your pages, and buttons keep risky actions from firing by accident.

Going deeper: the other costs of getting it wrong

  • SEO and crawlability. Crawlers follow <a href>. They don’t click buttons or run click handlers, so navigation hidden behind router.push() on a button is invisible to search engines and link-preview bots.
  • Browser features users expect. Links get middle-click, Cmd/Ctrl-click, drag-to-bookmark, copy link address, hover URL preview, :visited and Back. Fake links lose all of it, and users feel it even if they can’t name it.
  • HTTP semantics and security. Links issue GET requests, which must be safe and repeatable. Anything that changes data belongs behind a button and a POST/PUT/PATCH/DELETE. A “Delete” link can be triggered by a prefetcher, a browser extension or a malicious <img src> (a CSRF vector) without anyone clicking.
  • Performance. <NuxtLink> and <RouterLink> prefetch route chunks when they enter the viewport or on hover. A button calling router.push() gets none of that, so the navigation feels slower.
  • Analytics. Name events by intent (signup_started), not by element (button_click), so swapping a button for a link doesn’t break dashboards.
  • Testing. Query by role in tests (getByRole('link', { name: 'Reports' }) in Testing Library, Playwright or Vitest browser mode). If the test can’t find it by role, neither can a screen reader. This turns semantics into something CI enforces.

Building it into a component library

If your team has a shared <BaseButton> component, it should be able to render as either a button or a link, with the same look. Then nobody is tempted to use the wrong element just to get the right style.

Diagram of BaseButton rendering. With a to prop it renders NuxtLink, which outputs a href=/reports. With an href prop it renders a plain a href. With only a click handler it renders button type=button. All three look identical on screen. Figure 8. One component, three possible elements, identical styling.

Going deeper: a polymorphic button in Vue/Nuxt

The component picks the element from its props: to means a router link, href means a plain link, and neither means a button.

<script setup lang="ts">
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { NuxtLink } from '#components'

const props = withDefaults(defineProps<{
  to?: RouteLocationRaw
  href?: string
  type?: 'button' | 'submit' | 'reset'
  variant?: 'primary' | 'secondary' | 'ghost'
}>(), { type: 'button', variant: 'primary' })

const isButton = computed(() => !props.to && !props.href)
const tag = computed(() => props.to ? NuxtLink : props.href ? 'a' : 'button')
</script>

<template>
  <component
    :is="tag"
    :to="to"
    :href="href"
    :type="isButton ? type : undefined"
    :class="['btn', `btn--${variant}`]"
  >
    <slot />
  </component>
</template>

Rules that keep it honest:

  • Default type to "button". Never inherit the browser’s submit default by accident.
  • Only pass button-only attributes to buttons. type, disabled, aria-pressed and aria-expanded are invalid or meaningless on <a>.
  • No disabled links. A link that goes nowhere shouldn’t be a link. Render text, or a button with aria-disabled.
  • Make icon-only usage require a label. Use a TypeScript discriminated union so iconOnly: true without label: string fails to compile.
  • One set of styles for both elements, including :focus-visible, :hover and a reset for the link underline, so styling never drives element choice.
  • Document the decision rule in the component’s docs page, with the “new tab” test, so consumers pick to/href vs @click correctly.
  • Lint for it. eslint-plugin-vuejs-accessibility flags clickable non-interactive elements and missing labels. Add role-based component tests (see the testing note above).

Quick checklist

  • Does it take you somewhere new (page, section, file)? → Link with a real href.
  • Does it do something right here (send, open, toggle, save, delete)? → Button with an explicit type.
  • Would someone want to open it in a new tab? → Link.
  • Does it change data on the server? → Button.
  • Does it switch something on/off or open/closed? → Button, plus aria-pressed or aria-expanded.
  • Is it only an icon? → Give it a name a screen reader can say.
  • Does it need to look like the other one? → Change the CSS, never the element.

Shareable checklist card titled Link or button. Use a link when it takes you to a new page, section or file; someone might open it in a new tab; it could be bookmarked or shared; Back should undo it; search engines should follow it. Use a button when it sends, saves or deletes; changes server data; opens, closes or toggles something here; it only has an icon; it lives in a form. Footer: change the CSS, never the element. Figure 9. The checklist as a single image, sized for social sharing or a printable cheat sheet.

Further reading (pick your rabbit hole)

If you want to go deeper on…Read
The button element and its attributesMDN: <button>
The link element and its attributesMDN: <a>
Toggle, menu and disclosure button patternsWAI-ARIA Authoring Practices: Button
Link patternsWAI-ARIA Authoring Practices: Link
Every accessibility criterion referenced hereWCAG 2.2
How accessible names are calculatedAccessible Name and Description Computation
When (not) to use ARIAUsing ARIA