@use 'sprucecss/scss/spruce' as *;
.password-field {
@include layout-stack('xs');
&__control {
position: relative;
.form-control {
padding-inline-end: 3rem;
}
}
&__toggle {
inset-block-start: 50%;
inset-inline-end: spacer('xxs');
position: absolute;
translate: 0 -50%;
&[aria-pressed='true'] {
color: color('primary');
}
}
&__strength {
color: color('muted');
font-size: config('font-size-sm', $typography);
}
&__rules {
font-size: config('font-size-sm', $typography);
list-style: none;
padding: 0;
@include layout-stack('xxs');
li {
align-items: center;
color: color('muted');
display: flex;
gap: spacer('xs');
&::before {
block-size: 0.75rem;
border: 2px solid currentcolor;
border-radius: 50%;
content: '';
flex-shrink: 0;
inline-size: 0.75rem;
}
&.is-valid {
color: color('success', 'alert');
&::before {
background-color: currentcolor;
}
}
}
}
}
<div class="password-field" data-password-field>
<label class="form-label form-label--required" for="new-password">New password</label>
<div class="password-field__control">
<input class="form-control" id="new-password" type="password" autocomplete="new-password" aria-describedby="password-strength password-rules" required>
<button class="btn btn--ghost-secondary btn--icon btn--sm password-field__toggle" type="button" aria-pressed="false" aria-controls="new-password" data-password-toggle>
<svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>
<span class="sr-only">Show password</span>
</button>
</div>
<meter class="password-field__meter" min="0" max="4" low="2" high="3" optimum="4" value="0" aria-hidden="true" data-password-meter></meter>
<p class="password-field__strength" id="password-strength" aria-live="polite" data-password-strength>Strength: too short</p>
<ul class="password-field__rules" id="password-rules" role="list">
<li data-rule="length">At least 12 characters</li>
<li data-rule="case">Upper- and lowercase letters</li>
<li data-rule="number">At least one number</li>
<li data-rule="symbol">At least one symbol</li>
</ul>
</div>
(() => {
const rules = {
length: (value) => value.length >= 12,
case: (value) => /[a-z]/.test(value) && /[A-Z]/.test(value),
number: (value) => /\d/.test(value),
symbol: (value) => /[^A-Za-z0-9]/.test(value)
};
const labels = ['too weak', 'weak', 'fair', 'good', 'strong'];
document.querySelectorAll('[data-password-field]').forEach((field) => {
const input = field.querySelector('input');
const toggle = field.querySelector('[data-password-toggle]');
const meter = field.querySelector('[data-password-meter]');
const strength = field.querySelector('[data-password-strength]');
toggle.addEventListener('click', () => {
const isVisible = toggle.getAttribute('aria-pressed') === 'true';
input.type = isVisible ? 'password' : 'text';
toggle.setAttribute('aria-pressed', String(!isVisible));
toggle.querySelector('.sr-only').textContent = isVisible ? 'Show password' : 'Hide password';
});
input.addEventListener('input', () => {
let score = 0;
Object.entries(rules).forEach(([name, test]) => {
const isValid = test(input.value);
field.querySelector(`[data-rule="${name}"]`)?.classList.toggle('is-valid', isValid);
score += isValid ? 1 : 0;
});
meter.value = score;
strength.textContent = input.value.length ? `Strength: ${labels[score]}` : 'Strength: too short';
});
});
})();