How to Build HTML Forms Right: User Experience

As you build out forms for the web, getting the semantics, accessibility, and styling right is a lot of work. If you can get all those right, you’re doing quite well for yourself. However, there are still some thing we can do to make life better for the folks filling out our forms.

In this article, we’ll look at some of the do’s and don’ts about HTML form user experience (UX). And if you are looking for a refresher on the previous steps mentioned above, take a look at the other articles in this series.

Content

Request the least amount of information

As a user of the internet, I can speak from experience when I say that entering more data than necessary into a form is annoying. So if you really only need an email, consider not asking for first name, last name, and phone number. By creating forms with fewer inputs, you will improve the user experience. Some studies have even shown that smaller forms have higher conversion rates. That’s a win for you. Plus, reducing the data you collect has a chance of reducing your privacy concerns, although this depends greatly on the data.

Keep it simple

It can be tempting to bring your creativity into form design. However, it’s easy to go overboard and make things confusing. By sticking to simple designs that use standard input types, you are creating a more cohesive experience, not only across your site, but across the internet. This means users are less likely to get confused by some fancy and novel input. Stick to the classics. And remember that selection inputs like checkboxes (allows multiple selected items) generally use boxed inputs, and radios (allows only a single selection) use circles.

Semantics are good for a11y and UX

I covered semantics in much more detail in a previous article, but the short version is that choosing the right input types improves the experience on many levels: semantics, accessibility, and user experience. Folks are used to the way inputs work across the web, so we can take advantage of that by using the same inputs for the same things. Not to mention that by using the right inputs, we get a lot of things for free like keyboard navigation support and validation.

Put country selector before city/state

This is a simple rule for anyone adding locales to their forms. If you are going to ask for a user’s country, put that before the city & state fields. The reason is, usually the city and state will be populated based on the country. So if your country selection defaults to the United States, and the user lives in Oaxaca, Mexico, they will need to skip past the city & state fields, select the country of Mexico, then go back and fill in their city and state once the list is updated. By putting the country first, you maintain the flow of the form, which is especially nice for users that use the keyboard to navigate.

Paginate long forms

This relates to my first point in that ideally you don’t have too much data. However, in some cases it cannot be helped. In those cases, it might make sense to paginate a form so that the information is not overwhelming. If you choose to paginate the form, my best advice is to show the user some sort of UI about their progress in the form, and the option to remove the pagination and show the form in it’s entirety.

General Functionality

Prevent browser refresh/navigation

Have you ever been filling out a long form and accidentally refreshed the page, losing all your work? It’s the worst. Fortunately, the browser provides us with the beforeunload event that we can use to inform the user they are about to lose any unsaved work.

We can setup a variable to track whether the form has any unsaved changes, and we can attach a handler to the beforeunload event which will prevent the browser navigation if any changes have been made.

// You'll need some variable for tracking the status. We'll call it hasChanges here.

window.addEventListener("beforeunload", (event) {
  if (!hasChanges) return;

  event.preventDefault();
  event.returnValue = "";
})

form.addEventListener('change', () => {
  hasChanges = true;
});

form.addEventListener('submit', () => {
  hasChanges = false;
})

The gist of this snippet is that we are tracking some variable called hasChanges. If hasChanges is false when the beforeunload event fires, we can allow the browser to navigate away just fine. If hasChanges is true, the browser will prompt the user letting them know they have unsaved changes and ask if they want to continue away or stay on the page. Lastly, we add appropriate event handlers to the form to update the hasChanges variable.

Your implementation may look slightly different for the hasChanges variable. For example, if you are using a JavaScript framework with some state management. And if you are creating a single page application, then this solution will not be quite enough because the beforeunload event does not fire on single page app navigation. For more details on that, please check out my article “How to prevent browser refresh, URL changes, or route navigation in Vue“.

Store unsaved changes

Along the same lines as the previous point, there are times when we accidentally lose all our work on a long form. Fortunately, we can avoid causing this grief to our users by taking advantage of browser features like sessionStorage. Let’s say, for example, that we wanted to store all the data in a form any time a change event occurs. We can use FormData to capture the form and all it’s current values, then store the data as a JSON string in sessionStorage.

const form = document.querySelector('form')

form.addEventListener('change', event => {
  const formData = new FormData(form);
  sessionStorage.setItem('your-identifier', JSON.stringify(formData));
});

With the data saved, the user can refresh all they want and the data is not lost. The next step is to check localStorage on page load to see if we have any previously saved data to pre-fill the form with. If we do, we can parse the string into an object, then loop over each key/value pair and add that saved data to it’s respective input. It’s slightly different for different input types.

const previouslySavedData = sessionStorage.getItem('form-data');

if (previouslySavedData) {
  const inputValues = JSON.parse(savedData);

  for(const [name, value] of Object.entries(inputValues)) {
    const input = form.querySelector(`input[name=${name}]`);
    switch(input.type) {
      case 'checkbox':
        input.checked = !!value;
        break;
      // other input type logic
      default:
        input.value = value;
    }
  }
}

