Solution: Email sign-up form

Front-End Challenges Club - Solution #001

This is the solution to Challenge #001, so let’s first identify what we’re trying to achieve:

  • We’re presenting a form that checks to see if the email is valid before it submits
  • If the email isn’t valid, we present an error message
  • If the email is valid, we submit the form and replace it with a success message

It’s a pretty straightforward context, but there’s a few things we need to make sure that we get right to make it as helpful as possible and the first of those is working out what our minimum viable experience is:

A HTML rendered form that when submission is attempted, the email address is validated. If it is valid it will submit. If not, an error will show on the field if the browser supports native HTML validation.

You could go even further and say our minimum viable experience is just a HTML form that submits, which is true, but it’s also implied already.

Now we know what we want to build, let’s dig into it!

This example has the following code files:

  1. index.html, referred to as “your markup” for the rest of this tutorial
  2. css/global.css, referred to as “your CSS” for the rest of this tutorial
  3. js/main.js, referred to as “your JS” for the rest of this tutorial

You can see a live demo of the final solution here.

HTML permalink

Let’s first add the base HTML structure to your empty index.html file:

Code language
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Front-End Challenges Club - 001</title>
<link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" />
<link rel="stylesheet" href="/css/global.css" />
</head>
<body>
<main class="[ signup ] [ flow ]">
<h1 class="signup__heading">Sign up for the latest updates</h1>
</main>
<script type="module" src="/js/main.js" async defer></script>
</body>
</html>

This gives us our basics and importantly, we pull in our CSS and JS. There’s a couple of bits that I want to explain before we move on:

  • We’re pulling in a reset before our CSS file. This is a reference to a tiny reset I like to use that you can grab here and read about here.
  • Our <main> element is used as our signup component which contains a single <h1> for our main heading.

There’s not much going on, so let’s add the form and the elements within it. Add this to your markup, after the <h1> element.

Code language
HTML
<form id="signupForm" action="/" class="[ signup__form ] [ flow ]" method="POST">
<label for="email">Email address</label>
<div class="inline-field-control">
<input
type="email"
name="email"
id="email"
autocapitalize="none"
autocorrect="off"
required
pattern="[^@]+@[^\.]+\..+"
/>

<button type="submit" class="button">
<span class="visually-hidden">Submit email</span>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
width="1em"
height="1em"
viewBox="0 0 24 24"
>

<path
fill="currentColor"
d="M11.293 5.707L16.586 11H5a1 1 0 000 2h11.586l-5.293 5.293a.999.999 0 101.414 1.414l7-7a1.006 1.006 0 000-1.414l-7-7a.999.999 0 10-1.414 1.414z"
/>

</svg>
</button>
</div>
</form>

You’ll notice I’ve added the action and method attributes to the form, but in the example they don’t really make a difference.

Label

I have a <label> which has a for attribute. This links the label to the element that has the same id as the label’s for attribute. In this instance, the label is linked to the element that has the id of “email”.

Input

The <input /> has a type of email. This attribute is important because it instructs your browser and operating system to provide the relevant assistance to fill it in. This could be a change in keyboard on your phone or the way it is announced to screen readers. It also has the usual name and id attributes. Because the id is “email”, it’s now linked up to the <label>.

You’ll also notice there’s some other attributes on that <input />, so let’s go through them one-by-one:

  • autocapitalize="none" prevents the browser from capitalising the first letter of your email address. This is often a problem with iOS devices.
  • autocorrect="off" prevents the browser from autocorrecting your spelling while you type. This can be incredibly frustrating for a user when they are inputting an email address or a search query.
  • required tells the browser that the user must complete this field before submitting the form
  • pattern="…" is the regular expression that the browser can test the user’s input with. By default, an email input type only looks for an @ in the string, which isn’t good enough. The regular expression that we are using is simple, but more robust and will make sure that the email is better formatted.

Button

We’re using a proper <button> here, so we get all of that lovely accessibility and usability goodness for free. We don’t need to add a type of submit because that’s a button’s default behaviour. Because we’ve added it inside a form element, it’ll submit that form by default.

