← Forms

Password Checklist

The password rules under the field tick themselves off as you type. Each circle fills with colour and a tick draws in with a little pop. Break a rule again and it unticks.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap a password input in an element with data-password-checklist.
  3. The list of rules is added under the field.

Options

Add any of these attributes to the same element.

data-rulesWhich rules, in order, comma-separated from length, upper, lower, number and symbol. Default length,upper,number,symbol.
data-minThe minimum length. Default 8.
data-colorTick colour. Default #16a34a.
data-autoplayIt types a password that meets the rules one by one, then deletes it, over and over while in view. It stops as soon as you use the field.
data-textWhat autoplay types. Default Sunset42!.

Listen for the checklist event: e.detail.met is true once every rule passes.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Password Checklist: a list of rules under the password field ticks
    // itself off as you type. Each rule's circle fills with colour and a tick
    // draws in with a little pop; break the rule again and it unticks.
    // Usage: <div data-password-checklist><input type="password"></div>
    //   The list is added inside the wrapper, under the field.
    // Optional: data-min (minimum length, default 8), data-rules (which rules
    // and in what order, comma-separated from: length, upper, lower, number,
    // symbol; default "length,upper,number,symbol"), data-color (tick colour,
    // default #16a34a), data-autoplay (it types a password that meets the rules
    // one by one, then deletes it, over and over while in view; it stops as
    // soon as you use the field), data-text (what autoplay types, default
    // "Sunset42!").
    // Listen for the "checklist" event: e.detail.met is true when every rule passes.
    var DEFAULTS = { min: 8, rules: 'length,upper,number,symbol', color: '#16a34a', text: 'Sunset42!' };

    var RULES = {
      length: { text: function (min) { return 'At least ' + min + ' characters'; }, test: function (p, min) { return p.length >= min; } },
      upper: { text: function () { return 'One uppercase letter'; }, test: function (p) { return /[A-Z]/.test(p); } },
      lower: { text: function () { return 'One lowercase letter'; }, test: function (p) { return /[a-z]/.test(p); } },
      number: { text: function () { return 'One number'; }, test: function (p) { return /\d/.test(p); } },
      symbol: { text: function () { return 'One symbol'; }, test: function (p) { return /[^A-Za-z0-9\s]/.test(p); } }
    };

    var css =
      '.pcl-list{display:grid;gap:0.45em;margin:0.8em 0 0;padding:0;list-style:none;text-align:left;font-size:0.88em}' +
      '.pcl-item{display:flex;align-items:center;gap:0.55em;color:#71717a;transition:color .25s}' +
      '.pcl-met{color:inherit}' +
      '.pcl-dot{position:relative;flex:none;display:grid;place-items:center;width:1.2em;height:1.2em;border-radius:50%;box-sizing:border-box;border:0.1em solid #d4d4d8}' +
      '.pcl-fill{position:absolute;inset:-0.1em;border-radius:50%;background:var(--pcl-color);transform:scale(0)}' +
      '.pcl-dot svg{position:relative;width:70%;height:70%;overflow:visible}' +
      '.pcl-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}' +
      '.pcl-text{white-space:pre;letter-spacing:0.08em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    function setup(el) {
      var d = el.dataset;
      var input = el.querySelector('input');
      var field = input || el.querySelector('[data-field]') || el.firstElementChild;
      if (!field) return;
      var real = !!input;
      var min = parseInt(d.min, 10) || DEFAULTS.min;
      el.style.setProperty('--pcl-color', d.color || DEFAULTS.color);

      var list = document.createElement('ul');
      list.className = 'pcl-list';
      var items = (d.rules || DEFAULTS.rules).split(',').map(function (k) {
        var rule = RULES[k.trim()];
        if (!rule) return null;
        var li = document.createElement('li');
        li.className = 'pcl-item';
        li.innerHTML = '<span class="pcl-dot" aria-hidden="true"><span class="pcl-fill"></span>' +
          '<svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>' +
          '<span></span><span class="pcl-sr"></span>';
        li.children[1].textContent = rule.text(min);
        list.appendChild(li);
        return {
          rule: rule, li: li, dot: li.firstChild, fill: li.firstChild.firstChild, sr: li.lastChild,
          tick: anime.svg.createDrawable(li.querySelector('path')), met: null
        };
      }).filter(Boolean);
      el.appendChild(list);

      var text = null, value = '';
      if (!real) {
        text = document.createElement('span');
        text.className = 'pcl-text';
        field.textContent = '';
        field.appendChild(text);
      }
      var get = function () { return real ? input.value : value; };
      var set = function (v) {
        if (real) input.value = v;
        else { value = v; text.textContent = v.replace(/./g, '•'); }
        update();
      };

      var allMet = null;
      function update() {
        var p = get();
        var delay = 0;
        items.forEach(function (it) {
          var met = it.rule.test(p, min);
          if (met === it.met) return;
          var first = it.met === null;
          it.met = met;
          it.li.classList.toggle('pcl-met', met);
          it.sr.textContent = met ? ' (done)' : ' (not yet)';
          if (first || reduced()) {
            anime.utils.set(it.fill, { scale: met ? 1 : 0 });
            anime.utils.set(it.tick, { draw: met ? '0 1' : '0 0' });
            return;
          }
          if (met) {
            // The dot squeezes, fills from the middle, and the tick draws in.
            anime.animate(it.dot, { scale: [0.75, 1], duration: 500, delay: delay, ease: 'outElastic(1, .5)' });
            anime.animate(it.fill, { scale: [0, 1], duration: 300, delay: delay, ease: 'out(3)' });
            anime.animate(it.tick, { draw: ['0 0', '0 1'], duration: 320, delay: delay + 140, ease: 'out(3)' });
            anime.animate(it.li.children[1], { x: [0, '0.25em', 0], duration: 360, delay: delay + 80, ease: 'out(2)' });
            delay += 90;
          } else {
            anime.animate(it.tick, { draw: '1 1', duration: 180, ease: 'in(2)' });
            anime.animate(it.fill, { scale: 0, duration: 240, delay: 120, ease: 'in(2)' });
          }
        });
        var all = items.every(function (it) { return it.met; });
        if (all !== allMet) {
          allMet = all;
          el.dispatchEvent(new CustomEvent('checklist', { bubbles: true, detail: { met: all } }));
        }
      }

      if (real) {
        input.addEventListener('input', update);
        input.addEventListener('pointerdown', stop);
        input.addEventListener('keydown', stop);
        input.addEventListener('focus', stop);
      }
      update();

      var timer = null, stopped = false;
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
      }
      if (on(d.autoplay) && !reduced()) {
        var pw = d.text || DEFAULTS.text;
        var steps = [];
        var i;
        for (i = 1; i <= pw.length; i++) {
          (function (n) { steps.push([function () { set(pw.slice(0, n)); }, 220 + Math.random() * 120]); })(i);
        }
        steps.push([function () {}, 1700]);
        for (i = pw.length - 1; i >= 0; i--) {
          (function (n) { steps.push([function () { set(pw.slice(0, n)); }, 55]); })(i);
        }
        steps.push([function () {}, 900]);
        var run = function (i) {
          if (stopped) return;
          steps[i][0]();
          timer = setTimeout(function () { run((i + 1) % steps.length); }, steps[i][1]);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (entries[0].isIntersecting) { set(''); timer = setTimeout(function () { run(0); }, 600); }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-password-checklist]').forEach(function (el) {
        if (el._pcl) return;
        el._pcl = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-password-checklist data-min="10">
  <input type="password" aria-label="Password">
</div>