UI Patterns
Input

Password

Password input patterns — show/hide toggle and a live strength meter.

Examples

Show/hide toggle

Lets the user reveal what they typed — useful on mobile, or when the password manager didn't autofill. The toggle swaps the <input> type between password and text and updates its own label so screen readers announce the new state.

components/examples/password-toggle.tsx
"use client";

import * as React from "react";
import { Eye, EyeOff } from "lucide-react";

import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function PasswordToggleDemo() {
  const id = React.useId();
  const [show, setShow] = React.useState(false);
  const [value, setValue] = React.useState("");

  return (
    <div className="grid w-full gap-2">
      <Label htmlFor={id}>Password</Label>
      <div className="relative">
        <Input
          id={id}
          type={show ? "text" : "password"}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder="Enter your password"
          autoComplete="current-password"
          className="pr-10"
        />
        <button
          type="button"
          onClick={() => setShow((v) => !v)}
          aria-label={show ? "Hide password" : "Show password"}
          aria-pressed={show}
          aria-controls={id}
          className={cn(
            "text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 focus-visible:border-ring absolute inset-y-0 right-0 flex items-center justify-center rounded-md px-3 outline-none focus-visible:ring-[3px]",
          )}
        >
          {show ? (
            <EyeOff className="size-4" aria-hidden="true" />
          ) : (
            <Eye className="size-4" aria-hidden="true" />
          )}
        </button>
      </div>
    </div>
  );
}

Notes:

  • Use a real <button type="button">, not a clickable <div> or <span>, so the control is focusable and operable with Enter/Space.
  • Update aria-label (not just the icon) when state flips, and use aria-pressed to expose the toggle state to assistive tech.
  • aria-controls={id} points at the input the button operates on.
  • Keep the default autoComplete="current-password" (or new-password on signup) so password managers still fill the field — they key off the input name/autocomplete, not the type.
  • pr-10 on the input leaves room for the button so text does not run under the icon.

Strength meter

Gives the user live feedback on how their password is scoring. Instead of pulling in a dictionary-based library like zxcvbn, this demo estimates entropy with a tiny LZ77-style compressor. The flat length × log₂(poolSize) formula is a bad lie — it rates aaaaaaaa and k7!mQp#2 at the same 37 bits. Compression fixes that: a repeated substring (aaaaaaaa, abcabcabc, passpass) encodes as a cheap back-reference, and a run of consecutive or adjacent code points (abcdef, 111111) collapses to ~1–2 bits per character. A long passphrase like correct horse battery staple still lands in the hundreds of bits. See xkcd 936 for why length beats character variety.

Caveats: compression catches self-similarity, but it doesn't know that password is in every leaked-credential list on earth or that qwerty is a keyboard walk. Reach for zxcvbn when you need dictionary coverage.

Long passphrases beat short, complex passwords. See xkcd 936.

components/examples/password-strength.tsx
"use client";

import * as React from "react";
import { Eye, EyeOff } from "lucide-react";

import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

function poolSize(password: string): number {
  let pool = 0;
  if (/[a-z]/.test(password)) pool += 26;
  if (/[A-Z]/.test(password)) pool += 26;
  if (/\d/.test(password)) pool += 10;
  if (/[^A-Za-z0-9\s]/.test(password)) pool += 32;
  if (/\s/.test(password)) pool += 1;
  if (/[^\x20-\x7e]/.test(password)) pool += 100;
  return pool;
}

// Estimate entropy with a tiny LZ77-style compressor. A naive
// `length * log2(poolSize)` formula rates `aaaaaaaa` and `k7!mQp#2` the same;
// compression correctly charges near-zero bits for runs and back-references,
// and a consecutive-character penalty catches sequences like `abcdef` or
// `111111` that LZ alone wouldn't discount.
function estimateEntropy(password: string): number {
  if (!password) return 0;

  const perChar = Math.log2(poolSize(password));
  let bits = 0;
  let i = 0;

  while (i < password.length) {
    // 1. Longest back-reference into the prefix (LZ77).
    let matchLen = 0;
    for (let off = 1; off <= i; off++) {
      let len = 0;
      while (
        i + len < password.length &&
        password[i - off + len] === password[i + len]
      )
        len++;
      if (len > matchLen) matchLen = len;
    }
    if (matchLen >= 2) {
      bits += Math.log2(i + 1) + Math.log2(matchLen + 1);
      i += matchLen;
      continue;
    }

    // 2. Consecutive same or adjacent code-point (`aa`, `ab`, `12`).
    if (i > 0) {
      const delta = Math.abs(
        password.charCodeAt(i) - password.charCodeAt(i - 1),
      );
      if (delta <= 1) {
        bits += 1 + delta;
        i += 1;
        continue;
      }
    }

    // 3. Otherwise pay the full per-character cost.
    bits += perChar;
    i += 1;
  }

  return bits;
}

