Skip to main content
Photography of Alvaro Montoro being a doofus
Alvaro Montoro

Tech Writer

screenshot of a login form

Building an Interactive Form: Interactivity

html css webdev tutorial

The main challenge with the original form was handling interactivity. Without any JavaScript, we had to resort to hacks and tricks that made the code difficult to read and not organized.

Having JS is going to simplify our life. It will also extend our browser support considerably as some of the things we did were not standard and they were not going to work outside of WebKit browsers (the vast majority now, but still).

Note: I am not going to use any JS framework or transpiler, and stick with vanilla JavaScript for this form. It makes the snippet lighter, cleaner (that's my opinion), and framework-agnostic. If you are using a framework/transpiler, you can easily adapt the code to it.

Using classes

Many of the events triggered by user actions are going to consist of changing colors, positions, or sizes. Instead of applying the changes directly in the function, when possible what we will do is apply a class to the element and handle the needed changes on CSS.

This has some advantages:

  • Cleaner JavaScript: the code will consist mostly of adding/removing classes when an event happens.
  • Separation of operations: JavaScript will be in charge of the logic (determining when a change is triggered), and CSS will be in charge of the styling.
  • Easy to maintain and modify: not having a messy JS code simplifies later changes and maintenance.

But let's start with a small change... and the only one that doesn't need a class change:

Show/hide password

In the original form, we handled the show/hide password functionality with the non-standard -webkit-text-security. It was one of the reasons why the code was not in the natural order and, as a non-standard feature, it limited the reach of our form.

At the moment it is better to handle that functionality using JavaScript, and lucky for us, it is a fairly simple thing: we'll attach an event listener to the checkbox and every time it changes, we will update the type of the password input to text (if the box is checked) or password (if unchecked):

// show password interactivity
document.querySelector("#show-password").addEventListener("input", function() {
  document.querySelector("#password").type = this.checked ? "text" : "password";
});

We could have used an if...else structure, but the ternary simplifies the code considerably and it still works and is readable:

Animation showing how the show/hide password works now

Extending the input styles

In the next (and final) step of the tutorial, we will add some styles for element events (hover, focus, etc.) But some cases cannot be fully covered just with CSS.

The main one is styling the label text when the input has the focus. We could use :focus-within applied to the label, but it is not supported by all browsers. In the other form, it was "easy": we swapped the order of input and text and were able to use a sibling selector.

We will need to check if the input got -or lost- the focus and add a class to the containing label:

const labels = document.querySelectorAll("#login-form label input");
for (let x = 0; x < labels.length; x++) {
    labels[x].addEventListener("focus", function() {
    this.parentNode.classList.add("state-focus");
  });

  labels[x].addEventListener("blur", function() {
    this.parentNode.classList.remove("state-focus");
  });
}

Then we'll have special cases for that class. Making the label text and the input borders a lighter version of the main color:

label.state-focus span {
  color: hsl(var(--fgColorH), calc(var(--fgColorS) * 2), calc(var(--fgColorL) * 1.15));
}

label input:focus,
label.state-focus input,
label.state-focus span.checkbox-label::before {
  border-color: hsl(var(--fgColorH), calc(var(--fgColorS) * 2), calc(var(--fgColorL) * 1.15));
}

label.state-focus input[type="checkbox"]:checked + span.checkbox-label::after {
  background: hsl(var(--fgColorH), calc(var(--fgColorS) * 2), calc(var(--fgColorL) * 1.15));
}

In the following step, we will use :focus-within as a "fallback" option to this JavaScript snippet.

Form validation

Ideally, validation should happen both in the front and back end. The back-end side validation is out of the scope of this tutorial, so I will focus on the front-end... but not too much :-/

We are delegating the front-end validation on the browser, but we could still add some logic in JavaScript in case we find a browser that doesn't support HTML validation. Ours will be some basic validation and not that complex, just checking for a minimum length (something that can be done also with minlength but for testing purposes, we won't add):

// form submission = validation
document.querySelector(".login-form").addEventListener("submit", function(e) {
  let errorMessage = "";
  const error = document.querySelector("#error-message");
  const username = document.querySelector("#username");
  const password = document.querySelector("#password");

  error.textContent = "";

  // check password length
  if (password.value.length < 8) {
    errorMessage = "Password is mandatory";
    password.parentNode.classList.add("state-error");
    password.focus();
  // check email length
  } else if (username.value.length < 5) {
    errorMessage = "Username is too short (min 5 chars)";
    username.parentNode.classList.add("state-error");
    username.focus();
  } 

  if (errorMessage != "") {
    error.textContent = errorMessage;
    e.preventDefault();
  }
});

// remove state error on field change
function removeError(e) {
    e.target.parentNode.classList.remove("state-error");
  document.querySelector("#error-message").textContent = "";
}
document.querySelector("#password").addEventListener("input", removeError);
document.querySelector("#username").addEventListener("input", removeError);

