# JavaScript Helper

> Complete guide to the StaticForm.js client-side library for form submission, validation error display, and CAPTCHA integration (reCAPTCHA, Turnstile, hCaptcha).

The StaticForm JavaScript helper is a lightweight, dependency-free script that handles everything between your HTML form and the StaticForm API. It intercepts form submissions, sends them via `fetch`, maps validation errors to individual fields, and handles CAPTCHA token injection for reCAPTCHA v3, reCAPTCHA v2, Cloudflare Turnstile, and hCaptcha.

You don't need this library to use StaticForm. A plain HTML form with `action` and `method="POST"` works fine. But the helper gives you a much better user experience: inline error messages, loading states, and no full-page reloads.

## Installation

Add the script to your page. It exposes a single global: `window.StaticForm`.

```html
<script src="https://staticform.app/scripts/staticform.js" defer></script>
```

The script is ~7.5 KB minified (~2.6 KB gzipped) and has no dependencies.

## How It Works

When you call `StaticForm.attach(form, options)`, the library:

1. **Prevents the default form submit**, so there is no page reload
2. **Collects all form data** using the native `FormData` API
3. **Injects a CAPTCHA token** if you configured a provider (reCAPTCHA v3/v2, Turnstile, or hCaptcha)
4. **Sends the data via `fetch`** as `multipart/form-data` to your endpoint
5. **Parses the response**. On success, it resets the form (and the CAPTCHA widget) and calls your callback. If your form is configured for HTTP Redirect, it follows the redirect and navigates the browser to the configured success or error URL
6. **Maps validation errors to fields**. On failure (non-redirect mode), it shows error messages next to the relevant inputs and highlights them

Throughout this process, it dispatches custom events on the form element so you can hook into the submission lifecycle (loading states, success messages, etc.).

## Basic Example

Here's a minimal contact form with the helper:

```html
<form id="contact-form">
  <div>
    <label for="name">Name</label>
    <input type="text" id="name" name="name" />
    <p data-sf-error="name"></p>
  </div>

  <div>
    <label for="email">Email</label>
    <input type="email" id="email" name="email" />
    <p data-sf-error="email"></p>
  </div>

  <div>
    <label for="message">Message</label>
    <textarea id="message" name="message"></textarea>
    <p data-sf-error="message"></p>
  </div>

  <p data-sf-general-error></p>

  <button type="submit">Send Message</button>
</form>

<script src="https://staticform.app/scripts/staticform.js" defer></script>
<script>
  document.addEventListener('DOMContentLoaded', function () {
    StaticForm.attach(document.getElementById('contact-form'), {
      endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
    });
  });
</script>
```

That's it. When the user submits the form, the helper sends the data. If there are validation errors, it displays them next to the relevant fields.

> **Why the `DOMContentLoaded` wrapper?** The HTML spec ignores `defer` on inline `<script>` blocks, so without the wrapper the inline call would run *before* the deferred `staticform.js` finishes loading and throw `ReferenceError: StaticForm is not defined`. Waiting for `DOMContentLoaded` guarantees both the DOM and `staticform.js` are ready.

## `StaticForm.attach(form, options)`

This is the only method exposed by the library. It takes two arguments:

| Argument | Type | Description |
|----------|------|-------------|
| `form` | `HTMLFormElement` | The form element to attach to. |
| `options` | `object` | Configuration object (see below). |

### Options

| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| `endpoint` | `string` | Yes | | The full StaticForm submission URL for your form. Format: `https://api.staticform.app/api/v1/forms/{FORM_ID}` |
| `captcha` | `object` | No | | CAPTCHA configuration. See [CAPTCHA Integration](#captcha-integration). |
| `success` | `object` | No | `{ type: 'button' }` | Controls what happens after a successful submission. See [Success Behaviour](#success-behaviour). |
| `loading` | `object` | No | | Opt-in built-in loading state for the submit button. Pass `{}` to enable, or `{ buttonText: 'Sending…' }` to also change the button text while fetching. |
| `styles` | `object` | No | See below | Overrides for visual styling of error states, focus states, and the success button color. |
| `onSuccess` | `function(response)` | No | | Callback fired after a successful submission. In JSON mode, receives the parsed response body. In HTTP Redirect mode, receives `{ redirected: true, url: string }` and the browser navigates to the redirect URL immediately after. |
| `onError` | `function(errors)` | No | | Callback fired after a failed submission. Receives the array of error objects after they've been mapped to fields. |

### Style Options

The `styles` object lets you customize how errors and focus states look. All values are CSS strings.

| Property | Default | Description |
|----------|---------|-------------|
| `errorColor` | `'#ef4444'` | Color for error text (not currently applied to text, but available for your use). |
| `focusColor` | `'rgba(39, 52, 105, 0.4)'` | Border color when an input is focused (and not in error state). |
| `focusShadow` | `'0 0 0 3px rgba(39, 52, 105, 0.06)'` | Box shadow applied on input focus. |
| `defaultBorderStyle` | `'1.5px solid rgba(39, 52, 105, 0.12)'` | The default border style for all inputs. Applied on load and restored after errors clear. |
| `errorBorderStyle` | `'1.5px solid #ef4444'` | Border style applied to inputs that have a validation error. |
| `successColor` | `'#16a34a'` | Background color applied to the submit button in `success: { type: 'button' }` mode. |

Example with custom styles:

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  styles: {
    errorColor: '#dc2626',
    focusColor: 'rgba(59, 130, 246, 0.5)',
    focusShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)',
    defaultBorderStyle: '1px solid #d1d5db',
    errorBorderStyle: '1px solid #dc2626',
  },
});
```

## Error Display

The helper uses HTML attributes to find where to display errors. There are two types of errors:

### Field-Specific Errors

Add a `data-sf-error` attribute to an element next to each input. The attribute value must match the input's `name`:

```html
<input type="email" name="email" />
<p data-sf-error="email"></p>
```

When the API returns a validation error for `email`, the helper:
1. Sets the text content of the `data-sf-error="email"` element to the error message
2. Makes the error element visible (`display: block`)
3. Changes the border of the matching `<input name="email">` to the `errorBorderStyle`

When the user starts typing in that input, the error is automatically cleared.

### General Errors

For errors that aren't tied to a specific field (e.g. "Something went wrong", network errors), add:

```html
<p data-sf-general-error></p>
```

The API returns general errors with the name `generalErrors`. The helper also shows a general error when a network request fails entirely.

### Error Clearing

Errors are cleared in two ways:
- **On new submit**: all errors are cleared before a new submission
- **On input**: when the user types in an errored field, that field's error is cleared immediately

### Complete Error Markup Example

```html
<form id="my-form">
  <!-- Field with inline error -->
  <div class="field">
    <label>Name</label>
    <input type="text" name="name" required />
    <p data-sf-error="name" style="display: none; color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;"></p>
  </div>

  <!-- Another field -->
  <div class="field">
    <label>Email</label>
    <input type="email" name="email" required />
    <p data-sf-error="email" style="display: none; color: #ef4444; font-size: 0.875rem; margin-top: 0.25rem;"></p>
  </div>

  <!-- General error (network issues, server errors, etc.) -->
  <div data-sf-general-error style="display: none; color: #ef4444; padding: 0.75rem; background: #fef2f2; border-radius: 0.5rem; margin-bottom: 1rem;"></div>

  <button type="submit">Submit</button>
</form>
```

> **Tip:** Hide error elements by default with `style="display: none"`. The helper will show them when needed and hide them again when the error is resolved.

## Success Behaviour

The `success` option controls what the helper does after a successful (non-redirect) submission. Four modes are available:

| `type` | What happens |
|--------|--------------|
| `'button'` (default) | The submit button turns green and shows `buttonText`. Resets after `duration` seconds (`0` = stays green). |
| `'replace'` | The `<form>` is hidden and the nearest `[data-sf-success]` element is shown. |
| `'banner'` | The nearest `[data-sf-success]` element is shown without hiding the form. Auto-dismisses after `duration` seconds (`0` = stays visible). |
| `'none'` | No built-in behaviour. Use the `sf:success` event or `onSuccess` callback to handle it yourself. |

### Options

| Property | Default | Description |
|----------|---------|-------------|
| `type` | `'button'` | Which success mode to use (see table above). |
| `buttonText` | `'✓'` | Button label shown in `button` mode. |
| `message` | `'Thank you! Your message has been sent.'` | Used as a fallback if no `[data-sf-success]` element is found (not rendered by the helper — provide your own element). |
| `duration` | `3` | Seconds before auto-reset (`button`) or auto-dismiss (`banner`). Set to `0` to disable. |

### Button mode (default)

No extra HTML needed. The submit button animates to green on success:

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  success: {
    type:       'button',
    buttonText: '✓', // text shown on the button after success
    duration:   3,         // seconds before the button resets (0 = never)
  },
});
```

Customize the color via `styles.successColor`:

```javascript
StaticForm.attach(form, {
  endpoint: '...',
  success: { type: 'button' },
  styles:  { successColor: '#2563eb' },
});
```

