Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsCore inputs & forms

Password field

A password input with a morphing show/hide eye, an entropy-style strength meter, live rule checklist, Caps Lock warning, strong password generator with copy, and a confirm-match mode.

Preview

Change password

Very strong~109 bits
  • 12+ characters (met)
  • Upper and lower case (met)
  • A number (not met)
  • A symbol (met)

Type the same password again

Why “Summer2026” is weak

Very weak~23 bits

Contains a very common password.

The meter estimates entropy from the character mix and length, then subtracts for common words, repeats, runs like “abc”, and word-plus-year patterns.

Install

Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.

npx shadcn@latest add https://pebbleui.com/r/password-field.json

Props

PropTypeDefaultDescription
value / defaultValuestring""Controlled or initial password.
onValueChange(value: string) => void—Called on typing or Generate.
showStrengthbooleantrueStrength meter with level word and estimated bits.
rules{ id, label, test }[]defaultRulesChecklist ticked live; [] hides it.
generatebooleanfalseShow Generate, which fills a strong random password and offers Copy.
generateLengthnumber18Length of generated passwords.
matchstring—Confirm mode: the password this field must equal.
invalidbooleanfalseForce the invalid state.
rootClassNamestring—Classes for the wrapper.
Build notes
- `estimateStrength`: pool size from classes present (26/26/10/33, +100 for
  non-ASCII), bits = length × log2(pool), then penalties: common word
  (length × 3.5), each extra repeated char (3), each step of an alphabet or
  digit run of 3+ (3), word-plus-year shape (16). Score bands at 28/40/60/80
  bits.
- `generatePassword` picks one from each class, fills from the union
  (ambiguous characters like l/1/O/0 removed), then Fisher–Yates shuffles,
  all with `crypto.getRandomValues`. It runs in handlers only.
- Caps Lock comes from `KeyboardEvent.getModifierState("CapsLock")` on key
  down and up.
- The eye morph animates the slash's `pathLength` 0 → 1 and the pupil's `r`
  2 → 1.25.

<!-- PEBBLE-LICENSE -->
View source

registry/input/password-field/password-field.tsx

"use client";

/* PEBBLE-LICENSE
Built by Pebble. Component: password-field.

First-party license: MIT

The following first-party terms apply only to original Pebble material.

MIT License

Copyright (c) 2026 Praxys Solutions LLC (Pebble)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */

import * as React from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";

import { cn } from "@/lib/utils";

/* -------------------------------------------------------------------------- */
/* Strength heuristic (ours; no dictionary library)                           */
/* -------------------------------------------------------------------------- */

const COMMON = [
  "password",
  "passw0rd",
  "qwerty",
  "letmein",
  "welcome",
  "admin",
  "iloveyou",
  "monkey",
  "dragon",
  "sunshine",
  "123456",
  "abc123",
  "football",
  "login",
  "summer",
  "winter",
  "spring",
  "autumn",
];

export type Strength = {
  /** Rough entropy estimate after penalties. */
  bits: number;
  /** 0 very weak … 4 very strong. */
  score: 0 | 1 | 2 | 3 | 4;
  label: "Very weak" | "Weak" | "Fair" | "Strong" | "Very strong";
  /** Short hints about what lowered the score. */
  warnings: string[];
};

const LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"] as const;

/**
 * Character-pool entropy (length × log2 pool) minus penalties for common
 * words, repeats and keyboard/alphabet runs. Not a cracker model, but it
 * ranks "Summer2026!" below "tidal-orbit-mango-42" for the right reasons.
 */
export function estimateStrength(password: string): Strength {
  if (!password) return { bits: 0, score: 0, label: LABELS[0], warnings: [] };
  let pool = 0;
  if (/[a-z]/.test(password)) pool += 26;
  if (/[A-Z]/.test(password)) pool += 26;
  if (/\d/.test(password)) pool += 10;
  if (/[^\w\s]|_/.test(password)) pool += 33;
  if (/\s/.test(password)) pool += 1;
  if ([...password].some((c) => c.charCodeAt(0) > 127)) pool += 100;
  let bits = password.length * Math.log2(Math.max(pool, 2));
  const warnings: string[] = [];
  const lower = password.toLowerCase();

  const common = COMMON.find((w) => lower.includes(w));
  if (common) {
    bits -= common.length * 3.5;
    warnings.push("Contains a very common password");
  }
  const repeats = lower.match(/(.)\1{2,}/g);
  if (repeats) {
    bits -= repeats.reduce((n, r) => n + (r.length - 1) * 3, 0);
    warnings.push("Avoid repeated characters");
  }
  let run = 1;
  let runPenalty = 0;
  for (let i = 1; i < lower.length; i++) {
    const d = lower.charCodeAt(i) - lower.charCodeAt(i - 1);
    run = d === 1 || d === -1 ? run + 1 : 1;
    if (run >= 3) runPenalty += 3;
  }
  if (runPenalty) {
    bits -= runPenalty;
    warnings.push("Avoid sequences like abc or 123");
  }
  if (/^[a-z]+\d{1,4}[^\w]?$/i.test(password)) {
    bits -= 16;
    warnings.push("A word plus a year is easy to guess");
  }
  bits = Math.max(0, Math.round(bits));
  const score = (
    bits < 28 ? 0 : bits < 40 ? 1 : bits < 60 ? 2 : bits < 80 ? 3 : 4
  ) as Strength["score"];
  return { bits, score, label: LABELS[score], warnings };
}

