<form action="" x-data="form" @focusout="change" @input="change" @submit="submit">
  <h1>Register</h1>

  <label for="username">Username</label>
  <input name="username" id="username" type="text" x-bind:class="{'invalid':username.errorMessage}" data-rules='["required"]' data-server-errors='[]'>
  <p class="error-message" x-show="username.errorMessage" x-text="username.errorMessage" x-transition:enter></p>

  <label for="email">Email</label>
  <input name="email" type="email" id="email" x-bind:class="{'invalid':email.errorMessage}" data-rules='["required","email"]' data-server-errors='[]'>
  <p class="error-message" x-show="email.errorMessage" x-text="email.errorMessage" x-transition:enter></p>

  <label for="password">Password</label>
  <input name="password" type="password" id="password" x-bind:class="{'invalid':password.errorMessage}" data-rules='["required","minimum:8"]' data-server-errors='[]'>
  <p class="error-message" x-show="password.errorMessage" x-text="password.errorMessage" x-transition:enter></p>

  <label for="passwordConf">Confirm Password</label>
  <input name="passwordConf" type="password" id="passwordConf" x-bind:class="{'invalid':passwordConf.errorMessage}" data-rules='["required","minimum:8","matchingPassword"]' data-server-errors='[]'>
  <p class="error-message" x-show="passwordConf.errorMessage" x-text="passwordConf.errorMessage" x-transition:enter></p>

  <input type="submit">
</form>
* {
  font-family: "Inter", sans-serif;
}
form {
  width: 40%;
  min-width: 450px;
  margin: auto;
}
label {
  font-size: 1.5em;
}

input {
  width: 100%;
  display: block;
  font-size: 2em;
  border: solid 4px;
  border-color: hsl(210, 100%, 30%);
  margin-bottom: 1.5em;
}
input[type="submit"] {
  width: fit-content;
  font-size: 1.5em;
}

input[type="submit"]:focus {
  background-color: hsl(210, 100%, 80%);
}
input[type="submit"]:active {
  background-color: hsl(200, 100%, 80%);
}
.invalid {
  border-color: darkred;
  background-color: hsl(0, 30%, 95%);
  margin-bottom: 0em;
}
.error-message {
  margin-bottom: 1em;
  color: hsl(0deg, 100%, 15%);
}
import Alpine from "https://cdn.skypack.dev/alpinejs";
import kingshottIodine from "https://cdn.skypack.dev/@kingshott/iodine";

Alpine.data("form", form);
Alpine.start();

function form() {
  return {
    inputElements: [],
    init() {
      //Set up custom Iodine rules
      Iodine.addRule(
        "matchingPassword",
        (value) => value === document.getElementById("password").value
      );
      Iodine.messages.matchingPassword =
        "Password confirmation needs to match password";
      //Store an array of all the input elements with 'data-rules' attributes
      this.inputElements = [...this.$el.querySelectorAll("input[data-rules]")];
      this.initDomData();
      this.updateErrorMessages();
    },
    initDomData: function () {
      //Create an object attached to the component state for each input element to store its state
      this.inputElements.map((ele) => {
        this[ele.name] = {
          serverErrors: JSON.parse(ele.dataset.serverErrors),
          blurred: false
        };
      });
    },
    updateErrorMessages: function () {
      //map throught the input elements and set the 'errorMessage'
      this.inputElements.map((ele) => {
        this[ele.name].errorMessage = this.getErrorMessage(ele);
      });
    },
    getErrorMessage: function (ele) {
      //Return any server errors if they're present
      if (this[ele.name].serverErrors.length > 0) {
        return input.serverErrors[0];
      }
      //Check using iodine and return the error message only if the element has not been blurred
      const error = Iodine.is(ele.value, JSON.parse(ele.dataset.rules));
      if (error !== true && this[ele.name].blurred) {
        return Iodine.getErrorMessage(error);
      }
      //return empty string if there are no errors
      return "";
    },
    submit: function (event) {
      const invalidElements = this.inputElements.filter((input) => {
        return Iodine.is(input.value, JSON.parse(input.dataset.rules)) !== true;
      });
      if (invalidElements.length > 0) {
        event.preventDefault();
        document.getElementById(invalidElements[0].id).scrollIntoView();
        //We set all the inputs as blurred if the form has been submitted
        this.inputElements.map((input) => {
          this[input.name].blurred = true;
        });
        //And update the error messages.
        this.updateErrorMessages();
      }
    },
    change: function (event) {
      //Ignore all events that aren't coming from the inputs we're watching
      if (!this[event.target.name]) {
        return false;
      }
      if (event.type === "input") {
        this[event.target.name].serverErrors = [];
      }
      if (event.type === "focusout") {
        this[event.target.name].blurred = true;
      }
      //Whether blurred or on input, we update the error messages
      this.updateErrorMessages();
    }
  };
}

External CSS

  1. https://fonts.googleapis.com/css2?family=Inter:wght@500&amp;display=swap

External JavaScript

This Pen doesn't use any external JavaScript resources.