If there's an error, the form will not be sent (event.preventDefault() will do that) and we will show an error message (that we just added to the form):

Form with error message

As part of the validation, we will also add an error-state class to the field's label. That way we can also stylize the wrong fields however we want (in our case, with a red border and text.) This error state will be deleted whenever the input changes.

Animating the character

And finally -finally!- the real fun part. The one that will make our form unique: the animation of the CSS character that we created in the previous section.

Many things can be done with the character:

  • Make the smile bigger when the fields are correct.
  • Close the eyes when the "show password" box is checked.
  • Cover the eyes with the hands when the "show password" box is checked (as Darin Senneff does in his famous yeti form).
  • Animate the eyebrows depending on the errors/correct fields.
  • Have the eyes follow the path of the text (I did something similar in a previous form.)
  • Have the whole head move while following the text input (Darin Senneff's has this.)
  • Have different expressions with the mouth if an error happened.
  • Make the character go impatient if the input is focused but nothing is written after X seconds.
  • Have confetti/Party hat falling if all fields are valid.
  • ...anything you can imagine.

That's just a shortlist with suggestions. Many of them can be combined, while some of them are incompatible with some others from the list. Picking which ones you want to add is your call. For this demo, we will just add a few of them:

  • Make the smile bigger when a field is correct.
  • Make the smile even bigger when all fields are correct.
  • Close eyes when the "show password" box is checked.

Smile if correct animation

Adjusting the smile will depend on the number of fields that are invalid, and we achieve it by changing the size and position of the mouth div. The JavaScript code is simple by using classes (again!), and we have to play a little bit with the height/width/top position of the mouth until we get what we want:

const invalidFields = document.querySelectorAll("input:invalid").length;
document.querySelector(".mouth").className = `mouth errors-${invalidFields}`;
/* already existing default state: 2 errors */
figure .head .mouth {
  border: 0.125rem solid transparent;
  border-bottom: 0.125rem solid var(--borderDarker);
  width: 25%;
  border-radius: 50%;
  transition: all 0.333s;
  top: 75%;
  left: 50%;
  height: 10%;
}

/* only one input is invalid: bigger smile */
figure .head .mouth.errors-1 {
  top: 61%;
  width: 35%;
  height: 40%;
}

/* no inputs are invalid: the biggest smile */
figure .head .mouth.errors-0 {
  top: 53%;
  width: 45%;
  height: 55%;
}

That's 25 lines of CSS + JS (11 of which already existed for the mouth), and we get an effect that looks like this:

Animation showing the character smiling depending on the input values

Show/Hide password animation

As for closing the eyes, we would need to add 1 line of code to the already existing code to handle the "show password" checkbox change event (plus the extra CSS):

document.querySelector(".eyes").className = `eyes ${this.checked && " closed"}`;
figure .head .eyes.closed::before,
figure .head .eyes.closed::after {
  height: 0.125rem;
  animation: none;
}

That was not that bad, was it? So why not add something extra? Let's add the movement of the eyes as the user types on the username field!

Follow the text writing

This one is a bit trickier, especially because the font that I picked is not a monospace (which would simplify things considerably). That means that different characters have different widths (e.g. 60 I's would fit in the textbox while only 12 M's would fit in the same space), and we need to eyeball (no pun intended) how much the eyes need to move.

Not all users will have a username that is all I's or all M's, and generally, the widths of the other letters will be something in between those two. So let's pretend that normally 25-30 letters will fit in the text field. 27?

Then our logic will be like this:

  • If the username input gets the focus, move eyes down (to pretend the character is looking)
  • If the username input loses the focus, return eyes to their vertical position.
  • Considering a 27 character max, move the eyes to the left "length of value - 14" pixels.

The code could look like this (no need for CSS changes):

// move eyes following username input 
function moveEyes(e) {
  const eyes = document.querySelector(".eyes");
  const length = e.target.value.length;
  // this is a bit trickier because the eyes already have a translation!
  eyes.style.transform = `translate(calc(-50% + ${Math.min(length/2 - 7, 7)}px), calc(-50% + 0.25rem))`;
}
document.querySelector("#username").addEventListener("focus", moveEyes);
document.querySelector("#username").addEventListener("input", moveEyes);
document.querySelector("#username").addEventListener("blur", function() {
  document.querySelector(".eyes").style.transform = "translate(-50%, -50%)";
});

Note: the transform calculation is a bit messy because the eyes' div already has a transformation applied to it. I tried using margins, but then the transition is not as smooth and nice.


This is the form as it stands right now on JSFiddle:

One last step: adjusting some styles so we don't have a dependency on JavaScript, and to clean some additional things.

Article originally published on