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 init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add password-strength
pnpm dlx ermineui add password-strength
yarn dlx ermineui add password-strength
bunx ermineui add password-strength

That 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

FileWhere it goes
PasswordStrength.vuesrc/components/ui/password-strength/PasswordStrength.vue
index.tssrc/components/ui/password-strength/index.ts
strength.tssrc/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
}