Make an accessible password reveal input

Posted:
Tagged with:

Intro

Oftentimes it's useful to be able to tell if we entered the correct password on a login form, right? I mostly use a password manager, so most of my passwords are auto-filled, but not every password I use is in my password manager, I commit a couple to memory, as these are the ones I don't want to save. I obviously then have to type these out when I log into those accounts and I often make mistakes when doing that.

If I'm sitting at my desk, I can just inspect the HTML and/or change it to reveal my password, but most of the time I do this, I'm actually on my phone and I can't do that. I don't know why I make so many errors with my typing on my phone, I don't even have large thumbs, it's probably related to my denial I need glasses, though, but that's another story.

This obviously isn't about me, it's about everyone. I do benefit from a reveal password toggle for the reason I just mentioned, but so many people benefit from this functionality, for so many different reasons:

In fact, most folk can benefit from these, as once we enter the problem solving stage of "Why won't you let me in?", it's kinda nice to be able to press a button and at least see or hear that our password matches what we meant to type, right?

It's actually super simple to make a password toggle, but of course, you're not here for super simple, you're here for inclusive, for something that considers as many users as possible, right? Cool, then let's discuss what we need to consider before we dive into any code.

Accessibility considerations

Obviously we are all here for the common good and we want to make the input and its functionality work for all, so let's list some primary considerations.

Security considerations

Perhaps the first security concern is one that can affect everybody and that is when we change the <input>'s type attribute from text to password, we are creating a vulnerability, which is actually fine until the point the users submits. It then becomes an issue as we are no longer masking the password and we are submitting plain text and the browser may remember that. Think of the times you have used an input on a site, where you may have typed several things into this input and the browser's own autocomplete list appears, with previously typed suggestions. This may not be a huge issue on your own device that nobody else has access to, but a shared device could allow someone else to discover your password. I'm not sure if this would be an issue on a shared computer in a cafe or library, I honestly don't know if they save that kind of thing between sessions. It may be an issue for parents of young kids who let the kids play games on their phone and the little cherubs kindly order £200 worth of V-Bucks or Candy Crush credit, etc? Whatever the reason, I'm sure it's best to avoid it. I actually learned about this from this GDS article from a few years back and it was really informative.

Another consideration is screen reader users, especially those with very low or no vision. It is entirely possible that they could toggle the field by accident or like anybody else, simply forget they toggled it. Unfortunately, shoulder surfing is a thing, somebody may watch over somebody's shoulder and look at the input data they have entered and then use that to access their accounts. Firstly, lemme say that I have absolutely no doubt that a screen reader user will be as security conscious as everybody else. They are not going to be sitting on a train, using their phone without earphones entering their password and email into their banking app for a carriage full of strangers to hear. I guess most of the concern here is either the user accidentally toggling or simply forgetting and then somebody peering over their shoulder.

Where a sighted user would see the toggled password and "should" then be aware they may need to take action to hide it, a screen reader user may appreciate a little extra than the basic change of state announcement they get from the aria-pressed attribute. All we want to do here is provide an explicit warning or status message just to make sure our screen reader user is aware of the password's visibility.

When a password field has characters input into it, the browser's own security masks these characters and they appear as large dots. A screen reader user navigating within that field will hear "Star, Star..." or words to that effect (depending on screen reader/browser). The moment we remove that masking, the characters are displayed as plain text and of course a screen reader user would then hear the literal characters, should they navigate within the field.

So, let's build something

As an aside, we will have to put this in a form, with a submit button, as we need to demonstrate the functionality, as always, it will just be on CodePen, I don't have any personal worries about CodePen, but still, just to be safe, don't go entering any of your actual passwords into any inputs. We need the <form> element as we need a submit button, so we can run a simple check before we allow the submission. As always, we will build this with progressive enhancement in mind, it'll work just fine as a password input when JavaScript is turned off, it just won't have the toggle functionality, as we need the JS to do that for us.

