HTML Buttons
The <button> element is the primary control for triggering actions that do not navigate to a new URL. It submits forms, opens dialogs, toggles UI state, runs scripts, and resets fields. Getting buttons right means choosing the correct element, setting an explicit type, wiring form association attributes when needed, and exposing an accessible name and state.
This guide covers the full surface area of HTML buttons: how they differ from links and <input type="button">, the three type values, all form* override attributes, disabled and autofocus behavior, icon-only patterns, toggle buttons with aria-pressed, dialogs, CSS reset pitfalls, keyboard activation rules, and browser quirks that still surprise teams in production.
Prefer native <button> over role-button divs. Prefer links for navigation. Always declare type inside forms — the default is submit, which causes many accidental submissions.
The type attribute on <button> accepts three values. Omitting it defaults to submit — even for buttons that only run JavaScript. That default is the root cause of many “page reloads on click” bugs.
| type | Default action | When to use |
|---|---|---|
| submit | Submit the associated form | Primary form save / continue |
| reset | Reset fields to initial values | Rare — confirm before use |
| button | None (script / UI only) | Almost all JS-driven controls |
| 1 | <form method="post" action="/api/signup"> |
| 2 | <label>Email <input name="email" type="email" required /></label> |
| 3 | |
| 4 | <!-- Explicit submit --> |
| 5 | <button type="submit">Create account</button> |
| 6 | |
| 7 | <!-- Explicit non-submitting control --> |
| 8 | <button type="button" id="toggle-password">Show password</button> |
| 9 | |
| 10 | <!-- Reset restores defaultValue / defaultChecked --> |
| 11 | <button type="reset">Clear form</button> |
| 12 | </form> |
| 13 | |
| 14 | <!-- Outside a form, type still matters for consistency --> |
| 15 | <button type="button">Open menu</button> |
warning
note
HTML associates a button with a form either by nesting or by the form attribute pointing at a form id. Override attributes on the button can change how that specific submit control posts data — without changing the form element itself.
| Attribute | Overrides | Example |
|---|---|---|
| form | Which form the control belongs to | form="checkout" |
| formaction | form action URL | formaction="/api/draft" |
| formenctype | enctype | formenctype="multipart/form-data" |
| formmethod | method | formmethod="get" |
| formnovalidate | Skips constraint validation | formnovalidate |
| formtarget | Browsing context / target | formtarget="_blank" |
| 1 | <form id="profile" method="post" action="/api/profile" enctype="application/x-www-form-urlencoded"> |
| 2 | <label>Name <input name="name" required /></label> |
| 3 | <label>Avatar <input name="avatar" type="file" /></label> |
| 4 | |
| 5 | <!-- Nested submit uses the form's action/method --> |
| 6 | <button type="submit">Save profile</button> |
| 7 | </form> |
| 8 | |
| 9 | <!-- Button outside the form, associated by id --> |
| 10 | <button type="submit" form="profile">Save (footer)</button> |
| 11 | |
| 12 | <!-- Same form, different endpoint + skip validation (draft) --> |
| 13 | <button |
| 14 | type="submit" |
| 15 | form="profile" |
| 16 | formaction="/api/profile/draft" |
| 17 | formmethod="post" |
| 18 | formnovalidate |
| 19 | > |
| 20 | Save draft |
| 21 | </button> |
| 22 | |
| 23 | <!-- Upload path needs multipart --> |
| 24 | <button |
| 25 | type="submit" |
| 26 | form="profile" |
| 27 | formaction="/api/profile/avatar" |
| 28 | formenctype="multipart/form-data" |
| 29 | > |
| 30 | Upload avatar only |
| 31 | </button> |
| 32 | |
| 33 | <!-- Open printable confirmation in a new tab --> |
| 34 | <button type="submit" form="profile" formaction="/profile/print" formmethod="get" formtarget="_blank"> |
| 35 | Print preview |
| 36 | </button> |
info
pro tip
Boolean and name/value attributes control focus, participation in form data, and whether the control can be activated.
| Attribute | Effect |
|---|---|
| disabled | Not focusable, not successful, not activatable; matches :disabled |
| autofocus | Focus on page load or when a dialog opens (use sparingly) |
| name | If present on a successful submit button, included in form data |
| value | Value paired with name when the button is the submitter |
| 1 | <form method="post" action="/checkout"> |
| 2 | <button type="submit" name="intent" value="pay">Pay now</button> |
| 3 | <button type="submit" name="intent" value="save-cart">Save cart</button> |
| 4 | </form> |
| 5 | |
| 6 | <!-- Only the activated submit control is successful --> |
| 7 | <!-- POST body might be: intent=pay --> |
| 8 | |
| 9 | <button type="button" disabled aria-busy="true">Saving…</button> |
| 10 | |
| 11 | <dialog id="confirm"> |
| 12 | <form method="dialog"> |
| 13 | <p>Delete this item?</p> |
| 14 | <button type="submit" value="cancel">Cancel</button> |
| 15 | <button type="submit" value="confirm" autofocus>Delete</button> |
| 16 | </form> |
| 17 | </dialog> |
| 1 | const pay = document.querySelector('button[value="pay"]'); |
| 2 | pay.disabled = true; // prevent double submit |
| 3 | pay.setAttribute('aria-busy', 'true'); |
| 4 | |
| 5 | // Re-enable after fetch settles |
| 6 | async function onPay(e) { |
| 7 | e.preventDefault(); |
| 8 | pay.disabled = true; |
| 9 | try { |
| 10 | await fetch('/checkout', { method: 'POST', body: new FormData(e.target) }); |
| 11 | } finally { |
| 12 | pay.disabled = false; |
| 13 | pay.removeAttribute('aria-busy'); |
| 14 | } |
| 15 | } |
warning
note
Screen readers announce a button by its accessible name. Visible text usually provides that name. Icon-only buttons must get a name from aria-label, aria-labelledby, or visually hidden text — not from a title tooltip alone.
| 1 | <!-- Good: visible text --> |
| 2 | <button type="button">Search</button> |
| 3 | |
| 4 | <!-- Good: icon + visually hidden text --> |
| 5 | <button type="button"> |
| 6 | <svg aria-hidden="true" focusable="false" width="16" height="16">...</svg> |
| 7 | <span class="visually-hidden">Search</span> |
| 8 | </button> |
| 9 | |
| 10 | <!-- Good: aria-label when no visible text --> |
| 11 | <button type="button" aria-label="Search"> |
| 12 | <svg aria-hidden="true" focusable="false" width="16" height="16">...</svg> |
| 13 | </button> |
| 14 | |
| 15 | <!-- Bad: decorative SVG becomes the name --> |
| 16 | <button type="button"> |
| 17 | <svg><!-- paths with no title --></svg> |
| 18 | </button> |
| 19 | |
| 20 | <!-- Bad: title alone is unreliable --> |
| 21 | <button type="button" title="Search"> |
| 22 | <svg aria-hidden="true"></svg> |
| 23 | </button> |
| 1 | .visually-hidden { |
| 2 | position: absolute; |
| 3 | width: 1px; |
| 4 | height: 1px; |
| 5 | padding: 0; |
| 6 | margin: -1px; |
| 7 | overflow: hidden; |
| 8 | clip: rect(0, 0, 0, 0); |
| 9 | white-space: nowrap; |
| 10 | border: 0; |
| 11 | } |
best practice
Browser default button styles differ wildly. Resets often strip focus outlines, set cursor incorrectly, or make buttons inherit font sizes inconsistently. Restore focus visibility and do not remove :focus-visible styles.
| 1 | /* Dangerous reset */ |
| 2 | button { |
| 3 | all: unset; /* removes focus ring, keyboard affordances perception */ |
| 4 | } |
| 5 | |
| 6 | /* Safer baseline */ |
| 7 | button { |
| 8 | font: inherit; |
| 9 | color: inherit; |
| 10 | background: #111; |
| 11 | border: 1px solid #00FF41; |
| 12 | padding: 0.5rem 1rem; |
| 13 | border-radius: 4px; |
| 14 | cursor: pointer; |
| 15 | } |
| 16 | |
| 17 | button:disabled { |
| 18 | opacity: 0.5; |
| 19 | cursor: not-allowed; |
| 20 | } |
| 21 | |
| 22 | button:focus-visible { |
| 23 | outline: 2px solid #00FF41; |
| 24 | outline-offset: 2px; |
| 25 | } |
| 26 | |
| 27 | /* Do not do this */ |
| 28 | button:focus { |
| 29 | outline: none; /* kills keyboard UX unless replaced */ |
| 30 | } |
danger
warning
Native buttons activate on Space and Enter (with slight press/release nuances). Links activate on Enter only. Custom role=button elements must reimplement Space handling, prevent page scroll on Space, and manage tabindex — another reason to prefer real buttons.
| 1 | // Native button: no extra keyboard code needed |
| 2 | el.addEventListener('click', onAction); |
| 3 | |
| 4 | // If you must polyfill role=button (avoid): |
| 5 | div.addEventListener('keydown', (e) => { |
| 6 | if (e.key === ' ' || e.key === 'Enter') { |
| 7 | e.preventDefault(); |
| 8 | div.click(); |
| 9 | } |
| 10 | }); |
| Control | Enter | Space | Tab |
|---|---|---|---|
| button | Activates | Activates | Focuses |
| a[href] | Activates | Scrolls page | Focuses |
| div role=button | You must handle | You must handle | Needs tabindex=0 |
best practice
A few long-lived quirks still affect production forms and component libraries.
| Quirk | Detail | Mitigation |
|---|---|---|
| Default type=submit | Omitted type submits enclosing form | Always set type |
| Implicit submission | Enter in a text field submits | Single text field forms submit unexpectedly |
| IE legacy type | Historical bugs around button type | Irrelevant in modern evergreen, still in old docs |
| iOS tap delay (old) | 300ms myths linger | Use touch-action / modern browsers |
| Disabled pointer events | Clicks ignored; tooltips hard | Wrap or use aria-disabled pattern |
| formaction + GET | Query serialization quirks with files | Do not GET file inputs |
| 1 | <!-- Implicit submission: one text field + Enter submits --> |
| 2 | <form action="/search"> |
| 3 | <input type="search" name="q" /> |
| 4 | <!-- implicit submit even without a visible button in some cases --> |
| 5 | </form> |
| 6 | |
| 7 | <!-- Add type=button siblings so they do not submit --> |
| 8 | <form> |
| 9 | <input name="q" /> |
| 10 | <button type="button" id="clear">Clear</button> |
| 11 | <button type="submit">Search</button> |
| 12 | </form> |
note
Interactive samples covering primary actions, disabled state, and a pressed toggle.
Use this checklist when reviewing button markup in PRs and design systems.
| 1 | [ ] Correct element: button for actions, a for navigation |
| 2 | [ ] Explicit type on every button (especially inside forms) |
| 3 | [ ] Accessible name from text, aria-label, or aria-labelledby |
| 4 | [ ] Decorative icons are aria-hidden |
| 5 | [ ] Loading state disables duplicate submits (or aria-disabled) |
| 6 | [ ] Focus-visible styles preserved after CSS resets |
| 7 | [ ] Toggle state uses aria-pressed; menus use aria-expanded |
| 8 | [ ] Destructive actions confirmed in a dialog |
| 9 | [ ] form* overrides documented for multi-submit forms |
| 10 | [ ] No href="#" / javascript: void “buttons” |
| 11 | [ ] Hit target ≥ 24×24 CSS px (prefer 44×44 on touch) |
| 12 | [ ] Contrast meets WCAG for text and UI components |
best practice
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.