We have added a class of .button to it which we will hook onto with our CSS, when it’s available. We could just style the button directly, but often, some links will be made to look like buttons too, so this approach helps with that.

Inside the button, we have two key elements. The visual design calls for this button to just have an arrow icon, but we have to provide better context for those who can’t see the elements. We use the sibling <span class="visually-hidden"> element to do that for us and we’ll discuss the CSS that powers that later. Importantly, this approach means that if there’s no CSS, the context is available wether you can see or not.

The <svg> itself has a couple of important features. First of all, we hide it from assistive tech, using aria-hidden="true" and we prevent it from being focused (IE, pre-Chromium Edge) by adding focusable="false". By setting both the width and height to 1em gives us two advantages:

  1. When CSS isn’t available, the icons won’t take up the entire width of the viewport.
  2. The icons can size themselves based on the font-size of their parent context. I wrote about that in some detail.

The last trick on the <svg> is setting the <path> fill to be currentColor. This means that it will inherit whatever the color value is of its parent. You could apply this to the <svg>, if that’s what you prefer, too.

Right, that was a lot to digest, so less reading: more coding. Add this HTML after the </form> (~ line 26):

Code language
HTML
<div aria-atomic="true" role="alert" class="signup__alert"></div>

This is the container for our feedback messages: both success and error states. We add role="alert" so a screen reader interrupts the user. This is useful when there is important information to convey, such as an invalid field error. If you have lots of errors on a form, don’t be tempted to use loads of different role="alert" elements, but instead, group errors into a single one. Lastly, we add aria-atomic="true" so multiple invalid submissions or changes are announced. This is particularly a problem with VoiceOver for iOS.

You’ll notice that the element is being left empty. This is very deliberate because although it’s tempting to present an error message on input or blur, it can actually be very confusing an overloading for someone with a cognitive impairment, so a safer, more inclusive approach is to validate when a user attempts to submit the form.

Thats it for HTML and guess what: we have a minimum viable experience 🎉

Just so we’re all on the same page: your HTML should look like this:

Code language
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Front-End Challenges Club - 001</title>
<link rel="preload" as="font" type="font/woff2" href="/fonts/Inter-Black.woff2" />
<link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" />
<link rel="stylesheet" href="/css/global.css" />
</head>
<body>
<main class="[ signup ] [ flow ]">
<h1 class="signup__heading">Sign up for the latest updates</h1>
<form id="signupForm" action="/" class="[ signup__form ] [ flow ]" method="POST">
<label for="email">Email address</label>
<div class="inline-field-control">
<input
type="email"
name="email"
id="email"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
required
pattern="[^@]+@[^\.]+\..+"
/>

<button class="button">
<span class="visually-hidden">Submit email</span>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
width="1em"
height="1em"
viewBox="0 0 24 24"
>

<path
fill="currentColor"
d="M11.293 5.707L16.586 11H5a1 1 0 000 2h11.586l-5.293 5.293a.999.999 0 101.414 1.414l7-7a1.006 1.006 0 000-1.414l-7-7a.999.999 0 10-1.414 1.414z"
/>

</svg>
</button>
</div>
</form>
<div aria-atomic="true" role="alert" class="signup__alert"></div>
</main>
<script type="module" src="/js/main.js" async defer></script>
</body>
</html>

Let’s move on to some CSS!

CSS permalink

Let’s start with the basics and add the global style stuff. You’ll need to add the woff2 font file from the provided font assets. Open up your CSS file (/css/global.css) and add the following:

Code language
CSS
/**
* FONT FACE
*/

@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('/fonts/Inter-Black.woff2') format('woff2');
}

/**
* VARIABLES
*/

:root {
--color-primary: #4c2982;
--color-secondary: #f9d170;
--color-bg: #f9f7f3;
--color-text: #252525;
--color-light: #f3f3f3;
--color-success: #067973;
--color-success-bg: #f5fffe;
--color-error: #b71540;
--color-error-bg: #fdeff3;
--color-shadow: rgba(23, 11, 41, 0.12);
--font-base-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
--font-heading-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
--metric-rhythm: 2rem;
--metric-gutter: 2rem;
--metric-box-padding: 1rem 1rem;
--metric-interaction-padding: 0.6rem 0.6rem;
}