The last thing to do is to make sure that once the form is submitted, we clean up any previously saved data. This is also part of the reason we used sessionStorage instead of localStorage. We want our saved data to be somewhat impermanent.

form.addEventListener('submit', () => {
  sessionStorage.removeItem('form-data');
});

The last thing to say about this feature is that it’s not right for all data. Any private or sensitive data should be left out of any localStorage persistence. And some input types just plain won’t work. For example, there would be no way to persist a file input. With those caveats understood, however, it can be a great feature to add to almost any form. Especially any longer forms.

Do not prevent copy/paste

One of the most annoying things I’ve experienced recently was on the IRS website. They asked me for my bank account number and bank routing number. These are not short numbers, we’re talking like 15 characters. On most websites, it’s no problem, I copy the numbers from my bank’s website and paste it into the input field. On the IRS website, however, they chose to disable pasting into the inputs which meant I had to manually fill in the details for each number…twice. I have no idea why they did this, but it’s very frustrating for users, and actually increases the likelihood of errors. Please don’t do this.

Input Functionality

inputmode

If you have not heard about inputmode before, then let me turn you on to it now. inputmode is an HTML input attribute that let’s you tell the browser the input format. This may not be immediately clear, and if you are on your desktop computer then you won’t notice it, but for mobile users it makes a huge difference. By selecting different input modes, the browser will present the user with a different virtual keyboard to input their data.

You can greatly improve the user experience of filling out a form for mobile users by simply adding a different input mode. For example, if you are asking for numeric data like a credit card number, you can set the inputmode to numeric. That makes it easier for the user to add numbers. The same for emails, inputmode=email.

Available values for inputmode are none, text, tel, url, email, numeric, decimal, and search. For more examples, check out inputmodes.com (ideally on a mobile device).

autocomplete

Along with inputmode, the autocomplete attribute is a built in feature that can greatly improve the user experience of your forms. Many, many website use forms to request the same information from users: email, address, phone, credit cards, etc. And a very nice feature that is built into the browsers is the ability for users to save their own information so that it can be auto-completed across different forms and sites. autocomplete lets us tap into this.

The autocomplete attribute is valid on any text or numeric input as well as <textarea>, <select>, and <form> elements. There are way to many available values to use for me to list here, but some that stand out are current-password, one-time-code, street-address, cc-number (and various other credit card options), and tel.

Providing these options can make a nicer experience for many users, and don’t worry about this being a security concern because the information only exists on the users machine, and they have to allow their browser to implement it.

autofocus

The last built-in attribute I’ll mention is autofocus. By adding it to an input, the browser will place focus on an input, select, or textarea (Chrome also supports using it on <button>, <a>, and elements with tabindex). This can be super useful if the main point of the current page is to fill out the form. For example, if you open duckduckgo.com, you’ll notice that the search input is already focused. This is not the default behavior, but they have added it. It’s nice.

A word or caution here, however. Not every form is right for autofocus. Putting focus on an element will scroll to that element. So if there is other content on the page, we may scroll past all that content. This is an especially jarring experience for users relying on assistive technology like screen-readers. Please use this feature only if it actually improves the experience for all users.

Auto-expanding textarea

A very minor feature, but one that I appreciate is a textarea that automatically expands to match the content in it. That way you don’t have to deal with textareas that are huge, or those that are too small and need a scrollbar to get around. It’s probably not the right feature for every use case, but it can really add some polish to some forms. Here’s a naive implementation.

textarea.addEventListener('input', () => {
  textarea.style.height = "";
  textarea.style.height = Math.min(textarea.scrollHeight, 300) + "px";
});

I call this a naive implementation because in my experience, it’s hard to get a one-size-fits-all solution due to the different sites having different CSS rules applied to textareas. Sometimes it’s impacted by padding or border-width, and others it’s because the box-sizing property is different. In any case, you can use this as a starting point, or of course you could reach for a library.

Disable scroll event on number inputs

If you are not familiar, there is a browser feature on number inputs that allows you to increment or decrement the value using a mouse wheel. This can be a nice feature if you need to quickly change the value and do not want to type. However, this feature can also lead to bugs because on long pages where scrolling is required, a user can sometimes accidentally decrement their input when they mean to scroll down the page. There is a simple enough solution:

<input type="number" onwheel="return false;" />

By adding this onwheel event handler, we are basically telling the browser to ignore that event (it will still fire any attached wheel events though). So if we are working with numbers like addresses, zip codes, phone numbers, social security, credit cards, or anything else that clearly does not need to be incremented or decremented, we can use this handy snippet. However, in those cases I would probably recommend to use a text input instead and not have to worry about this issue at all.

Validation

Validation is when you take some form data and make sure that it matches the format you’re looking for. For example, if you want someone to submit an email in the form, you need to validate that it contains an @ symbol. There are plenty of different types of validations, and plenty of approaches. Some validations happen on the client-side, and others happen on the server-side. We’ll take a look at some “dos” and “don’ts”.

Delay validation to blur or submit events

With HTML5, it’s easy enough to add some client-side validation to your forms. You may decide to enhance it with some JavaScript as well, but when you choose to validate inputs matters.

