All docs
4 min read

Customization

The embed widget is opinionated by default and customizable when you need it. Three escape hatches cover most real cases: theming via CSS variables, callbacks for custom behavior, and full headless mode when you want your own markup.

Theming with CSS variables

The widget exposes its visual surface as CSS custom properties on the host element. Override them in your stylesheet to match your brand.

[data-form-id] {
  /* Colors */
  --fs-color-primary:        #6366f1;
  --fs-color-primary-hover:  #4f46e5;
  --fs-color-text:           #111827;
  --fs-color-muted:          #6b7280;
  --fs-color-bg:             #ffffff;
  --fs-color-border:         #e5e7eb;
  --fs-color-error:          #dc2626;
  --fs-color-success:        #059669;

  /* Layout */
  --fs-radius:        8px;
  --fs-spacing:       16px;
  --fs-input-padding: 10px 12px;

  /* Typography */
  --fs-font-family:   inherit;
  --fs-font-size:     14px;
  --fs-line-height:   1.5;
}

The defaults work on light and dark backgrounds. The widget ships a data-theme="dark" token set as well, applied automatically when data-theme="dark" or data-theme="auto" resolves to dark.

Class hooks for finer-grained styling:

.fs-form { /* the <form> wrapper */ }
.fs-field { /* each field block */ }
.fs-label { /* field labels */ }
.fs-input,
.fs-textarea,
.fs-select { /* form controls */ }
.fs-button { /* submit button */ }
.fs-error { /* validation messages */ }
.fs-success { /* success state */ }

Class names are stable within a major version.

Custom CSS via the dashboard

If you don't have direct control over the host page's CSS (e.g. embedding into a CMS that strips style tags), paste custom CSS into the form's edit page → Embed → Custom CSS. It's loaded inside the widget's render scope, so it can't break the surrounding page.

There's a 16KB limit on saved custom CSS. For larger overrides, host the file yourself and reference it with data-custom-css="https://...".

Mounting in a specific element

By default the widget renders into the [data-form-id] element it found. To render into a different element, pass mountTo:

<aside id="newsletter-spot"></aside>

<script>
  window.formspringConfig = {
    formId: 'frm_01H...',
    mountTo: '#newsletter-spot'
  };
</script>
<script src="https://formspring.io/embed.js" defer></script>

mountTo accepts a CSS selector or a DOM node. If the selector matches multiple elements, the widget renders into the first one.

Lifecycle callbacks

Hook into submission lifecycle events:

window.formspringConfig = {
  formId: 'frm_01H...',

  // Called once the widget has rendered
  onReady: (api) => {
    console.log('form rendered', api);
  },

  // Called after the user clicks submit but before validation runs.
  // Return false to cancel.
  onBeforeSubmit: (data) => {
    if (!data.email.endsWith('@example.com')) {
      alert('Use your work email.');
      return false;
    }
  },

  // Called when the submission was accepted by Formspring.
  onSuccess: (submission) => {
    window.gtag?.('event', 'form_submit', { form_id: 'contact' });
  },

  // Called when the submission was rejected.
  onError: (error) => {
    console.warn('submission failed', error);
  },

  // Called every time a field's value changes (debounced 200ms)
  onChange: (data) => {}
};

api (passed to onReady) exposes:

Method Notes
api.reset() Clears the form
api.submit() Submits programmatically
api.setField(name, value) Sets a field value
api.getValues() Returns the current values as an object
api.destroy() Tears the widget down

Programmatic field overrides

Override field labels, placeholders, help text, and required-ness without touching the dashboard:

window.formspringConfig = {
  formId: 'frm_01H...',
  fields: {
    email: { label: 'Work email', placeholder: 'you@company.com' },
    message: { placeholder: 'How can we help?', rows: 6 },
    company: { hidden: true }   // remove from rendering
  }
};

Fields you don't list are rendered with their dashboard defaults. You cannot add fields here — only fields that exist on the form can be customized or hidden.

Headless mode

When you want your own markup but Formspring's validation, file-upload, captcha, and spam-protection logic, use headless mode:

<form id="contact" novalidate>
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

<script src="https://formspring.io/embed.js" defer></script>
<script>
  document.getElementById('contact').addEventListener('submit', async (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.target));

    try {
      const result = await Formspring.submit({
        formId: 'frm_01H...',
        data
      });
      // Your success UI
      console.log('ok', result.submissionId);
    } catch (err) {
      // Your error UI
      console.warn(err.code, err.message);
    }
  });
</script>

The Formspring.submit({ formId, data }) helper handles the POST, applies the form's spam protection (honeypot, rate limit, captcha if configured), and returns a { submissionId, accepted } payload on success or throws an error with code, message, and fields on failure.

In headless mode you're responsible for:

  • Form HTML and styling
  • Loading captcha scripts and rendering challenges (the helper expects the token in data['h-captcha-response'] or data['g-recaptcha-response'])
  • Rendering success and error states

You get the benefit of Formspring's spam-rejection logic and submission storage without giving up control of the markup.

What's next