Skip to content
Brok UI

Loading…

No results

Password Strength

Open source

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).

Version
v1.1.2
Stability
stable
License
MIT
Related
Input

Preview

Password strength:

Disabled
previews.components.password-strength.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/password-strength/password-strength.blade.php
  • js resources/js/ui/password-strength.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

password-strength.md
# 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.blade.php Blade
{{-- 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

password-strength password-strength-rules

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

Specialized input

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
root password-strength-rules
Theming hooks
input

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/password-strength/password-strength.blade.php Blade
{{-- 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>
resources/js/ui/password-strength.js JS
/**
 * 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