### Replace mode

Hides the form and shows a `[data-sf-success]` element. Place it as a sibling of the `<form>` or anywhere inside the form's parent:

```html
<form id="contact-form">
  <!-- ...fields... -->
  <button type="submit">Send</button>
</form>

<div data-sf-success style="display: none;">
  Thank you! We'll be in touch soon.
</div>
```

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  success: { type: 'replace' },
});
```

### Banner mode

Shows a `[data-sf-success]` element without hiding the form. Useful when you want the form to remain visible for reuse:

```html
<form id="contact-form">
  <div data-sf-success style="display: none;">
    Message sent — we'll reply within one business day.
  </div>
  <!-- ...fields... -->
  <button type="submit">Send</button>
</form>
```

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  success: {
    type:     'banner',
    duration: 5, // auto-dismiss after 5 seconds; 0 = stays visible
  },
});
```

### Disabling built-in success behaviour

Pass `success: { type: 'none' }` to opt out and handle success yourself via events:

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  success: { type: 'none' },
});

form.addEventListener('sf:success', function () {
  document.getElementById('my-custom-success').style.display = 'block';
});
```

## Custom Events

The helper dispatches `CustomEvent`s on the form element at each stage of the submission lifecycle. Use these to build loading states, success messages, analytics tracking, etc.

| Event | `event.detail` | When it fires |
|-------|----------------|---------------|
| `sf:submitting` | | Immediately when the user clicks submit, before the fetch starts. |
| `sf:success` | Parsed response body, or `{ redirected: true, url: string }` in redirect mode | After a successful submission (HTTP 200). The form has already been reset at this point. In HTTP Redirect mode, the browser navigates to the redirect URL immediately after this event fires. |
| `sf:error` | Array of error objects | After a failed submission. Errors have already been mapped to fields. Each error is `{ name: string, reason: string }`. |
| `sf:settled` | | After either success or error. Always fires, like `finally` in a promise. |

### Event Flow

```
User clicks Submit
  → sf:submitting
  → fetch(endpoint)
  → (on success) sf:success → sf:settled
  → (on error)   sf:error   → sf:settled
```

### Loading State Example

The simplest way is to use the built-in `loading` option, which disables the button and sets `aria-busy="true"` while the fetch is in flight:

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  loading: { buttonText: 'Sending…' }, // pass {} to disable without changing text
});
```

For custom loading UIs, listen to the `sf:submitting` and `sf:settled` events instead:

```javascript
var submitBtn = form.querySelector('button[type="submit"]');
var originalText = submitBtn.textContent;

form.addEventListener('sf:submitting', function () {
  submitBtn.disabled = true;
  submitBtn.textContent = 'Sending...';
});

form.addEventListener('sf:settled', function () {
  submitBtn.disabled = false;
  submitBtn.textContent = originalText;
});

StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
});
```

### Success Message Example