All we’ve done here is set our @font-face and our Custom Properties. The Custom Properties are mostly colour and font settings, but we’ve also got some metrics like gutter and rhythm set to keep things consistent.

Let’s add some global styles now and tackle our base body and raw HTML elements:

Code language
CSS
/**
* GLOBAL STYLES
*/

body {
font-family: var(--font-base-family, sans-serif);
display: grid;
place-items: center;
background: var(--color-light);
color: var(--color-text);
padding: var(--metric-gutter, 2rem);
}

h1 {
font-family: var(--font-heading-family, sans-serif);
font-size: 2rem;
font-weight: 900;
color: var(--color-primary);
line-height: 1.1;
}

label {
color: var(--color-primary);
text-transform: uppercase;
font-weight: 700;
}

This is all pretty straightforward stuff, really, so I won’t dwell too much on it. One bit I will pick out is this on the <body>, though:

Code language
CSS
display: grid;
place-items: center;

Because all of our content is a <main> element and the <body> has min-height: 100vh in the reset: using the CSS above vertically and horizontally centers the content. Neat, huh?

Let’s jog along and add some more global styles that handle both our <button> and <input /> elements. Add this to your CSS:

Code language
CSS
input[type],
button
{
border: none;
margin: 0;
font: inherit;
line-height: 1;
padding: 0.8rem;
padding: var(--metric-interaction-padding);
outline-offset: -1px;
}

@media screen and (-ms-high-contrast: active) {
input[type],
button
{
border: 1px solid;
}
}

Because our main <button> and <input /> elements sit inline, we need to make sure that a lot of the styles are shared. A little trick to pick out is font: inherit, which will grab outer context typography which generally means writing a lot less typography CSS.

The -ms-high-contrast: active media query lets us target high contrast mode on windows where I’m adding the border back to our elements to make them easier to see.

Let’s tackle global focus. Add this to your CSS:

Code language
CSS
/**
* GLOBAL FOCUS
*/

:focus {
outline: 1px solid var(--color-primary);
}

I’ve just targeted all elements by using :focus as the selector. You could add a * if you want, too. This is a great way of setting solid, consistent focus styles across the board.

Utilities

Add this flow utility to your CSS:

Code language
CSS
/**
* FLOW UTILITY
*/

.flow {
--flow-space: var(--metric-rhythm);
}

.flow > * + * {
margin-top: 1em;
margin-top: var(--flow-space);
}

This is a great way of adding space between sibling elements. You may have spotted the use of this already in the HTML and if you refresh your project, you’ll notice that elements magically have space between them! You can read more about this utility in a post I wrote last year.

Let’s add another utility to your CSS:

Code language
CSS
/**
* VISUALLY HIDDEN UTILITY
*/

.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: auto;
margin: 0;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}

This does exactly what it says on the tin. It hides the element visually, but the content is still accessible to assistive technology like screen readers. If you refresh your project now, you’ll notice the button text has disappeared and only the icon can be seen.

Signup component

Let’s add the CSS for our .signup component, which is the <main> element of this demo. Add this CSS:

Code language
CSS
/**
* SIGNUP COMPONENT
*/

.signup {
max-width: 20rem;
}

.signup__form + .signup__alert {
--flow-space: 1rem;
}

It’s pretty straightforward is this one. I’m setting a sensible max width which, if the user of the site hasn’t changed their base font size, will be about 320px wide. This also means that the .signup component will be 100% until it hits that max, which is great for mobile-first design.

The second bit sets the --flow-space for the .flow utility. One of my favourite features CSS Custom Properties is that they can be contextually overridden. This gives .flow so much power to be useful in this sort of context.

Inline Field Control component

