Component Archaeology
Field Study 02 — Controls

The Button

The web had a correct button from day one. We spent twenty years replacing it with worse ones.

20 min read · runnable demos Updated

Every page has a button. Not in the way every page has a footer — in the way every page has a cursor. You cannot describe a web interface without one. And yet the history of the web button is a history of the industry rejecting the one that shipped. <button> has been in every browser since 1995. We spent the next twenty-five years using everything except it.

The hard parts were simpler than the dialog’s. A button does not trap focus or manage stacking layers; it just activates. Space to press it. Enter to confirm. A label the screen reader can announce. The platform handled all of it from day one — and we threw it away in pursuit of rounded corners.

This is a study in cosmetic drift: what happens when an industry optimises for appearance at the expense of semantics, and what it costs to find the way back.1

00 Context · before the web

Desktop GUI Buttons, 1984–1992

Before the web, buttons were solved. The operating system drew them, owned their keyboard behaviour, and handed their role to assistive technology through platform APIs. The web would spend twenty years trying to rebuild this.

The default button — heavy border, Return to press — was a platform convention, not a styling trick.Recreation after Apple Macintosh System 7 (1991). Drawn in CSS; not a screenshot.
The Push Button control in WINAPI: beveled grey, dotted focus ring drawn by the OS, Space to activate.Recreation after Microsoft Windows 3.1 (1992). Drawn in CSS; not a screenshot.

The desktop OS drew buttons from system resources, which meant they matched every other button on the machine. They were focusable via Tab. The focused one responded to Space. The default button in a dialog responded to Return. Focus rings were platform-drawn — thick, high-contrast, guaranteed. Assistive technology read the button’s label from the system’s own accessibility API.

None of this required any code. It was the platform. The web would inherit the concept of the button while inheriting none of its implementation — and would then spend the following two decades re-inventing, badly, what desktops had given away for free in 1984.

01 Context · the first web button

input[type=submit], 1993

The web's first button was a form control. It worked perfectly — keyboard-operable, correctly labelled, platform-drawn. Its only crime was appearance. That was enough to start twenty years of replacement attempts.

input[type=submit] · native, unstyled Live

HTML 2.0 shipped <input type="submit"> in 1993. One attribute for the label (value). One tab stop. Space and Enter for activation. Screen readers announced it correctly. The browser drew the focus ring. It participated in form submission without JavaScript. By every measure that matters for accessible interactive design, it was correct on arrival.

It was also unstyled. You could give it a background colour in Internet Explorer 3 and roughly nothing else. Designers wanted custom type, rounded corners, drop shadows, hover states. The <input> was a flat grey rectangle that the operating system drew and the browser refused to restyle. So the industry reached for something it could control — and the drift began.

It did everything right except the one thing designers cared about.

<input type="submit" value="Log In">
<input type="reset"  value="Clear">
<!-- Space and Enter activate it. Focus ring included.
     No JavaScript. Label is the value attribute. -->

The entire API. Two attributes: type selects the behaviour, value provides the label. Space and Enter included.

so we reached for images and links

02 The impersonator era

Image buttons & the link disguise, 2000

Two escape routes appeared from the styling dead end. One wrapped a button in a GIF. The other was a link wearing a button costume. Both looked correct. Both silently broke the keyboard contract.

input[type=image] & a[href='#'] · 2000 Live

<input type="image"> replaced the grey rectangle with a graphical button — a GIF or PNG, sized and styled however you liked. The keyboard still worked; focus still existed. The label lived in the alt attribute and was therefore frequently absent, forgotten, or set to the filename. “submit2.gif” as an accessible name was the failure mode for a decade.

The second escape route was worse: <a href="#" onclick="doSomething(); return false">. A link wearing a button costume. Tab worked — links are focusable. Enter worked — links follow their href. Space did not work; it scrolled the page. Screen readers announced “link” where the visual design showed a button. The role was a lie, the activation key was wrong, and the entire keyboard behaviour contract was broken.

The button looked however you wanted. Everything it communicated was wrong.

Why it spread

