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 Keyboard2.4.4 Link Purpose (In Context)3.2.4 Consistent Identification4.1.2 Name, Role, Value
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'sLinkall 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.
Do Not Make Links Do Button Work
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="#"orhref="javascript:void(0)"as a placeholder. If there is no destination, the element is a button. - Never remove
hreffrom an anchor to disable it. An anchor withouthrefis not focusable and not announced as a link — it becomes an invisible control. - If a control genuinely navigates and runs code, keep the
hrefreal 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.
Implementation Guidelines:
- Prefer two components sharing a style layer over one polymorphic component with an
asprop. - If you keep
as, type the props sohrefis required whenas="a"and forbidden otherwise. - Never let a
Buttonrender an anchor without anhref; 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 thedisabledattribute when the user needs to discover why —disabledremoves 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-busyplus a live-region message is better than disabling it silently.
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-validcatcheshref="#"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 byEnteronly, 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
hreffor 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.
Related guides
- Semantic HTML vs ARIA in Component Trees — the parent guide on element choice.
- Accessible Headings & Document Outline in Components — the same "structure over appearance" rule for headings.
- Accessible Navigation & Landmark Structure — where links live and how they are grouped.
- Configuring eslint-plugin-jsx-a11y for React — the rules that enforce this automatically.