Now let’s tackle the inline field control which smooshes the submit button and the field together. I think it goes without saying that we’re getting our flex on here, so let’s dig in and add this to your CSS:

Code language
CSS
/**
* INLINE FIELD CONTROL COMPONENT
*/

.inline-field-control {
--flow-space: 0.5rem;
display: flex;
box-shadow: 0 2px 10px var(--color-shadow);
}

.inline-field-control input {
flex: auto;
}

Again, you can see --flow-space being overridden. This time, we’re saying that this element—.inline-field-control—should only have 0.5rem of margin if it’s a sibling element. In our context, it means that there’s only 0.5rem of space between the <label> and our field.

We set the .inline-field-control component to be display: flex which forces our <input> and <button> to sit inline with each other. By setting .inline-field-control input to be flex: auto we’re saying: “Hey <input>, if there’s any space left on the horizontal axis, do us a solid and fill it up for us”. Handy, right?

Button component

This one is very straightforward. Add this to your CSS:

Code language
CSS
/**
* BUTTON COMPONENT
*/

.button {
background: var(--color-secondary);
color: var(--color-primary);
font-size: 1.6rem;
min-width: 3.5rem;
cursor: pointer;
}

.button:hover {
filter: brightness(1.05);
}

.button svg {
transform: translateY(1px); /* Optical adjustment */
}

We tackled most of the style globally, so all we’re doing here is setting some specific styles and then adding a hover state. There’s also an optical adjustment where I’ve nudged the <svg> by 1px. Optical adjustments are fascinating and there’s a great post here.

Alert component

The last component is the .alert component. This is what get’s rendered by JavaScript in our role="alert" container. Add this CSS:

Code language
CSS
/**
* ALERT COMPONENT
*/

.alert {
--alert-text: var(--color-text);
--alert-bg: var(--color-bg);

display: flex;
align-items: flex-start;
background: var(--alert-bg);
color: var(--alert-text);
border: 1px solid;
padding: var(--metric-box-padding);
margin-top: 1rem;
animation: slide-up 250ms ease;
}

.alert[data-state='error'] {
--alert-text: var(--color-error);
--alert-bg: var(--color-error-bg);
}

.alert[data-state='success'] {
--alert-text: var(--color-success);
--alert-bg: var(--color-success-bg);
}

.alert__icon {
font-size: 1.6em;
flex-shrink: 0;
}

.alert__content {
padding-left: 0.8rem;
}

.alert__content b {
display: block;
}

There’s quite a bit of CSS here, so I’ll just pick out the headlines:

  • We’re using and then overriding Custom Properties using [data-state] hooks. This means that when the JavaScript sets a state, we can set the correct colours, without touching the actual CSS properties.
  • The .alert__icon  element has flex-shrink: 0 that means if the container has run out of space, it can’t attempt to shrink that element. This is really useful for inline icons.
  • Lastly, there’s this animation rule: slide-up 250ms ease. We haven’t set that yet so it’ll have no effect. CSS is awesome because regardless of what that animation does, this component still works without it. Proper progressive enhancement!

Animation

As mentioned in the last section, we have a slide-up animation to define, so let’s add it to your CSS:

Code language
CSS
/**
* ANIMATIONS
*/

