Password Strength
A password field with a live strength meter (segmented bar + text label, conveying level by more than colour), a show/hide toggle, and an optional requirements checklist that ticks off as rules are met. Scoring is self-contained (length + character-class variety, no npm lib). Inherits a wrapping field's name via @aware and self-wires aria-invalid/aria-describedby off the shared error bag; old() is intentionally not restored (Laravel never flashes passwords).
Preview
Password strength:
<div class="mx-auto w-full max-w-sm">
<x-ui.field name="password" label="Password">
<x-ui.password-strength name="password" placeholder="Enter a password" />
</x-ui.field>
</div>
Installation
php artisan ui:add password-strength
Note
This component ships an Alpine behavior module at
resources/js/ui/password-strength.js. Import it once from your bundle so it registers on alpine:init:
import './password-strength.js';
Registry contract
php artisan ui:add password-strength
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/password-strength/password-strength.blade.php -
resources/js/ui/password-strength.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Password Strength (`password-strength`)
A password field with a live strength meter (segmented bar + text label, conveying level by more than colour), a show/hide toggle, and an optional requirements checklist that ticks off as rules are met. Scoring is self-contained (length + character-class variety, no npm lib). Inherits a wrapping field's name via @aware and self-wires aria-invalid/aria-describedby off the shared error bag; old() is intentionally not restored (Laravel never flashes passwords).
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add password-strength
```
## Usage
```blade
<div class="mx-auto w-full max-w-sm">
<x-ui.field name="password" label="Password">
<x-ui.password-strength name="password" placeholder="Enter a password" />
</x-ui.field>
</div>
```
## Props
- `name` (mixed|null, default `null`) — Form field name; inherited from a wrapping field via @aware or set directly.
- `size` (string, default `md`) — Control height, matching the shared input size scale.
- `disabled` (bool, default `false`) — Disables the field and excludes it from submission.
- `invalid` (bool, default `false`) — Forces the invalid visual state independent of the shared error bag.
- `placeholder` (mixed|null, default `null`) — Placeholder text shown while the field is empty.
- `rules` (bool, default `true`) — Shows the requirements checklist below the meter.
- `minLength` (int, default `8`) — Minimum character count required to pass the length rule.
- `maxLength` (mixed|null, default `null`) — Maximum character count, when the form enforces one.
- `requireLowercase` (bool, default `true`) — Requires at least one lowercase letter to pass above Weak.
- `requireUppercase` (bool, default `true`) — Requires at least one uppercase letter to pass above Weak.
- `requireNumber` (bool, default `true`) — Requires at least one digit to pass above Weak.
- `requireSymbol` (bool, default `true`) — Requires at least one symbol to pass above Weak.
- `disallow` (array, default `[]`) — Words the password must not contain, such as the user's name, email or the product name.
## Use when
- Use when formatting, validation, or constrained entry meaningfully reduces errors, such as OTP, phone, currency, password, or numeric entry.
- A signup or password-change form where the user should see live strength feedback and requirements before submitting.
- Wanting the level conveyed by both a segmented meter and a text label, not colour alone.
## Avoid when
- Do not add specialized behavior if a plain field would be simpler and more forgiving.
- A password confirmation field with nothing to score; use input type="password" instead.
- The product enforces requirements server-side only with no live feedback wanted; use input type="password" instead.
## Anti-patterns
- Adding formatting behavior that makes correction harder
## Rules
- Use the `<brok:password-strength>` tag (or `<x-ui.password-strength>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/password-strength
- Registry JSON (files, props, contract): https://brokui.dev/r/open/password-strength.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
Requirements
{{-- Your rules: a 12-character minimum, no symbol required, and personal
details the password must not contain. The checklist shows only what you
set; the meter still judges by best practice. --}}
<div class="mx-auto w-full max-w-sm">
<x-ui.field name="password" label="Password" hint="A long passphrase is fine; a symbol is optional.">
<x-ui.password-strength name="password" placeholder="Choose a passphrase" :min-length="12" :require-symbol="false" :disallow="['Avery', 'avery.stone', 'Acme']" />
</x-ui.field>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| name | mixed | null | null | Form field name; inherited from a wrapping field via @aware or set directly. |
| size | string | md | Control height, matching the shared input size scale. |
| disabled | bool | false | Disables the field and excludes it from submission. |
| invalid | bool | false | Forces the invalid visual state independent of the shared error bag. |
| placeholder | mixed | null | null | Placeholder text shown while the field is empty. |
| rules | bool | true | Shows the requirements checklist below the meter. |
| minLength | int | 8 | Minimum character count required to pass the length rule. |
| maxLength | mixed | null | null | Maximum character count, when the form enforces one. |
| requireLowercase | bool | true | Requires at least one lowercase letter to pass above Weak. |
| requireUppercase | bool | true | Requires at least one uppercase letter to pass above Weak. |
| requireNumber | bool | true | Requires at least one digit to pass above Weak. |
| requireSymbol | bool | true | Requires at least one symbol to pass above Weak. |
| disallow | array | [] | Words the password must not contain, such as the user's name, email or the product name. |
Slots
default— Unused; the field renders from props only.x-ui.password-strength.password-strength— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Scores the password live as the user types: length-driven, with penalties for repeats, sequences, common passwords and disallowed words.
- A password that misses a required character class never rates above Weak, even if long.
- The show/hide toggle switches the input type between password and text; the value is never restored via old() because Laravel never flashes passwords.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Collect constrained information with error-reducing formatting.
Use when
- Use when formatting, validation, or constrained entry meaningfully reduces errors, such as OTP, phone, currency, password, or numeric entry.
- A signup or password-change form where the user should see live strength feedback and requirements before submitting.
- Wanting the level conveyed by both a segmented meter and a text label, not colour alone.
Avoid when
- Do not add specialized behavior if a plain field would be simpler and more forgiving.
- A password confirmation field with nothing to score; use input type="password" instead.
- The product enforces requirements server-side only with no live feedback wanted; use input type="password" instead.
Use instead
- Plain text or number field with validation
Anti-patterns
- Adding formatting behavior that makes correction harder
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The field's aria-describedby always points at the strength readout, so a screen reader announces the level even without the visual meter.
- The level is conveyed by both the meter's text label and colour, and each requirement row shows a check or dot mark rather than colour alone.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="password-strength-{{ $record->id }}">
<div class="mx-auto w-full max-w-sm">
<x-ui.field name="password" label="Password">
<x-ui.password-strength name="password" placeholder="Enter a password" />
</x-ui.field>
</div>
</div>
Validation
Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:password-strength
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware, so the
password field self-wires name + aria-invalid/aria-describedby off the shared
$errors bag (mirroring input). old() is intentionally NOT restored — Laravel
never flashes password input. The strength readout is always referenced via
aria-describedby so screen-reader users hear the level. Inert with no field
ancestor and no own name — fully backward-compatible. --}}
@aware([
'name' => null,
])
{{--
Requirements are yours to set: minLength / maxLength and the four class
toggles drive both the checklist and the meter (a password that misses one
of them never rates above Weak). `disallow` lists words the password must
not contain — the user's name, email or the product name. The meter itself
follows best practice (length-driven entropy, with penalties for repeats,
sequences, common passwords and disallowed words), so a long passphrase
with no symbol scores well when you did not require one.
--}}
@props([
'name' => null,
'size' => 'md',
'disabled' => false,
'invalid' => false,
'placeholder' => null,
'rules' => true,
'minLength' => 8,
'maxLength' => null,
'requireLowercase' => true,
'requireUppercase' => true,
'requireNumber' => true,
'requireSymbol' => true,
'disallow' => [],
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$input = $styles['input'];
$awareName = $name ?? null;
$fieldName = $awareName ?? $attributes->get('name');
$bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
$hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);
$isInvalid = $invalid || $hasBagError;
$resolvedId = $attributes->get('id', filled($fieldName) ? $fieldName : 'password');
$meterId = $resolvedId.'-strength';
// Describe the field by the strength readout, plus the field error when present.
$describedBy = collect([$meterId, (filled($awareName) && $hasBagError) ? $fieldName.'-error' : null])
->filter()->implode(' ');
$size = $size === 'default' ? 'md' : (isset($input['sizes'][$size]) ? $size : 'md');
$state = $isInvalid ? $input['states']['invalid'] : $input['states']['default'];
// Trailing padding leaves room for the show/hide toggle.
$classes = trim($input['base'].' '.$state.' '.$input['sizes'][$size].' pe-10');
@endphp
<div
data-slot="password-strength"
x-data="uiPasswordStrength({{ Js::from([
'minLength' => (int) $minLength,
'maxLength' => $maxLength !== null ? (int) $maxLength : null,
'require' => ['lower' => (bool) $requireLowercase, 'upper' => (bool) $requireUppercase, 'number' => (bool) $requireNumber, 'symbol' => (bool) $requireSymbol],
'disallow' => array_values(array_filter(array_map('strval', (array) $disallow))),
'labels' => [
'length' => __('At least :n characters', ['n' => (int) $minLength]),
'maxLength' => __('At most :n characters', ['n' => (int) $maxLength]),
'lower' => __('A lowercase letter'),
'upper' => __('An uppercase letter'),
'number' => __('A number'),
'symbol' => __('A symbol'),
'disallow' => __('Not your name, email or other personal details'),
'levels' => ['', __('Weak'), __('Fair'), __('Good'), __('Strong')],
],
]) }})"
@if ($hasBagError) data-invalid="true" @endif
{{ $attributes->except(['name', 'id'])->merge(['class' => 'w-full space-y-2']) }}
>
<div class="relative">
<input
type="password"
x-bind:type="show ? 'text' : 'password'"
@if (filled($fieldName)) name="{{ $fieldName }}" @endif
id="{{ $resolvedId }}"
x-model="password"
@disabled($disabled)
@if ($isInvalid) aria-invalid="true" @endif
aria-describedby="{{ $describedBy }}"
@if (filled($placeholder)) placeholder="{{ $placeholder }}" @endif
autocomplete="new-password"
class="{{ $classes }}"
/>
<button
type="button"
@click="show = !show"
x-bind:aria-pressed="show.toString()"
x-bind:aria-label="show ? '{{ __('Hide password') }}' : '{{ __('Show password') }}'"
class="absolute inset-y-0 end-0 flex items-center rounded-e-md px-3 text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<svg x-show="!show" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" />
</svg>
<svg x-show="show" x-cloak class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" /><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" /><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" /><line x1="2" x2="22" y1="2" y2="22" />
</svg>
</button>
</div>
{{-- Strength meter — segments + text label (level conveyed by both, not colour alone). --}}
<div x-show="password.length > 0" x-cloak class="space-y-2">
<div class="flex gap-1" aria-hidden="true">
<template x-for="i in 4" :key="i">
<span
class="h-1 flex-1 rounded-full transition-colors"
:class="{
'bg-muted': i > level,
'bg-destructive': i <= level && level === 1,
'bg-warning': i <= level && (level === 2 || level === 3),
'bg-success': i <= level && level >= 4,
}"
></span>
</template>
</div>
<p
id="{{ $meterId }}"
class="text-xs font-medium"
:class="{
'text-destructive': level === 1,
'text-warning': level === 2 || level === 3,
'text-success': level >= 4,
}"
aria-live="polite"
>
<span class="text-muted-foreground">{{ __('Password strength:') }}</span>
<span x-text="label"></span>
</p>
</div>
@if ($rules)
<ul class="space-y-1" data-slot="password-strength-rules">
<template x-for="rule in rules" :key="rule.id">
<li
class="flex items-center gap-2 text-xs transition-colors"
:class="passed(rule) ? 'text-success' : 'text-muted-foreground'"
>
<span class="inline-flex size-4 shrink-0 items-center justify-center" aria-hidden="true">
<svg x-show="passed(rule)" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
<svg x-show="!passed(rule)" class="size-2" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="12" /></svg>
</span>
<span x-text="rule.label"></span>
</li>
</template>
</ul>
@endif
</div>
/**
* Password-strength behaviour.
*
* Two things at once, kept honest with each other:
*
* 1. Requirements — what the application set: a minimum (and optional maximum)
* length, which character classes are required, and words the password must
* not contain. They drive the checklist, and a password that misses one never
* rates above Weak, whatever the meter thinks.
* 2. The meter — best practice (NIST SP 800-63B, OWASP): length matters most, so
* strength is an entropy estimate (length × log2 of the character pool in use),
* with penalties for repeated runs, keyboard and alphabet sequences, common
* passwords and disallowed words. A long passphrase with no symbol scores well
* when no symbol was required.
*
* Exposes a 0–4 `level`, a human `label`, `bits`, and per-rule pass state for the
* live checklist. The bar conveys level by filled-segment count + the label, not
* colour alone.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
const COMMON = [
'password', 'passw0rd', 'password1', '123456', '12345678', '123456789', '1234567890', 'qwerty', 'qwertyuiop',
'letmein', 'welcome', 'admin', 'iloveyou', 'monkey', 'dragon', 'football', 'baseball', 'abc123', 'abcd1234',
'111111', '123123', 'sunshine', 'princess', 'trustno1', 'master', 'login', 'hello', 'freedom', 'whatever',
'shadow', 'superman', 'michael', 'charlie', 'jordan', 'access', 'secret', 'starwars', 'zaq12wsx', 'passwort',
'wachtwoord', 'azerty', 'summer', 'winter', 'changeme', 'default',
];
const KEYBOARD = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm', 'qwertzuiop', 'azertyuiop', '1234567890'];
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiPasswordStrength', (config = {}) => ({
password: '',
show: false,
minLength: config.minLength ?? 8,
maxLength: config.maxLength ?? null,
require: { lower: true, upper: true, number: true, symbol: true, ...(config.require ?? {}) },
disallow: (config.disallow ?? []).map((w) => String(w).toLowerCase()).filter((w) => w.length >= 3),
labels: config.labels ?? {},
rules: [],
init() {
const L = this.labels;
this.rules.push({ id: 'length', label: L.length ?? `At least ${this.minLength} characters`, test: (v) => v.length >= this.minLength });
if (this.maxLength) {
this.rules.push({ id: 'maxLength', label: L.maxLength ?? `At most ${this.maxLength} characters`, test: (v) => v.length <= this.maxLength });
}
if (this.require.lower) this.rules.push({ id: 'lower', label: L.lower ?? 'A lowercase letter', test: (v) => /\p{Ll}/u.test(v) });
if (this.require.upper) this.rules.push({ id: 'upper', label: L.upper ?? 'An uppercase letter', test: (v) => /\p{Lu}/u.test(v) });
if (this.require.number) this.rules.push({ id: 'number', label: L.number ?? 'A number', test: (v) => /\p{N}/u.test(v) });
if (this.require.symbol) this.rules.push({ id: 'symbol', label: L.symbol ?? 'A symbol', test: (v) => /[^\p{L}\p{N}]/u.test(v) });
if (this.disallow.length) {
this.rules.push({ id: 'disallow', label: L.disallow ?? 'Not a personal detail', test: (v) => !this.containsDisallowed(v) });
}
},
passed(rule) {
return rule.test(this.password);
},
get metCount() {
return this.rules.filter((r) => r.test(this.password)).length;
},
get requirementsMet() {
return this.metCount === this.rules.length;
},
containsDisallowed(value) {
const v = value.toLowerCase();
return this.disallow.some((w) => v.includes(w));
},
/** Characters that are only there as a repeated run or a sequence, and so add little. */
guessableChars(value) {
let guessable = 0;
// Repeated runs: "aaaa" counts as one character plus three guessable.
for (const run of value.match(/(.)\1{2,}/g) ?? []) guessable += run.length - 1;
// Alphabet / digit sequences in either direction: "abcd", "9876".
const codes = [...value].map((c) => c.codePointAt(0));
let runLength = 1;
for (let i = 1; i < codes.length; i++) {
const step = codes[i] - codes[i - 1];
if (Math.abs(step) === 1 && (i < 2 || step === codes[i - 1] - codes[i - 2])) {
runLength++;
if (runLength >= 3) guessable++;
} else {
runLength = 1;
}
}
// Keyboard rows: "qwerty", "asdf".
const lower = value.toLowerCase();
for (const row of KEYBOARD) {
for (let len = row.length; len >= 4; len--) {
for (let i = 0; i + len <= row.length; i++) {
const slice = row.slice(i, i + len);
if (lower.includes(slice) || lower.includes([...slice].reverse().join(''))) {
guessable += len - 1;
len = 0; // longest match per row is enough
break;
}
}
}
}
return Math.min(guessable, Math.max(0, value.length - 1));
},
/** Entropy estimate in bits, after the penalties. */
get bits() {
const v = this.password;
if (!v) return 0;
const stripped = v.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '').replace(/\d+$/, '');
if (COMMON.includes(v.toLowerCase()) || (stripped.length >= 4 && COMMON.includes(stripped))) return 8;
let pool = 0;
if (/\p{Ll}/u.test(v)) pool += 26;
if (/\p{Lu}/u.test(v)) pool += 26;
if (/\p{N}/u.test(v)) pool += 10;
if (/[^\p{L}\p{N}]/u.test(v)) pool += 33;
if (pool === 0) pool = 26;
let effective = v.length - this.guessableChars(v);
const lower = v.toLowerCase();
for (const word of this.disallow) {
if (lower.includes(word)) effective -= word.length - 1;
}
return Math.max(0, Math.round(Math.max(effective, 1) * Math.log2(pool)));
},
/** 0 (empty) → 4 (strong). Never above 1 while a set requirement fails. */
get level() {
if (!this.password) return 0;
if (!this.requirementsMet) return 1;
const b = this.bits;
if (b < 36) return 1;
if (b < 50) return 2;
if (b < 70) return 3;
return 4;
},
get label() {
const levels = this.labels.levels ?? ['', 'Weak', 'Fair', 'Good', 'Strong'];
return levels[this.level];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0