export type PasswordRule = { id: string; label: string; test: (value: string) => boolean };

export const defaultRules: PasswordRule[] = [
  { id: "length", label: "12+ characters", test: (v) => v.length >= 12 },
  { id: "case", label: "Upper and lower case", test: (v) => /[a-z]/.test(v) && /[A-Z]/.test(v) },
  { id: "number", label: "A number", test: (v) => /\d/.test(v) },
  { id: "symbol", label: "A symbol", test: (v) => /[^A-Za-z0-9]/.test(v) },
];

/** A strong random password from crypto.getRandomValues (call in handlers only). */
export function generatePassword(length = 18) {
  const sets = [
    "abcdefghijkmnpqrstuvwxyz",
    "ABCDEFGHJKLMNPQRSTUVWXYZ",
    "23456789",
    "!#$%&*+-=?@^_~",
  ];
  const all = sets.join("");
  const rand = (n: number) => {
    const buf = new Uint32Array(1);
    crypto.getRandomValues(buf);
    return buf[0] % n;
  };
  // One from each set, the rest from all, then shuffle.
  const chars = sets.map((s) => s[rand(s.length)]);
  while (chars.length < length) chars.push(all[rand(all.length)]);
  for (let i = chars.length - 1; i > 0; i--) {
    const j = rand(i + 1);
    [chars[i], chars[j]] = [chars[j], chars[i]];
  }
  return chars.join("");
}

/* -------------------------------------------------------------------------- */
/* Icons (ours)                                                               */
/* -------------------------------------------------------------------------- */

/** Eye that morphs to "hidden": the slash draws across and the pupil shrinks. */
function EyeIcon({ hidden }: { hidden: boolean }) {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      className="size-4"
    >
      <path d="M1.75 8S4 3.75 8 3.75 14.25 8 14.25 8 12 12.25 8 12.25 1.75 8 1.75 8Z" />
      <motion.circle
        cx="8"
        cy="8"
        initial={false}
        animate={{ r: hidden ? 1.25 : 2 }}
        transition={{ duration: 0.2 }}
      />
      <motion.path
        d="M2.5 2.5 13.5 13.5"
        initial={false}
        animate={{ pathLength: hidden ? 1 : 0, opacity: hidden ? 1 : 0 }}
        transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
      />
    </svg>
  );
}

const icon = "size-3.5 shrink-0";
const Check = () => (
  <svg
    viewBox="0 0 16 16"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden
    className={icon}
  >
    <path d="M3.5 8.5 6.5 11.5l6-7" />
  </svg>
);
const Dot = () => (
  <svg viewBox="0 0 16 16" aria-hidden className={icon}>
    <circle cx="8" cy="8" r="2" fill="currentColor" />
  </svg>
);

/* -------------------------------------------------------------------------- */
/* PasswordField                                                              */
/* -------------------------------------------------------------------------- */

export type PasswordFieldProps = Omit<
  React.ComponentProps<"input">,
  "type" | "value" | "defaultValue" | "onChange"
> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Show the strength meter. */
  showStrength?: boolean;
  /** Rules ticked live under the field; pass [] to hide. */
  rules?: PasswordRule[];
  /** Show a "Generate" button that fills a strong password. */
  generate?: boolean;
  generateLength?: number;
  /** Confirm mode: the password this field must match. */
  match?: string;
  invalid?: boolean;
  /** Classes for the outer wrapper. */
  rootClassName?: string;
};