@keyframes slide-up {
0% {
opacity: 0;
transform: translateY(0.4rem);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

This is as simple as it get’s really. I set the default state so that the element is hidden and nudged down a bit, then at 100% this is reversed again. That’ll mean in the context of our demo, the little .alert will slide up and fade in when it’s rendered.

Notice how I use an animation and not a transition for this. Yeh, we could use a transition, but it would mean tweaking states and using timeouts in JavaScript to make sure it isn’t janky when it get’s rendered in the DOM. Setting it as an animation means we can set it and wait until it’s rendered. Nice and simple!

Ok, that’s all the CSS done. It should look like this:

Code language
CSS
/**
* FONT FACE
*/

@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('/fonts/Inter-Black.woff2') format('woff2');
}

/**
* VARIABLES
*/

:root {
--color-primary: #4c2982;
--color-secondary: #f9d170;
--color-bg: #f9f7f3;
--color-text: #252525;
--color-light: #f3f3f3;
--color-success: #067973;
--color-success-bg: #f5fffe;
--color-error: #b71540;
--color-error-bg: #fdeff3;
--color-shadow: rgba(23, 11, 41, 0.12);
--font-base-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
--font-heading-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans,
Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
--metric-rhythm: 2rem;
--metric-gutter: 2rem;
--metric-box-padding: 1rem 1rem;
--metric-interaction-padding: 0.6rem 0.6rem;
}

/**
* GLOBAL STYLES
*/

body {
font-family: var(--font-base-family, sans-serif);
display: grid;
place-items: center;
background: var(--color-light);
color: var(--color-text);
padding: var(--metric-gutter, 2rem);
}

h1 {
font-family: var(--font-heading-family, sans-serif);
font-size: 2rem;
font-weight: 900;
color: var(--color-primary);
line-height: 1.1;
}

label {
color: var(--color-primary);
text-transform: uppercase;
font-weight: 700;
}

input[type],
button
{
border: none;
margin: 0;
font: inherit;
line-height: 1;
padding: 0.8rem;
padding: var(--metric-interaction-padding);
outline-offset: -1px;
}

@media screen and (-ms-high-contrast: active) {
input[type],
button
{
border: 1px solid;
}
}

/**
* GLOBAL FOCUS
*/

:focus {
outline: 1px solid var(--color-primary);
}

/**
* FLOW UTILITY
*/

.flow {
--flow-space: var(--metric-rhythm);
}

.flow > * + * {
margin-top: 1em;
margin-top: var(--flow-space);
}

/**
* VISUALLY HIDDEN UTILITY
*/

.visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: auto;
margin: 0;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}

/**
* SIGNUP COMPONENT
*/

.signup {
max-width: 20rem;
}

.signup__form + .signup__alert {
--flow-space: 1rem;
}

/**
* INLINE FIELD CONTROL COMPONENT
*/

.inline-field-control {
--flow-space: 0.5rem;
display: flex;
box-shadow: 0 2px 10px var(--color-shadow);
}

.inline-field-control input {
flex: auto;
}

/**
* BUTTON COMPONENT
*/

.button {
background: var(--color-secondary);
color: var(--color-primary);
font-size: 1.6rem;
min-width: 3.5rem;
cursor: pointer;
}

.button:hover {
filter: brightness(1.05);
}

.button svg {
transform: translateY(1px); /* Optical adjustment */
}

/**
* ALERT COMPONENT
*/

.alert {
--alert-text: var(--color-text);
--alert-bg: var(--color-bg);

display: flex;
align-items: flex-start;
background: var(--alert-bg);
color: var(--alert-text);
border: 1px solid;
padding: var(--metric-box-padding);
margin-top: 1rem;
animation: slide-up 250ms ease;
}

.alert[data-state='error'] {
--alert-text: var(--color-error);
--alert-bg: var(--color-error-bg);
}

.alert[data-state='success'] {
--alert-text: var(--color-success);
--alert-bg: var(--color-success-bg);
}

.alert__icon {
font-size: 1.6em;
flex-shrink: 0;
}

.alert__content {
padding-left: 0.8rem;
}

.alert__content b {
display: block;
}

/**
* ANIMATIONS
*/

