{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "password-meter",
  "type": "registry:block",
  "title": "Password Meter",
  "description": "A password field with a four-part strength meter that springs between levels and blends from red to green, an entropy and time-to-crack readout, and a checklist whose ticks draw on as each rule is met. The eye toggle opens and closes while every character scrambles in, and a caps-lock warning appears when it matters.",
  "docs": "Password Meter was added to public/vividsites/password-meter.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/password-meter",
  "categories": [
    "ui",
    "forms"
  ],
  "files": [
    {
      "path": "components/vividsites/password-meter.html",
      "type": "registry:file",
      "target": "public/vividsites/password-meter.html",
      "content": "<!--\nvs-password-meter: a real <input type=\"password\"> with a strength meter that springs between\n  levels and blends its colour on the way, a crossfading strength word, an entropy and\n  time-to-crack readout, a rule checklist whose ticks draw on as each rule is met, a caps-lock\n  warning and a reveal toggle whose eye opens and closes while every character scrambles in\n  (or folds back into a dot). The characters you see are a mirror drawn over the native field,\n  so the caret, selection, paste, undo and password managers are all the browser's own.\n\n  Markup: the block below (the JS wires ids, labels and aria-describedby).\n  Attributes (on .vs-password-meter):\n    data-min-length   minimum length (default 8); the first rule and \"Too short\" follow it\n    data-label        field label (default \"Password\"); or edit .vs-password-meter-label\n    data-name         name for form submission (default \"password\")\n    data-disabled     dims and disables the field\n    data-theme        \"light\" for light surfaces (the dark look is the default)\n  Scoring: an offline estimate in the spirit of zxcvbn: it splits the password into letter,\n    digit and symbol runs, prices words, years, sequences and repeats far below random\n    characters, knows ~100 of the most common passwords (with l33t swaps and trailing digits),\n    and reports bits of entropy plus time to crack offline at 10 billion guesses a second.\n    Levels: Too short, Weak (<28 bits), Fair (<45), Strong (<64), Excellent.\n  Variables (set on .vs-password-meter): --vs-password-meter-accent (focus + caret),\n    -size (base font size; all in em), -font (labels), -mono (the password; keep it monospace),\n    -ink, -mute, -field, -line, -radius, -ok (met rules), -c0..-c4 (meter colours by level),\n    -surface (the card behind it: dark by default so the paste reads on a white page; set it to\n    transparent and -pad to 0 when your page is already dark), -pad (card padding).\n  Events: \"vs-password-meter-change\" {level (0-4), label, bits, rules: {length, case, number,\n    symbol, common}}. el.value gets the password; el.reveal(true|false) toggles it.\n  Reduced motion: no springs, scramble or pops; everything still updates.\n-->\n<style>\n.vs-password-meter {\n  --vs-password-meter-accent: #86a8ff;          /* focus ring + caret */\n  --vs-password-meter-ink: #e9ebf3;\n  --vs-password-meter-mute: rgba(214,219,236,.55);\n  --vs-password-meter-field: #161a26;\n  --vs-password-meter-line: rgba(200,208,235,.14);\n  --vs-password-meter-ok: #7cc79d;               /* met rules */\n  --vs-password-meter-c0: #c7645a;               /* too short */\n  --vs-password-meter-c1: #cf7a52;               /* weak */\n  --vs-password-meter-c2: #d5a653;               /* fair */\n  --vs-password-meter-c3: #a3bf66;               /* strong */\n  --vs-password-meter-c4: #5fbd8d;               /* excellent */\n  --vs-password-meter-radius: 14px;\n  --vs-password-meter-size: 15px;\n  --vs-password-meter-font: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n  --vs-password-meter-surface: #0f1118;          /* its own card, so it reads on any page; transparent to drop it */\n  --vs-password-meter-pad: 1.5em;                /* card padding; 0 with a transparent surface */\n  --vs-password-meter-mono: ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n  --vs-password-meter-c: var(--vs-password-meter-c0); /* live strength colour, written by the JS */\n\n  position: relative; box-sizing: border-box; width: 100%; max-width: calc(31em + 2 * var(--vs-password-meter-pad)); font-size: var(--vs-password-meter-size);\n  padding: var(--vs-password-meter-pad); border-radius: calc(var(--vs-password-meter-radius) + var(--vs-password-meter-pad) * .5); background: var(--vs-password-meter-surface);\n  font-family: var(--vs-password-meter-font); color: var(--vs-password-meter-ink); -webkit-font-smoothing: antialiased;\n}\n.vs-password-meter *, .vs-password-meter *::before, .vs-password-meter *::after { box-sizing: border-box; }\n/* light surfaces: data-theme=\"light\" */\n.vs-password-meter[data-theme=\"light\"] {\n  --vs-password-meter-surface: #f6f7f9;\n  --vs-password-meter-accent: #3d63dd; --vs-password-meter-ink: #161923; --vs-password-meter-mute: rgba(22,25,35,.56);\n  --vs-password-meter-field: #ffffff; --vs-password-meter-line: rgba(22,25,35,.16); --vs-password-meter-ok: #2e9b68;\n  --vs-password-meter-c0: #c2463b; --vs-password-meter-c1: #cc6a36; --vs-password-meter-c2: #c9912a; --vs-password-meter-c3: #7d9f3a; --vs-password-meter-c4: #2e9b68;\n}\n.vs-password-meter[data-theme=\"light\"] .vs-password-meter-field { box-shadow: inset 0 0 0 .5px var(--vs-password-meter-line), 0 1px 2px rgba(22,25,35,.06), 0 12px 28px -18px rgba(22,25,35,.35); }\n.vs-password-meter[data-theme=\"light\"] .vs-password-meter-bars i { background: rgba(22,25,35,.08); }\n.vs-password-meter[data-theme=\"light\"] .vs-password-meter-rules circle { stroke: rgba(22,25,35,.25); }\n.vs-password-meter[data-theme=\"light\"] .vs-password-meter-rules path { stroke: #fff; }\n.vs-password-meter[data-theme=\"light\"] .vs-password-meter-caps { color: #9a6a12; background: rgba(214,160,60,.14); box-shadow: inset 0 0 0 .5px rgba(180,130,40,.4); }\n.vs-password-meter-head { display: flex; align-items: center; justify-content: space-between; gap: 1em; margin-bottom: .6em; min-height: 1.6em; }\n.vs-password-meter-label { font-size: .88em; font-weight: 500; letter-spacing: .005em; color: color-mix(in srgb, var(--vs-password-meter-ink) 82%, transparent); cursor: default; }\n.vs-password-meter-caps {\n  display: inline-flex; align-items: center; gap: .4em; height: 1.75em; padding: 0 .7em 0 .55em; border-radius: 99px;\n  font-size: .76em; font-weight: 500; color: #f0c982; background: rgba(240,201,130,.1); box-shadow: inset 0 0 0 .5px rgba(240,201,130,.35);\n  opacity: 0; transform: translateY(.3em) scale(.9); transition: opacity .2s cubic-bezier(.2,0,0,1), transform .45s cubic-bezier(.34,1.56,.64,1); pointer-events: none;\n}\n.vs-password-meter-caps svg { width: 1.05em; height: 1.05em; }\n.vs-password-meter.is-caps .vs-password-meter-caps { opacity: 1; transform: none; }\n\n.vs-password-meter-field {\n  position: relative; height: 3.7em; border-radius: var(--vs-password-meter-radius); background: var(--vs-password-meter-field);\n  box-shadow: inset 0 0 0 .5px var(--vs-password-meter-line), inset 0 1px 0 rgba(255,255,255,.045), 0 1px 2px rgba(0,0,0,.3), 0 14px 32px -18px rgba(0,0,0,.8);\n  transition: box-shadow .25s cubic-bezier(.2,0,0,1), background-color .25s cubic-bezier(.2,0,0,1);\n}\n@media (hover: hover) { .vs-password-meter-field:hover { box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--vs-password-meter-line), var(--vs-password-meter-ink) 16%), inset 0 1px 0 rgba(255,255,255,.05), 0 1px 2px rgba(0,0,0,.3), 0 14px 32px -18px rgba(0,0,0,.8); } }\n.vs-password-meter-field:focus-within {\n  background: color-mix(in srgb, var(--vs-password-meter-field), var(--vs-password-meter-accent) 3%);\n  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-password-meter-accent) 70%, transparent), 0 0 0 4px color-mix(in srgb, var(--vs-password-meter-accent) 14%, transparent), 0 14px 32px -18px rgba(0,0,0,.8);\n}\n.vs-password-meter-input, .vs-password-meter-mirror { font: 400 1.3em/1 var(--vs-password-meter-mono); letter-spacing: .06em; }\n.vs-password-meter-input {\n  position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; border: 0; outline: none; border-radius: inherit;\n  padding: 0 2.7em 0 .85em; background: transparent; color: transparent; -webkit-text-fill-color: transparent;\n  caret-color: var(--vs-password-meter-accent); -webkit-appearance: none; appearance: none;\n}\n.vs-password-meter-input::placeholder { color: var(--vs-password-meter-mute); -webkit-text-fill-color: var(--vs-password-meter-mute); opacity: .7; letter-spacing: 0; font-family: var(--vs-password-meter-font); font-size: .7em; }\n.vs-password-meter-input::selection { background: color-mix(in srgb, var(--vs-password-meter-accent) 32%, transparent); }\n.vs-password-meter-input::-ms-reveal { display: none; }\n.vs-password-meter-input:-webkit-autofill { transition: background-color 9999s 9999s; }\n.vs-password-meter-mirror { position: absolute; left: .85em; right: 2.7em; top: 0; bottom: 0; overflow: hidden; pointer-events: none; }\n.vs-password-meter-track { position: absolute; left: 0; top: 0; bottom: 0; will-change: transform; }\n.vs-password-meter-ch { position: absolute; top: 0; bottom: 0; display: grid; place-items: center; padding-right: .06em; will-change: transform; } /* padding = letter-spacing, so glyphs sit where the native ones do */\n.vs-password-meter-ch b { font-weight: 400; transition: opacity .16s cubic-bezier(.2,0,0,1), transform .38s cubic-bezier(.34,1.56,.64,1), filter .2s; white-space: pre; }\n.vs-password-meter-ch i {\n  position: absolute; left: 50%; top: 50%; width: .42em; height: .42em; margin: -.21em 0 0 -.21em; border-radius: 50%;\n  background: var(--vs-password-meter-ink); transition: opacity .16s cubic-bezier(.2,0,0,1), transform .4s cubic-bezier(.34,1.8,.5,1);\n}\n.vs-password-meter-ch b { opacity: 0; transform: scale(.5) translateY(.2em); filter: blur(2px); }\n.vs-password-meter.is-shown .vs-password-meter-ch b { opacity: 1; transform: none; filter: none; }\n.vs-password-meter.is-shown .vs-password-meter-ch i { opacity: 0; transform: scale(.2); }\n.vs-password-meter-ch.is-scr b { color: var(--vs-password-meter-accent); }\n\n.vs-password-meter-eye {\n  position: absolute; right: .5em; top: 50%; width: 2.5em; height: 2.5em; margin-top: -1.25em; border: 0; padding: 0; font-size: inherit; border-radius: calc(var(--vs-password-meter-radius) - 5px);\n  display: grid; place-items: center; background: transparent; color: var(--vs-password-meter-mute); cursor: pointer; -webkit-tap-highlight-color: transparent;\n  transition: background-color .18s cubic-bezier(.2,0,0,1), color .18s, transform .3s cubic-bezier(.34,1.56,.64,1);\n}\n.vs-password-meter-eye svg { width: 1.3em; height: 1.3em; overflow: visible; }\n.vs-password-meter-eye path, .vs-password-meter-eye circle { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }\n.vs-password-meter-eye .pupil { fill: currentColor; stroke: none; }\n@media (hover: hover) { .vs-password-meter-eye:hover { background: rgba(200,208,235,.07); color: var(--vs-password-meter-ink); } }\n.vs-password-meter-eye:active { transform: scale(.9); }\n.vs-password-meter-eye:focus-visible { outline: 2px solid var(--vs-password-meter-accent); outline-offset: 0; color: var(--vs-password-meter-ink); }\n.vs-password-meter-eye[aria-pressed=\"true\"] { color: var(--vs-password-meter-ink); }\n\n.vs-password-meter-bars { display: grid; grid-template-columns: repeat(4, 1fr); gap: .38em; margin-top: 1em; }\n.vs-password-meter-bars i { position: relative; height: .34em; border-radius: 1em; overflow: hidden; background: rgba(200,208,235,.09); }\n.vs-password-meter-bars b {\n  position: absolute; inset: 0; border-radius: inherit; transform-origin: 0 50%; transform: scaleX(0); will-change: transform;\n  background: linear-gradient(90deg, color-mix(in srgb, var(--vs-password-meter-c), #000 8%), color-mix(in srgb, var(--vs-password-meter-c), #fff 8%));\n  box-shadow: inset 0 1px 0 rgba(255,255,255,.2);\n}\n.vs-password-meter-status { display: flex; align-items: baseline; justify-content: space-between; gap: 1em; margin-top: .7em; min-height: 1.4em; }\n.vs-password-meter-word { position: relative; display: grid; font-size: .92em; font-weight: 500; color: var(--vs-password-meter-c); }\n.vs-password-meter-word span { grid-area: 1 / 1; white-space: nowrap; transition: opacity .3s cubic-bezier(.2,0,0,1), transform .5s cubic-bezier(.34,1.4,.64,1), filter .3s; }\n.vs-password-meter-word span[data-out] { opacity: 0; transform: translateY(-.55em); filter: blur(3px); }\n.vs-password-meter-word span[data-in] { opacity: 0; transform: translateY(.55em); filter: blur(3px); }\n.vs-password-meter-crack { font: 400 .76em/1.2 var(--vs-password-meter-mono); color: var(--vs-password-meter-mute); font-variant-numeric: tabular-nums; white-space: nowrap; transition: opacity .3s; }\n.vs-password-meter.is-empty .vs-password-meter-crack { opacity: 0; }\n.vs-password-meter.is-empty .vs-password-meter-word { color: var(--vs-password-meter-mute); }\n\n.vs-password-meter-rules { list-style: none; margin: 1.15em 0 0; padding: 1.05em 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(13.5em, 1fr)); gap: .62em 1.2em; border-top: .5px solid var(--vs-password-meter-line); }\n.vs-password-meter-rules li { display: flex; align-items: center; gap: .62em; font-size: .9em; color: var(--vs-password-meter-mute); transition: color .3s cubic-bezier(.2,0,0,1); }\n.vs-password-meter-rules svg { width: 1.25em; height: 1.25em; flex: none; overflow: visible; transition: transform .45s cubic-bezier(.34,1.8,.5,1); }\n.vs-password-meter-rules circle { fill: transparent; stroke: rgba(200,208,235,.26); stroke-width: 1.2; transition: fill .3s cubic-bezier(.2,0,0,1), stroke .3s; }\n.vs-password-meter-rules path { fill: none; stroke: #0f1118; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .32s cubic-bezier(.65,0,.35,1); }\n.vs-password-meter-rules li.is-met { color: var(--vs-password-meter-ink); }\n.vs-password-meter-rules li.is-met svg { transform: scale(1.08); }\n.vs-password-meter-rules li.is-met circle { fill: var(--vs-password-meter-ok); stroke: var(--vs-password-meter-ok); }\n.vs-password-meter-rules li.is-met path { stroke-dashoffset: 0; transition-delay: .08s; }\n.vs-password-meter-rules li.is-settled svg { transform: none; }\n.vs-password-meter-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n.vs-password-meter[data-disabled] { opacity: .45; filter: saturate(.3); }\n.vs-password-meter[data-disabled] .vs-password-meter-input { cursor: not-allowed; }\n@media (prefers-reduced-motion: reduce) {\n  .vs-password-meter *, .vs-password-meter *::before { transition-duration: .01s !important; transition-delay: 0s !important; }\n}\n@media (forced-colors: active) {\n  .vs-password-meter-input { color: FieldText; -webkit-text-fill-color: FieldText; }\n  .vs-password-meter-mirror { display: none; }\n  .vs-password-meter-bars b { background: Highlight; }\n}\n</style>\n\n<div class=\"vs-password-meter\" data-min-length=\"8\">\n  <div class=\"vs-password-meter-head\">\n    <label class=\"vs-password-meter-label\">Password</label>\n    <span class=\"vs-password-meter-caps\" aria-hidden=\"true\"><svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\" stroke-linecap=\"round\"><path d=\"M8 2.5 2.8 8h2.9v3h4.6V8h2.9z\"/><path d=\"M5.7 13.5h4.6\"/></svg>Caps Lock is on</span>\n  </div>\n  <div class=\"vs-password-meter-field\">\n    <input class=\"vs-password-meter-input\" type=\"password\" autocomplete=\"new-password\" spellcheck=\"false\" autocapitalize=\"off\" autocorrect=\"off\" placeholder=\"Create a password\">\n    <span class=\"vs-password-meter-mirror\" aria-hidden=\"true\"><span class=\"vs-password-meter-track\"></span></span>\n    <button class=\"vs-password-meter-eye\" type=\"button\" aria-pressed=\"false\" aria-label=\"Show password\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path class=\"lid-u\"/><path class=\"lid-l\"/><circle class=\"pupil\" cx=\"12\" cy=\"12\" r=\"0\"/><path class=\"lash\"/></svg></button>\n  </div>\n  <div class=\"vs-password-meter-bars\" aria-hidden=\"true\"><i><b></b></i><i><b></b></i><i><b></b></i><i><b></b></i></div>\n  <div class=\"vs-password-meter-status\">\n    <span class=\"vs-password-meter-word\"><span>Enter a password</span></span>\n    <span class=\"vs-password-meter-crack\"></span>\n  </div>\n  <ul class=\"vs-password-meter-rules\">\n    <li data-rule=\"length\"><svg viewBox=\"0 0 20 20\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"10\" r=\"8.4\"/><path pathLength=\"1\" d=\"M6.2 10.3l2.5 2.5 5.1-5.3\"/></svg><span>At least <span data-min>8</span> characters</span></li>\n    <li data-rule=\"case\"><svg viewBox=\"0 0 20 20\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"10\" r=\"8.4\"/><path pathLength=\"1\" d=\"M6.2 10.3l2.5 2.5 5.1-5.3\"/></svg><span>Upper and lower case</span></li>\n    <li data-rule=\"number\"><svg viewBox=\"0 0 20 20\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"10\" r=\"8.4\"/><path pathLength=\"1\" d=\"M6.2 10.3l2.5 2.5 5.1-5.3\"/></svg><span>A number</span></li>\n    <li data-rule=\"symbol\"><svg viewBox=\"0 0 20 20\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"10\" r=\"8.4\"/><path pathLength=\"1\" d=\"M6.2 10.3l2.5 2.5 5.1-5.3\"/></svg><span>A symbol</span></li>\n    <li data-rule=\"common\"><svg viewBox=\"0 0 20 20\" aria-hidden=\"true\"><circle cx=\"10\" cy=\"10\" r=\"8.4\"/><path pathLength=\"1\" d=\"M6.2 10.3l2.5 2.5 5.1-5.3\"/></svg><span>Not a common password</span></li>\n  </ul>\n  <p class=\"vs-password-meter-sr\" aria-live=\"polite\"></p>\n</div>\n\n<script>\n(() => {\n  if (window.__vsPasswordMeter) return; window.__vsPasswordMeter = true;\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const COMMON = new Set(('password passw0rd password1 password123 123456 1234567 12345678 123456789 1234567890 12345 1234 111111 000000 123123 654321 666666 121212 112233 abc123 abcdef qwerty qwertyuiop qwerty123 asdfgh asdfghjkl zxcvbn zxcvbnm 1q2w3e4r 1qaz2wsx letmein welcome welcome1 admin administrator login master monkey dragon shadow sunshine princess football baseball basketball soccer hockey iloveyou trustno1 superman batman starwars whatever freedom hello secret access flower cheese summer winter autumn spring michael charlie jordan hunter ranger buster tigger cookie orange banana computer internet pepper ginger killer mustang maggie jessica ashley daniel thomas robert matthew andrew joshua love lovely loveme angel angels friends family forever blessed jesus master1 qazwsx zaq12wsx passpass test test123 guest changeme default root toor pass letmein1 hello123 iloveu nothing').split(' '));\n  const LEET = { '0': 'o', '1': 'l', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b', '@': 'a', '$': 's', '!': 'i', '+': 't' };\n  const deleet = (s) => s.replace(/[0134578@$!+]/g, (c) => LEET[c]);\n  const isCommon = (pw) => {\n    const l = pw.toLowerCase(); if (COMMON.has(l) || COMMON.has(deleet(l))) return true;\n    const core = l.replace(/^[\\d\\W_]+|[\\d\\W_]+$/g, ''); // \"Summer2024!\" -> \"summer\"\n    return core.length >= 4 && core.length >= l.length - 5 && (COMMON.has(core) || COMMON.has(deleet(core)));\n  };\n  const seqRun = (s) => { if (s.length < 3) return false; const d = s.charCodeAt(1) - s.charCodeAt(0); if (Math.abs(d) !== 1) return false;\n    for (let i = 2; i < s.length; i++) if (s.charCodeAt(i) - s.charCodeAt(i - 1) !== d) return false; return true; };\n  const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm', '1234567890'];\n  const keyRun = (s) => s.length >= 4 && ROWS.some((r) => r.includes(s.toLowerCase()) || [...r].reverse().join('').includes(s.toLowerCase()));\n  // bits of entropy, pricing human patterns far below random characters\n  function estimate(pw) {\n    if (!pw) return 0;\n    if (isCommon(pw)) return Math.min(8, 2 + pw.length * .5);\n    const runs = pw.match(/[a-zA-Z]+|\\d+|[^a-zA-Z\\d]+/g) || []; let bits = 0;\n    for (const r of runs) {\n      const n = r.length;\n      if (/^(.)\\1+$/.test(r)) { bits += Math.log2(/\\d/.test(r) ? 10 : /[a-z]/i.test(r) ? 26 : 33) + Math.log2(n); continue; }\n      if (seqRun(r) || keyRun(r)) { bits += 4 + Math.log2(n); continue; }\n      if (/^[a-zA-Z]+$/.test(r)) {\n        const lower = r.toLowerCase(), caps = (r.match(/[A-Z]/g) || []).length;\n        if (COMMON.has(lower) || COMMON.has(deleet(lower))) { bits += 9; continue; }\n        const capBits = caps === 0 ? 0 : (caps === 1 && /^[A-Z]/.test(r)) || caps === n ? 1 : Math.min(n, caps * 2.2);\n        bits += (n <= 3 ? n * 4.7 : n * 2.6) + capBits; // runs of 4+ letters read as words\n      } else if (/^\\d+$/.test(r)) {\n        bits += n === 4 && /^(19|20)\\d\\d$/.test(r) ? 6.6 : n * 3.32;\n      } else bits += n * 5;\n    }\n    bits += Math.log2(runs.length) * 1.5;\n    return Math.max(0, bits);\n  }\n  const crackTime = (bits) => {\n    const s = Math.pow(2, bits) / 2 / 1e10; // average guesses, offline at 10 billion a second\n    if (s < 1) return 'cracked instantly';\n    const u = [[60, 'second'], [60, 'minute'], [24, 'hour'], [30.4, 'day'], [12, 'month'], [100, 'year']];\n    let v = s, i = 0; for (; i < u.length && v >= u[i][0]; i++) v /= u[i][0];\n    if (i >= u.length) return 'centuries to crack';\n    const n = Math.max(1, Math.round(v)); return '~' + n + ' ' + u[i][1] + (n === 1 ? '' : 's') + ' to crack';\n  };\n  const LABELS = ['Too short', 'Weak', 'Fair', 'Strong', 'Excellent'];\n  const hex = (c) => { const m = c.trim().match(/^#?([\\da-f]{6})$/i); if (!m) return [200, 100, 90]; const n = parseInt(m[1], 16); return [n >> 16, (n >> 8) & 255, n & 255]; };\n  let uid = 0;\n\n  function mount(root) {\n    if (root.__vsPm) return; root.__vsPm = true;\n    const id = 'vs-password-meter-' + (++uid), $ = (s) => root.querySelector(s);\n    const input = $('.vs-password-meter-input'), label = $('.vs-password-meter-label'), track = $('.vs-password-meter-track');\n    const eye = $('.vs-password-meter-eye'), fills = [...root.querySelectorAll('.vs-password-meter-bars b')];\n    const word = $('.vs-password-meter-word'), crack = $('.vs-password-meter-crack'), sr = $('.vs-password-meter-sr');\n    const rulesEl = $('.vs-password-meter-rules'), rules = {}; root.querySelectorAll('[data-rule]').forEach((li) => (rules[li.dataset.rule] = li));\n    const lidU = eye.querySelector('.lid-u'), lidL = eye.querySelector('.lid-l'), pupil = eye.querySelector('.pupil'), lash = eye.querySelector('.lash');\n    input.id = id + '-input'; label.htmlFor = input.id; if (root.dataset.label) label.textContent = root.dataset.label;\n    input.name = root.dataset.name || input.name || 'password';\n    rulesEl.id = id + '-rules'; word.id = id + '-word'; input.setAttribute('aria-describedby', word.id + ' ' + rulesEl.id);\n    // each rule gets a hidden \"done / not done\" suffix for screen readers\n    Object.values(rules).forEach((li) => { const s = document.createElement('span'); s.className = 'vs-password-meter-sr'; s.textContent = ', not yet'; li.appendChild(s); li.__sr = s; });\n    const minLen = () => Math.max(1, +root.dataset.minLength || 8);\n    const syncAttrs = () => { input.disabled = root.hasAttribute('data-disabled'); eye.disabled = input.disabled; root.querySelectorAll('[data-min]').forEach((n) => (n.textContent = minLen())); };\n    syncAttrs();\n\n    /* ── rAF loop, alive only while something moves ── */\n    const tasks = new Set(); let raf = 0, last = 0;\n    const frame = (now) => { const dt = Math.min(.05, (now - last) / 1000 || .016); last = now; for (const t of tasks) if (!t(dt, now)) tasks.delete(t); raf = tasks.size ? requestAnimationFrame(frame) : 0; };\n    const run = (t) => { tasks.add(t); if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); } };\n    const spring = (x, k, c, on, eps = .001) => {\n      const s = { x, v: 0, g: x, k, c };\n      s.step = (dt) => { const n = Math.max(1, Math.ceil(dt * 240)), h = dt / n;\n        for (let i = 0; i < n; i++) { s.v += (-s.k * (s.x - s.g) - s.c * s.v) * h; s.x += s.v * h; }\n        const rest = Math.abs(s.v) < eps * 4 && Math.abs(s.x - s.g) < eps; if (rest) { s.x = s.g; s.v = 0; } on(s.x, s.v); return !rest; };\n      s.to = (g) => { s.g = g; if (reduce.matches) { s.x = g; s.v = 0; on(g, 0); return; } run(s.step); };\n      return s;\n    };\n\n    /* ── meter: one continuous value 0..4 fills the segments and blends the colour ── */\n    const stops = () => { const cs = getComputedStyle(root); return [0, 1, 2, 3, 4].map((i) => hex(cs.getPropertyValue('--vs-password-meter-c' + i))); };\n    let pal = stops();\n    const colourAt = (m) => { const t = Math.max(0, Math.min(4, m)), i = Math.min(3, Math.floor(t)), f = t - i, a = pal[i], b = pal[i + 1];\n      return `rgb(${a.map((v, k) => Math.round(v + (b[k] - v) * f)).join(',')})`; };\n    let lastM = 0;\n    const meter = spring(0, 170, 17, (m) => {\n      fills.forEach((b, i) => { const f = Math.max(0, Math.min(1.06, m - i)); b.style.transform = `scaleX(${f.toFixed(4)})`; });\n      root.style.setProperty('--vs-password-meter-c', colourAt(m));\n    });\n    // bits roll like a counter\n    let shownBits = 0, bitsText = '';\n    const bitSpring = spring(0, 120, 22, (b) => { shownBits = Math.max(0, Math.round(b)); paintCrack(); });\n    const paintCrack = () => { const t = shownBits + ' bits · ' + bitsText; if (crack.textContent !== t) crack.textContent = t; };\n\n    /* ── strength word crossfade ── */\n    let wordNow = word.firstElementChild;\n    const setWord = (text) => {\n      if (wordNow.textContent === text) return;\n      const nx = document.createElement('span'); nx.textContent = text; nx.setAttribute('data-in', ''); word.appendChild(nx);\n      const old = wordNow; wordNow = nx; old.setAttribute('data-out', ''); old.removeAttribute('data-in');\n      requestAnimationFrame(() => requestAnimationFrame(() => nx.removeAttribute('data-in')));\n      setTimeout(() => old.remove(), reduce.matches ? 0 : 420);\n    };\n\n    /* ── mirror: per-character slots over the native field ── */\n    const meas = document.createElement('input'); meas.setAttribute('aria-hidden', 'true'); meas.tabIndex = -1;\n    meas.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;padding:0;border:0;visibility:hidden;';\n    let advHidden = 10, advShown = 10;\n    const measure = () => {\n      const cs = getComputedStyle(input); meas.style.font = cs.font; meas.style.letterSpacing = cs.letterSpacing;\n      root.appendChild(meas);\n      meas.type = 'password'; meas.value = 'x'.repeat(40); advHidden = meas.scrollWidth / 40;\n      meas.type = 'text'; advShown = meas.scrollWidth / 40; meas.remove();\n      if (!(advHidden > 1)) advHidden = parseFloat(cs.fontSize) * .6; if (!(advShown > 1)) advShown = parseFloat(cs.fontSize) * .6;\n      layout();\n    };\n    let chars = [], shown = false, val = '';\n    const mix = spring(0, 300, 30, () => layout());\n    const layout = () => { const a = advHidden + (advShown - advHidden) * mix.x;\n      chars.forEach((c, i) => { c.style.transform = `translate3d(${(i * a).toFixed(2)}px,0,0)`; c.style.width = a.toFixed(2) + 'px'; }); syncScroll(); };\n    const syncScroll = () => { track.style.transform = `translate3d(${-input.scrollLeft}px,0,0)`; };\n    const GLYPHS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789#$%&*+=?@';\n    const scramble = (c, ch, delay, dur) => {\n      if (reduce.matches) { c.firstChild.textContent = ch; return; }\n      const t0 = performance.now() + delay; let lastSwap = 0;\n      c.classList.add('is-scr');\n      run((dt, now) => { if (!c.isConnected) return false;\n        if (now < t0) return true;\n        if (now - t0 > dur) { c.firstChild.textContent = ch; c.classList.remove('is-scr'); return false; }\n        if (now - lastSwap > 38) { lastSwap = now; c.firstChild.textContent = GLYPHS[(Math.random() * GLYPHS.length) | 0]; }\n        return true; });\n    };\n    const makeChar = (ch) => { const c = document.createElement('span'); c.className = 'vs-password-meter-ch'; c.innerHTML = '<b></b><i></i>'; c.firstChild.textContent = ch; return c; };\n    const render = (next) => {\n      // diff: common prefix + suffix; the middle is removed / inserted\n      const a = [...val], b = [...next]; let p = 0; while (p < a.length && p < b.length && a[p] === b[p]) p++;\n      let s = 0; while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;\n      const gone = chars.splice(p, a.length - p - s), added = b.slice(p, b.length - s).map(makeChar);\n      // removed characters drop away, right to left when many go at once (select all + delete)\n      gone.forEach((c, k) => { if (reduce.matches) return c.remove();\n        const d = Math.min((gone.length - 1 - k) * 14, 260);\n        c.animate([{ opacity: 1, transform: c.style.transform }, { opacity: 0, transform: c.style.transform + ' translateY(.45em) scale(.4)' }], { duration: 200, delay: d, easing: 'cubic-bezier(.4,0,1,1)', fill: 'forwards' }).onfinish = () => c.remove(); });\n      const ref = chars[p] || null; added.forEach((c) => track.insertBefore(c, ref));\n      chars.splice(p, 0, ...added); val = next; layout();\n      if (!reduce.matches) added.forEach((c, k) => {\n        c.firstElementChild.nextElementSibling.animate([{ transform: 'scale(0)', opacity: 0 }, { transform: 'scale(1.35)', opacity: 1, offset: .5 }, { transform: 'scale(1)', opacity: 1 }], { duration: 300, delay: k * 12, easing: 'cubic-bezier(.2,0,0,1)', fill: 'backwards' });\n        if (shown) { c.firstChild.animate([{ transform: 'translateY(.35em) scale(.7)', opacity: 0, filter: 'blur(3px)' }, { transform: 'none', opacity: 1, filter: 'none' }], { duration: 320, delay: k * 12, easing: 'cubic-bezier(.34,1.56,.64,1)', fill: 'backwards' }); }\n      });\n    };\n\n    /* ── eye: lids part, the pupil grows, the lashes fade ── */\n    const eyeSpring = spring(0, 260, 20, (o) => {\n      const u = 15 - 11 * o, l = 15 + 3.5 * o;\n      lidU.setAttribute('d', `M2.5 12.2 C 7 ${u.toFixed(2)} 17 ${u.toFixed(2)} 21.5 12.2`);\n      lidL.setAttribute('d', `M2.5 12.2 C 7 ${l.toFixed(2)} 17 ${l.toFixed(2)} 21.5 12.2`);\n      pupil.setAttribute('r', Math.max(0, 3.1 * o).toFixed(2)); pupil.setAttribute('cy', (12.4 + .6 * (1 - o)).toFixed(2));\n      const k = Math.max(0, 1 - o * 1.6);\n      lash.setAttribute('d', `M6.2 ${15.3 + 0}l-1.2 ${2.2 * k} M12 16.6l0 ${2.4 * k} M17.8 15.3l1.2 ${2.2 * k}`); lash.style.opacity = k.toFixed(3);\n    });\n    eyeSpring.to(0); eyeSpring.step(1 / 60);\n    const reveal = (on) => {\n      on = !!on; if (on === shown) return; shown = on;\n      const a = input.selectionStart, b = input.selectionEnd, dir = input.selectionDirection;\n      input.type = on ? 'text' : 'password';\n      try { if (document.activeElement === input) input.setSelectionRange(a, b, dir); } catch (e) {}\n      root.classList.toggle('is-shown', on); eye.setAttribute('aria-pressed', String(on)); eye.setAttribute('aria-label', on ? 'Hide password' : 'Show password');\n      eyeSpring.to(on ? 1 : 0); mix.to(on ? 1 : 0);\n      // a left-to-right wave: each glyph scrambles in, or folds back into a dot\n      const n = chars.length, step = Math.min(22, 300 / Math.max(1, n)), arr = [...val];\n      chars.forEach((c, i) => {\n        const b = c.firstChild, d = c.firstChild.nextSibling; b.style.transitionDelay = d.style.transitionDelay = (i * step) + 'ms';\n        setTimeout(() => { b.style.transitionDelay = d.style.transitionDelay = ''; }, i * step + 450);\n        if (on) scramble(c, arr[i], i * step, 150 + Math.random() * 90);\n      });\n      // swapping the type resets the field's scroll in some browsers: keep the caret in view\n      requestAnimationFrame(() => {\n        if (document.activeElement === input) { try { input.setSelectionRange(a, b, dir); } catch (e) {} const cs = getComputedStyle(input), inner = input.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);\n          const cx = (input.selectionEnd || 0) * (on ? advShown : advHidden);\n          if (cx - input.scrollLeft > inner) input.scrollLeft = cx - inner + 2; else if (cx < input.scrollLeft) input.scrollLeft = cx; }\n        syncScroll(); });\n      say(on ? 'Password shown' : 'Password hidden');\n    };\n    eye.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse') e.preventDefault(); }); // keep focus (and the caret) in the field\n    eye.addEventListener('click', () => reveal(!shown));\n\n    /* ── scoring ── */\n    let level = 0, lastLevel = -1, sayT = 0;\n    const say = (t) => { sr.textContent = ''; setTimeout(() => (sr.textContent = t), 40); };\n    const update = () => {\n      const pw = input.value, n = [...pw].length, min = minLen();\n      const bits = estimate(pw), common = n > 0 && isCommon(pw);\n      const r = { length: n >= min, case: /[a-z]/.test(pw) && /[A-Z]/.test(pw), number: /\\d/.test(pw), symbol: /[^A-Za-z\\d\\s]/.test(pw), common: n >= 4 && !common };\n      level = !n ? 0 : n < min ? 0 : common ? 1 : bits < 28 ? 1 : bits < 45 ? 2 : bits < 64 ? 3 : 4;\n      const m = !n ? 0 : n < min ? Math.min(.92, .12 + .8 * n / min) : level;\n      // the segment that just filled gets a brief lift of light\n      if (!reduce.matches && Math.floor(m) > Math.floor(lastM) && fills[Math.floor(m) - 1]) fills[Math.floor(m) - 1].animate([{ filter: 'brightness(1)' }, { filter: 'brightness(1.7)', offset: .3 }, { filter: 'brightness(1)' }], { duration: 650, delay: 120, easing: 'cubic-bezier(.2,0,0,1)' });\n      lastM = m; meter.to(m);\n      root.classList.toggle('is-empty', !n);\n      setWord(!n ? 'Enter a password' : n < min ? LABELS[0] : common ? 'Too common' : LABELS[level]);\n      bitsText = n ? crackTime(bits) : ''; bitSpring.to(bits); paintCrack();\n      for (const k in r) { const li = rules[k]; if (!li) continue; const was = li.classList.contains('is-met');\n        if (was !== r[k]) { li.classList.toggle('is-met', r[k]); li.classList.remove('is-settled'); li.__sr.textContent = r[k] ? ', done' : ', not yet'; if (r[k]) setTimeout(() => li.classList.add('is-settled'), 260); } }\n      if (level !== lastLevel || !n) { lastLevel = level; clearTimeout(sayT); sayT = setTimeout(() => { if (input.value) say('Strength: ' + (common ? 'too common' : n < min ? 'too short' : LABELS[level])); }, 700); }\n      root.dispatchEvent(new CustomEvent('vs-password-meter-change', { bubbles: true, detail: { level: n < min ? 0 : level, label: n < min ? LABELS[0] : LABELS[level], bits: Math.round(bits), rules: r } }));\n    };\n    input.addEventListener('input', () => { render(input.value); update(); requestAnimationFrame(syncScroll); });\n    ['keyup', 'click', 'select', 'scroll', 'focus'].forEach((t) => input.addEventListener(t, () => requestAnimationFrame(syncScroll)));\n    const caps = (e) => { if (e.getModifierState) root.classList.toggle('is-caps', e.getModifierState('CapsLock')); };\n    input.addEventListener('keydown', caps); input.addEventListener('keyup', caps);\n    input.addEventListener('blur', () => root.classList.remove('is-caps'));\n    new MutationObserver(() => { syncAttrs(); pal = stops(); update(); }).observe(root, { attributes: true, attributeFilter: ['data-min-length', 'data-disabled', 'data-theme'] });\n    new ResizeObserver(() => syncScroll()).observe(input);\n    if (document.fonts) document.fonts.ready.then(measure); measure();\n    if (input.value) { render(input.value); }\n    update();\n    Object.defineProperty(root, 'value', { get: () => input.value, configurable: true });\n    root.reveal = reveal;\n    root.setValue = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); };\n  }\n  const init = () => document.querySelectorAll('.vs-password-meter').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();\n  window.vsPasswordMeter = { mount };\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "ui",
    "group": "Forms",
    "tags": [
      "password",
      "strength meter",
      "input",
      "form",
      "auth",
      "sign up",
      "entropy",
      "reveal"
    ],
    "tech": [
      "CSS",
      "JS"
    ],
    "deps": [
      "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
      "https://api.fontshare.com/v2/css?f[]=general-sans@400,500&display=swap"
    ],
    "source": null,
    "free": true,
    "accent": "#5fbd8d",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/password-meter/index.html",
    "thumbnail": "https://vividsites.app/api/component/password-meter/thumb.jpg",
    "video": "https://vividsites.app/api/component/password-meter/thumb.mp4",
    "page": "https://vividsites.app/c/password-meter"
  }
}