← Forms

Inline Validation

As you type, a tick or a warning slides into the end of the field and a message opens underneath. A right answer shows straight away. A wrong one waits until you pause or leave the field, so it doesn’t nag mid-word. Try both fields.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an input in an element with data-inline-validation.
  3. Use the input’s own rules: type="email", minlength, maxlength, pattern or required.

Options

Add any of these attributes to the same element.

data-errorThe message when it’s wrong. Default: one that fits the rule it breaks.
data-successThe message when it’s right. Default Looks good.
data-delayHow long you pause before a warning shows. Default 700 ms.
data-minMinimum length, if the input has no minlength.
data-autoplayIt types half an address, waits for the warning, finishes it and gets the tick, over and over while in view. It stops as soon as you use the field.
data-textWhat autoplay types. Default [email protected].

The message is linked to the input with aria-describedby and read out politely, and the input gets aria-invalid. Listen for the validate event: e.detail.valid is true, false or null when empty.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Inline Validation: as you type, a tick or a warning slides into the end
    // of the field and a message opens underneath. A right answer shows at
    // once; a wrong one waits until you pause (or leave the field), so it
    // doesn't nag mid-word.
    // Usage: <div data-inline-validation><input type="email" placeholder="Email"></div>
    //   It checks what the input asks for: type="email", minlength, maxlength,
    //   pattern and required (required only complains when you leave it empty).
    // Optional: data-error (the message when it's wrong; default depends on
    // the check), data-success (the message when it's right, default "Looks
    // good"), data-delay (pause in ms before a warning shows, default 700),
    // data-min (minimum length, if the input has no minlength), data-autoplay
    // (it types a half-finished value, waits for the warning, finishes it and
    // gets the tick, over and over while in view; it stops as soon as you use
    // the field), data-text (what autoplay types, default "[email protected]").
    // Listen for the "validate" event: e.detail.valid is true, false or null (empty).
    var DEFAULTS = { success: 'Looks good', delay: 700, text: '[email protected]' };

    var OK = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#16a34a"/>' +
      '<path class="ilv-tick" d="M7 12.5l3.3 3.3L17 9" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
    var BAD = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="#dc2626"/>' +
      '<path d="M12 6.8v6.4" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/><circle cx="12" cy="17.2" r="1.5" fill="#fff"/></svg>';

    var css =
      '.ilv{text-align:left}' +
      '.ilv-box{position:relative}' +
      '.ilv .ilv-field{padding-right:2.8em}' +
      '.ilv.ilv-ok .ilv-field{border-color:#16a34a;box-shadow:0 0 0 3px rgba(22,163,74,.12)}' +
      '.ilv.ilv-err .ilv-field{border-color:#dc2626;box-shadow:0 0 0 3px rgba(220,38,38,.12)}' +
      '.ilv-icon{position:absolute;top:50%;right:.85em;width:1.3em;height:1.3em;margin-top:-.65em;pointer-events:none}' +
      '.ilv-icon svg{position:absolute;inset:0;width:100%;height:100%;opacity:0;overflow:visible}' +
      '.ilv-msg{height:0;overflow:hidden}' +
      '.ilv-msg-in{display:block;padding-top:.5em;font-size:.86em;line-height:1.35}' +
      '.ilv-ok .ilv-msg-in{color:#15803d}.ilv-err .ilv-msg-in{color:#dc2626}' +
      '.ilv-text{white-space:pre}' +
      '.ilv-caret{display:inline-block;width:1.5px;height:1.1em;margin-left:1px;vertical-align:-0.2em;background:currentColor;animation:ilv-blink 1s steps(1) infinite}' +
      '@keyframes ilv-blink{50%{opacity:0}}';
    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, textarea');
      var field = input || el.querySelector('[data-field]');
      if (!field) return;
      var delay = d.delay !== undefined ? parseInt(d.delay, 10) : DEFAULTS.delay;
      el.classList.add('ilv');
      field.classList.add('ilv-field');

      var box = document.createElement('div');
      box.className = 'ilv-box';
      field.parentNode.insertBefore(box, field);
      box.appendChild(field);
      var icon = document.createElement('span');
      icon.className = 'ilv-icon';
      icon.setAttribute('aria-hidden', 'true');
      icon.innerHTML = OK + BAD;
      box.appendChild(icon);
      var okIcon = icon.firstChild, badIcon = icon.lastChild;
      var tick = anime.svg.createDrawable(okIcon.querySelector('.ilv-tick'));
      var msg = document.createElement('div');
      msg.className = 'ilv-msg';
      msg.innerHTML = '<span class="ilv-msg-in"></span>';
      var msgIn = msg.firstChild;
      el.appendChild(msg);
      if (input) {
        msgIn.id = 'ilv-' + Math.random().toString(36).slice(2, 9);
        msgIn.setAttribute('aria-live', 'polite');
        input.setAttribute('aria-describedby', ((input.getAttribute('aria-describedby') || '') + ' ' + msgIn.id).trim());
      }

      // The mock field (thumbs) gets typed into by autoplay.
      var text = null, caret = null, value = '';
      if (!input) {
        field.textContent = '';
        text = document.createElement('span');
        text.className = 'ilv-text';
        field.appendChild(text);
      }
      var kind = input ? (input.getAttribute('type') || 'text') : 'email';
      var min = input && input.minLength > 0 ? input.minLength : parseInt(d.min, 10) || 0;
      var max = input && input.maxLength > 0 ? input.maxLength : 0;
      var pattern = input && input.getAttribute('pattern');
      var get = function () { return input ? input.value : value; };
      var set = function (v) {
        if (input) input.value = v;
        else {
          value = v;
          text.textContent = v;
          if (!caret) { caret = document.createElement('span'); caret.className = 'ilv-caret'; field.appendChild(caret); }
        }
        check(false);
      };

      // What's wrong with v, or '' when it's fine.
      function problem(v) {
        if (min && v.length < min) return 'Use at least ' + min + ' characters.';
        if (max && v.length > max) return 'Use ' + max + ' characters or fewer.';
        if (kind === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) return 'Enter an email like [email protected].';
        if (kind === 'url' && !/^https?:\/\/[^\s.]+\.[^\s]{2,}/.test(v)) return 'Enter a web address starting with https://';
        if (pattern) {
          try { if (!new RegExp('^(?:' + pattern + ')$').test(v)) return (input && input.title) || 'Use the format asked for.'; } catch (e) {}
        }
        return '';
      }

      var state = '', timer = null;
      function show(next, message) {
        if (next === state && (msgIn.textContent === message || !next)) return;
        var prev = state;
        state = next;
        el.classList.toggle('ilv-ok', next === 'ok');
        el.classList.toggle('ilv-err', next === 'err');
        if (input) input.setAttribute('aria-invalid', next === 'err' ? 'true' : 'false');
        el.dispatchEvent(new CustomEvent('validate', { bubbles: true, detail: { valid: next ? next === 'ok' : null } }));
        var gone = prev === 'ok' ? okIcon : prev === 'err' ? badIcon : null;
        var come = next === 'ok' ? okIcon : next === 'err' ? badIcon : null;
        var startH = msg.offsetHeight;
        if (next) msgIn.textContent = message;
        var endH = next ? msgIn.offsetHeight : 0;

        if (reduced()) {
          anime.utils.set([okIcon, badIcon], { opacity: 0 });
          if (come) anime.utils.set(come, { opacity: 1, x: 0, scale: 1 });
          if (next === 'ok') anime.utils.set(tick, { draw: '0 1' });
          msg.style.height = next ? 'auto' : '0px';
          return;
        }
        if (gone && gone !== come) {
          anime.utils.remove(gone);
          anime.animate(gone, { opacity: 0, scale: 0.4, y: '0.5em', duration: 200, ease: 'in(2)' });
        }
        if (come && come !== gone) {
          anime.utils.remove(come);
          anime.animate(come, { opacity: [0, 1], x: ['1.2em', 0], y: [0, 0], scale: [0.4, 1], rotate: next === 'err' ? [-25, 0] : [0, 0], duration: 460, ease: 'outBack(2.2)' });
          if (next === 'ok') anime.animate(tick, { draw: ['0 0', '0 1'], duration: 360, delay: 180, ease: 'out(2)' });
          else anime.animate(come, { rotate: [0, -14, 12, -6, 0], duration: 500, delay: 420, ease: 'out(2)' });
        }
        anime.utils.remove([msg, msgIn]);
        anime.animate(msg, {
          height: [startH, endH], duration: next ? 320 : 240, ease: next ? 'out(3)' : 'inOut(2)',
          onComplete: function () { msg.style.height = state ? 'auto' : '0px'; }
        });
        if (next && next !== prev) anime.animate(msgIn, { opacity: [0, 1], y: ['-0.4em', 0], duration: 320, delay: 60, ease: 'out(3)' });
      }

      function check(leaving) {
        clearTimeout(timer);
        var v = get();
        if (!v) {
          if (leaving && input && input.required) show('err', d.error || 'This field is required.');
          else show('', '');
          return;
        }
        var p = problem(v);
        if (!p) { show('ok', d.success || DEFAULTS.success); return; }
        if (state === 'ok') show('', '');
        var say = d.error || p;
        if (leaving || state === 'err') show('err', say);
        else timer = setTimeout(function () { show('err', say); }, delay);
      }

      if (input) {
        input.addEventListener('input', function () { stop(); check(false); });
        input.addEventListener('blur', function () { check(true); });
        input.addEventListener('focus', stop);
        el.addEventListener('pointerdown', stop);
        if (input.value) check(false);
      }

      var auto = null, stopped = false;
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(auto);
        if (input && document.activeElement !== input) { input.value = ''; check(false); }
      }
      if (on(d.autoplay) && !reduced()) {
        var word = d.text || DEFAULTS.text;
        var at = word.indexOf('@');
        var cut = at > 0 ? Math.min(word.length - 1, at + 4) : Math.ceil(word.length * 0.5);
        var steps = [];
        for (var i = 1; i <= word.length; i++) {
          (function (n) { steps.push([function () { set(word.slice(0, n)); }, n === cut ? delay + 1300 : 70 + Math.random() * 90]); })(i);
        }
        steps.push([function () {}, 1800]);
        for (var j = word.length - 1; j >= 0; j--) {
          (function (n) { steps.push([function () { set(word.slice(0, n)); }, 28]); })(j);
        }
        steps.push([function () {}, 900]);
        var run = function (k) {
          if (stopped) return;
          steps[k][0]();
          auto = setTimeout(function () { run((k + 1) % steps.length); }, steps[k][1]);
        };
        new IntersectionObserver(function (entries) {
          clearTimeout(auto);
          if (stopped) return;
          if (entries[0].isIntersecting) { set(''); auto = setTimeout(function () { run(0); }, 600); }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-inline-validation]').forEach(function (el) {
        if (el._ilv) return;
        el._ilv = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-inline-validation>
  <input type="email" placeholder="Email address">
</div>

<div data-inline-validation data-error="Use 4 or more characters.">
  <input type="text" placeholder="Username" minlength="4">
</div>