core accessibility principles for modern frameworks

Choosing Between Button and Link in Component APIs

"Should this be a button or a link?" sounds like a style question and is actually a behavioural one. Links navigate; they can be opened in a new tab, copied, bookmarked and prefetched, and they appear in a screen reader's links list. Buttons act; they activate on Space as well as Enter, they submit forms, and they appear in a completely different rotor list. Choosing wrong does not just mislabel the control — it removes capabilities users rely on.

This guide gives the decision rule, then covers the component-API patterns that keep the decision honest as a codebase grows. It extends Semantic HTML vs ARIA in Component Trees.

WCAG Success Criteria Addressed:

  • 2.1.1 Keyboard
  • 2.4.4 Link Purpose (In Context)
  • 3.2.4 Consistent Identification
  • 4.1.2 Name, Role, Value
The behavioural contract each element carries A comparison of anchor and button across six behaviours. An anchor with an href activates on Enter only, appears in the links rotor, supports open in a new tab, can be copied and bookmarked, is prefetchable, and changes the URL. A button activates on both Enter and Space, appears in the buttons rotor, cannot be opened in a new tab or bookmarked, is not prefetchable, and performs an action in place. A footnote says the decision is made by whether a URL changes, never by how the control looks. Behaviour <a href> <button> Activates on Space no — scrolls yes Appears in the links rotor yes no Open in a new tab, copy, bookmark yes no Prefetchable by the router yes no Submits a form no yes The test is whether a URL changes — not whether the control is styled as a pill, a bar, or plain underlined text.