The simplest way is to use the built-in `success` option — see [Success Behaviour](#success-behaviour) for the full reference. For fully custom success UIs, listen to the `sf:success` event:

```javascript
form.addEventListener('sf:success', function () {
  document.getElementById('success-message').style.display = 'block';
});

StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  success: { type: 'none' }, // disable built-in behaviour when using your own
});
```

### Error Handling Example

```javascript
form.addEventListener('sf:error', function (e) {
  // e.detail is an array like:
  // [{ name: "email", reason: "A valid email address is required." }]
  console.log('Validation errors:', e.detail);

  // The helper has already displayed the errors next to the fields,
  // but you can do additional handling here (e.g. analytics)
});
```

## CAPTCHA Integration

The helper supports four CAPTCHA providers out of the box. You configure them via the `captcha` option:

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  captcha: {
    type: 'turnstile',          // 'recaptcha-v3' | 'recaptcha-v2' | 'turnstile' | 'hcaptcha'
    siteKey: 'YOUR_SITE_KEY',   // required for recaptcha-v3
    action: 'submit',           // optional, recaptcha-v3 only
  },
});
```

| Provider | `type` value | Token field sent to API | Where the site key goes |
|----------|--------------|--------------------------|--------------------------|
| Google reCAPTCHA v3 | `'recaptcha-v3'` | `g-recaptcha-response` | `captcha.siteKey` (helper config) |
| Google reCAPTCHA v2 | `'recaptcha-v2'` | `g-recaptcha-response` | `data-sitekey` on the widget `<div>` |
| Cloudflare Turnstile | `'turnstile'` | `cf-turnstile-response` | `data-sitekey` on the widget `<div>` |
| hCaptcha | `'hcaptcha'` | `h-captcha-response` | `data-sitekey` on the widget `<div>` |

For **all** providers, the helper will:

- **Auto-load the provider's loader script** if it isn't already on the page, so you don't need to add the `<script src="https://...">` tag yourself
- Look up the current token before submitting and inject it into the form data
- Reset the CAPTCHA widget after the request finishes (success or error) so a new token can be generated for the next submission

In all cases the secret key must be configured in your form settings in the StaticForm dashboard (under CAPTCHA settings).

### Why only reCAPTCHA v3 needs `siteKey` in the helper config

This is the most common point of confusion, so it's worth being explicit about. The three widget-based providers (reCAPTCHA v2, Cloudflare Turnstile, hCaptcha) all read their site key directly from the `data-sitekey` attribute on the widget `<div>` you place inside your form. The provider's own script handles rendering the widget, solving the challenge, and writing a hidden `<input>` with the resulting token into the surrounding form. The helper never touches the site key; it just calls `getResponse()` on the global once the user submits.

reCAPTCHA v3 is the exception because **it has no widget**. There's nothing in the DOM for Google's script to read a site key from, so the helper has to:

1. Bake the key into the script URL itself (`https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY`) when it auto-loads the script
2. Pass it again to `grecaptcha.execute(siteKey, { action })` on every submit to generate a fresh token

Both of those need the key passed through the helper config. That's the only reason `captcha.siteKey` exists.

### Opting out of script auto-loading

If you'd rather load the provider's script yourself (for example, to apply a custom CSP nonce, host it on your own CDN, or control where in the page it appears), set `loadScript: false`:

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  captcha: {
    type: 'turnstile',
    loadScript: false, // you'll add the <script> tag yourself
  },
});
```

The auto-loader is also a no-op when the relevant global (`grecaptcha` / `turnstile` / `hcaptcha`) is already defined or when a matching `<script>` tag is already present in the document, so calling `attach()` on multiple forms on the same page only injects the script once.

### reCAPTCHA v3

reCAPTCHA v3 is invisible. There is no widget, so the helper calls `grecaptcha.execute()` on each submit to generate a fresh token. Just configure the helper:

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  captcha: {
    type: 'recaptcha-v3',
    siteKey: 'YOUR_SITE_KEY',
    action: 'submit', // optional, defaults to 'submit'
  },
});
```

The helper will inject `https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY` for you. If you've opted out of auto-loading via `loadScript: false`, include that script tag yourself.

If the reCAPTCHA script fails to load or token generation fails, the helper submits without a token and the API will reject the submission with a CAPTCHA error.

### reCAPTCHA v2

reCAPTCHA v2 is the classic "I'm not a robot" checkbox. Render the widget anywhere inside your form. Google's script will inject a hidden `g-recaptcha-response` input that the helper will pick up automatically.

**1. Add the widget inside your form:**

```html
<form id="contact-form">
  <!-- ...your fields... -->
  <div class="g-recaptcha" data-sitekey="YOUR_SITE_KEY"></div>
  <button type="submit">Send</button>
</form>
```

**2. Configure the helper:**

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  captcha: { type: 'recaptcha-v2' },
});
```

The helper auto-loads `https://www.google.com/recaptcha/api.js` for you. Set `loadScript: false` if you'd rather include the `<script>` tag yourself.

### Cloudflare Turnstile

Turnstile is Cloudflare's privacy-friendly CAPTCHA. It auto-renders into any element with class `cf-turnstile` and inserts a hidden `cf-turnstile-response` input into the surrounding form.

**1. Add the widget inside your form:**

```html
<form id="contact-form">
  <!-- ...your fields... -->
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button type="submit">Send</button>
</form>
```

**2. Configure the helper:**

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  captcha: { type: 'turnstile' },
});
```

The helper auto-loads `https://challenges.cloudflare.com/turnstile/v0/api.js` for you. Set `loadScript: false` if you'd rather include the `<script>` tag yourself.

### hCaptcha

hCaptcha is a privacy-focused alternative to reCAPTCHA. Like Turnstile and v2, it auto-renders into elements with class `h-captcha` and injects a hidden `h-captcha-response` input.

**1. Add the widget inside your form:**

```html
<form id="contact-form">
  <!-- ...your fields... -->
  <div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>
  <button type="submit">Send</button>
</form>
```

**2. Configure the helper:**

```javascript
StaticForm.attach(form, {
  endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  captcha: { type: 'hcaptcha' },
});
```

