Password strength
A four-step bar and a word for how strong a password is as it is typed, with a checklist of the rules it must meet.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add password-strengthpnpm dlx ermineui add password-strengthyarn dlx ermineui add password-strengthbunx ermineui add password-strengthThat copies 3 files into src/components/ui/password-strength/. Files you already have are kept.
Use
import { PasswordStrength, defaultRequirements, scorePassword } from '@/components/ui/password-strength'<PhoneInput v-model="phone" default-country="GB" /> <!-- "+447700900123" -->
<PasswordStrength :password="password" />
<Toolbar label="Text alignment">
<ToolbarButton label="Align left" :pressed="align === 'left'" @click="align = 'left'"><Icon name="menu" /></ToolbarButton>
<ToolbarSeparator />
<ToolbarButton @click="copy">Copy</ToolbarButton>
</Toolbar>
<Meter label="Storage used" :value="42" :max="64" :low="32" :high="56" optimum="low" :format="(value, max) => `${value} GB of ${max} GB`" />In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
PasswordStrength.vue | src/components/ui/password-strength/PasswordStrength.vue |
index.ts | src/components/ui/password-strength/index.ts |
strength.ts | src/components/ui/password-strength/strength.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
PasswordStrength.vue 137 lines
<script setup lang="ts">
import { computed } from 'vue'
import { defaultRequirements, scorePassword, type PasswordRequirement, type PasswordScore } from './strength'
const props = withDefaults(
defineProps<{
/** The password as typed so far. */
password: string
/** The rules to tick off underneath. Pass an empty list to show the bar alone. */
requirements?: readonly PasswordRequirement[]
/** What each score is called, weakest first. */
labels?: readonly [string, string, string, string]
/** Work the score out your own way, e.g. with zxcvbn. Return 0 to 4. */
score?: (password: string) => PasswordScore
}>(),
{ requirements: () => defaultRequirements, labels: () => ['Weak', 'Fair', 'Good', 'Strong'] as const },
)
const level = computed(() => (props.score ?? scorePassword)(props.password))
const word = computed(() => (level.value ? props.labels[level.value - 1] : ''))
const checks = computed(() => props.requirements.map((requirement) => ({ label: requirement.label, met: requirement.test(props.password) })))
</script>
<template>
<div class="er-password-strength" :data-score="level">
<div class="er-password-strength-top">
<div class="er-password-strength-bars" aria-hidden="true">
<span v-for="step in 4" :key="step" :data-on="step <= level ? '' : undefined" />
</div>
<!-- Announced a moment after typing stops, rather than the bar being described at every key. -->
<span class="er-password-strength-word" role="status"><template v-if="word"><span class="er-password-strength-hidden">Password strength: </span>{{ word }}</template></span>
</div>
<ul v-if="checks.length" class="er-password-strength-list">
<li v-for="check in checks" :key="check.label" :data-met="check.met ? '' : undefined">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path v-if="check.met" d="M5 12.500l4.500 4.500L19 7.500" />
<circle v-else cx="12" cy="12" r="3" />
</svg>
<span class="er-password-strength-hidden">{{ check.met ? 'Met: ' : 'Not met: ' }}</span>{{ check.label }}
</li>
</ul>
</div>
</template>
<style>
@layer components {
.er-password-strength {
display: grid;
gap: 0.5rem;
width: 100%;
--er-password-strength-color: var(--er-destructive);
}
.er-password-strength[data-score='2'] {
--er-password-strength-color: var(--er-warning);
}
.er-password-strength[data-score='3'],
.er-password-strength[data-score='4'] {
--er-password-strength-color: var(--er-success);
}
.er-password-strength-top {
display: flex;
align-items: center;
gap: 0.75rem;
}
.er-password-strength-bars {
display: grid;
flex: 1;
grid-template-columns: repeat(4, 1fr);
gap: 0.25rem;
}
.er-password-strength-bars span {
height: 0.25rem;
border-radius: 999px;
background: var(--er-muted);
transition: background-color var(--er-duration);
}
.er-password-strength-bars span[data-on] {
background: var(--er-password-strength-color);
}
/* How many bars are lit, and the word, carry the meaning; the colour only backs them up. */
.er-password-strength-word {
min-width: 3.25rem;
font-size: 0.8125rem;
font-weight: var(--er-weight-medium);
line-height: 1rem;
text-align: right;
}
.er-password-strength-list {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
color: var(--er-muted-foreground);
font-size: 0.8125rem;
line-height: 1.25rem;
list-style: none;
}
.er-password-strength-list li {
display: flex;
align-items: center;
gap: 0.375rem;
}
.er-password-strength-list li[data-met] {
color: var(--er-foreground);
}
.er-password-strength-list svg {
flex-shrink: 0;
width: 0.875rem;
height: 0.875rem;
}
.er-password-strength-list li[data-met] svg {
color: var(--er-success);
}
.er-password-strength-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
</style>index.ts 4 lines
/** Password strength — a four-step bar and a word for how strong a password is as it is typed, with a checklist of the rules it must meet. */
export { default as PasswordStrength } from './PasswordStrength.vue'
export { defaultRequirements, scorePassword, type PasswordRequirement, type PasswordScore } from './strength'strength.ts 51 lines
/** 0 is nothing typed; 1 weak, 2 fair, 3 good, 4 strong. */
export type PasswordScore = 0 | 1 | 2 | 3 | 4
export interface PasswordRequirement {
label: string
test: (password: string) => boolean
}
/** The usual house rules. Pass your own list to match what your server really enforces. */
export const defaultRequirements: readonly PasswordRequirement[] = [
{ label: 'At least 8 characters', test: (password) => password.length >= 8 },
{ label: 'A lower-case and an upper-case letter', test: (password) => /[a-z]/.test(password) && /[A-Z]/.test(password) },
{ label: 'A number or a symbol', test: (password) => /[^A-Za-z\s]/.test(password) },
]
// The ones attackers try first. A password built on one of these is weak however it is dressed up.
const common = ['password', 'passw0rd', '123456', '12345678', 'qwerty', 'letmein', 'welcome', 'admin', 'iloveyou', 'abc123', 'monkey', 'dragon', 'football', '111111']
const runs = ['abcdefghijklmnopqrstuvwxyz', 'qwertyuiop', 'asdfghjkl', 'zxcvbnm', '01234567890']
/**
* A rough guess at how hard a password is to crack, from its length and how varied it is, marked down for
* the tricks that add length without adding difficulty. It is a nudge for the person typing, not a check:
* the real rules belong on your server.
*/
export function scorePassword(password: string): PasswordScore {
if (!password) return 0
const lower = password.toLowerCase()
const kinds = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z\d]/].filter((kind) => kind.test(password)).length
// Repeated characters count once: "aaaaaaaa" is as easy to guess as "a".
const distinct = new Set(password).size
let points = Math.min(password.length, distinct * 2) * (1 + (kinds - 1) * 0.35)
if (common.some((word) => lower.includes(word))) points *= 0.35
// Three or more keys in a row from the alphabet, the number line or the keyboard.
for (const run of runs) {
for (let start = 0; start + 4 <= run.length; start++) {
const piece = run.slice(start, start + 4)
if (lower.includes(piece) || lower.includes([...piece].reverse().join(''))) {
points *= 0.7
break
}
}
}
if (points < 8) return 1
if (points < 12) return 2
if (points < 18) return 3
return 4
}