@keyframes slide-up {
0% {
opacity: 0;
transform: translateY(0.4rem);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

JavaScript permalink

We’re on the final hurdle now and we’re using JavaScript as it should be used: as an enhancement.

The role of the JavaScript in this context is to replace the native HTML validation and present a more accessible, helpful alternative. This is a good way to work because we know when our JavaScript fails, we will still be helping users, but when it’s available, we’re providing an enhanced experience.

Open up your JS (js/main.js) and add the following function:

Code language
JavaScript
/**
* Generate an alert component based on the passed state key
* @param {String} state must be 'error' or 'success'
* @return {String} A HTML string of the component output
*/

const renderAlert = (state = 'error') => {
const iconPaths = {
error:
'M11.148 4.374a.973.973 0 01.334-.332c.236-.143.506-.178.756-.116s.474.216.614.448l8.466 14.133a.994.994 0 01-.155 1.192.99.99 0 01-.693.301H3.533a.997.997 0 01-.855-1.486zM9.432 3.346l-8.47 14.14c-.422.731-.506 1.55-.308 2.29s.68 1.408 1.398 1.822c.464.268.976.4 1.475.402H20.47a3 3 0 002.572-4.507L14.568 3.346a2.995 2.995 0 00-4.123-1.014c-.429.26-.775.615-1.012 1.014zM11 9v4a1 1 0 002 0V9a1 1 0 00-2 0zm2 8a1 1 0 10-2 0 1 1 0 002 0z',
success:
'M19.293 5.293L9 15.586l-4.293-4.293a.999.999 0 10-1.414 1.414l5 5a.999.999 0 001.414 0l11-11a.999.999 0 10-1.414-1.414z',
};

const messages = {
error: '<b>Please use a valid email.</b> Like: person@inbox.com.',
success: '<b>Yay! Thank you!</b> We’ve sent a confirmation link to your inbox.',
};

return `
<figure class="alert" data-state="
${state}">
<svg class="alert__icon" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="
${iconPaths[state]}"/>
</svg>
<p class="alert__content">
${messages[state]}</p>
</figure>
`
;
};

What we have here is a simple function that takes a state parameter and returns back an .alert component with the relevant content in it. If it’s an error, we get the error content and data-state attribute (which controls style) or if it’s success, we get the opposite content and icon.

Notice how both the iconPath and messages objects have consistent error and success keys. This makes rendering the output markup much easier because we know (from the rest of our upcoming JS) that our state is either error or success, so we can confidently grab the icon with iconPaths[state].

Let’s move on the the next bit. Add the following function to your JS:

Code language
JavaScript
/**
* Main app function. Grabs signup elements and validates email
* with regex and blocks submission and renders alert if it fails.
* If successful, it’ll allow the form to progress.
*/

const init = () => {
const emailElement = document.querySelector('#email');
const formElement = document.querySelector('#signupForm');
const alertElement = document.querySelector('[role="alert"]');
const validationRegex = new RegExp(
emailElement.getAttribute('pattern') || '[^@]+@[^.]+..+',
'i'
);

emailElement.removeAttribute('required');
emailElement.removeAttribute('pattern');
formElement.setAttribute('novalidate', '');

formElement.addEventListener('submit', (evt) => {
evt.preventDefault();

if (!validationRegex.test(emailElement.value.trim())) {
alertElement.innerHTML = renderAlert('error');
emailElement.setAttribute('aria-invalid', 'true');
return;
}

// POST YOUR FORM WITH AJAX OR WHATNOT THEN RUN THIS
formElement.parentElement.removeChild(formElement);
alertElement.innerHTML = renderAlert('success');
});
};

This is our main function. Pretty tiny, right? Let’s break it down:

  • We start off by grabbing three elements: the email <input />, the outer <form> element and the <div role="alert"> element that is our alert container.
  • We then define our validation Regular Exception by trying to grab it from the email field. We set a static version as a fallback, by using the || operator, just in case we can’t grab it from the email input.
  • Next up, we remove the validation attributes. You might be thinking “what the heck is this guy doing?!”, but there’s a reason for this. Now that we’re validating with JavaScript, we should be providing the most consistent user experience as possible and because we’re handling feedback with a role="alert" element, the user, regardless of their tech, will see or hear a feedback message. The form also won’t submit. Solid, right?
  • We also add a novalidate attribute to the form, to make sure any other native messages don’t sneak in.
  • The last bit is a good ol’ event. We listen for the <form>’s submit event and run our validation check by testing the email <input>’s value against our Regular Exception. This returns false if it fails, so we can use a ! in our if block to test for the opposite return value. This means that code in our if block is the error code and we can set the success state as our default state, without using an else. Remember, to keep things clean like this, you must return out of your if block to prevent the default code running!
  • When the email validation fails, we set aria-invalid="true", which instructs assistive technology that there’s an error with the inputted content. We also run our renderMessage function and immediately set it as the HTML of the role="alert" element, which makes it immediately announce itself to a screen reader. We can use the innerHTML property safely because we have complete control of the HTML that’s generated.
  • The default success state is now up to you. You could post the data with [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to the server or if you want the native <form> functionality, you could move the evt.preventDefault() line to inside the validation error block instead. This means it’ll only prevent postback if there’s an issue! For our demo, we remove the form and let the success .alert completely take over. This would ideally happen after the form data has posted.

The last bit of this tutorial is to add a single line of JS:

Code language
JavaScript
init();

And now, if you refresh your browser, you’ll have a complete, working demo 🎉

Your JavaScript should look like this:

Code language
JavaScript
/**
* Generate an alert component based on the passed state key
* @param {String} state must be 'error' or 'success'
* @return {String} A HTML string of the component output
*/

const renderAlert = (state = 'error') => {
const iconPaths = {
error:
'M11.148 4.374a.973.973 0 01.334-.332c.236-.143.506-.178.756-.116s.474.216.614.448l8.466 14.133a.994.994 0 01-.155 1.192.99.99 0 01-.693.301H3.533a.997.997 0 01-.855-1.486zM9.432 3.346l-8.47 14.14c-.422.731-.506 1.55-.308 2.29s.68 1.408 1.398 1.822c.464.268.976.4 1.475.402H20.47a3 3 0 002.572-4.507L14.568 3.346a2.995 2.995 0 00-4.123-1.014c-.429.26-.775.615-1.012 1.014zM11 9v4a1 1 0 002 0V9a1 1 0 00-2 0zm2 8a1 1 0 10-2 0 1 1 0 002 0z',
success:
'M19.293 5.293L9 15.586l-4.293-4.293a.999.999 0 10-1.414 1.414l5 5a.999.999 0 001.414 0l11-11a.999.999 0 10-1.414-1.414z',
};

const messages = {
error: '<b>Please use a valid email.</b> Like: person@inbox.com.',
success: '<b>Yay! Thank you!</b> We’ve sent a confirmation link to your inbox.',
};

return `
<figure class="alert" data-state="
${state}">
<svg class="alert__icon" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="
${iconPaths[state]}"/>
</svg>
<p class="alert__content">
${messages[state]}</p>
</figure>
`
;
};

/**
* Main app function. Grabs signup elements and validates email
* with regex and blocks submission and renders alert if it fails.
* If successful, it’ll allow the form to progress.
*/

const init = () => {
const emailElement = document.querySelector('#email');
const formElement = document.querySelector('#signupForm');
const alertElement = document.querySelector('[role="alert"]');
const validationRegex = new RegExp(
emailElement.getAttribute('pattern') || '[^@]+@[^.]+..+',
'i'
);

emailElement.removeAttribute('required');
emailElement.removeAttribute('pattern');
formElement.setAttribute('novalidate', '');

formElement.addEventListener('submit', (evt) => {
evt.preventDefault();

if (!validationRegex.test(emailElement.value.trim())) {
alertElement.innerHTML = renderAlert('error');
emailElement.setAttribute('aria-invalid', 'true');
return;
}

// POST YOUR FORM WITH AJAX OR WHATNOT THEN RUN THIS
formElement.parentElement.removeChild(formElement);
alertElement.innerHTML = renderAlert('success');
});
};

init();

How you could improve this permalink

There’s a few things you could do to make this better:

  1. You could make the animations slicker
  2. You could implement a loading state for when the form is posting back to the server with fetch
  3. You could modify the renderAlert function to accept an optional message parameter and use that for if the form fails to post back

Wrapping up permalink

I really hope you’ve enjoyed this tutorial. You can grab a zip of the final code that I wrote and also see a live version too!

My favourite attempt at this was by Dana Byerly. Check out their Twitter thread. Their attention to detail and documentation was fantastic. Nice work, Dana!