As always, we will start with the good old HTML (I don't know why I always say this as starting with CSS or JS would be pretty challenging, right?):

<form class="form" onsubmit="return false;">
<div class="form__control">
<label class="input__label" for="pWord">
<span class="input__label-text">Password (required)</span>
<span class="input__label-reqs">
<span>At least 8 characters in length</span>
<span>Must contain at least 1 number</span>
<span>Must contain at least 1 lowercase letter</span>
<span>Must contain at least 1 uppercase letter</span>
</span>
</label>
<div class="input__wrapper">
<input
class="input--password"
id="pWord"
type="password"
required
autocomplete="current-password"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).{8,}$"
>

</div>
</div>
<button class="form__submit" id="submit" type="submit">Submit</button>
</form>
<div class="visually-hidden" id="announce" aria-live="assertive"></div>

Hopefully nothing unexpected in there, I'll summarise it below, in case you're curious:

Obviously, at this stage, it looks a hot mess, so let's tackle the first part of our styling now:

/* basic styles */

*,
*::before,
*::after
{
box-sizing: border-box;
}

body {
font-family: Arial, Helvetica, sans-serif;
line-height: 1.5;
font-size: 1.125rem;
}

button {
cursor: pointer;
}

button,
input
{
font: inherit;
}

/* End basic styles */

.visually-hidden {
position: absolute;
height: 1px;
width: 1px;
overflow: hidden;
white-space: nowrap;
clip: rect(0 0 0 0);
clip-path: inset(50%);
}

.form {
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid grey;
border-radius: 6px;
padding: 1rem .5rem;
max-width: 26rem;
}

.form__control {
width: 100%;
max-width: 25rem;
}

.input__label-text,
.input__label-reqs > *
{
display: block;
}

.input__label-text {
font-size: 1.375rem;
font-weight: bold;
}

.input__label-reqs {
display: block;
margin-bottom: .75rem;
}

.input--password {
margin-bottom: 1rem;
display: block;
border: 2px solid black;
border-radius: 4px;
padding: .25rem;
min-height: 2.5rem;
width: 100%;
}

.input--password:focus,
.form__submit:focus-visible
{
outline: 3px solid rebeccapurple;
outline-offset: 2px;
}

.form__submit {
border: none;
padding: .375rem;
border-radius: 4px;
background-color: rebeccapurple;
color: white;
}

Nothing spectacular in the CSS, we're just making it look reasonably OK. Perhaps the only thing of note are the password requirements. They are just lines of text here, they can't be in a list as they are inside the <label> element and it is against the HTML spec to add a list inside that element. We could have made them look like list items, but then they wouldn't be marked up correctly, which is a 1.3.1 Info and Relationships issue, albeit a quite trivial one. We could have put an actual list between the <label> and <input> and then used aria-describedby, but even then, the list would not be communicated as such, to a screen reader user who is focused on the password field, as semantics are not exposed to that attribute.

The above is as always, a case of "Test with actual users", this is something where the words of wisdom from actual screen reader users may highlight some nuance or provide you with a better approach that I failed to consider. Well, that wraps up the basic implementation of our password field, this is what users without JS enabled will experience.

Firstly, we need to add a tiny bit of JS to the <head> section in our HTML to provide us with a hook for our CSS (you may have something similar, already?):

<head>
<!-- Head stuff -->
<script>
document.documentElement.classList.add('has-js');
</script>
</head>

Nothing special there, we're just adding a class to the <html> element, when JS is available. We're not actually going into tooling here and making sure that the JS we use is supported on the browser, this is something you'd need to do yourself.

Now we'll address the JS functionality, which is actually pretty straightforward:

