Design-system buttons: where shared behavior ends

A tested button and report form show who owns submission, pending state, errors and focus—and how a safer default can break a consumer.
A shared button cannot own a save workflow
A button can look consistent while submitting the wrong form. For a frontend engineer maintaining shared controls, that is a more useful review case than another page of color tokens.
This example separates a native action button from a synthetic report editor. Preview reads the title without submitting. Save sends it to a fixture server that rejects the first request and accepts a retry. The shared code owns the element, its visible label, its explicit type and its focus style. The consuming form owns the request, pending state, error message and retry.
This is a reproducible demonstration, not an account of an Ulties client project. The server does not persist reports. The example helps locate responsibilities without prescribing an organization chart.
Make the primitive’s behavior explicit
A form-associated button with no valid type can submit its form. That default is dangerous for Preview: adding an otherwise harmless button to a form changes its behavior. Our helper explicitly defaults to type=button. A consumer must request type=submit for Save.
The helper returns a native button instead of recreating keyboard activation with keydown handlers. It does not infer a type from the label. The two consumers use the same control while choosing different behavior. The label checks reject an empty string. A reviewer still needs to decide whether the label explains the action in context.
export function actionButton(label, { type = 'button' } = {}) {
if (typeof label !== 'string' || !label.trim()) {
throw new TypeError('An action button needs a visible label');
}
if (!['button', 'submit'].includes(type)) {
throw new TypeError('Choose button or submit explicitly');
}
const button = document.createElement('button');
button.type = type;
button.className = 'action-control';
button.textContent = label;
return button;
}
Keep the request state with the form
The consumer creates Preview with the default type and Save with type=submit. Preview updates local text. The form’s submit handler prevents navigation and takes responsibility for the asynchronous request.
Disabling Save communicates that another activation is unavailable, but it is not the request guard. The fixture calls form.requestSubmit() twice while the first response is held. A pending flag checked inside the handler prevents those programmatic submissions from issuing additional requests. Removing that flag produces additional HTTP requests even though Save is disabled.
The handler treats a non-success HTTP response as a failure. It leaves the title input intact, writes an error and focuses that error element. In the supplied HTML, the error has tabindex=-1, so the script can focus it without adding another ordinary Tab stop. Shift+Tab reaches Save for a keyboard retry. This focus policy belongs to this small form. A dialog or a long editor may need a different destination.
The status and alert elements are included in the downloadable HTML. Their roles are intended to expose updates, but this browser test does not verify what a screen reader announces. The code also leaves the title editable during a request. It sends the value captured when that request starts. A production editor must distinguish saved and later unsaved edits.
import { actionButton } from './action-button.mjs';
const form = document.querySelector('form');
const title = document.querySelector('#title');
const status = document.querySelector('#status');
const error = document.querySelector('#error');
const preview = actionButton('Preview draft');
const save = actionButton('Save draft', { type: 'submit' });
preview.id = 'preview';
save.id = 'save';
document.querySelector('#actions').append(preview, save);
preview.addEventListener('click', () => {
document.querySelector('#preview-output').textContent = title.value;
});
let pending = false;
form.addEventListener('submit', async (event) => {
event.preventDefault();
// Disabling the button does not block programmatic form submission.
if (pending) return;
pending = true;
form.dataset.pending = 'true';
form.setAttribute('aria-busy', 'true');
save.disabled = true;
error.textContent = '';
status.textContent = 'Saving draft…';
try {
const response = await fetch('./save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value }),
});
if (!response.ok) throw new Error('Save rejected');
status.textContent = 'Draft saved.';
} catch {
status.textContent = '';
error.textContent = 'Could not save the draft. Try again.';
error.focus();
} finally {
pending = false;
form.dataset.pending = 'false';
form.setAttribute('aria-busy', 'false');
save.disabled = false;
}
});
Style a state you can actually exercise
The stylesheet applies to both real buttons. A focus-visible rule draws an outline, and the disabled rule changes the surface and text. The browser determines when focus-visible matches, so test the keyboard path.
The forced-colors rule uses the system color Highlight for the outline. Forced-colors mode can remove box shadows, so this focus treatment uses an outline rather than relying on a shadow. We keep the browser’s color adjustment enabled. The fixture emulates forced colors and checks the outline style and width. Actual operating-system themes and contrast combinations need separate review.
:root {
--focus-indicator: #1453c4;
}
.action-control {
max-inline-size: 100%;
padding: 0.7rem 1rem;
font: inherit;
color: #172438;
background: #fff;
border: 1px solid #64748b;
border-radius: 0.2rem;
overflow-wrap: anywhere;
}
.action-control:focus-visible {
outline: 3px solid var(--focus-indicator);
outline-offset: 3px;
}
.action-control:disabled {
color: #526173;
background: #e8edf3;
border-color: #a8b3c3;
cursor: wait;
}
@media (forced-colors: active) {
.action-control:focus-visible {
outline-color: Highlight;
}
}
Read the failures, not just the green result
The pinned environment uses Playwright 1.63.0, Chromium 153.0.8010.12 and Node 24.20.0. The normal flow passes sixteen observations covering keyboard activation, Preview’s lack of submission, pending behavior, failure focus, retained input, retry payload, long-label wrapping and the emulated outline.
Three altered copies must fail at specific assertions. Omitting the explicit button type makes Preview submit. Removing the pending guard permits duplicate requests. Removing the outline fails the keyboard-focus check. The verifier checks which assertions passed before each defect was detected. An unrelated browser crash fails the run.
The synthetic server holds responses until the test observes the requests. It then releases the first failure or the successful retry. That makes the pending assertion depend on actual request arrival, not a short sleep that might expire on a slower machine. All HTTP traffic stays on container loopback with external networking disabled.
These checks establish the demonstrated browser boundary. They do not establish server idempotency, authentication, persistence, cross-browser compatibility or complete accessibility. An in-memory flag cannot prevent another tab, a retrying proxy or another client from sending the same operation.
Review a consumer before changing the default
Suppose an existing helper omitted the type attribute. Adding the explicit type=button default fixes Preview, but a Save consumer that relied on the old implicit submission will stop submitting. Treat this as a behavioral migration.
I recommend inspecting each form consumer, marking intentional submissions explicitly, and running the actual keyboard and error paths before releasing the new default. Keep a small consumer fixture alongside the primitive tests so a library change has to preserve the behavior people use.
A consuming application can share a form-state abstraction once several workflows have the same rules. In this example, putting fetch, report-specific messages or error focus inside the button would make every use inherit those decisions. Keep that logic at the form boundary until there is evidence that the abstraction fits another consumer.
Reproduce the examples
Exact button, consumer, CSS, companion HTML, pinned browser runner, dependency lock and recorded results. Requires Docker and Python 3. The server simulates saving; it does not persist reports.
Download the source and tests (.zip)