Links were trivially styleable in a way form controls were not. Once the first stylesheet floated display: block on an <a> and added cursor: pointer, every site that wanted a branded call-to-action button copied the pattern. By 2003, “link that looks like a button” was the default idiom for interactive elements that were not form submissions. The accessible name problem was invisible until someone ran a screen reader or keyboard-only audit — which most teams never did.

<!-- Image button: accessible name = alt attribute -->
<input type="image" src="submit.gif" alt="Submit form">
<!-- alt="" → no name. Filename → wrong name.
     Most real buttons shipped with one or the other. -->

<!-- The link-as-button idiom begins -->
<a href="#" onclick="handleClick(); return false">
  Delete account
</a>
<!-- Role: "link". Space: scrolls the page.
     Enter: follows href="#". Tab: yes. Click: yes.
     Two out of four keyboard interactions correct. -->

Both patterns were mainstream by 2002. The image button could be saved; the link-as-button pattern compounded errors.

then CSS got powerful enough to fake it

03 The illusion era

CSS Sliding Doors, 2003

Dave Shea's Sliding Doors technique gave designers rounded buttons without images — or rather, with exactly two images on two nested elements. The corners were finally right. The element was still a link.

Sliding Doors technique · A List Apart 2003 Live

The insight was simple: a rounded pill button needs rounded left and right caps. Two nested elements, one for each cap — left image on the inner, right image on the outer — creates the illusion of a single styled shape whose width adjusts with the text. A List Apart published the technique in April 2003 and it became one of the most-copied CSS patterns of the decade.2

The markup was still an <a>. Sometimes it was an <input>, but <input> could not hold child elements for the two-image trick. <button> could — but <button> had notoriously erratic default styling in Internet Explorer and required a thorough reset before you could trust it. The path of least resistance kept routing back to the link.

The appearance was finally right. Nothing else had changed.

The end of Sliding Doors

border-radius arrived in Firefox 2 (2006) with a -moz- prefix, in Safari 3 (2007) with -webkit-, and in Internet Explorer 9 (2011) without a prefix. The day you could write border-radius: 6px in plain CSS and have it work in every browser your users ran, Sliding Doors died overnight. It had been a workaround for a missing CSS feature. The feature arrived; the workaround was abandoned. The element behind the technique — the <a> link — stayed.

/* Two nested elements × two background GIFs
   = one rounded button in IE6 */
a.btn {
  background: url(btn-right.gif) no-repeat right top;
  display: block; float: left;
}
a.btn span {
  background: url(btn-left.gif) no-repeat left top;
  display: block;
  padding: 6px 18px 6px 14px;
  white-space: nowrap;
}
/* border-radius does not exist in CSS yet.
   IE6 will never support it.
   This costs 2 HTTP requests per button. */

Two background images on two nested elements simulate what border-radius would later make trivial. The HTML underneath never changed.

then a widget promised to normalise it all

04 The widget era

jQuery UI Button, 2010

jQuery UI's .button() accepted any element — button, input, anchor — and dressed it identically. It normalised appearance across browsers. It did nothing about the accessible name of an icon.

jQuery UI 1.8 · Smoothness theme Live

jQuery UI took a pragmatic position: whatever element you gave it, .button() would make it look right. In Internet Explorer 6, form controls inherited no font styles and were drawn from Windows system resources — the normalisation had real value. ThemeRoller let you generate a consistent visual language across an entire application without touching the underlying markup.

The icon system used a single CSS sprite — one image file, hundreds of icons, each positioned via background offset. The icon <span> the widget injected had no alt attribute, no aria-hidden, no aria-label. What a screen reader announced depended on whether the browser tried to read the CSS class name, the background-image URL, or nothing. It was undefined behaviour in practice.

jQuery UI got the pixels right and left the semantics to chance.

What the WAI-ARIA spec said

aria-hidden="true" for decorative icons and aria-label for icon-only controls were both in the WAI-ARIA 1.0 specification, published in 2014. jQuery UI 1.8 shipped in 2010, before the spec was finalised — but the patterns were circulating in the accessibility community well before that. The widget system did not pick them up. If you shipped an icon-only button, the accessible name was your problem.