const levels = [
  { label: "Too weak", color: "bg-destructive", max: 28 },
  { label: "Weak", color: "bg-destructive", max: 40 },
  { label: "Fair", color: "bg-amber-500", max: 60 },
  { label: "Good", color: "bg-lime-500", max: 80 },
  { label: "Strong", color: "bg-emerald-500", max: Infinity },
] as const;

function scoreFromEntropy(entropy: number): number {
  return levels.findIndex((l) => entropy < l.max);
}

export function PasswordStrengthDemo() {
  const id = React.useId();
  const descId = `${id}-desc`;
  const [show, setShow] = React.useState(false);
  const [value, setValue] = React.useState("");

  const entropy = estimateEntropy(value);
  const score = scoreFromEntropy(entropy);
  const { label, color } = levels[score];

  return (
    <div className="grid w-full gap-3">
      <Label htmlFor={id}>Password</Label>
      <div className="relative">
        <Input
          id={id}
          type={show ? "text" : "password"}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder="Try 'correct horse battery staple'"
          autoComplete="new-password"
          aria-describedby={descId}
          className="pr-10"
        />
        <button
          type="button"
          onClick={() => setShow((v) => !v)}
          aria-label={show ? "Hide password" : "Show password"}
          aria-pressed={show}
          aria-controls={id}
          className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 focus-visible:border-ring absolute inset-y-0 right-0 flex items-center justify-center rounded-md px-3 outline-none focus-visible:ring-[3px]"
        >
          {show ? (
            <EyeOff className="size-4" aria-hidden="true" />
          ) : (
            <Eye className="size-4" aria-hidden="true" />
          )}
        </button>
      </div>
      <div id={descId} className="grid gap-2">
        <div
          className="flex gap-1"
          role="progressbar"
          aria-valuemin={0}
          aria-valuemax={4}
          aria-valuenow={score}
          aria-label={`Password strength: ${label}`}
        >
          {[0, 1, 2, 3].map((i) => (
            <div
              key={i}
              className={cn(
                "h-1 flex-1 rounded-full transition-colors",
                value && i <= score ? color : "bg-muted",
              )}
            />
          ))}
        </div>
        {value ? (
          <p className="text-muted-foreground text-xs">
            Strength:{" "}
            <span className="text-foreground font-medium">{label}</span>
            <span className="ml-2 tabular-nums">
              ~{entropy.toFixed(0)} bits of entropy
            </span>
          </p>
        ) : (
          <p className="text-muted-foreground text-xs">
            Long passphrases beat short, complex passwords. See{" "}
            <a
              href="https://xkcd.com/936/"
              target="_blank"
              rel="noreferrer"
              className="underline underline-offset-2"
            >
              xkcd 936
            </a>
            .
          </p>
        )}
      </div>
    </div>
  );
}

Notes:

  • For each position in the password the estimator tries three rules in order: (1) longest back-reference into the prefix — if the next 2+ characters already appeared earlier, charge log₂(offset) + log₂(length) bits and skip ahead; (2) consecutive or adjacent code point — if the previous character is equal (delta 0) or next to (delta 1) the current one, charge 1–2 bits; (3) otherwise charge the full per-character cost.
  • Pool size feeds the per-character cost: 26 lowercase, 26 uppercase, 10 digits, 32 ASCII symbols, plus small bonuses for whitespace and non-ASCII.
  • Quick sanity check: aaaaaaaa ≈ 8 bits, abcdefgh ≈ 19 bits, password ≈ 34 bits, correct horse battery staple ≈ 130 bits.
  • Entropy → label thresholds (28 / 40 / 60 / 80 bits) roughly match the NIST SP 800-63B reference points for weak / fair / good / strong.
  • The bar is wrapped in role="progressbar" with aria-valuemin / max / now / label so screen readers hear the strength estimate.
  • The whole block is linked to the input via aria-describedby, so focusing the field reads the current strength aloud.
  • Displaying the raw bit count (~72 bits of entropy) is optional — useful for technical audiences, noise for consumer flows.
  • Do not block submission silently — disabled submit buttons with no explanation are worse than letting the server reject the value. If you must gate, surface the threshold the user needs to clear.

On this page