DEV Community

Cover image for How to Build HTML Forms Right: Accessibility
Austin Gil
Austin Gil

Posted on • Originally published at austingil.com on

How to Build HTML Forms Right: Accessibility

Forms are arguably the most important parts of any web application. Without forms, we would not have sites like Google, Facebook, Amazon, Reddit, etc. However, the more I browse the web, the more I see poor implementations of forms.

In this series, we will examine the proper steps to creating forms for the web, how to think about the code we write, and considerations to make along the way. The series is broken up into the following parts:

  • Part 1: Semantics
  • Part 2: Accessibility
  • Part 3: Custom Styles
  • Part 4: User Experience
  • Part 5: Security

Semantics

In the previous section, we looked at semantics and all the benefits we get by writing semantic HTML. We touched briefly on accessibility, but there are a lot of accessibility wins to be had that we will see in more detail here. It makes sense to consider these first.

A good example of how semantics positively impacts accessibility is found in unordered lists. You may or may not have seen somewhere that the semantic way of creating a menu is:

<nav>
  <ul>
    <li>
      <a href="...">Menu item</a>
    </li>
    <!-- other items -->
  <ul>
<nav>
Enter fullscreen mode Exit fullscreen mode

This may seem quite verbose and unnecessary considering the <nav> will already let a user on assistive technology know that they are inside a navigation element, and the link is, well, a link.

However, the benefit we get from using <ul> and <li> is that users depending on assistive technologies are given details that they are in a list, how many items are in the list, and which list item they are currently on. You can imagine that if you could not see the list, it may be nice to know if it’s a short list, or a very long list.

As I mentioned before, the previous article covers semantics in more detail. I just wanted to mention here how important a role (pun intended) semantics plays in accessibility. The key takeaways for semantics are: always use a <form> tag, always include a <label> (or aria-label), use the appropriate input type attribute, and always include some sort of submit button (even if it’s not visible).

Labels

There are plenty of articles on the web that talk about accessibility. Most of them mention labels as the first thing, and rightly so. Any form input that is not labeled is a major accessibility issue, but not all labels are the same.

As we saw in the previous article, there are 3 options to add labels to an input:

  • Explicit label (for attribute references input ID): <label for="name">Name:</label><input name="name">
  • Implicit label (<label> element wraps input): <label>Name: <input name="name"></label>
  • aria-label: Name: <input name="name" aria-label="Name">

Use Explicit Labels

For a long time, I didn’t think there was much difference between explicit and implicit labels. but it turns out there is. Many assistive devices such as screen readers treat these the same. The same cannot be said about voice control, which is also an important accessibility consideration.

It turns out that Dragon Naturallyspeaking is a very common software for voice control, but it fails accessibility tests for implicit labels.

But what about aria-labels?

aria-labels are a great addition to our accessibility toolbelt. They give us the ability to provide context to non-text-based element. This can be critical for users of assistive technology.

However, there are some potential issues for users that rely on client-side translation tools. For example, some users might land on your website in your language, while they may speak a different language. Let’s say they have a browser extension that translates page content into their own language. If they do so, your aria-labels may be left out.

Google’s Chrome browser has translation support built-in, and they addressed this issue. It now supports translating aria-labels, and I can confirm that I’ve tested it. That’s great, but it does not apply to all client-side translators.

For the time being, I’ll continue using explicit labels as much as I can just in case I need to support a multi-lingual audience. But I’ll probably throw in an aria-label here and there to keep things simple.

Make Things Easy

So by now, we are set on using explicit labels for all our inputs. That’s great, but there’s one annoying thing about these. They require us to include a for attribute on the <label> and an id on the <input>. This can be tedious, it’s more verbose, and it can be difficult to think up truly unique ID’s if we don’t know the list of every other ID on the page. It can be even trickier if we are creating components that can go anywhere in the whole site.

Fortunately, it can also be easy. Just let the robots do what robots do best. ID’s don’t have to make any sense, and we don’t need to know the value of them if we are only using them for connecting forms with labels. So creating a random string generator is a great tool for this.