$('#save-btn').button({
  icons: { primary: 'ui-icon-disk' },
  label: 'Save changes'
});
// Works on <button>, <input>, <a> —
// but the icon sprite has no aria-hidden.
// SR announces: "save disk ui-icon-disk button"

An imperative API that normalised visual output while leaving accessible naming entirely implicit.

then the framework gave everyone a taxonomy

05 The design-system era

Bootstrap 3 .btn, 2011

Bootstrap gave buttons a taxonomy — default, primary, success, danger — an icon system, and a loading state. It made the broken idiom easy. Accessibility was the developer's homework.

Bootstrap 3.3 · Glyphicons Live

Bootstrap 3’s button vocabulary — .btn-default, .btn-primary, .btn-success, .btn-danger — became so widespread it entered the ambient vocabulary of web design. Entire products were spec’d in those terms. The taxonomy was genuinely useful: it gave visual hierarchy a shared language across teams.

The icon system was Glyphicons: a web font where specific Unicode code points mapped to icon shapes. <i class="glyphicon glyphicon-trash"></i> rendered a trash icon. For a screen reader, the character was either the Unicode private-use area glyph (silence, or a garbled code point), or the class name, depending on browser and reader. Bootstrap 3.0 shipped without aria-hidden="true" on the icon element — a later point release added it, after the pattern had already propagated everywhere.

Bootstrap made the broken idiom frictionless. The accessibility was your problem.

The documented antipattern

Bootstrap’s own documentation showed <a href="#" class="btn btn-primary" role="button"> for pages that needed buttons without JavaScript. Wrong element, plus an explicit role to patch the wrong element, plus an href that navigates instead of activating. Space still scrolled the page. The docs showed it; the docs did not explain why you should avoid it.

<!-- Glyphicons have no aria-hidden in Bootstrap 3.0 -->
<button class="btn btn-primary">
  <i class="glyphicon glyphicon-ok"></i> Save
</button>
<!-- SR reads: "ok Save button" -->

<!-- Icon-only: accessible name is empty -->

<button class="btn btn-default" title="Delete">
  <i class="glyphicon glyphicon-trash"></i>
</button>

<!-- The antipattern Bootstrap's own docs showed -->
<a href="#" class="btn btn-success" role="button">
  Confirm
</a>
<!-- Wrong element + explicit role to patch it.
     Space still doesn't activate (it scrolls). -->

Three problems in one block: glyph leaking into the accessible name, empty icon-only name, wrong element with a patching role.

then components made icons a prop

06 The component era

Material-UI Button, 2016

React's component model changed something structural: the icon became a prop, not embedded markup. Material-UI rendered it with aria-hidden automatically. For the first time, the easy path and the accessible path were the same path.

Material Design · contained · ripple Live

Material Design gave buttons a physics: elevation, ripple, a motion curve for the press state. More importantly, it gave them an API. startIcon={<SaveIcon />} told the component what the icon was; the component decided how to render it — including aria-hidden="true" on the icon element. For the first time in the button’s history, a mainstream library got icon accessibility right by default rather than by instruction.

The pattern still required text content inside the button. An icon-only <Button> with no text node had an empty accessible name — MUI’s documentation said “use aria-label” but the component did not enforce it or warn at runtime. The loading prop (MUI v5) went further: it swapped the icon for a spinner, preserved the label text (visually hidden), and set aria-disabled. The intent was right; the full picture required reading the docs.

The component made the easy path and the accessible path converge — for icons, at least.

What lock-in cost

Theming a Material-UI button meant understanding the JSS layer, the theme provider, the component’s slot system, and Material’s elevation tokens. The button was accessible; it was also entangled. Changing the primary colour required a theme object; changing the border radius required overriding a deep class. The component abstraction that fixed accessibility introduced a new dependency the rest of the codebase could not avoid.

// MUI v4 — startIcon aria-hidden automatically
<Button variant="contained" startIcon={<SaveIcon />}>
  Save changes
</Button>

// Loading state: label stays, icon swaps, button disables

<Button variant="contained" loading={saving} loadingPosition="start" startIcon={<SaveIcon />}>
  {saving ? 'Saving…' : 'Save'}