const inputWrapper = document.querySelector('.input__wrapper');
const pWord = document.getElementById('pWord');
const submit = document.getElementById('submit');
const announce = document.getElementById('announce');
const toggleBtnHTML = `<button class="input__toggle" type="button" id="toggle" aria-pressed="false" aria-controls="pWord">
<svg aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60"><path d="M59.715 28.969C59.238 28.176 47.863 9.594 30 9.594S.762 28.176.285 28.969a2.013 2.013 0 0 0 0 2.062C.762 31.824 12.137 50.406 30 50.406s29.238-18.582 29.715-19.375a2.013 2.013 0 0 0 0-2.062ZM30 46.399C16.66 46.398 6.973 33.741 4.398 30 6.968 26.25 16.628 13.602 30 13.602c13.34 0 23.027 12.656 25.602 16.402C53.032 33.75 43.372 46.398 30 46.398Zm0 0"/><path d="M30 16.496c-7.445 0-13.504 6.059-13.504 13.504 0 7.445 6.059 13.504 13.504 13.504 7.445 0 13.504-6.059 13.504-13.504 0-7.445-6.059-13.504-13.504-13.504Zm0 23c-5.238 0-9.496-4.262-9.496-9.496 0-5.238 4.258-9.496 9.496-9.496s9.496 4.258 9.496 9.496c0 5.234-4.258 9.496-9.496 9.496Zm0 0"/><path d="M30 24.824a5.175 5.175 0 1 0 0 10.348 5.174 5.174 0 1 0 0-10.348Zm0 0"/></svg>
<span class="visually-hidden">Show password</span>
</button>
`
;


inputWrapper.insertAdjacentHTML('beforeend', toggleBtnHTML);
const toggleBtn = document.getElementById('toggle');
inputWrapper.setAttribute('role', 'group');
inputWrapper.setAttribute('aria-labelledby', 'pWord');

toggleBtn.addEventListener('click', () => {
if (toggleBtn.getAttribute('aria-pressed') === 'false') {
toggleBtn.setAttribute('aria-pressed', 'true');
pWord.setAttribute('type', 'text');
announce.textContent = 'Your password is shown!';
} else {
setPasswordDefaults(false);
}
})

function setPasswordDefaults(ignore) {
toggleBtn.setAttribute('aria-pressed', 'false');
pWord.setAttribute('type', 'password');
if (!ignore) {
announce.textContent = 'Your password is hidden!';
}
}

submit.addEventListener('click', () => {
if (pWord.checkValidity() && pWord.getAttribute('type') === 'text') {
setPasswordDefaults(true);
}
// do validation and/or redirect stuff here
})

A quick overview of the above JS:

That wasn't too much hard work, hopefully it makes sense? we still have a couple of bits to do, but the actual toggle works and everything is announced correctly. Let's ensure that we address that security issue that stores passwords in the browser's autocomplete history, we just need a small bit of JS for that:

submit.addEventListener('click', () => {
if (pWord.checkValidity() && pWord.getAttribute('type') === 'text') {
setPasswordDefaults(true);
}
// do validation and/or redirect stuff here
})

So, just a simple click handler on our submit button where we are checking two things:

When both of those conditions are true, we call the setPasswordDefaults(true) function (I told you we would reuse that), to flip everything back to the more secure defaults, before then doing whatever we need in our auth process. We're passing through true here, as we don't want to inject the live region with any text. Remember, my validation approach is brittle by design, here. I'm just using the easiest way of doing this, with HTML5 validation, as I just want to provide a "working" example, but in reality we would use something other than just HTML5 validation, ideally both JS and some of that backend stuff that some people use.

So, that's all of the functionality done, everything works as we intended it to, it's progressively enhanced, so our users that may have disabled JS will still get that minimum viable experience and the users that do have JS enabled get the additional functionality.

One last thing to do, we made it look a bit naff, as we don't have any styles related to the button, so let's smarten it up a bit:

I've done the final few CSS declarations, but I'm not totally happy with the focus indicators for the adjacent elements, I dislike how they overlap the other element a little bit, if this were me building this for production, I'd come up with something a little nicer, but it works and it is obvious where focus is.

Also note that I added a small animation to the eye icon, just because it's little and not jarring so it shouldn't cause any issues at all. The CSS I'm adding here can simply go at the end of the earlier CSS file

.has-js .input__wrapper {
display: flex;
}

.has-js .input--password {
border-right: none;
border-radius: 4px 0 0 4px;
}