If you are using JavaScript it might look like this:

/**
 * @param {number} [length=10]
 * @pram {string} [allowed='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'] 
 * @return {string}
 */
function randomString(length = 10, allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") {
  let result = ""
  for (let i = 0; i < length; i++) {
    result += allowed.charAt(Math.floor(Math.random() * allowed.length))
  }
  return result
}
Enter fullscreen mode Exit fullscreen mode

You can use this function whenever you are generating your HTML by assigning a random string to a variable, and using that in your template for the for attribute and the id. This works especially well in component-driven architectures, but you want to make sure to account for any case where an ID is explicitly defined, and fall back to a randomly generated one if none is provided.

The “visually-hidden” Utility Class

The previous point is great. We all want to create accessible inputs, so we resolve to put labels on all our inputs, and for our international audience, we avoid using the aria-label attribute. Life is good.

But what if our designer hands us a prototype of a search feature that’s just an input with some placeholder saying “Type your search term and press ‘Enter'”. Now we have some issues:

  • There’s no obvious label (placeholders are not a substitute)
  • There’s no submit button.

To address the challenge of wanting to write good, semantic, accessible markup and stick to a design that does not include the elements necessary, we can add a class to our stylesheets like this:

.visually-hidden {
  position: absolute;
  overflow: hidden;
  clip: rect(0 0 0 0);
  width: 1px;
  height: 1px;
  margin: -1px;
  border: 0;
  padding: 0;
}
Enter fullscreen mode Exit fullscreen mode

I add this utility class to pretty much every project I create. It works brilliantly with forms that have visually hidden labels and/or submit buttons, such as the search box example above:

<form>
  <label for="search">
    <span class="visually-hidden">Search</span>
    <input id="search" name="search" />
    <button class="visually-hidden">Submit</button>
  </label>
</form>
Enter fullscreen mode Exit fullscreen mode

Now my form has an accessible label, it’s translation friendly, and we provide a good user experience for screen reader users.

It’s not perfect though. Users like myself that prefer keyboard navigation within forms, but also rely on visual cues can tab through the form, reach the inputs, then get to the submit button and our focus state is lost because the submit button is visually hidden.

A simple solution for that could be adding styles to the form submit button once it has received focus to show an absolute or fixed position, high-contrast button:

button.visually-hidden:focus,
input[type="submit"].visually-hidden:focus {
  position: fixed;
  top: 10px;
  left: 10px;
  clip: unset;
  width: unset;
  height: unset;
  border: 3px solid;
  padding: 5px;
  background: #fff;
}
Enter fullscreen mode Exit fullscreen mode

Note that this is more of a subjective decision. There’s plenty of ways to add the button to the page, but the point is that you should add it. If you don’t like this approach, another good one is to add a search icon to the form that serves as a submit button.

<form>
  <label>
    <span class="visually-hidden">Search</span>
    <input type="search" >
    <button>
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke="currentcolor" stroke-width="2">
        <circle cx="14" cy="14" r="12" />
        <path d="M23 23 L30 30" />
      </svg>
      <span class="visually-hidden">Submit</span>
    </button>
  </label>
</form>
Enter fullscreen mode Exit fullscreen mode

You will need to add a few more styles there to make the button look right.

The “name” Attribute is Important

The HTML name attribute is available for several elements, but in our case we want to focus on how it relates to <input> elements. By default, when a form is submitted, the form data is passed to the request (either in the body or the query string) such that each input’s name maps to its value.

With the mass adoption of JavaScript, it’s easy to omit the name attribute because FormData and URLSearchParams make it trivial to submit a form with whatever data you want.

Frameworks and libraries such as Vue and React also make it easy bind input values with data in a component’s state. When you select a radio input or tick a checkbox, the respective data in the state object updates, and vice versa.