Prerequisites

  • A design system where link and button styling can be applied independently of the element used — otherwise the visual design will keep forcing the wrong choice.
  • A router whose link component renders a real anchor (next/link, NuxtLink, React Router's Link all do).

The Decision Rule

One question settles almost every case: does activating it change the URL?

Implementation Guidelines:

  • URL changes — including in-page fragments and client-side routes — are links. Everything else is a button.
  • "Opens a dialog", "submits", "toggles", "copies to clipboard", "adds to cart" are all buttons, however they are styled.
  • A link that must be styled as a solid pill is fine. A button that must be styled as underlined text is also fine. Appearance is CSS.
  • If a control does two things depending on state, split it into two controls. A single element that navigates sometimes and acts otherwise cannot be announced correctly.

The most common inversion is <a href="#" onClick>, which produces a control announced as a link, activated by Enter only, and offering a "copy link address" that copies the current page.

Implementation Guidelines:

  • Never use href="#" or href="javascript:void(0)" as a placeholder. If there is no destination, the element is a button.
  • Never remove href from an anchor to disable it. An anchor without href is not focusable and not announced as a link — it becomes an invisible control.
  • If a control genuinely navigates and runs code, keep the href real and let the handler enhance it, so middle-click and Cmd-click still work.
  • Prevent default only when you have actually handled the navigation client-side, and never for modified clicks.
// Progressive enhancement: the href is real, the handler is an optimisation.
<a
  href={href}
  onClick={(event) => {
    // Let the browser handle new-tab, download and modified clicks.
    if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
    event.preventDefault();
    router.push(href);
  }}
>
  {children}
</a>

Polymorphic Components Need Guardrails

A single <Button as="a"> component is convenient and is where the semantics usually get lost — the styling comes along, the behaviour does not.

Two shapes for a polymorphic control component On the left, one component takes an as prop that can be button or anchor plus optional href and onClick, so a caller can render an anchor with no href or a button with an href, both of which are broken. On the right, two components share one style layer: Button takes onClick and never href, LinkButton requires href and never takes onClick. The invalid combinations cannot be expressed. One component, an "as" prop as?: "button" | "a" href?: string onClick?: handler <Control as="a" /> — anchor, no href: not focusable, not a link, invisible. Every invalid combination compiles. Two components, one style layer Button: onClick required, no href LinkButton: href required, no onClick shared className, shared tokens The wrong element cannot be paired with the wrong props at all. Same visual result, no invalid states.

Implementation Guidelines:

  • Prefer two components sharing a style layer over one polymorphic component with an as prop.
  • If you keep as, type the props so href is required when as="a" and forbidden otherwise.
  • Never let a Button render an anchor without an href; that combination is focusable by nothing and announced as nothing.
  • Keep the visual variants (primary, ghost, danger) on both, so nobody picks the wrong element to get the right look.
type ButtonProps = { onClick: () => void; href?: never } & CommonProps;
type LinkProps = { href: string; onClick?: never } & CommonProps;

export function Button(props: ButtonProps) { /* renders <button> */ }
export function LinkButton(props: LinkProps) { /* renders <a>, same styles */ }

Disabled States Differ Too

disabled exists on buttons and not on links, which is a hint that "a disabled link" is usually a design problem.

Implementation Guidelines:

  • For buttons, prefer aria-disabled="true" plus a no-op handler over the disabled attribute when the user needs to discover whydisabled removes the control from the tab order entirely.
  • For links, do not fake disabled by removing href. Render plain text, or keep the link and explain the state at the destination.
  • Never style a control as disabled without conveying it programmatically; colour alone fails 1.4.1 Use of Color.
  • Where a control is temporarily busy, aria-busy plus a live-region message is better than disabling it silently.
Three ways to express "not available right now" Three treatments compared. The disabled attribute removes the control from the tab order entirely, so a user cannot reach it to find out why it is unavailable. Aria-disabled keeps the control focusable and announced as dimmed while a no-op handler prevents the action, so the reason can be read from associated help text. Removing the href from an anchor is the worst option: the element is neither focusable nor announced as anything. disabled attribute (button) Not focusable. Fine when the reason is obvious; poor when the user needs to ask why. aria-disabled plus a no-op handler Focusable and announced as dimmed, so associated help text explains the state. Anchor with the href removed Neither focusable nor announced as a link — the control disappears entirely. Avoid.

How to Verify

  • Rotor check. Open the links list in a screen reader. Anything that does not navigate should be absent; anything that does should be present.
  • Space test. Focus each action control and press Space. Buttons activate; a control that scrolls the page instead is an anchor pretending.
  • New-tab test. Cmd/Ctrl-click every navigating control. If nothing opens, it is not a real link.
  • Automated scan. jsx-a11y/anchor-is-valid catches href="#" and missing hrefs at author time — see configuring eslint-plugin-jsx-a11y for React.
  • Consistency check. The same action in two places should use the same element and the same name — that is 3.2.4 Consistent Identification.
  • Middle-click every navigating control. A real link opens a new tab; a button styled as a link does nothing, which is the fastest way to find the ones that were built wrong.
  • Read the markup of your primary call to action. It is the control most likely to have been styled first and chosen second, and the one whose behaviour users notice immediately.

Common Accessibility Mistakes

  • <a href="#" onClick>. Announced as a link, activated by Enter only, and its context menu offers to copy the wrong URL.
  • <div onClick> styled as a button. No role, no keyboard, no name — the bug the interactive-element lint rules exist to catch.
  • Anchors without href for disabled links. Not focusable, not announced, invisible to everyone using assistive technology.
  • Buttons that navigate via window.location. Loses new-tab, prefetch, copy and the links list for no benefit.
  • Polymorphic components with untyped as. Every invalid element/prop combination compiles, and one of them ships.
  • Choosing by appearance. "It looks like a button so it should be a <button>" gets the decision backwards.

Conclusion

The rule is one question long: if it changes the URL it is a link, and otherwise it is a button. Everything else follows — the keyboard contract, the rotor list, new-tab support, prefetching, form submission. Keep styling independent of the element so design never forces the wrong answer, prefer two typed components over one polymorphic one, and let a lint rule catch the href="#" that slips through anyway. The result is a codebase where the question stops being asked, because the API only lets you express the right answer.

The wider point is that HTML elements are behavioural contracts, not styling hooks. An anchor promises a destination; a button promises an action; an input promises a value. When a component picks its element by appearance, it signs a contract it does not intend to honour, and every downstream feature that relies on that contract — new-tab, prefetch, form submission, the links rotor — quietly stops working. Picking the element by behaviour first, and styling second, is the cheapest way to keep those promises without thinking about them again.

Frequently Asked Questions

What about a control that opens a modal showing a page's content? It is a button, because no URL changes. If the modal is deep-linkable — a route that renders as an overlay — then the control is a link to that route, and the overlay is the route's presentation. Pick based on whether the address bar changes.

Should a "Download" control be a link or a button? A link with href pointing at the file and a download attribute. The URL is the file; the browser handles the rest, and users keep the ability to copy the address or open it in a new tab.

Is role="button" on an anchor acceptable? It fixes the announced role and not the behaviour: Space still scrolls unless you add a key handler, and the context menu still offers link actions. It is a last resort for markup you cannot change, never a design choice.

How should a link that triggers a client-side route change be marked? As a normal anchor with a real href. The router intercepts the click for the fast path, but the href keeps new-tab, prefetch and copy working — and the page still works if JavaScript fails to load.

What about buttons inside links, or links inside buttons? Neither is valid HTML and both produce unpredictable focus and activation behaviour. Restructure so the two controls are siblings: a card with a title link and a separate "Save" button beside it, not one nested inside the other.