← Forms

Vanishing Input

Press Enter (or the round send button) and the text you typed breaks into tiny particles that blow away from right to left, drifting up and fading as they go, leaving the field clear for the next message.

Type something and press Enter.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-vanishing-input to a <form> (or any element) holding a text input.
  3. A round send button is added unless there’s already a button inside. Listen for the vanish event to send the text yourself.

Options

Add any of these attributes to the same element.

data-durationHow long the text takes to blow away. Default 1100 ms.
data-autoplayIt types a message and sends it, over and over while in view. It stops as soon as you use the field.
data-wordsWhat autoplay types, separated by |.

The form doesn’t submit itself: e.detail.value in the vanish event is the text that was sent. The particles are drawn on a canvas over the field, in the field’s own font and colour.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Vanishing Input: send what you typed and the text breaks into tiny
    // particles that blow away, right to left, clearing the field for the
    // next message.
    // Usage: <form data-vanishing-input>
    //          <input type="text" placeholder="Ask anything" aria-label="Message">
    //        </form>
    //   A round send button is added unless the element already holds a
    //   button. Enter or the button sends; the form doesn't submit itself.
    // Optional: data-duration (ms, default 1100), data-autoplay (it types a
    // message and sends it, over and over while in view; it stops as soon as
    // you use the field), data-words (what autoplay types, separated by "|").
    // Listen for the "vanish" event: e.detail.value is the text that was sent.
    var DEFAULTS = { duration: 1100, words: 'What’s the weather like?|Book a table for two|Play something upbeat|Remind me at 6 pm' };

    var ARROW = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M5.5 11.5 12 5l6.5 6.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';

    var css =
      '.vni{position:relative}' +
      '.vni-canvas{position:absolute;left:0;top:0;pointer-events:none}' +
      '.vni-send{position:absolute;top:50%;right:.4em;display:grid;place-items:center;width:2.3em;height:2.3em;margin:-1.15em 0 0;padding:0;border:0;border-radius:50%;' +
      'background:var(--vni-color,#18181b);color:#fff;cursor:pointer;transition:background-color .25s,opacity .25s,transform .2s;-webkit-tap-highlight-color:transparent}' +
      '.vni-send svg{width:1.15em;height:1.15em}' +
      '.vni-empty .vni-send{opacity:.25;cursor:default}' +
      '.vni-send:focus-visible{outline:2px solid var(--vni-color,#18181b);outline-offset:2px}' +
      '.vni-busy input::placeholder{color:transparent}' +
      '.vni-busy [data-field] .vni-text:empty::before{opacity:0}' +
      '.vni [data-field] .vni-text{white-space:pre}' +
      '.vni [data-field] .vni-text:empty::before{content:attr(data-ph);color:#8a8a93;transition:opacity .3s}' +
      '.vni-caret{display:inline-block;width:1.5px;height:1.1em;margin-left:1px;vertical-align:middle;background:currentColor;animation:vni-blink 1s steps(1) infinite}' +
      '.vni-busy .vni-caret{visibility:hidden}' +
      '@keyframes vni-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 mock = !!el.closest('[aria-hidden="true"]');
      var input = el.querySelector('input, textarea');
      var field = input || el.querySelector('[data-field]');
      if (!field) return;
      var dur = parseFloat(d.duration) || DEFAULTS.duration;
      el.classList.add('vni');

      var text = null;
      if (!input) {
        field.innerHTML = '<span class="vni-text"></span><span class="vni-caret"></span>';
        text = field.firstChild;
        text.setAttribute('data-ph', field.dataset.ph || '');
      }
      var btn = el.querySelector('button, [data-send]');
      if (!btn) {
        btn = document.createElement(mock || !input ? 'span' : 'button');
        if (btn.tagName === 'BUTTON') { btn.type = 'submit'; btn.setAttribute('aria-label', 'Send'); }
        btn.className = 'vni-send';
        btn.innerHTML = ARROW;
        el.appendChild(btn);
      }

      var canvas = document.createElement('canvas');
      canvas.className = 'vni-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');

      function value() { return input ? input.value : text.textContent; }
      function setValue(v) {
        if (input) input.value = v; else text.textContent = v;
        el.classList.toggle('vni-empty', !v.trim());
      }
      setValue(value());

      // Particles, in CSS pixels relative to the field's box.
      var ghost = document.createElement('canvas');
      var parts = [], raf = 0, start = 0, last = 0, minX = 0, maxX = 0, W = 0, H = 0, dpr = 1, color = '#000';

      // The canvas lies exactly over the field.
      function size() {
        W = field.offsetWidth; H = field.offsetHeight;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.style.left = field.offsetLeft + 'px';
        canvas.style.top = field.offsetTop + 'px';
        canvas.style.width = W + 'px';
        canvas.style.height = H + 'px';
        canvas.width = Math.round(W * dpr);
        canvas.height = Math.round(H * dpr);
      }
      size();
      if (window.ResizeObserver) new ResizeObserver(function () { if (!el.classList.contains('vni-busy')) size(); }).observe(el);

      function shatter() {
        var v = value();
        var cs = getComputedStyle(field);
        size();
        color = cs.color;

        // Draw the text where it sat, then read its pixels back.
        var x0;
        if (input) x0 = parseFloat(cs.borderLeftWidth) + parseFloat(cs.paddingLeft) - input.scrollLeft;
        else x0 = text.offsetLeft - field.offsetLeft;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, W, H);
        ctx.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
        ctx.textBaseline = 'middle';
        ctx.fillStyle = '#000';
        ctx.fillText(v, x0, H / 2 + 0.5);
        var img = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
        // Keep a crisp copy of the text for the part the sweep hasn't reached.
        ghost.width = canvas.width; ghost.height = canvas.height;
        var g = ghost.getContext('2d');
        g.setTransform(dpr, 0, 0, dpr, 0, 0);
        g.font = ctx.font;
        g.textBaseline = 'middle';
        g.fillStyle = cs.color;
        g.fillText(v, x0, H / 2 + 0.5);
        ctx.clearRect(0, 0, W, H);
        parts = [];
        var step = Math.max(1, Math.round(1.5 * dpr));
        minX = Infinity; maxX = -Infinity;
        for (var y = 0; y < canvas.height; y += step) {
          for (var x = 0; x < canvas.width; x += step) {
            var a = img[(y * canvas.width + x) * 4 + 3];
            if (a < 90) continue;
            var px = x / dpr, py = y / dpr;
            parts.push({ x: px, y: py, a: a / 255, r: step / dpr, vx: 0, vy: 0, t: -1 });
            if (px < minX) minX = px;
            if (px > maxX) maxX = px;
          }
        }
        setValue('');
        if (!parts.length || reduced()) { parts = []; return; }
        el.classList.add('vni-busy');
        start = 0;
        cancelAnimationFrame(raf);
        raf = requestAnimationFrame(frame);
      }

      // A sweep runs right to left; each particle it passes lifts off and fades.
      function frame(now) {
        if (!start) start = last = now;
        var f = Math.min(3, (now - last) / 16.7 || 1);
        last = now;
        var t = now - start, sweepDur = dur * 0.55, life = dur * 0.45;
        var sweepX = maxX - (maxX - minX + 1) * Math.min(1, t / sweepDur);
        ctx.clearRect(0, 0, W, H);
        var sx = Math.max(0, sweepX), sweeping = t < sweepDur;
        if (sweeping && sx > 0) ctx.drawImage(ghost, 0, 0, sx * dpr, ghost.height, 0, 0, sx, H);
        ctx.fillStyle = color;
        var alive = sweeping;
        for (var i = 0; i < parts.length; i++) {
          var p = parts[i];
          if (p.t < 0 && p.x >= sweepX) {
            p.t = t;
            p.vx = 0.4 + Math.random() * 1.6;
            p.vy = (Math.random() - 0.6) * 1.4;
          }
          if (p.t < 0) continue;
          var k = (t - p.t) / life;
          if (k >= 1) continue;
          p.x += p.vx * f; p.y += p.vy * f;
          p.vy -= 0.02 * f;
          alive = true;
          ctx.globalAlpha = p.a * (1 - k);
          var s = p.r * (1 - k * 0.6);
          ctx.fillRect(p.x, p.y, s, s);
        }
        ctx.globalAlpha = 1;
        if (alive && t < dur * 2) raf = requestAnimationFrame(frame);
        else { ctx.clearRect(0, 0, W, H); el.classList.remove('vni-busy'); }
      }

      function send() {
        var v = value();
        if (!v.trim()) return;
        if (!reduced()) anime.animate(btn, { scale: [0.82, 1], duration: 420, ease: 'outElastic(1, .6)' });
        shatter();
        el.dispatchEvent(new CustomEvent('vanish', { detail: { value: v }, bubbles: true }));
      }

      if (input) {
        input.addEventListener('input', function (e) { if (e.isTrusted) stop(); el.classList.toggle('vni-empty', !input.value.trim()); });
        input.addEventListener('keydown', function (e) {
          if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); stop(); send(); }
        });
        input.addEventListener('pointerdown', stop);
        input.addEventListener('focus', stop);
      }
      if (el.tagName === 'FORM') el.addEventListener('submit', function (e) { e.preventDefault(); stop(); send(); });
      else if (!mock) btn.addEventListener('click', function (e) { e.preventDefault(); stop(); send(); });

      // Autoplay: type a message, send it, repeat.
      var timer = 0, stopped = false, auto = on(d.autoplay) && !reduced();
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (auto && input && typedByUs && input.value === typedByUs) setValue('');
      }
      var words = (d.words || DEFAULTS.words).split('|'), w = 0, typedByUs = '';
      function typeNext() {
        var word = words[w++ % words.length], n = 0;
        (function type() {
          if (stopped) return;
          n++;
          typedByUs = word.slice(0, n);
          setValue(typedByUs);
          if (n < word.length) timer = setTimeout(type, 45 + Math.random() * 75);
          else timer = setTimeout(function () {
            if (stopped) return;
            typedByUs = '';
            send();
            timer = setTimeout(typeNext, dur + 900);
          }, 900);
        })();
      }
      if (auto) {
        new IntersectionObserver(function (entries) {
          clearTimeout(timer);
          if (stopped) return;
          if (entries[0].isIntersecting) { setValue(''); timer = setTimeout(typeNext, 700); }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-vanishing-input]').forEach(function (el) {
        if (el._vni) return;
        el._vni = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<form data-vanishing-input>
  <input type="text" placeholder="Ask anything" aria-label="Message">
</form>

<script>
  document.querySelector('[data-vanishing-input]').addEventListener('vanish', function (e) {
    console.log('Sent:', e.detail.value);
  });
</script>