</Button>
// startIcon: aria-hidden="true" ✓ // loadingIndicator: aria-hidden="true" ✓ // while loading:
aria-disabled="true"

startIcon is rendered with aria-hidden automatically. The loading state keeps the label in the DOM — screen readers see 'Saving…' not a spinning wheel.

then the platform caught up with CSS

07 The return

Native <button> renaissance, 2020

Two things converged: CSS custom properties made full theming possible without a framework, and :focus-visible made the focus ring sane. Every aesthetic argument against <button> evaporated simultaneously.

Native <button> · :focus-visible · aria-pressed Live

Two things changed in the early 2020s that together made <button> the obvious choice again. The first was the CSS property model maturing. With all: unset clearing browser defaults cleanly, and CSS custom properties making full theming possible without a class system or a build step, there was no styling reason left to prefer an <a> or an <input>. A <button> could look like anything; it just needed the CSS to say so.

The second was :focus-visible. The original objection to the native focus ring was not that it was ugly — it was that it appeared on mouse clicks, which designers found visually jarring. The decade-long response was a { outline: none }, which silently disabled keyboard navigation for millions of users. :focus-visible shows the ring only when the keyboard was actually used. Suddenly the platform could provide a perfect focus ring with no tradeoff. The last aesthetic argument against <button> was gone.3

The web had a correct button in 1993. It needed the CSS to catch up.

What the native button can do now

aria-pressed for toggles. aria-expanded for triggers. aria-label for icon-only variants. disabled for unavailable states (or aria-disabled when you need the click handler to stay live). popovertarget for native popover control — a button that opens a popover without any JavaScript at all. The platform attribute set has quietly accumulated everything any framework offered, and ships with zero dependencies.

button {
  all: unset;               /* wipe browser defaults */
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 20px;
  border-radius: var(--radius);
  font: 600 14px/1 system-ui, sans-serif;
  background: var(--btn-bg);
  color: var(--btn-fg);
  transition: filter .12s;
}

/_ Ring only for keyboard — the fix that arrived 25 years late _/
button:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 3px;
}

button:hover:not(:disabled) { filter: brightness(1.12); }
button:active:not(:disabled) { transform: scale(0.97); }

all: unset + :focus-visible + aria-pressed gives you a fully styled, fully accessible button with no library and no build tooling.

The shape of the dig

The button’s arc is cleaner than the dialog’s. The dialog needed the platform to invent new primitives — a top layer, a native <dialog> element, inert. The button needed nothing new. Everything required for a correct, beautiful, accessible <button> was already in the HTML spec in 1993. The problem was never the element; it was the CSS.

Read the strata downward and you can watch a single constraint — “we cannot style form controls” — propagate through every era. The image button was a workaround. Sliding Doors was a workaround. The link-as-button was a workaround. jQuery UI, Bootstrap, and Material-UI were all, in part, elaborate workarounds for a styling limitation that CSS eventually removed.

The journey back needed no new APIs. It needed border-radius (2011), all: unset (2016), CSS custom properties (2017), and :focus-visible (2022). Once those landed, the original button was not just acceptable — it was the best choice available. The irony is complete: the control the industry spent twenty years replacing was always correct. It just needed the rest of the platform to catch up.

Notes & sources
  1. The WAI-ARIA 1.0 Authoring Practices (W3C, 2014) defines the button role, activation keys (Space, Enter), and the accessible name computation. Patterns for aria-pressed, aria-label, and aria-hidden on icons follow the WAI-ARIA 1.2 Authoring Practices Guide, “Button Pattern.”

  2. Dave Shea, “Sliding Doors of CSS,” A List Apart, 7 April 2003. border-radius reached stable cross-browser baseline (Chrome, Firefox, Safari, IE9) between 2010 and 2011.

  3. :focus-visible is specified in CSS Selectors Level 4. Browser support reached Baseline in 2022. The practice of removing focus rings with outline: 0 or outline: none has been documented as a keyboard accessibility failure since at least 2010. See Karl Groves, “Don’t Fear the Focus Ring,” Simply Accessible, 2010.