ACCESSIBILITY
A Button Is Never Just a Button
What a small control reveals about accessibility, interaction design, and frontend quality.
In a design file, a button can look almost trivial: a rectangle, a label, maybe an icon, and a handful of variants.
Most review conversations start with the visible details. Is the color right? Is the padding right? Does the radius match the rest of the system? Those details matter, but they are only part of the control.
A button is defined by the promise it makes.
Activate it and the product is promising that something will happen: a form will submit, a dialog will open, an item will be deleted, a menu will expand, or a setting will be saved.
That promise has to hold whether the person is using a mouse, keyboard, screen reader, touch, or voice control. It also has to hold while the action is loading, when it fails, and when the same button pattern is reused across different flows.
That is what makes buttons interesting to me. They are small, but they expose a surprising amount of product behavior.
What a button is
At its simplest, a button is a control that triggers an action. The WAI-ARIA Authoring Practices Guide uses examples such as submitting a form, opening a dialog, canceling an action, or deleting something. MDN describes the native <button> element in much the same way: it is an interactive control that can be activated by several input methods and then performs an action.
Common button actions include:
- Save changes
- Open settings
- Submit application
- Delete file
- Add another address
- Show filters
- Close dialog
The tricky part is that visual treatment does not tell us the semantic role. A link, tab, menu item, or card action can all be styled to look button-like.
I find the most useful question is simply: what happens when the person activates this control? If it performs an action in the current context, a button is probably the right starting point.
Button or link?
One of the first decisions I make is whether a control is a button or a link. A link takes someone somewhere; a button performs an action. The visual styling can be identical and the underlying element can still be different.
Use a link when the control navigates:
<a href="/services">Explore services</a>Use a button when it performs an action:
<button type="button" onClick={openDialog}>
Open settings
</button>Teams often say, “Make that link look like a button.” That can be perfectly reasonable. If it navigates, it should still be an anchor underneath the styling:
<a className="button button-primary" href="/contact">
Start a conversation
</a>It keeps the behavior people expect from a link: an href, browser navigation, the ability to open in a new tab, and a link role exposed to assistive technology.
The more fragile version is an anchor pretending to be an action:
<a href="#" onClick={saveChanges}>
Save changes
</a>For an action, use the element that already has action semantics:
<button type="button" onClick={saveChanges}>
Save changes
</button>The difference affects keyboard behavior, screen reader expectations, browser behavior, testing, routing, and maintenance. Matching the element to the job keeps all of those pieces easier to reason about.
Native behavior is doing more than people think
A native button arrives with useful behavior before we write any JavaScript.
- It can receive focus.
- It can be activated with expected keyboard commands.
- It exposes a button role.
- It can take its accessible name from visible text.
- It participates in form behavior.
- Browsers and assistive technologies already understand what it is.
Native controls can still be misused. A button can have a poor label, an invisible focus style, or confusing state behavior. The native element just gives us a much better place to start.
Compare that with a clickable <div>:
<div className="button" onClick={saveChanges}>
Save changes
</div>It may respond to a mouse click and look right in a screenshot, but it is missing the behavior people expect from a button. The usual response is to start rebuilding that behavior by hand:
<div
className="button"
role="button"
tabIndex={0}
onClick={saveChanges}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
saveChanges();
}
}}
>
Save changes
</div>Now there is more code to own, more behavior to test, and more ways for the control to drift from the native interaction. In this case, the simpler implementation is also the stronger one:
<button type="button" onClick={saveChanges}>
Save changes
</button>WCAG’s Name, Role, Value criterion requires user interface components to expose programmatically determinable names and roles, along with applicable states and values. Standard HTML controls already provide much of that information when they are used according to their specification.
That does not make native HTML magic. It means we should take advantage of the platform before recreating it.
The anatomy of a button
When I review a button in a real product, I am looking at more than the rectangle and label. The control has several parts that need to agree with one another:
- semantic element
- accessible name
- visual label
- interaction behavior
- keyboard behavior
- focus style
- state
- feedback
- placement
- relationship to surrounding content
Design reviews naturally spend time on visible details such as the label, icon, size, color, spacing, and variant. I also want to know what happens when someone actually uses the control: can they reach it, activate it, understand its name and state, recover from an error, and keep track of focus after the interface changes?
A polished button with a vague label is still hard to use. Consider:
<button type="button" onClick={deleteItem}>
Click here
</button>The action is clearer when the label says what will happen:
<button type="button" onClick={deleteItem}>
Delete file
</button>Repeated controls may need more context so people can tell them apart:
<button type="button" onClick={() => deleteFile(file.id)}>
Delete quarterly-report.pdf
</button>Sometimes the visible label needs to stay short while the accessible name supplies the missing context:
<button
type="button"
aria-label={`Delete ${file.name}`}
onClick={() => deleteFile(file.id)}
>
Delete
</button>The right choice depends on the interface. The useful test is whether the button’s name communicates the action clearly enough in the context where people will encounter it.
Icon-only buttons need names
Icon-only controls show up everywhere: trash cans, pencils, gears, three-dot menus, and close icons. The icon may be obvious visually, but it does not automatically give the button an accessible name.
For example:
<button type="button" onClick={openSettings}>
<SettingsIcon />
</button>Depending on how the icon is implemented, assistive technology may not get a useful name for that button. One option is to name the button directly and hide the decorative icon from the accessibility tree:
<button type="button" aria-label="Open settings" onClick={openSettings}>
<SettingsIcon aria-hidden="true" />
</button>In design review, “Is the icon clear?” is only half the question. I also want to know what the button is called. That helps screen reader and voice-control users, and it usually makes the intended action clearer for QA, documentation, and the team building the feature.
State is not just a visual variant
Design tools are good at showing visual states: default, hover, active, focus, disabled, loading, pressed, and selected. Product behavior has to define what those states mean when someone uses the control.
A loading button is a useful example:
<button type="submit" disabled={isSaving}>
{isSaving ? 'Saving…' : 'Save changes'}
</button>The markup is only the start. The flow still needs decisions about duplicate submissions, the label and spinner, what else is disabled, how failure is explained, and how success is communicated.
A destructive button has the same issue:
<button type="button" className="button-danger" onClick={deleteAccount}>
Delete account
</button>Red can help signal risk, but it cannot answer whether the action needs confirmation, can be undone, what will be deleted, or where the person lands afterward. Those decisions are part of the button pattern too.
Disabled buttons can hide the problem
Disabled buttons are one place where a product decision can disappear behind a visual state.
There are good reasons to disable a button. Preventing a second submission while a request is already running is one:
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting…' : 'Submit'}
</button>But a permanently disabled Submit button can leave someone staring at a form with no explanation. Is a required field empty? Is a format wrong? Is something loading? Is the account missing permission? The control tells them they cannot continue without telling them what to do next.
In many forms, letting the person submit and then showing clear, field-associated validation gives them a more useful path forward:
<button type="submit">Submit</button>
Then, after submit:
<label htmlFor="email">Email</label>
<input id="email" name="email" aria-describedby="email-error" />
<p id="email-error">Enter an email address.</p>I do not treat this as a rule that buttons should never be disabled. I ask what the disabled state is protecting, and whether the person can understand why it is there.
Focus is part of the button’s behavior
Visible focus tells keyboard users which control they are about to operate. WCAG Focus Visible requires keyboard-operable interfaces to provide a mode where that focus indicator can be seen.
A simple implementation might look like this:
button:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}The exact visual treatment can vary. What matters is that the indicator remains visible and usable instead of being removed simply because the browser default does not match the design.
This removes useful information without replacing it:
button:focus {
outline: none;
}Focus also matters after activation. If a button opens a dialog, focus needs to enter that dialog. When it closes, focus usually returns to the trigger. If an item is removed from a list, focus needs a sensible place to go. If submitting a form reveals errors, the person needs a clear route to those errors.
The WAI-ARIA Authoring Practices Guide spends a lot of time on keyboard behavior because custom widgets do not inherit all of this from the platform. The more custom the interaction becomes, the more behavior the team has to own and test.
Forms add another layer
Buttons inside forms have a few details that are easy to miss. One of the simplest habits is setting the button type explicitly:
<button type="button">Cancel</button>
<button type="submit">Save changes</button>MDN notes that buttons not meant to submit form data should usetype="button". Otherwise, a control intended for something like adding a row or canceling an edit can submit the form unexpectedly.
A real form may contain several buttons with different jobs:
<form onSubmit={saveProfile}>
<button type="button" onClick={addPhoneNumber}>
Add phone number
</button>
<button type="button" onClick={cancelEditing}>
Cancel
</button>
<button type="submit">
Save profile
</button>
</form>Only one of those controls submits the form. Making that intent explicit helps the browser, the person using the form, and the next engineer who has to maintain it.
Toggle buttons are different from regular buttons
Some buttons perform an action once. Others represent an on/off state: Mute, Bold, Favorite, Show grid, or Pin sidebar. Those controls need to expose the current state as well as support activation.
<button
type="button"
aria-pressed={isMuted}
onClick={toggleMuted}
>
Mute
</button>The WAI-ARIA button pattern uses aria-pressed for toggle buttons and recommends keeping the label stable when the pressed state changes. In that pattern, “Mute” stays “Mute” while the pressed state communicates whether mute is active.
That is different from swapping the visible label:
<button type="button" onClick={toggleMuted}>
{isMuted ? 'Unmute' : 'Mute'}
</button>There are interfaces where changing the label is the right choice. What matters is knowing whether the control is representing a state, performing an action, or trying to do both, and implementing that contract deliberately.
Buttons become design-system decisions
A single bad button can be fixed locally. When the same uncertainty shows up across a product, the component and its guidance deserve a closer look.
A useful button component defines more than color and size. Teams need to know when to use it, when to use a link instead, what each variant means, how loading and disabled states behave, how icon-only buttons are named, which native button types are supported, what focus treatment is required, and which tests protect the behavior.
A design-system API might be as simple as:
<Button variant="primary" type="submit">
Save changes
</Button>The abstraction should still respect the native control underneath it. Setting type should be easy. Focus styles should not disappear. Accessible names should remain available. A generic clickable wrapper should not become the answer for every interaction.
For me, “accessible by default” means the component gives teams a good starting point and makes common mistakes harder to introduce. It does not remove the need to think about the product flow around the button.
A button review checklist
When I review a button, I usually start with the behavior rather than a long compliance checklist:
- What happens when this is activated?
- Is this really a button, or should it be a link?
- Is the native element being used?
- Can it be reached and activated with the keyboard?
- Is focus visible?
- Does the button have a clear name?
- If it uses only an icon, where does the accessible name come from?
- Does the visual label match the action?
- Does the button communicate loading, disabled, or pressed state correctly?
- What happens after the action succeeds or fails?
- Where does focus go if the interface changes?
- Is this pattern already defined in the design system?
These are not specialist-only questions. Designers, engineers, product managers, and QA all make decisions that affect the answers. A button is simply a small place where those decisions are easy to see together.
What this teaches us
Buttons are familiar enough that it is easy to underestimate them. Look closely, though, and one control can involve semantics, naming, keyboard access, focus, state, feedback, forms, error handling, component APIs, tests, and documentation.
That is useful beyond buttons. The way a team handles a small control often shows how it handles product behavior more generally: whether it relies on visuals alone, uses the platform well, documents state and feedback, includes keyboard and assistive-technology users in the normal audience, and makes good decisions repeatable.
The interaction contract
A button is not finished because it matches the mockup. It is finished when the person using it can understand what it does and the product follows through on that expectation.
So when a button comes up in review, I try to move past “Does it look right?” and ask a few concrete questions instead: What does it do? What is it called? How does it behave from the keyboard? What state does it expose? What happens after activation?
Those answers are the interaction contract. They are also where a small UI control becomes a product-quality decision.