.input__toggle {
position: relative;
display: flex;
justify-content: center;
align-items: center;
border: 2px solid rebeccapurple;
border-radius: 0 4px 4px 0;
height: 2.5rem;
background-color: rebeccapurple;
}

.input__toggle::before {
content: "";
position: absolute;
height: 2.5rem;
width: 0;
border-left: 4px solid white;
transform: rotate(45deg) scaleY(0);
transition: transform 300ms ease-in;
}

.input__toggle[aria-pressed="true"]::before {
transform: rotate(45deg) scaleY(1);
}

.input__toggle svg {
height: 2rem;
width: 2rem;
fill: white;
}

.input__toggle:focus-visible {
outline: 3px solid rebeccapurple;
outline-offset: 2px;
}

So, that's it and there we have it, pretty simple and straightforward, but it works well.

Final considerations

There are definitely some things to consider here, maybe there are improvements to be made? If i were in a position to use this on a project and I had colleagues on my team that were native screen reader users or we had some budget to pay for user testing I'd want to know if a few of my assumptions were correct or I could do better:

I'd be happy to implement any changes that came about during user testing, the goal is never compliance, it's always usable for all.

Codepen

Share on:

TwitterLinkedIn

Site preferences

Please feel free to display our site, your way by finding the preferences that work best for you. We do not track any data or preferences at all, should you select any options in the groups below, we store a small non-identifiable token to your browser's Local Storage, this is required for your preferencesto persist across pages accordion be present on repeat visits. You can remove those tokens if you wish, by simply selecting Unset, from each preference group.

Theming

Theme
Code block theme

Code theme help

Code block themes can be changed independent of the site theme.

  • Default: (Unset) Code blocks will have the same theme as the site theme.
  • Light 1: will be default for users viewing the light theme, this maintains the minimum 7:1 (WCAG Level AAA) contrast ratio we have used throughout the site, it can be quite difficult to identify the differences in colour between various syntax types, due to the similarities in colour at that contrast ratio
  • Light 2: drops the contrast for syntax highlighting down to WCAG Level AA standards (greater than 4.5:1)
  • Dark: Syntax highlighting has a minimum contrast of 7:1 and due to the dark background differences in colour may appear much more perceivable

Motion

Motion & animation

Motion & animation help

  • Default (Unset): Obeys device settings, if present. If no preference is set, there are subtle animations on this site which will be shown. If you have opted for reduce motion, smooth scrolling as well as expanding and collapsing animations will no longer be present, fading transtitions and micro animations will still be still present.
  • None: All animations and transitions are completely removed, including fade transitions.

Links

Underline all links

Underline all links help

  • Default (Unset): Most links are underlined, with a few exceptions such as: the top level links in the main navigation (on large screens), cards, tags and icon links.
  • Yes: Will add underlines to the exceptions outlined above, resulting in every link being underlined

Text and paragraphs

Font size (main content)

Font size help

This setting does not apply to the site's header or footer regions

  • Default (Unset): Font sizes are set to site defaults
  • Selecting Large or Largest will increase the font size of the main content, the size of the increase depends on various factors such as your display size and/or zoom level. The easiest way to determine which option suits you best would be to view this text after clicking either size's button
Letter spacing

Letter spacing help

  • Default (Unset): Default letter spacing applies
  • Increased: Multiplies the font size by 0.12 and adds the sum as spacing between each character
Line height

Line height help

  • Default (Unset): all text has a minimum line height of 1.5 times the size of the text
  • Increased: all text has a line height of twice the size of the text
Line width

Line width help

  • Default (Unset): all text has a maximum line width of 80 REM units (this averages around 110 characters per line)
  • Decreased: all text has a maximum line width of 55 CH units (this averages around 80 characters per line)
Paragraph spacing

Paragraph spacing help

  • Default (Unset): The space between paragraphs is equivalent to 1.5 times the height of the paragraph's text
  • Increased: The space between paragraphs is equivalent to 2.25 times the height of the paragraph's text
Word spacing preference

Word spacing help

  • Default (Unset): No modifications to word spacing are present
  • Increased: Spaces between words are equivalent to 0.16 times the font size