Let’s say you have a function that accepts an input DOM node, checks it’s ValidityState, and toggles a class if it’s valid or not:

function validate(input) {
  if (input.validity.valid) {
    input.classList.remove('invalid')
  } else {
    input.classList.add('invalid')
  }
}

You have to choose when to run this function. It could be any time the user clicks on the input, presses a key, leaves the input, or submits the form. My recommendation is to keep validation events for either the blur event (when an input looses focus) or on a form’s submit event. Validating on the initial focus seems out of place, and validating on key presses can be annoying. It’s like someone trying to correct you before you’ve finished your comments.

In most cases, I like to keep my validation logic tied to the submit event. I think it simplifies things, and maintains a more cohesive experience in case I want some server side validation logic as well. That said, the blur event is also a very handy place to validate things.

Don’t hide validation criteria

Another useful, if not obvious tip is to clearly tell users up front what makes an input valid or invalid. By sharing that information, they already know that their new password needs to be 8 characters long, contain upper and lowercase letters, and include special characters. They don’t have to go through the steps of trying out one password just to be told that they need to pick another.

There are two waysI would recommend implementing this. If it’s a basic format, you might be able to get away with using a placeholder attribute. For something more complex, I recommend putting the requirements in plain text immediately below the input, and include an aria-labelledby attribute on the input so that those requirements are also passed to assistive technology users.

Send all server validation errors back at once

Another very annoying experience for users when filling out forms is having to resubmit the same form multiple times because some of the data is invalid. This can happen because the server only validates one field at a time and returns the errors immediately, or because an input has multiple validation criteria but the server returns the validation error as soon as it encounters the first one, rather than capturing every error.

To paint an example, let’s say I have a registration form that needs my email and a password with a minimum of eight characters, at least one letter, and at least one number. The worst case scenario is if I didn’t know any better, I may need to resubmit the form multiple times.

  • Error because I did not include an email
  • Error because my password was too short
  • Error because my password needs to include letters
  • Error because my password needs to include numbers
  • Success!

As developers writing forms, we don’t always have control over the backend logic, but if we do, we should try to provide all the errors back as one message: “First input must be an email. Password must be 8 characters. Can only contain letters and numbers. Password must contain 1 letter and 1 number.” or something like that. Then the user can fix all the errors at once and resubmit.

Submissions

Submit with JavaScript

Regardless how you feel about the explosion of JavaScript into every part of our lives, there is no denying that it is a useful tool to make user experiences much better. Forms are a perfect example of this. Rather than waiting for the browser to submit the form, we can use JavaScript and avoid a page reload.

To do so, we add an event listener to the submit event, capture the form’s input values by passing the form (event.target) into FormData, and send the data to the target URL (form.action) with a combination of fetch and URLSearchParams.

function submitForm(event) {
  const form = event.target
  const formData = new FormData(form)
  
  fetch(form.action, {
    method: form.method,
    body: new URLSearchParams(formData)
  })
  
  event.preventDefault()
}

document.querySelector('form').addEventListener('submit', submitForm)

The event.preventDefault() at the end of the handler is important to so the browser does not do its default behavior of submitting the event through an HTTP request. That would cause the page to reload, and is not as nice of an experience. One key thing here is that we put this method at the end just in case we have an exception somewhere higher up in the handler, our form will still fall back to HTTP requests and the form will still be submitted.

Including status indicators

This tip ties in very closely with the previous one. If we are going to be submitting forms with JavaScript, we need to be updating the user on the status of their submission. For example, when the user hits the submit button, there should be some sort of indication (ideally visual AND non-visual) that the request was sent. In fact, there are 4 states we can account for:

  • Before the request was sent (probably nothing special needed here)
  • Request pending.
  • Successful response received.
  • Failed response received.

There are too many possibilities for me to tell you exactly what you will need in your case, but the point is that you remember to account for all of these. Do not keep the user wondering if there was an error with the request being sent. (That’s a quick way to get them to spam that submit button). Do not assume every request will succeed. Tell them there was an error, and if possible, how to address it. And give them some confirmation when their request is successful.

Scroll to errors

In the event that your form does it’s best to let the user know exactly what went wrong (as we saw above) and where. Especially on long scrolling pages, it’s possible that your user may try to submit a form that has some sort of error on it, and even if you color the input red and add some validation error messages, they may not see it because it is not on the same part of the screen where they are.

Once again, JavaScript can help us out here by searching for the first invalid input element in the form, and focusing on it. The browser automatically scrolls to any element that receives focus, so with very little code, you can provide a nicer experience.

function focusInvalidInputs(event) => {
  const invalidInput = event.target.querySelector(':invalid')
  invalidInput.focus()

  event.preventDefault()
}

document.querySelector('form').addEventListener('submit', focusInvalidInputs)

That’s about all I have for you. User experience is a very subjective thing and this list not intended to be entirely complete, but I hope it provided you with some concepts and patterns for improving your forms.

If you liked this article, it would really mean a lot to me if you would share it. And if this is the sort of thing you’d like to see more regularly, you should also subscribe to my newsletter and follow me on Twitter.