Skip to main content Accessibility Feedback


Progressively add Ajax support to <form> elements.

Source Code

Basic Usage

Wrap <form> elements in the <ajax-form> custom element. The <form> must have method and action attributes for the <ajax-form> component to work properly.

    <form method="post" action="/newsletter">
        <!-- Form fields... -->

By default, the <ajax-form> Web Component will add an accessible status notification element, show a Submitting... message, and prevent the user from submitting the form twice while waiting for a server response.

Options & Settings

The <ajax-form> element can be customized with a handful of attributes…

  • prevent-default - Stop the form from redirecting/reloading the page. Use this if you want to keep the user on the current page after the form submits.
  • msg-submitting - Customize the “submitting” message.
  • msg-success - Customize the “success” message.
  • msg-error - Customize the “error” message.
  • target - If the server returns HTML and you want to update the current UI after the form successfully submits, provide one or more selector strings (comma-separated) for the element to update. Each selector should be identical in the current UI and the returned HTML from the server.

With the msg-submitting, msg-success, and msg-error attributes, you can include ${field_name} in the attribute string to include the value of the matching field in your message.

    This form includes some customizations.
    After submitting, it will update the [data-list="todos"] element with new HTML.
    msg-submitting='Adding the "${todo}" todo item...' 
    msg-success='The "${todo}" todo item was added.' 
    <form method="post" action="/add-todo">
        <label for="todo">Add a todo item</label>
        <input id="todo" name="todo" type="text" required>
        <button>Add Todo</button>

    This gets updated after the form submits.
    It expects HTML to be returned by the server.
<div data-list="todos">
    <h2>Your Todos</h2>
    <p>You don't have any todos yet.</p>


The <ajax-form> element receives a [form-submitting] attribute when the form is actively awaiting a response from the server.

You can hook into the attribute in your CSS to style your form and visually indicate submitting status.

 * Decrease button opacity when submitting
[form-submitting] button {
    opacity: 0.8;


The <ajax-form> component emits a few custom events to let you extend it’s functionality beyond what it offers out-of-the-box.

  • ajax-form:submit is emitted when the form is submitted, but before it sends data to the server. The event.detail property is an object of form field values. Running event.preventDefault() will cancel the form submission.
  • ajax-form:success is emitted when the form successfully returns data from the server. The event.detail property is the Response object from the server.
  • ajax-form:error is emitted when the server returns an error. The event.detail property is the Response object from the server.

You can listen for events with the Element.addEventListener() method.

document.addEventListener('ajax-form:success', function (event) {

    // The `<ajax-form>` element

    // The event details


The Web Component

customElements.define('ajax-form', class extends HTMLElement {

     * The class constructor object
    constructor () {

        // Always call super first in constructor

        // Add a form status element
        let announce = document.createElement('div');
        announce.setAttribute('role', 'status');

        // Set base properties
        this.announce = announce;
        this.form = this.querySelector('form');
        this.handler = this.createSubmitHandler();

        // Define options
        this.preventDefault = this.hasAttribute('prevent-default');
        this.msgSubmitting = this.getAttribute('msg-submitting') ?? 'Submitting...';
        this.msgSuccess = this.getAttribute('msg-success') ?? 'Success!';
        this.msgError = this.getAttribute('msg-error') ?? 'Something went wrong. Please try again.';
        let target = this.getAttribute('target');
        this.targets = target ? target.split(',').map(target => target.trim()) : null;


     * Create a submit handler with the instance bound to the callback
     * @return {Function} The callback function
    createSubmitHandler () {
        return async (event) => {

            // If the form is already submitting,
            // OR if default should be prevented
            // Stop form from reloading the page
            if (this.isDisabled() || this.preventDefault) {

            // If the form is already submitting, do nothing
            // Otherwise, disable future submissions
            if (this.isDisabled()) return;

            // Emit a submit event (useful for validations)
            if (!this.emit('submit', this.getData())) return;

            try {

                // Show status message

                // If not preventing default behavior, end early
                if (!this.preventDefault) return;

                // Call the API
                let {action, method} =;
                let response = await fetch(action, {
                    body: this.serialize(),
                    headers: {
                        'Content-type': 'application/x-www-form-urlencoded'

                // If there's an error, throw
                if (!response.ok) throw response;

                // If UI should be updated, do so
                if (this.targets) {
                    let str = await response.clone().text();

                // Emit a success event
                this.emit('success', response.clone());

                // Show success URL

                // Clear the form

            } catch (error) {
                this.emit('error', error.clone());
            } finally {


     * Listen for form submissions when the form is attached in the DOM
    connectedCallback () {
        this.form.addEventListener('submit', this.handler);

     * Stop listening for form submissions when the form is attached in the DOM
    disconnectedCallback () {
        this.form.removeEventListener('submit', this.handler);

     * Emit a custom event
     * @param  {String} type   The event type
     * @param  {Object} detail Any details to pass along with the event
    emit (type, detail = {}) {

        // Create a new event
        let event = new CustomEvent(`ajax-form:${type}`, {
            bubbles: true,
            cancelable: true,
            detail: detail

        // Dispatch the event
        return this.dispatchEvent(event);


     * Disable a form so I can't be submitted while waiting for the API
    disable () {
        this.setAttribute('form-submitting', '');

     * Enable a form after the API returns
    enable () {

     * Check if a form is submitting to the API
     * @return {Boolean} If true, the form is submitting
    isDisabled () {
        return this.hasAttribute('form-submitting');

     * Get the value of a form field by its [name]
     * @param  {String} id The field name
     * @return {String}    The value
    getFieldValue (id) {

        // Get the field
        let field = this.form.querySelector(`[name="${id}"]`);
        if (!field) return;

        // If select element, get selected element text
        if (field.tagName.toLowerCase() === 'select') {
            return field.options[field.selectedIndex].textContent;

        // Otherwise, return value
        return field.value;


     * Replace placeholders in message with field values
     * @param  {String} msg The message text
     * @return {String}     The message text with placeholders replaced
    getMessageText (msg) {
        let instance = this;
        return msg.replace(/\$\{([^}]+)\}/g, function (match) {

            // Remove the wrapping curly braces
            match = match.slice(2, -1);

            // Get the field value
            let value = instance.getFieldValue(match);

            // Return the string
            if (!value) return '{{' + match + '}}';
            return value;


     * Update the form status in a field
     * @param  {String} msg The message to display
    showStatus (msg) {
        this.announce.innerHTML = this.getMessageText(msg);

     * Serialize all form data into an encoded query string
     * @return {String} The serialized form data
    serialize () {
        let data = new FormData(this.form);
        let params = new URLSearchParams();
        for (let [key, val] of data) {
            params.append(key, val);
        return params.toString();

     * Serialize all form data into an object
     * @return {Object} The serialized form data
    getData () {
        let data = new FormData(this.form);
        let obj = {};
        for (let [key, value] of data) {
            if (obj[key] !== undefined) {
                if (!Array.isArray(obj[key])) {
                    obj[key] = [obj[key]];
            } else {
                obj[key] = value;
        return obj;

     * Render the updated UI into the DOM
     * @param  {String} str The HTML string for the updated UI
    render (str) {

        // Parse returned string into HTML
        let parser = new DOMParser();
        let doc = parser.parseFromString(str, 'text/html');
        if (!doc.body) return;

        // Render each target
        for (let selector of this.targets) {

            // Find target element in the DOM
            let target = document.querySelector(selector);
            if (!target) continue;

            // Get the target element from the returned HTML
            let updated = doc.body.querySelector(selector);
            if (!updated) continue;

            // Update the UI



     * Reset the form element values
    reset () {