The form is a common element on the website. We must make sure that it is accessible to different users. This article helps you to understand how to create an accessible form.
Table of Contents
See the Website
You can use these resources as your reference. Here are the links.
Starter Files
You can try to follow along by downloading the starter files (ZIP). You can visit the repository first before downloading the starter files.
Here is what you will get.
βββ design
β βββ banner.png
β βββ desktop.png
β βββ hover-state.png
β βββ invalid-email.png
β βββ invalid-form.png
β βββ mobile.png
βββ svg
β βββ icon-error.svg
βββ .gitignore
βββ index.html
βββ README.md
βββ style-guide.md
Feel free to follow along!
HTML
Every website needs HTML. HTML can impact the way assistive technology, search engine bots, and other tools understand the page content.
So, it is important to get it right.
Planning
So, how do we structure the page?
Here is what I came up with.
main
h1
form
div
label
span
input:text
p
div
label
span
input:email
p
button:submit
For your information: I use Emmet syntax as the syntax for my pseudo-code. It's up to you how to write pseudo-code.
There are reasons why I decided to write the HTML markup like this.
-
main
- all page content must live inside the landmark element. This will help search engine bots and assistive technologies to understand the page content. In this case, all of them are the main content of the page. -
h1
- each page must have oneh1
. No more, no less. -
form
- wrap all theinput
elements withform
. -
div
- wrapper for eachinput
. Only for styling purposes. -
label
element to wrap theinput
- this way, I don't need to use thefor
attribute on thelabel
. Also, I don't have to addid
on eachinput
element. It's less code to write. -
span
- make the label for the input a block element. This way, the text, and the input are on its line. -
input
with correcttype
value - to make the mobile users get the right keyboard layout. -
p
- to hold the alert message. Text content must always be wrapped by a meaningful element. -
button
withtype="submit"
- don't useinput
withtype="submit"
. It's a legacy element that is used before thebutton
element exists.
Let's turn that pseudo-code into real-code.
<main>
<h1>Accessible Form - Project Example</h1>
<form>
<div>
<label>
<span>Name</span>
<input type="text" />
</label>
<p></p>
</div>
<div>
<label>
<span>Email</span>
<input type="email" />
</label>
<p></p>
</div>
<button type="submit">Sign Up</button>
</form>
</main>
That HTML code gives us this.
The page only gets the browser's default styling. But, we can understand the page content.
This can be one of the ways to validate HTML markup. If you can't understand the page content without styling, then there's a good chance there's something wrong with the HTML.
ARIA Attributes
ARIA (Accessible Rich Internet Application) attributes allow us to extend the ability of HTML accessibility.
It's important to know that no ARIA is better than bad ARIA. So, we need to have a reason to use ARIA attributes. Otherwise, we will only make the website to be more inaccessible.
For your information: Increased ARIA usage on pages correlated to higher detected errors. The more ARIA attributes that were present, the more detected accessibility errors could be expected. From WebAIM: The WebAIM Million - The 2022 report on the accessibility of the top 1,000,000 home pages.
We need ARIA attributes to make the alert messages get pronounced by screen readers. aria-live
attribute allows us to do that.
If you don't know about
aria-live
, check the MDN documentation for thearia-live
attribute.
<p aria-live="polite"></p>
The p
is empty at first. Then, we will use JavaScript to inject the alert message.
We choose polite
because the validation runs after the users try to submit the form. If the validation runs when the users are typing, we use assertive
to immediately tell the users about the error.
Next, we need to tell that the p
is linked to the input
.
We use aria-describedby
. This allows us to tell assistive technologies that the p
is used to describe the linked input
element.
If you don't know about
aria-describedby
, check the MDN documentation for thearia-describedby
attribute.
<div>
<label>
<span>Name</span>
<input type="text" aria-describedby="name-alert" />
</label>
<p id="name-alert" aria-live="polite"></p>
</div>
<div>
<label>
<span>Email</span>
<input type="email" aria-describedby="email-alert" />
</label>
<p id="email-alert" aria-live="polite"></p>
</div>
Finish the HTML
We need to tell the users that all the inputs are required.
<input type="text" required aria-describedby="name-alert" />
<input type="email" required aria-describedby="email-alert" />
Then, we don't want native HTML validation. We will create our validation with JavaScript.
<form novalidate></form>
Validate HTML
So, here's what we have so far.
<main>
<h1>Accessible Form - Project Example</h1>
<form novalidate>
<div>
<label>
<span>Name</span>
<input type="text" required aria-describedby="name-alert" />
</label>
<p id="name-alert" aria-live="polite"></p>
</div>
<div>
<label>
<span>Email</span>
<input type="email" required aria-describedby="email-alert" />
</label>
<p id="email-alert" aria-live="polite"></p>
</div>
<button type="submit">Sign Up</button>
</form>
</main>
Before we are moving to the styling process, let's make sure that our HTML is valid.
We use The W3C Markup Validation Service.
Everything should be valid if you use my starter files and have the exact HTML that I wrote in this blog post.
CSS
So, we are done with the HTML. Now, all we need to do is to make it looks better.
CSS Reset
First, we need to use a CSS reset. This way, we can have a good foundation for our styling.
I recommend using the Modern CSS Reset from Piccalilli (Andy Bell).
CSS Methodology
I use BEM (Block Element Modifier) for the class naming convention.
Planning
Now, let's take a look at the HTML. But, we are now thinking the styling.
This is what I came up with.
<main class="flow">
<h1 class="text-align-center title">Accessible Form - Project Example</h1>
<form class="card flow form" novalidate>
<div class="form__control">
<label class="form__wrapper">
<span class="form__label">Name</span>
<input class="form__input focus-visible-black" type="text" required aria-describedby="name-alert" />
</label>
<p class="form__alert" id="name-alert" aria-live="polite"></p>
</div>
<div>
<label class="form__wrapper">
<span class="form__label">Email</span>
<input class="form__input focus-visible-black" type="email" required aria-describedby="email-alert" />
</label>
<p class="form__alert" id="email-alert" aria-live="polite"></p>
</div>
<button
class="button button--block button--round button--bold button--uppercase button--black focus-visible-black"
type="submit"
>
Sign Up
</button>
</form>
</main>
Custom Properties
Let's prepare the CSS custom properties (or variables).
:root {
--black: hsl(0, 0%, 0%);
--white: hsl(0, 0%, 100%);
--very-light-gray: hsl(0, 0%, 90%);
--red: hsl(0, 100%, 46%);
--body-background-color: var(--very-light-gray);
--card-background-color: var(--white);
--card-text-color: var(--black);
--card-box-shadow: 0 3.125rem 3.125rem -1.5625rem rgba(75, 92, 154, 0.24);
--card-max-width: 31.25rem;
--invalid-color: var(--red);
}
I recommend writing the variables' names with hyphenated naming conventions. CSS properties are based on hyphenated things such as background-color
, font-size
, etc. So, to make everything consistent, we will follow the language naming convention.
For the font size, I use Utopia to generate CSS variables for font sizes. This helps us to have fluid typography effortlessly. I used the following setting to generate the fluid font sizes.
We only need the following variables.
:root {
--step--1: clamp(0.88rem, calc(0.96rem + -0.14vw), 0.94rem);
--step-0: clamp(1.13rem, calc(1.06rem + 0.32vw), 1.25rem);
--step-1: clamp(1.35rem, calc(1.14rem + 1.06vw), 1.77rem);
--step-2: clamp(1.62rem, calc(1.17rem + 2.23vw), 2.5rem);
}
Then, we use the --step-0
as the body
's font size.
:root {
--body-font-size: var(--step-0);
}
Centering the elements
This is the way.
body {
display: grid;
place-items: center;
}
Spacing
For the spacing between the h1
and the card is using .flow
class. The same goes for the space among elements inside the form
.
.flow > * + * {
margin-block-start: var(--space, 1em);
}
For your information: The
* + *
selector is called "Lobotomized Owl Selector". It will only apply themargin-block-start
(the logical property formargin-top
) if there's an element before it. I recommend reading "Get to Know The Lobotomized Owl Selector - Pine"
If we want a different amount of space, we can define the --space
property. For example, if we need more space between input elements inside the card, we can do the following.
.card {
--space: 1.4rem;
}
Focus Visible
The users can use the keyboard to navigate through interactive elements. To tell them where they are currently, we can create a custom :focus-visible
styling.
.focus-visible-black:focus-visible {
outline: 0.2rem dashed var(--black);
outline-offset: 0.3rem;
}
I decided to use a CSS class for this size of the project. Usually, I will have each component has its :focus-visible
styling.
Button
For the button
, I want it to apply the styling with single-responsibility principle. It means each modifier class is only responsible for one thing.
.button {
--button-border: 0.2rem solid var(--black);
cursor: pointer;
border: none;
border-inline: var(--button-border);
border-block: var(--button-border);
padding-inline: 0.6rem;
padding-block: 0.6rem;
text-decoration: none;
}
.button--round {
border-radius: 0.5em;
}
.button--block {
display: block;
width: 100%;
}
.button--bold {
font-weight: 700;
}
.button--uppercase {
text-transform: uppercase;
}
.button--black {
background-color: var(--black);
color: var(--white);
}
The purpose of doing this is to make the button can have different styles. So, we are not limited to only having one styling.
For example, .button-submit
can only be used for submit button. It is not reusable. But, when we separate all the styling, we can have different button styles. This way, we can compose different CSS classes to create different buttons.
Other Elements
The rest of the styling is straightforward. So, you can try to understand the code by yourself. As a developer, understanding other's people code is a beneficial skill.
If you don't understand something then feel free to ask your questions in the comment.
One more thing, you can try to challenge yourself to write the styling by yourself. Know that the site is responsive without any media queries.
Validate CSS
Don't forget to validate your CSS! I recommend using The W3C CSS Validation Service.
JavaScript
Finally, the JavaScript part! π
I will cover Regular Expression, best practices, and how I did the form validation.
Planning
For me, I planned my JavaScript with plain text.
FUNCTION clearAllAlerts () {
"make the form to initial state"
}
FUNCTION checkAllInputs () {
"check all the inputs"
THEN
"if the input is not valid then show the alert message"
}
FUNCTION validateForm () {
"clear all the alert messages"
"check all the input elements"
IF "the form is not valid" {
prevent form submission
}
}
form.addEventListener("submit", validateForm)
HTML
I recommend selecting the HTML elements with js-
classes. The purpose is to separate the classes for CSS and JavaScript. This way, if we change the styling in the future then the functionality will not break.
<form
class="js-form"
novalidate
>
<div>
<label>
<span>Name</span>
<input
class="js-input"
type="text"
name="name"
required
aria-describedby="name-alert"
data-id="name"
/>
</label>
<p
id="name-alert"
class="js-alert"
aria-live="polite"
data-id="name"
></p>
</div>
<div>
<label>
<span>Email</span>
<input
class="js-input"
type="email"
name="email"
required
aria-describedby="email-alert"
data-id="email"
/>
</label>
<p
id="email-alert"
class="js-alert"
aria-live="polite"
data-id="email"
></p>
</div>
<button type="submit">
Sign Up
</button>
</form>
Note: I remove all the styling classes so that it's easier for you to see
js-
classes.
There's something important to know. It's the data-id
attribute.
<div>
<label>
<span>Name</span>
<input
class="js-input"
type="text"
name="name"
required
aria-describedby="name-alert"
data-id="name"
/>
</label>
<p
id="name-alert"
class="js-alert"
aria-live="polite"
data-id="name"
></p>
</div>
Note: we need to make sure that the
input
and thep
has thedata-id
attribute and they have the same value. The above code snippet shows that thep
and theinput
hasname
as the value of thedata-id
.
The data-id
attributes are used to connect the associated error message with the input
element. The benefit of doing this is no matter the position of the alert, it will always connect with the right error message.
For example, we can use the querySelector()
to select the error message for the associated input
.
const handleAlert = (message, input) => {
const inputParentElement = input.parentElement;
const alert = inputParentElement.querySelector(".js-alert");
showAlert(message, alert, input);
}
The problem here is that if the error message has a different parent element with the input
then, this code isn't working.
So, by using data-id
, it won't be a problem.
const alerts = document.querySelectorAll(".js-alert");
const handleAlert = (input, message) => {
alerts.forEach((alert) => {
const inputID = input.dataset.id;
const alertID = alert.dataset.id;
if (inputID === alertID) {
showAlertMessage({ input, alert, message });
}
});
};
As a result, the JavaScript code doesn't depend on the structure of the HTML.
Select The DOM Elements
const form = document.querySelector(".js-form");
const inputs = document.querySelectorAll(".js-input");
const alerts = document.querySelectorAll(".js-alert");
Form Validation
Next, we make the form
listen to submit
event. Then, run the form validation functionality.
const validateForm = (event) => {
clearAllAlerts();
const isFormValid = checkAllInputs();
if (!isFormValid) {
event.preventDefault();
}
};
form.addEventListener("submit", validateForm);
The clearAllAlerts
function will select all the inputs. Then, remove the invalid styling.
It also selects all the alert elements. After that, clear the alert message from each input element.
const clearAllAlerts = () => {
alerts.forEach((alert) => (alert.textContent = ""));
inputs.forEach((input) => input.classList.remove("is-invalid"));
};
For the checkAllInputs
function, we use forEach
to select each input
. Then use switch
to run necessary validation for different types of input.
const checkAllInputs = () => {
let isNameFilled = false;
let isEmailFilled = false;
let isEmailValid = false;
inputs.forEach((input) => {
const id = input.dataset.id;
const value = input.value;
switch (id) {
case "name":
isNameFilled = isInputFilled(input, value);
break;
case "email":
isEmailFilled = isInputFilled(input, value);
if (isEmailFilled) {
isEmailValid = validateEmail(input, value);
}
break;
default:
console.error(`${id} input doesn't exist`);
}
});
const areAllInputsValid = isNameFilled && isEmailFilled && isEmailValid;
return areAllInputsValid;
};
Then, we create another function called isInputFilled
. It has two parameters. The first one is the input
element that we want to check. The second one is the value of the input.
The isInputFilled
function is to check whether the input
is empty or filled.
const isInputFilled = (input, value) => {
const isFilled = !!value;
if (!isFilled) {
handleAlert(input, `This ${input.dataset.id} input is required`);
}
return isFilled;
};
The double exclamation marks (or double NOT) are the same as Boolean(value)
. Since an empty string is a falsy value, it will return false
. This function returns a Boolean
regardless of whether it's true
or false
.
But, if it is false
we need to show the alert message to the users. So, we create another function which is called handleAlert
.
const handleAlert = (input, message) => {
alerts.forEach((alert) => {
const inputID = input.dataset.id;
const alertID = alert.dataset.id;
if (inputID === alertID) {
showAlertMessage({ input, alert, message });
}
});
};
The purpose of handleAlert
function is to get the associated alert element of the input element. Then, it calls showAlertMessage
function.
const showAlertMessage = ({ input, alert, message }) => {
alert.textContent = message;
input.classList.add("is-invalid");
};
The function takes an Object
. Then, destructure it directly. After that, we can show the alert message to the users.
I suggest reading "Destructuring assignment - JavaScript | MDN" to learn more about destructuring.
Email Validation
For the email input, we need to check the email format. So, if the input is filled in but the email address is not valid, then we need to show a different message.
const validateEmail = (input, email) => {
const emailValidation =
/^(?:[a-z0-9.]){2,30}@(?:[a-z0-9-]){2,30}\.(?:[a-z0-9]){2,3}(?:\.(?:[a-z0-9]){2,3})?$/;
const isValid = emailValidation.test(email);
if (!isValid) {
handleAlert(input, "Please provide a valid email address");
}
return isValid;
};
It uses Regular Expression to validate the email address. Let me explain the Regular Expression.
-
^
- make sure the start of the string follows the RegEx format. -
(?:[a-z0-9.]){2,30}
- the user name of the email can only contain:- lowercase
a
throughz
. (a-z
) - number
0
-9
. (0-9
) - one or more periods. (
.
) - at least two characters and can't be more than 30 characters. (
{2, 30}
)
- lowercase
-
@
- needs to have an@
symbol -
(?:[a-z0-9-]){2,30}
- the domain name has a bit different rule from the user name. The only difference is that it allows having one or more dashes (-
) instead of periods (.
). -
.
- must contain a period after the domain name. -
(?:[a-z0-9]){2,3}
- the top level domain can only contain:- lowercase
a
throughz
. (a-z
) - number
0
-9
. (0-9
) - at least two characters and can't be more than three characters. (
{2, 3}
)
- lowercase
-
(?:.(?:[a-z0-9]){2,3})?
- the second top-level domain has the same rule as the first top-level domain. But, it is optional to have this because of the question mark at the end of it. -
$
- with a^
at the start and a dollar sign at the end, it means that the string must follow all the RegEx rules. So, from the first character to the last character must follow the RegEx to be valid. -
(?:)
- it's a non-capturing group. It means I group a set of rules but I don't want each part of the email to be in its group. I recommend reading the MDN documentation for a better explanation.
Example of valid email addresses.
I recommend copy-pasting the RegEx to RegExr. Then, play around with it by yourself to understand it.
Conclusion
Let's do a quick recap of what we have learned.
- Plan HTML markup
- ARIA attributes (
aria-live
andaria-describedby
) - Fluid Typography
- Create custom
:focus-visible
styling - Separation of concern (separating CSS classes by using
js-
classes) - Regular Expression to validate the email address
You might also learn that I am not a designer. π
That's it! If you have any feedback or suggestions for improvements, please do let me know. You can write your suggestions in the comment section. Also, let me know if this is helpful.
By the way, do you think the design of the form is boring?
Top comments (0)