export function PasswordField({
  value: valueProp,
  defaultValue = "",
  onValueChange,
  showStrength = true,
  rules = defaultRules,
  generate = false,
  generateLength = 18,
  match,
  invalid = false,
  rootClassName,
  className,
  id,
  autoComplete = "new-password",
  onKeyDown,
  onKeyUp,
  onFocus,
  onBlur,
  ...props
}: PasswordFieldProps) {
  const uid = React.useId();
  const inputId = id ?? `${uid}-input`;
  const [internal, setInternal] = React.useState(defaultValue);
  const value = valueProp ?? internal;
  const [visible, setVisible] = React.useState(false);
  const [caps, setCaps] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [touched, setTouched] = React.useState(false);
  const [copied, setCopied] = React.useState(false);
  const [generated, setGenerated] = React.useState(false);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const confirm = match !== undefined;
  const strength = estimateStrength(value);
  const set = (v: string) => {
    setInternal(v);
    onValueChange?.(v);
  };

  // Confirm state: quiet while typing a prefix of the target, loud once it diverges or on blur.
  const matches = confirm && value.length > 0 && value === match;
  const mismatch =
    confirm && value.length > 0 && !matches && (touched || !match!.startsWith(value));
  const isInvalid = invalid || mismatch;

  const describedBy = [
    showStrength && !confirm && `${uid}-strength`,
    rules.length > 0 && !confirm && `${uid}-rules`,
    caps && focused && `${uid}-caps`,
    confirm && `${uid}-match`,
    props["aria-describedby"],
  ]
    .filter(Boolean)
    .join(" ");

  const readCaps = (e: React.KeyboardEvent) => setCaps(e.getModifierState?.("CapsLock") ?? false);

  return (
    <MotionConfig reducedMotion="user">
      <div data-slot="password-field" className={cn("flex w-full flex-col gap-2", rootClassName)}>
        <div className="group/pw relative">
          <input
            ref={inputRef}
            id={inputId}
            type={visible ? "text" : "password"}
            autoComplete={confirm ? "new-password" : autoComplete}
            spellCheck={false}
            autoCapitalize="off"
            value={value}
            aria-invalid={isInvalid || undefined}
            aria-describedby={describedBy || undefined}
            onChange={(e) => {
              set(e.target.value);
              setGenerated(false);
            }}
            onKeyDown={(e) => {
              readCaps(e);
              onKeyDown?.(e);
            }}
            onKeyUp={(e) => {
              readCaps(e);
              onKeyUp?.(e);
            }}
            onFocus={(e) => {
              setFocused(true);
              onFocus?.(e);
            }}
            onBlur={(e) => {
              setFocused(false);
              setTouched(true);
              onBlur?.(e);
            }}
            className={cn(
              "h-9 w-full rounded-md border border-border-control bg-card ps-3 text-base text-foreground inset-shadow-xs md:text-sm",
              visible
                ? "font-mono tracking-normal"
                : "tracking-[0.12em] placeholder:tracking-normal",
              generate ? "pe-[7.25rem]" : "pe-10",
              "transition-[border-color,box-shadow] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
              "placeholder:text-muted-foreground hover:border-muted-foreground",
              "outline-none focus-visible:border-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              "aria-invalid:border-danger aria-invalid:ring-[3px] aria-invalid:ring-danger/20 aria-invalid:focus-visible:ring-2 aria-invalid:focus-visible:ring-danger/40",
              matches && "border-success",
              "disabled:pointer-events-none disabled:bg-muted disabled:opacity-50",
              className,
            )}
            {...props}
          />
          <div className="absolute inset-y-0 end-1 flex items-center gap-0.5">
            {confirm && (
              <AnimatePresence initial={false}>
                {matches && (
                  <motion.span
                    key="ok"
                    initial={{ scale: 0.5, opacity: 0 }}
                    animate={{ scale: 1, opacity: 1 }}
                    exit={{ scale: 0.5, opacity: 0 }}
                    className="me-1 grid size-4 place-items-center rounded-full bg-success text-card"
                  >
                    <svg
                      viewBox="0 0 12 12"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.75}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden
                      className="size-2.5"
                    >
                      <path d="M3 6.25 5 8.25l4-4.5" />
                    </svg>
                  </motion.span>
                )}
              </AnimatePresence>
            )}
            {generate && (
              <button
                type="button"
                data-slot="password-generate"
                onClick={() => {
                  set(generatePassword(generateLength));
                  setVisible(true);
                  setGenerated(true);
                  setCopied(false);
                }}
                className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors duration-(--motion-fast) hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none active:scale-[0.97]"
              >
                <svg
                  viewBox="0 0 16 16"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={1.5}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden
                  className="size-3.5"
                >
                  <path d="M13 8a5 5 0 1 1-1.5-3.55M13 2.75v2.5h-2.5" />
                </svg>
                Generate
              </button>
            )}
            <button
              type="button"
              aria-label={visible ? "Hide password" : "Show password"}
              aria-pressed={visible}
              aria-controls={inputId}
              data-slot="password-toggle"
              onClick={() => setVisible((v) => !v)}
              className="grid size-7 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors duration-(--motion-fast) hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              <EyeIcon hidden={!visible} />
            </button>
          </div>
        </div>

        <AnimatePresence initial={false}>
          {caps && focused && (
            <motion.p
              key="caps"
              id={`${uid}-caps`}
              role="status"
              initial={{ opacity: 0, height: 0 }}
              animate={{ opacity: 1, height: "auto" }}
              exit={{ opacity: 0, height: 0 }}
              className="-mt-0.5 flex items-center gap-1.5 overflow-hidden text-xs text-warning-strong"
            >
              <svg
                viewBox="0 0 16 16"
                fill="none"
                stroke="currentColor"
                strokeWidth={1.5}
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden
                className="size-3.5"
              >
                <path d="M8 2.5 13 8h-2.75v3.5h-4.5V8H3L8 2.5ZM5.75 13.5h4.5" />
              </svg>
              Caps Lock is on
            </motion.p>
          )}
        </AnimatePresence>

        {generated && (
          <div className="-mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
            <span>Strong password generated.</span>
            <button
              type="button"
              onClick={async () => {
                try {
                  await navigator.clipboard.writeText(value);
                  setCopied(true);
                } catch {
                  setCopied(false);
                }
              }}
              className="cursor-pointer rounded px-1 font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
            >
              {copied ? "Copied" : "Copy"}
            </button>
            <span aria-live="polite" className="sr-only">
              {copied ? "Password copied" : ""}
            </span>
          </div>
        )}

        {confirm ? (
          <p
            id={`${uid}-match`}
            aria-live="polite"
            className={cn(
              "flex min-h-4 items-center gap-1.5 text-xs",
              matches
                ? "text-success-strong"
                : mismatch
                  ? "text-danger-strong"
                  : "text-muted-foreground",
            )}
          >
            {matches
              ? "Passwords match"
              : mismatch
                ? "Passwords don’t match"
                : "Type the same password again"}
          </p>
        ) : (
          <>
            {showStrength && (
              <div
                id={`${uid}-strength`}
                data-slot="password-strength"
                className="flex items-center gap-3"
              >
                <span
                  aria-hidden
                  className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted inset-shadow-xs"
                >
                  <motion.span
                    className={cn(
                      "absolute inset-y-0 start-0 rounded-full",
                      ["bg-danger", "bg-danger", "bg-warning", "bg-success", "bg-success"][
                        strength.score
                      ],
                    )}
                    initial={false}
                    animate={{
                      width: value
                        ? `${Math.max(6, Math.min(100, (strength.bits / 90) * 100))}%`
                        : "0%",
                    }}
                    transition={{ type: "spring", stiffness: 420, damping: 32, mass: 0.8 }}
                  />
                </span>
                <span className="flex shrink-0 items-baseline justify-end gap-1.5 text-xs whitespace-nowrap">
                  <span
                    className={cn(
                      "font-medium",
                      !value
                        ? "text-muted-foreground"
                        : [
                            "text-danger-strong",
                            "text-danger-strong",
                            "text-warning-strong",
                            "text-success-strong",
                            "text-success-strong",
                          ][strength.score],
                    )}
                  >
                    {value ? strength.label : "No password"}
                  </span>
                  {value && (
                    <span className="font-mono text-[10px] text-muted-foreground tabular-nums">
                      ~{strength.bits} bits
                    </span>
                  )}
                </span>
              </div>
            )}
            {value && strength.warnings[0] && (
              <p className="-mt-1 text-xs text-muted-foreground">{strength.warnings[0]}.</p>
            )}
            {rules.length > 0 && (
              <ul
                id={`${uid}-rules`}
                aria-label="Password requirements"
                className="grid grid-cols-2 gap-x-3 gap-y-1"
              >
                {rules.map((r) => {
                  const ok = r.test(value);
                  return (
                    <li
                      key={r.id}
                      data-met={ok || undefined}
                      className={cn(
                        "flex items-center gap-1.5 text-xs transition-colors duration-(--motion-fast)",
                        ok ? "text-foreground" : "text-muted-foreground",
                      )}
                    >
                      <span
                        className={cn(
                          "grid size-4 place-items-center rounded-full transition-[background-color,color] duration-(--motion-base)",
                          ok ? "bg-success/15 text-success-strong" : "text-muted-foreground/50",
                        )}
                      >
                        {ok ? <Check /> : <Dot />}
                      </span>
                      {r.label}
                      <span className="sr-only">{ok ? " (met)" : " (not met)"}</span>
                    </li>
                  );
                })}
              </ul>
            )}
          </>
        )}
      </div>
    </MotionConfig>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