The problem is, without the name attribute, some inputs (specifically the radio type) do not behave as expected. If you omit the name attribute, every radio input is treated as a separate input, rather than as different options in a group. You can use the tab key to access each input individually and enable/disable it, but the expected behavior is as follows:

  • tab key should focus on the selected input. If none is selected, it should loop over each in order.
  • shift + tab key should focus on the selected input. If none is selected it should loop over each in reverse order.
  • spacebar should select the currently focused input if none is selected.
  • up /left arrow keys should select the previous radio input.
  • down /right arrow keys should select the next radio input.

That’s all fine and dandy, but an example really serves better. Try navigating this group of inputs using only your keyboard:

See the Pen Radio inputs without name by Austin (@Stegosource) on CodePen.

Compare that to a group of radios without the name attribute (and keep in mind the expected keys above):

See the Pen Radio inputs without name by Austin (@Stegosource) on CodePen.

This is all to say that your keyboard-navigation users (like myself) will thank you when the keyboard works as intended.

Names are also good for developers!

Adding the name attribute to every input in your form actually makes your developer life easier too. It makes submitting forms trivial by combining FormData and URLSearchParams:

const form = document.querySelector("#your-form")
const url = "https://jsonplaceholder.typicode.com/posts"

form.addEventListener('submit', e => {
  e.preventDefault()

  const data = new FormData(e.target)

  fetch(url, {
    method: 'POST',
    body: new URLSearchParams(data)
  })
})
Enter fullscreen mode Exit fullscreen mode

This is so nice that I rarely ever use input binding in my Vue or React forms anymore. Also note that if you can send FormData directly as the request body, but it will set the HTTP headers to multipart/form-data (not usually what I want).

Keep Your States in Check

We’ve covered a lot of ground so far in terms of helping users with assistive technology understand the form inputs, and helping keyboard users be able to navigate their forms. Next, we should focus on providing the right information about the status of the form inputs.

Required

HTML5 input validators make it easy enough for us to mark an input field as required by simply adding the required attribute:

<input name="example" required>
Enter fullscreen mode Exit fullscreen mode

By simply adding this attribute to required inputs, you can provide more context to some assistive technology.

One thing to note is that if you like use an asterisk (*) next to the label text to denote required inputs, you will be doing your screen reader uses a favor by wrapping it with an aria-hidden attribute so it is not read out loud.

<label for="example">
  Label text <span aria-hidden>*</span>
  <input id="example" name="example" required>
</label>
Enter fullscreen mode Exit fullscreen mode

Or if you choose to add that asterisk with the CSS content property, be sure to remember to include the CSS speak: never; rule so it does not accidentally get read by screen readers.

Disabled

In some cases, you may need to disable an input. Once again, it is simple enough to do so with the HTML disabled attribute:

<input name="example" disabled>
Enter fullscreen mode Exit fullscreen mode

From an accessibility perspective, this will affect the keyboard navigation such that the input will be skipped. I guess it’s up to your application needs to know if that’s the right behavior, but it’s important to know how it works.

Focus

Properly managing focus states is one of the most challenging parts of accessibility. This is important for users (like myself) that are visually able, and use keyboards to navigate. This group of users depends on the browser’s focus state to navigate a site, so you should NEVER completely remove the focus ring. You may, however, change it.

A practical example of when it’s easy to get muck this up is with the common pattern of custom checkbox or radio inputs. Unfortunately, there is no easy way to customize the native UI, so folks have come up with a way to hide the native one and create their own custom one.

We’ll look at implementing custom input designs in more detail in a future article. Today you just need to know that the markup we are interested in looks something like this:

<input name="example" type="checkbox" class="visually-hidden" />
<span class="custom-checkbox">Input label text</span>
Enter fullscreen mode Exit fullscreen mode

The input is visually hidden which means it will receive focus from keyboard navigation, but no one will be able to see that it’s focused. We can fix this with a handy CSS snippet that applies a custom focus ring to the <span> that looks like the native browser styles whenever the input is focused:

