Form-button

Variants

Submitting

Success

Error

Sizes

Disabled


  
/*
 * 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.