/*
* Form-button — element (form)
*
* Submit / reset / cancel button with form-aware states. Per ADR-0013:
* distinct from generic Button because it carries states (submitting,
* success, error) that generic Button does not.
*
* State is driven by data-state on the button itself. CSS handles
* visual swap; consumers set data-state when transitioning. The
* button shares --button-* tokens for visual consistency with
* generic Button.
*
* <button type="submit" class="form-button -primary">Save</button>
*
* <button type="submit" class="form-button -primary" data-state="submitting" aria-busy="true">
* Save
* <span class="form-button__indicator" aria-hidden="true">
* <span class="spinner -s -inverse"></span>
* </span>
* </button>
*/
@layer malevich.components {
.form-button {
/* Inherit visual shape from Button by reusing the same selector
* surface. We can't extend in CSS, so we duplicate the structural
* rules and consume the same tokens.
*/
appearance: none;
-webkit-appearance: none;
margin: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-gap-elements-s);
padding-block: var(--space-inset-element-s);
padding-inline: var(--space-inset-element-m);
min-block-size: var(--size-control-m);
font-family: var(--font-body-support-family);
font-size: var(--font-body-support-size);
font-weight: var(--font-body-support-weight);
line-height: var(--font-body-support-line-height);
letter-spacing: var(--font-body-support-letter-spacing);
text-decoration: none;
white-space: nowrap;
border: var(--border-width-hairline) solid transparent;
border-radius: var(--radius-button);
transition:
background-color var(--motion-fast) var(--motion-easing-default),
border-color var(--motion-fast) var(--motion-easing-default),
color var(--motion-fast) var(--motion-easing-default);
}
.form-button:focus-visible {
outline: var(--border-width-focus) solid var(--color-accent);
outline-offset: var(--border-width-focus);
}
/* The indicator slot holds the spinner / success-check / error-glyph.
* Hidden by default; data-state reveals the matching child.
*/
.form-button__indicator {
display: none;
align-items: center;
justify-content: center;
}
.form-button[data-state="submitting"] .form-button__indicator,
.form-button[data-state="success"] .form-button__indicator,
.form-button[data-state="error"] .form-button__indicator {
display: inline-flex;
}
/* Variants — mirror Button v1.0 names */
.form-button.-primary {
background-color: var(--color-accent);
color: var(--color-ink-inverse);
border-color: var(--color-accent);
}
.form-button.-primary:hover:not(:disabled):not([aria-disabled="true"]):not([data-state]) {
background-color: var(--color-accent-strong);
border-color: var(--color-accent-strong);
}
.form-button.-secondary {
background-color: transparent;
color: var(--color-ink-strong);
border-color: var(--color-border-strong);
}
.form-button.-secondary:hover:not(:disabled):not([aria-disabled="true"]):not([data-state]) {
background-color: var(--color-ink-strong);
color: var(--color-ink-inverse);
border-color: var(--color-ink-strong);
}
.form-button.-ghost {
background-color: transparent;
color: var(--color-ink-strong);
border-color: transparent;
}
.form-button.-ghost:hover:not(:disabled):not([aria-disabled="true"]):not([data-state]) {
background-color: var(--color-surface-canvas);
}
.form-button.-danger {
background-color: var(--color-danger);
color: var(--color-ink-inverse);
border-color: var(--color-danger);
}
.form-button.-success {
background-color: var(--color-success);
color: var(--color-ink-inverse);
border-color: var(--color-success);
}
/* Submitting — cursor wait, slightly dimmed */
.form-button[data-state="submitting"] {
cursor: progress;
opacity: 0.85;
}
/* Success — flips to success color regardless of variant */
.form-button[data-state="success"] {
background-color: var(--color-success);
color: var(--color-ink-inverse);
border-color: var(--color-success);
}
/* Error — flips to danger color */
.form-button[data-state="error"] {
background-color: var(--color-danger);
color: var(--color-ink-inverse);
border-color: var(--color-danger);
}
/* Disabled */
.form-button:disabled,
.form-button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.5;
}
/* Sizes */
.form-button.-s {
padding-block: var(--space-inset-element-s);
padding-inline: var(--space-inset-element-s);
min-block-size: var(--size-control-s);
font-family: var(--font-caption-family);
font-size: var(--font-caption-size);
font-weight: var(--font-caption-weight);
line-height: var(--font-caption-line-height);
letter-spacing: var(--font-caption-letter-spacing);
}
.form-button.-l {
padding-block: var(--space-inset-element-m);
padding-inline: var(--space-inset-element-l);
min-block-size: var(--size-control-l);
font-family: var(--font-body-regular-family);
font-size: var(--font-body-regular-size);
font-weight: var(--font-body-regular-weight);
line-height: var(--font-body-regular-line-height);
letter-spacing: var(--font-body-regular-letter-spacing);
}
}
This component is pure CSS — no JavaScript required.
# Form-Button — AGENTS.md
> Auto-generated from `form-button.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.
## Identity
- **Component:** `form-button`
- **Layer:** elements
- **Status:** stable
- **Last updated:** 2026-05-19
## Summary
- Inside a `<form>`, as the primary submit action.
## Variants
| Variant | Class |
|-----------|------------------------------|
| Primary | `.form-button.-primary` |
| Secondary | `.form-button.-secondary` |
| Ghost | `.form-button.-ghost` |
| Danger | `.form-button.-danger` |
| Success | `.form-button.-success` |
Sizes: `.-s` and `.-l` (default = medium).
## Modifier applicability
Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.
| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `flat` |
| `effect` | `glow`, `ring` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | — |
## Anatomy
```html
<!-- Default -->
<button type="submit" class="form-button -primary">Save</button>
<!-- Submitting -->
<button type="submit" class="form-button -primary"
data-state="submitting" aria-busy="true">
Saving
<span class="form-button__indicator" aria-hidden="true">
<span class="spinner -s -inverse" role="presentation"></span>
</span>
</button>
<!-- Success -->
<button type="submit" class="form-button -primary" data-state="success">
Saved
<span class="form-button__indicator" aria-hidden="true">✓</span>
</button>
<!-- Error -->
<button type="submit" class="form-button -primary" data-state="error">
Try again
<span class="form-button__indicator" aria-hidden="true">⚠</span>
</button>
```
Authors render all four state contents conditionally (or swap inner
content on transition). A common pattern is a single inner indicator
slot that the application updates on each state change.
## Tokens consumed
- `--color-accent`, `--color-accent-strong` — primary variant
- `--color-ink-strong`, `--color-ink-inverse`
- `--color-border-strong` — secondary border
- `--color-surface-canvas` — ghost hover
- `--color-danger` — danger variant + error state
- `--color-success` — success variant + success state
- `--space-inset-element-*`, `--space-gap-elements-s`
- `--size-control-*`, `--radius-button`
- `--border-width-hairline` / `--border-width-focus`
- `--font-action-{s,m,l}-*` — typography
- `--motion-fast` / `--motion-easing-default`
## Accessibility contract
- `<button type="submit">` carries the form-submission contract.
- `aria-busy="true"` during `data-state="submitting"` announces the
busy state to assistive tech. Without `aria-busy`, screen readers
do not know the submission is in flight.
- The state indicator is `aria-hidden="true"` — meaning is carried
by the button's text label and `aria-busy`.
- For success / error feedback that should be announced, pair with a
form-level `aria-live` region that names the outcome ("Saved",
"Couldn't save — try again"). The button visual is reinforcement,
not the only signal.
## Guidance
### Do
- Use `<button type="submit">` for the primary form action.
- Pair `data-state="submitting"` with `aria-busy="true"` and
`disabled` (or `aria-disabled="true"`).
- Announce success / error via a form-level `aria-live` region.
### Don't
- Don't use Form-button outside a `<form>` — use Button instead.
- Don't change the button text mid-submit if it confuses (e.g. don't
swap "Save" for "Loading…" in the SAME slot if your design also
shows a spinner; pick one signal or both consistently).
- Don't transition through `success` / `error` if the form-level
`aria-live` region already announces the outcome — pick one
surface to avoid duplicate announcements.