input:focus + .custom-checkbox {
  outline-width: 1px;
  outline-style: auto;
  outline-color: Highlight;
  outline-color: -webkit-focus-ring-color;
}
Enter fullscreen mode Exit fullscreen mode

If you’re not familiar with that CSS selector syntax, you can find an explanation of the whole thing here.

Invalid

It’s quite often with forms that we have certain criteria that the inputs need to meet before we will accept the form. For example, a username cannot have certain characters, an email needs to be an email, and a password must be longer than some minimum. Quite often, when a user inputs invalid data, we want to let them know by showing them some feedback. This is a good thing, and it’s just as important to provide that feedback for assistive technology.

First and foremost, we can once again rely on HTML’s built-in validation attributes. These can tell some assistive technology what to expect while the input is being filled out.

We can also improve our experience by leaning on two more ARIA attributes: aria-invalid and aria-describedby. In the same way we might use color to indicate an input is invalid, and error messages to state what the errors are, we can use these tools to provide the same feedback to non-visual users. In fact, doing so is quite simple once we have the rest setup:

<label for="my-input">
  Example:
  <input id="my-input" name="example" minlength="3" aria-invalid="true" aria-describedby="description" oninput="this.setAttribute('aria-invalid', !this.validity.valid)" />
<label>
<p id="description">Must be at least 3 characters.</p>
Enter fullscreen mode Exit fullscreen mode

This example serves to get the point across, but in your application you may want the event listener in a separate file, and perhaps it would update the error messages in the description.

A11y Tools

Alright, so now we’ve just about reached the end of this long journey and maybe you’ve realized that you have some work to do. It can seem daunting at first, I know. Luckily, there are tools available to help us.

Know Where You Are

It’s hard to know how well or poorly we are doing without testing out projects. To get a good understanding of where I stand (assuming I’m not starting from scratch) I like to let the robots do what the robots do well, like auditing my site.

While You Code

Once we’ve run audits and fixed all our code, we want to make sure to avoid any accessibility issues in the future. Once again, we can ask the robots for help here.

Force Your Team to Commit

It’s one thing to test things ourselves and setup our own code environment for accessibility, but if the res of our team does not join in the effort then it may not work out. If you can convince your team that it’s worth it, then you can enforce good accessibility practices by adding tools to your dev pipeline.

  • pa11y-lint-config is an ESLint plugin that will notify you about errors in your code. You can even configure it to reject, of (if possible) automatically fix any errors on a commit hook.
  • Pa11y.org also has a CI/CD tool you can introduce to your build pipeline that can audit a site and prevent any builds from being deployed to production if the audit does not pass.

Let Someone Else do the Work

Lastly, one of the best ways to ensure you are building things accessibly is to adopt a framework that already does accessibility well. Some do a great job, others not so much. So make sure that if you are going to grab a framework, it’s one that is going to boost your productivity AND your accessibility.

  • If you are using React, Reakit looks good. I haven’t used it, but I looked at the output HTML.
  • For Vue.js devs, Vuetify and Bootstrap-vue do a great job, and my own Vuetensils focuses on accessibility specifically.
  • I don’t have much other experience, but I think the basic Bootstrap framework also focuses on being accessible.

Many of these tools are general purpose, and not just for forms. Some work exceptionally well, but in many cases they cannot replace mually inspecting your code or having users actually test your app.

When it comes to accessibility, a passing test is not always a passing experience.

Other Resources

Over the years I have read many excellent articles. Unfortunately, I don’t have too many bookmarked, but while I was writing this article, I did come across a couple of very interesting ones that I’d like to share.

  • Dave Rupert has a compilation of several articles that points out very good accessibility issues to know about.
  • And Oscar (sorry, I don’t know your last name) has a great article specifically on accessible components.
  • Heydon Pickering has a great website on inclusive components. It’s not really about forms, but still good.
  • Finally, the WAI-ARIA guideline looks overwhelming but is actually full of great resources and examples.

If you enjoyed this article, be sure to follow me on Twitter and sign up for the newsletter to get the latest updates.

Top comments (0)