Links vs Buttons
Navigate with links, act with buttons — correct semantics, keyboard behavior, and the SPA footguns that break both.
- html
- links
- buttons
- accessibility
- semantics
Links go somewhere. Buttons do something. That split drives semantics, keyboard expectations, middle-click/open-in-new-tab behavior, and SEO. Using the wrong element is one of the most common frontend accessibility bugs — and one of the easiest to fix.
Docs: MDN <a>, MDN <button>, APG, HTML spec.
The rule of thumb
| User intent | Element | Why |
|---|---|---|
| Go to a URL / route | <a href="…"> |
Real navigation, shareable URL, open in new tab, crawlable when applicable |
| Submit a form, toggle UI, delete, play, open dialog | <button type="…"> |
Action without changing location (or not primarily navigation) |
| “Looks like a button, navigates” | Still <a> — style it |
Keep navigation semantics |
| “Looks like a link, runs JS only” | Usually <button> |
Fake links break expectations |
If the primary result is a new location (including in-app routes that update the URL), start with a link. If the primary result is an action on this page, start with a button.
What browsers and AT give you for free
Link (<a href>)
<a href="/pricing">Pricing</a>
<a href="https://example.com/docs">Docs</a>
- Enter and (typically) activation conventions users know
- Middle-click / Cmd-click → new tab
- “Copy link address,” share sheet, status-bar URL preview
- In lists of navigation, announced as link
- Without
href, an<a>is not a full link — don’t use bare<a>as a button
Button (<button>)
<button type="button" onclick="save()">Save</button>
<button type="submit">Create account</button>
- Space and Enter activate (links are primarily Enter; Space scrolls the page for focused links in many browsers)
- Form association with
type="submit"/reset - Disabled state via
disabled - Announced as button
Using <div role="button" tabindex="0"> means you reimplement keyboard, disabled semantics, and form behavior. Prefer native.
SPA routers
Client routers still benefit from real anchors:
// Prefer the router's Link that renders <a href="/about">
<Link to="/about">About</Link>
// Avoid
<span onClick={() => navigate('/about')}>About</span>
Reasons:
- Progressive enhancement / open-in-new-tab.
- Screen reader and browser features still see a link.
- Cmd-click and “open in new tab” work when
hrefis real. - Analytics and crawlers get URLs.
preventDefault on click for client navigation is fine on an actual href. Replacing the anchor with a clickable div is not.
When product design blurs the line
Button-styled navigation
<a class="btn btn--primary" href="/signup">Start free trial</a>
Correct. CSS does not change the element choice.
Link-styled actions
<button type="button" class="linkish" id="show-more">Show more</button>
Also correct. Don’t use <a href="#"> or <a href="javascript:void(0)"> for actions — that’s a legacy anti-pattern (history pollution, focus weirdness, wrong role).
Download
<a href="/report.pdf" download>Download report</a>
Navigation-ish resource fetch → link. A button that triggers a JS-generated blob download can stay a button if there’s no stable URL.
Same-page targets
<a href="#billing">Billing section</a>
In-page navigation → link. Manage focus to the target when needed for skip links / accessibility.
Icon-only controls
Every control needs an accessible name:
<a href="/settings" aria-label="Settings">
<svg aria-hidden="true">…</svg>
</a>
<button type="button" aria-label="Close">
<svg aria-hidden="true">…</svg>
</button>
Visible text is better when space allows. aria-hidden on decorative SVGs prevents double announcements. See accessible SVGs.
Disabled and “busy”
- Prefer
disabledon<button>for unavailable actions. - Links don’t have a native disabled state; use
aria-disabled="true"plus prevent navigation, or render plain text if it’s not interactive. Styling a link gray without removing interactivity is a trap. - For in-flight submits, disable the button and expose busy state (
aria-busyon a region, or live text “Saving…”).
Security and UX side notes
target="_blank"on links should includerel="noopener noreferrer"(modern browsers defaultnoopenerfor_blank, but be explicit in older targets). Tell users when a new tab opens if it matters.- Never put untrusted HTML inside link/button content without escaping — classic XSS sink via markup or
javascript:URLs onhref. - Large hit targets help Core Web Vitals indirectly (fewer rage clicks) and meet touch-size guidance.
Decision flowchart (short)
- Does it change the URL / take you to a resource? →
<a href> - Does it submit a form? →
<button type="submit"> - Otherwise an on-page action? →
<button type="button"> - Must it look like the other one? → Style it. Don’t change the element.
Interview angle
State the navigate vs act rule. Explain Space vs Enter differences at a high level. Call out href="#" anti-pattern and SPA Link rendering real anchors. Mention accessible names for icon-only controls.
Live coding: toolbar with “Docs” (link), “Save” (button), and icon-only “Close” (button + aria-label).
Related on this site
- Forms, labels, and inputs
- dialog element
- Semantic HTML
- WCAG principles (POUR)
- Focus management
- Keyboard accessibility checklist
- XSS
Further reading
Related guides
- The dialog ElementNative modal and non-modal dialogs with showModal, light dismiss, focus, and the top layer — without a heavyweight widget library.
- Forms, Labels, and InputsWire labels to controls correctly, choose input types, group fields, and avoid the accessibility bugs that fail real users and audits.
- Semantic article section asideWhen to use article, section, and aside — standalone content vs thematic grouping vs complementary, with heading pairing.
- Semantic HTMLPick elements for meaning, not looks — landmarks, headings, forms, media, and how semantics feed a11y and SEO.
- Accessibility Tree OverviewHow browsers build the accessibility tree from DOM and CSS — roles, names, states, what’s pruned, and how to inspect it in DevTools.