Tutorials 5 min read

How to create a login/register form with Tailwind CSS: JavaScript and Alpine.js

Build a login form with client-side validation using Tailwind CSS, two ways: vanilla JavaScript or Alpine.js. Email check, password rules, show/hide toggle.

This Thursday we are building a login form with client-side validation. We’ll build it twice, first with vanilla JavaScript, then with Alpine.js, so you can pick whichever fits your stack.

What is an authentication form?

Authentication forms are a crucial part of web and application security: they are how users prove who they are before getting access. Ours is a classic email and password login with client-side validation, a show/hide password toggle, and inline error messages.

Use cases

  • Traditional username and password: the standard login most apps ship first.
  • Multi-factor authentication: pair the password with a code from SMS or an authenticator app.
  • Social logins and SSO: let users sign in with existing accounts or one set of corporate credentials.
  • Magic links: skip the password entirely and email the user a login link.

The markup

  • id="loginFormContainer": the container that wraps the form.
  • <form id="loginForm">: the form we listen to for submissions.
  • id="login_email" and id="emailError": the email input and the error message shown when the email is not filled in.
  • id="login_password" and id="passwordError": the password input and the error message shown when the password does not meet the requirements.
  • id="togglePassword": the button that toggles the visibility of the password input field.

Classes are omitted for brevity and clarity.

html
<div id="loginFormContainer">
  <form id="loginForm">
    <div>
      <label for="login_email">Email</label>
      <input
        type="email"
        id="login_email"
        placeholder="Enter your email"
        required
      />
      <p id="emailError">Email is required</p>
    </div>
    <div>
      <label for="login_password">Password</label>
      <div class="relative">
        <input
          type="password"
          id="login_password"
          placeholder="Enter your password"
          required
        />
        <span id="togglePassword">Show</span>
      </div>
      <p>
        Password must contain at least one capital letter and a special
        character.
      </p>
      <p id="passwordError">Password does not meet requirements</p>
    </div>
    <div>
      <button type="submit">Login</button>
    </div>
  </form>
</div>

The script

  • document.addEventListener("DOMContentLoaded", () => {: waits until the page is loaded, then grabs the form, the inputs, the error messages, and the toggle button.
  • const passwordPattern = /^(?=.*[A-Z])(?=.*\W).+$/;: the regular expression used to validate the password, and let showPassword = false; stores the state of the toggle.
  • togglePassword.addEventListener("click", () => {: flips showPassword, switches the input between text and password, and updates the button label to Hide or Show.
  • loginForm.addEventListener("submit", (event) => {: prevents the default submission, then shows or hides emailError depending on whether the email is filled in, and does the same for passwordError using the pattern.
  • When the email is present and the password passes the pattern, we perform the login action, here just an alert("Login successful").
js
document.addEventListener("DOMContentLoaded", () => {
  const loginForm = document.getElementById("loginForm");
  const loginEmail = document.getElementById("login_email");
  const loginPassword = document.getElementById("login_password");
  const emailError = document.getElementById("emailError");
  const passwordError = document.getElementById("passwordError");
  const togglePassword = document.getElementById("togglePassword");

  const passwordPattern = /^(?=.*[A-Z])(?=.*\W).+$/;
  let showPassword = false;

  togglePassword.addEventListener("click", () => {
    showPassword = !showPassword;
    loginPassword.type = showPassword ? "text" : "password";
    togglePassword.textContent = showPassword ? "Hide" : "Show";
  });

  loginForm.addEventListener("submit", (event) => {
    event.preventDefault();

    if (!loginEmail.value) {
      emailError.classList.remove("hidden");
    } else {
      emailError.classList.add("hidden");
    }

    if (!passwordPattern.test(loginPassword.value)) {
      passwordError.classList.remove("hidden");
    } else {
      passwordError.classList.add("hidden");
    }

    if (loginEmail.value && passwordPattern.test(loginPassword.value)) {
      // Perform login action here
      alert("Login successful");
    }
  });
});

The Alpine.js version

Same component, no separate script: the state lives in x-data.

  • x-data="{ loginEmail: '', loginPassword: '', passwordPattern: ..., showPassword: false }": holds the email, the password, the validation pattern, and the visibility toggle.
  • x-on:submit.prevent="login": prevents the form from submitting the default way.
  • x-model="loginEmail": binds the email input to the loginEmail variable, and x-show="!loginEmail" shows the error message if the email is not filled in.
  • :type="showPassword ? 'text' : 'password'", x-text="showPassword ? 'Hide' : 'Show'", and @click="showPassword = !showPassword": together they handle the show/hide password toggle.
  • x-show="loginPassword && !passwordPattern.test(loginPassword)": shows the error message only when the password does not meet the requirements.
  • The text saying the password must contain a capital letter and a special character is always visible to avoid confusion, that’s part of good UX.
html
<div
  x-data="{ loginEmail: '', loginPassword: '', passwordPattern: /^(?=.*[A-Z])(?=.*\W).+$/, showPassword: false }"
>
  <form x-on:submit.prevent="login">
    <div>
      <label for="login_email">Email</label>
      <input
        type="email"
        id="login_email"
        x-model="loginEmail"
        placeholder="Enter your email"
        required
      />
      <p x-show="!loginEmail" class="text-red-500 ">Email is required</p>
    </div>
    <div>
      <label for="login_password">Password</label>
      <div class="relative">
        <input
          :type="showPassword ? 'text' : 'password'"
          id="login_password"
          x-model="loginPassword"
          placeholder="Enter your password"
          required
        />
        <span
          x-text="showPassword ? 'Hide' : 'Show'"
          @click="showPassword = !showPassword"
          >Show</span
        >
      </div>
      <p class="...">
        Password must contain at least one capital letter and a special
        character.
      </p>
      <p
        x-show="loginPassword && !passwordPattern.test(loginPassword)"
        class="text-red-500..."
      >
        Password does not meet requirements
      </p>
    </div>
    <div class="mt-4">
      <button type="submit">Login</button>
    </div>
  </form>
</div>

You can try the Alpine version separately: live demo and source code.

Which one should you use?

If your project already ships Alpine, the x-data version keeps the whole form self-contained in the markup. If not, the vanilla script gives you the same validation without an extra dependency.

Conclusion

We covered creating the form, handling submissions, validating user input, and showing error messages, in both flavors. Remember to keep your login form responsive, user-friendly, and secure, and test it thoroughly before shipping.

Hope you enjoyed this tutorial and have a great day!

/Michael Andreuzza