The helper auto-loads `https://js.hcaptcha.com/1/api.js` for you. Set `loadScript: false` if you'd rather include the `<script>` tag yourself.

## Using Without the Helper

You don't need this library at all. A standard HTML form works:

```html
<form method="POST" action="https://api.staticform.app/api/v1/forms/YOUR_FORM_ID">
  <input type="text" name="name" required />
  <input type="email" name="email" required />
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>
```

This will submit the form as a standard POST and the response depends on your form's [response type configuration](/docs/form-configuration#response-types):

- **JSON mode**: returns a JSON response (you'll see raw JSON in the browser)
- **HTTP Redirect mode**: redirects the user to your configured success/error URL

The JavaScript helper is most useful when you want:
- No full-page reload on submit
- Inline validation error messages next to each field
- Loading states and success messages
- Automatic reCAPTCHA token injection

### Custom fetch() calls

If you submit via your own `fetch()` call instead of a plain HTML form, always include the `X-Requested-With: XMLHttpRequest` header:

```javascript
fetch('https://api.staticform.app/api/v1/forms/YOUR_FORM_ID', {
  method: 'POST',
  body: new FormData(form),
  headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
```

Without this header, the API treats the request as a plain browser POST and returns HTTP redirects instead of JSON. This causes two problems for `fetch()` callers:

- **Errors are silently swallowed.** If your form has a redirect URL configured for errors, `fetch()` follows the redirect automatically and returns a `200` from the error page. Your code has no way to know the submission failed.
- **Payment flows break.** If payment is enabled, the API redirects to Stripe Checkout instead of returning the Stripe URL as JSON, so your code never gets a chance to handle it.

With the header set, all responses come back as JSON regardless of your form's redirect configuration.

## Full Working Example

Here's a complete, copy-paste-ready contact form:

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>Contact</title>
  <!-- Optional: reCAPTCHA v3 -->
  <!-- <script src="https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY"></script> -->
</head>
<body>
  <form id="contact-form" style="max-width: 500px; margin: 2rem auto;">
    <div style="margin-bottom: 1rem;">
      <label for="name" style="display: block; margin-bottom: 0.25rem; font-weight: 600;">Name</label>
      <input type="text" id="name" name="name" style="width: 100%; padding: 0.5rem; border: 1.5px solid rgba(39,52,105,0.12); border-radius: 0.375rem;" />
      <p data-sf-error="name" style="display: none; color: #ef4444; font-size: 0.875rem; margin: 0.25rem 0 0;"></p>
    </div>

    <div style="margin-bottom: 1rem;">
      <label for="email" style="display: block; margin-bottom: 0.25rem; font-weight: 600;">Email</label>
      <input type="email" id="email" name="email" style="width: 100%; padding: 0.5rem; border: 1.5px solid rgba(39,52,105,0.12); border-radius: 0.375rem;" />
      <p data-sf-error="email" style="display: none; color: #ef4444; font-size: 0.875rem; margin: 0.25rem 0 0;"></p>
    </div>

    <div style="margin-bottom: 1rem;">
      <label for="message" style="display: block; margin-bottom: 0.25rem; font-weight: 600;">Message</label>
      <textarea id="message" name="message" rows="4" style="width: 100%; padding: 0.5rem; border: 1.5px solid rgba(39,52,105,0.12); border-radius: 0.375rem;"></textarea>
      <p data-sf-error="message" style="display: none; color: #ef4444; font-size: 0.875rem; margin: 0.25rem 0 0;"></p>
    </div>

    <div data-sf-general-error style="display: none; color: #ef4444; padding: 0.75rem; background: #fef2f2; border-radius: 0.5rem; margin-bottom: 1rem;"></div>

    <button type="submit" id="submit-btn" style="padding: 0.625rem 1.5rem; background: #273469; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-weight: 600;">
      Send Message
    </button>
  </form>

  <script src="https://staticform.app/scripts/staticform.js" defer></script>
  <script>
    document.addEventListener('DOMContentLoaded', function () {
      StaticForm.attach(document.getElementById('contact-form'), {
        endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
        // captcha: { type: 'turnstile' },                                     // Cloudflare Turnstile
        // captcha: { type: 'hcaptcha' },                                      // hCaptcha
        // captcha: { type: 'recaptcha-v2' },                                  // reCAPTCHA v2
        // captcha: { type: 'recaptcha-v3', siteKey: 'YOUR_SITE_KEY' },        // reCAPTCHA v3
        success: { type: 'button', buttonText: '✓', duration: 3 },
        loading: { buttonText: 'Sending…' },
      });
    });
  </script>
</body>
</html>
```