← Forms

Watching Mascot Login

A little bear peeks over the top of the login form. Type your email and its eyes follow the caret along the field, its head tilting after them. Move to the password and it lifts its paws to cover its eyes, then lowers them again when you leave. Now and then it blinks.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-watching-mascot-login to your login form (or any element holding its fields): the bear is added at the top.
  3. Put the fields in a card just below it, so the bear looks as if it’s peeking over the edge. Size the bear with font-size: it’s 9em wide.

Options

Add any of these attributes to the same element.

data-colorFur colour. Default #b28ee4.
data-autoplayIt types an email and password by itself while in view, and stops as soon as you use the form.
data-emailWhat autoplay types as the email. Default [email protected].
data-passwordWhat autoplay types as the password. Default hunter22.

The bear is decorative and hidden from screen readers; the fields are your own and work as usual. Any password field makes it cover its eyes.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Watching Mascot Login: a little bear peeks over the top of a login form.
    // Its eyes follow the caret as you type your email, and it covers them
    // with its paws while you type your password. It blinks now and then.
    // Usage: <div data-watching-mascot-login>
    //          <input type="email" placeholder="Email">
    //          <input type="password" placeholder="Password">
    //        </div>
    //   The bear is added at the top of the element. Text fields are watched;
    //   password fields make it cover its eyes.
    // Optional: data-color (fur colour, default #b28ee4), data-autoplay (it
    // types an email and password by itself, over and over while in view; it
    // stops as soon as you use the form), data-email and data-password (what
    // autoplay types).
    var DEFAULTS = { color: '#b28ee4', email: '[email protected]', password: 'hunter22' };

    var css =
      '.wml{position:relative}' +
      '.wml-bear{display:block;width:9em;height:auto;margin:0 auto -1px;overflow:hidden;pointer-events:none}' +
      '.wml-bear .wml-fur{fill:var(--wml-color)}' +
      '.wml-bear .wml-dark{fill:#3a2350}' +
      '.wml-bear .wml-paw{fill:var(--wml-color);fill:color-mix(in srgb,var(--wml-color) 82%,#3a2350);stroke:rgba(58,35,80,.3);stroke-width:1.5}' +
      '.wml [data-field] .wml-text{white-space:pre}' +
      '.wml [data-field] .wml-caret{display:none;width:1.5px;height:1.1em;margin-left:1px;vertical-align:middle;background:currentColor;animation:wml-blink 1s steps(1) infinite}' +
      '.wml [data-field].wml-on .wml-caret{display:inline-block}' +
      '.wml [data-field]:not(.wml-on) .wml-text:empty::before{content:attr(data-ph);color:#8a8a93}' +
      '@keyframes wml-blink{50%{opacity:0}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var BEAR =
      '<svg class="wml-bear" viewBox="0 0 160 104" aria-hidden="true">' +
      '<g class="wml-head">' +
      '<circle class="wml-fur" cx="38" cy="36" r="17"/><circle class="wml-fur" cx="122" cy="36" r="17"/>' +
      '<circle cx="38" cy="36" r="9" fill="#f3eafc"/><circle cx="122" cy="36" r="9" fill="#f3eafc"/>' +
      '<ellipse class="wml-fur" cx="80" cy="84" rx="58" ry="54"/>' +
      '<ellipse cx="50" cy="88" rx="8" ry="4.5" fill="#f59ec4" opacity=".55"/><ellipse cx="110" cy="88" rx="8" ry="4.5" fill="#f59ec4" opacity=".55"/>' +
      '<ellipse cx="80" cy="94" rx="21" ry="15" fill="#f3eafc"/>' +
      '<g class="wml-eyes">' +
      '<g class="wml-eye"><ellipse cx="61" cy="70" rx="11" ry="12" fill="#fff"/><g class="wml-pupil"><circle class="wml-dark" cx="61" cy="70" r="5.6"/><circle cx="63" cy="68" r="1.7" fill="#fff"/></g></g>' +
      '<g class="wml-eye"><ellipse cx="99" cy="70" rx="11" ry="12" fill="#fff"/><g class="wml-pupil"><circle class="wml-dark" cx="99" cy="70" r="5.6"/><circle cx="101" cy="68" r="1.7" fill="#fff"/></g></g>' +
      '</g>' +
      '<ellipse class="wml-dark" cx="80" cy="87" rx="6.5" ry="4.5"/>' +
      '<path class="wml-mouth" d="M73 96 Q80 101 87 96" fill="none" stroke="#3a2350" stroke-width="2" stroke-linecap="round"/>' +
      '</g>' +
      '<g class="wml-paw-l"><rect class="wml-paw" x="45" y="74" width="26" height="70" rx="13"/><ellipse class="wml-paw" cx="60" cy="72" rx="19" ry="16"/><circle cx="52" cy="66" r="3" fill="#f3eafc"/><circle cx="60" cy="63.5" r="3" fill="#f3eafc"/><circle cx="68" cy="66" r="3" fill="#f3eafc"/></g>' +
      '<g class="wml-paw-r"><rect class="wml-paw" x="89" y="74" width="26" height="70" rx="13"/><ellipse class="wml-paw" cx="100" cy="72" rx="19" ry="16"/><circle cx="92" cy="66" r="3" fill="#f3eafc"/><circle cx="100" cy="63.5" r="3" fill="#f3eafc"/><circle cx="108" cy="66" r="3" fill="#f3eafc"/></g>' +
      '</svg>';

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }

    var measureCtx = null;
    function textWidth(text, field) {
      if (!measureCtx) measureCtx = document.createElement('canvas').getContext('2d');
      var cs = getComputedStyle(field);
      measureCtx.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
      return measureCtx.measureText(text).width;
    }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('wml');
      el.style.setProperty('--wml-color', d.color || DEFAULTS.color);
      el.insertAdjacentHTML('afterbegin', BEAR);
      var svg = el.querySelector('.wml-bear');
      var head = svg.querySelector('.wml-head');
      var eyes = svg.querySelector('.wml-eyes');
      var pupils = svg.querySelectorAll('.wml-pupil');
      var pawL = svg.querySelector('.wml-paw-l');
      var pawR = svg.querySelector('.wml-paw-r');

      // Fields: real inputs, or mock <span data-field> (data-type="password").
      var fields = [];
      el.querySelectorAll('input, textarea, [data-field]').forEach(function (f) {
        var real = f.tagName === 'INPUT' || f.tagName === 'TEXTAREA';
        if (real && /^(hidden|submit|button|checkbox|radio)$/.test(f.type)) return;
        var item = { el: f, real: real, secret: real ? f.type === 'password' : f.dataset.type === 'password' };
        if (!real) {
          f.innerHTML = '<span class="wml-text"></span><span class="wml-caret"></span>';
          item.text = f.firstChild;
          item.text.setAttribute('data-ph', f.dataset.ph || '');
        }
        fields.push(item);
      });

      // State, drawn every frame while it changes.
      var st = { gx: 0, gy: 0, tx: 0, ty: 0, cover: 0, blink: 1 };
      var raf = 0;
      function draw() {
        var dx = st.gx * 5.5, dy = st.gy * 4;
        pupils.forEach(function (p) { p.setAttribute('transform', 'translate(' + dx.toFixed(2) + ' ' + dy.toFixed(2) + ')'); });
        head.setAttribute('transform', 'rotate(' + (st.gx * 4).toFixed(2) + ' 80 140) translate(0 ' + (st.gy * 1.5).toFixed(2) + ')');
        eyes.setAttribute('transform', 'translate(0 70) scale(1 ' + st.blink.toFixed(3) + ') translate(0 -70)');
        // Paws rise from below the edge, ending over the eyes, tilted in a touch.
        var c = st.cover, lift = (1 - c) * 70;
        pawL.setAttribute('transform', 'translate(' + ((1 - c) * -14 + st.gx * 4 * c).toFixed(2) + ' ' + lift.toFixed(2) + ') rotate(' + ((1 - c) * -25) + ' 60 72)');
        pawR.setAttribute('transform', 'translate(' + ((1 - c) * 14 + st.gx * 4 * c).toFixed(2) + ' ' + lift.toFixed(2) + ') rotate(' + ((1 - c) * 25) + ' 100 72)');
      }
      function tick() {
        raf = 0;
        st.gx += (st.tx - st.gx) * 0.2;
        st.gy += (st.ty - st.gy) * 0.2;
        if (Math.abs(st.tx - st.gx) > 0.002 || Math.abs(st.ty - st.gy) > 0.002) raf = requestAnimationFrame(tick);
        else { st.gx = st.tx; st.gy = st.ty; }
        draw();
      }
      function look(x, y) {
        st.tx = x; st.ty = y;
        if (reduced()) { st.gx = x; st.gy = y; draw(); return; }
        if (!raf) raf = requestAnimationFrame(tick);
      }
      var covered = false;
      function cover(v) {
        if (v === covered) return;
        covered = v;
        if (reduced()) { st.cover = v ? 1 : 0; draw(); return; }
        anime.animate(st, { cover: v ? 1 : 0, duration: v ? 420 : 480, ease: v ? 'out(3)' : 'inOut(2)', onUpdate: draw });
      }
      draw();

      // Where's the caret? Map its x across the bear's width to a gaze of -1..1.
      function follow(item) {
        if (item.secret) { cover(true); look(0, 1); return; }
        cover(false);
        var f = item.el, x;
        if (item.real) {
          var cs = getComputedStyle(f);
          x = parseFloat(cs.paddingLeft) + parseFloat(cs.borderLeftWidth) + textWidth(f.value, f) - f.scrollLeft;
          x = clamp(x, 0, f.clientWidth);
        } else {
          x = clamp(item.text.offsetLeft - f.offsetLeft + item.text.offsetWidth, 0, f.clientWidth);
        }
        var fr = f.getBoundingClientRect(), br = svg.getBoundingClientRect();
        var cx = br.left + br.width / 2;
        look(clamp((fr.left + x - cx) / Math.max(60, fr.width / 2), -1, 1), 1);
      }
      function rest() { cover(false); look(0, 0); }

      // Blink every few seconds while the eyes are open.
      var blinkTimer = 0, visible = false;
      function blinkSoon() {
        clearTimeout(blinkTimer);
        if (!visible || reduced()) return;
        blinkTimer = setTimeout(function () {
          if (!covered) {
            anime.animate(st, { blink: 0.08, duration: 90, ease: 'in(2)', onUpdate: draw, onComplete: function () {
              anime.animate(st, { blink: 1, duration: 140, ease: 'out(2)', onUpdate: draw });
            } });
          }
          blinkSoon();
        }, 2200 + Math.random() * 2800);
      }

      // Real use.
      var current = null;
      fields.forEach(function (item) {
        if (!item.real) return;
        var f = item.el;
        ['focus', 'input', 'keyup', 'click'].forEach(function (ev) {
          f.addEventListener(ev, function (e) {
            if (e.isTrusted) stop();
            current = item;
            follow(item);
          });
        });
        f.addEventListener('blur', function () {
          current = null;
          setTimeout(function () { if (!current) rest(); }, 60);
        });
      });

      // Autoplay: a pretend visitor fills in the form.
      var timer = 0, stopped = false;
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (!auto) return;
        // Clear whatever the pretend visitor typed, so the form is yours.
        fields.forEach(function (it) { it.el.classList.remove('wml-on'); setValue(it, ''); });
        rest();
      }
      function setValue(item, v) {
        if (item.real) item.el.value = v;
        else item.text.textContent = item.secret ? v.replace(/./g, '•') : v;
      }
      function activate(item) {
        fields.forEach(function (it) { it.el.classList.toggle('wml-on', it === item); });
      }
      var userField = fields.filter(function (it) { return !it.secret; })[0];
      var passField = fields.filter(function (it) { return it.secret; })[0];
      var steps = [];
      function step(fn, wait) { steps.push([fn, wait]); }
      function typeInto(item, word) {
        step(function () { activate(item); follow(item); }, 500);
        for (var i = 1; i <= word.length; i++) {
          (function (n) { step(function () { setValue(item, word.slice(0, n)); follow(item); }, 70 + Math.random() * 90); })(i);
        }
        step(function () {}, 700);
      }
      if (userField) typeInto(userField, d.email || DEFAULTS.email);
      if (passField) typeInto(passField, d.password || DEFAULTS.password);
      step(function () { activate(null); rest(); }, 1300);
      step(function () { fields.forEach(function (it) { setValue(it, ''); }); }, 900);
      function run(i) {
        if (stopped) return;
        steps[i][0]();
        timer = setTimeout(function () { run((i + 1) % steps.length); }, steps[i][1]);
      }

      var auto = on(d.autoplay) && !reduced() && steps.length > 3;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        blinkSoon();
        if (!auto || stopped) return;
        clearTimeout(timer);
        if (visible) {
          fields.forEach(function (it) { setValue(it, ''); });
          activate(null);
          rest();
          timer = setTimeout(function () { run(0); }, 600);
        }
      }).observe(el);
      if (auto) {
        el.addEventListener('pointerdown', stop);
        document.addEventListener('focusin', function (e) {
          if (e.target.matches && e.target.matches('input, textarea, select')) stop();
        });
      }
    }

    function init() {
      document.querySelectorAll('[data-watching-mascot-login]').forEach(function (el) {
        if (el._wml) return;
        el._wml = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<form data-watching-mascot-login>
  <div class="card">
    <input type="email" placeholder="Email" aria-label="Email">
    <input type="password" placeholder="Password" aria-label="Password">
    <button type="submit">Log in</button>
  </div>
</form>