← Typing

Ghost Text Autocomplete

Text types in, pauses, and a faded suggestion appears after the cursor with a little Tab key beside it. The key presses, the suggestion turns solid with a soft flash, and typing carries on, just like an AI writing assistant.

Reply to Sam

Thanks for sending this over! [[I’ll take a proper look this afternoon]] and get back to you [[by the end of the day.]]

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ghost-text-autocomplete to a paragraph. Put the suggested parts inside [[ ]]; everything else is typed.

Options

Add any of these attributes to the same element.

data-keyThe key shown beside a suggestion. Default Tab; none hides it.
data-speedTyping time per character. Default 55 ms.
data-thinkPause before a suggestion appears. Default 450 ms.
data-waitHow long a suggestion waits before it is accepted. Default 1100 ms.
data-loopFade out and play again after a pause.
data-holdHow long the finished text stays before looping. Default 2600 ms.
--gta-flash, --gta-caretCSS variables for the accept flash and the caret colour.

The paragraph keeps its finished height from the start, so the text below never jumps as it types.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Ghost Text Autocomplete: text types in, then a faded suggestion appears
    // after the cursor with a "Tab" hint. A beat later the suggestion is
    // accepted: it turns solid with a soft flash and typing carries on.
    // Usage:
    //   <p data-ghost-text-autocomplete>Thanks for the update! [[I'll take a look this afternoon.]]</p>
    // Text inside [[ ]] is the suggestion; everything else is typed. Use several
    // [[ ]] parts for several suggestions.
    // Optional: data-key (the hint's key name, default "Tab"; "none" hides it),
    // data-speed (ms per typed character, default 55), data-think (ms before a
    // suggestion appears, default 450), data-wait (ms a suggestion waits before
    // it is accepted, default 1100), data-loop (clear and replay), data-hold (ms
    // the finished text stays before looping, default 2600).
    var DEFAULTS = { key: 'Tab', speed: 55, think: 450, wait: 1100, hold: 2600 };

    var css =
      '.gta-ghost{opacity:0;color:inherit;border-radius:3px;transition:opacity .3s}' +
      '.gta-ghost.gta-show{opacity:.38}' +
      '.gta-ghost.gta-taken{opacity:1;animation:gta-flash 1s ease-out}' +
      '@keyframes gta-flash{0%{background:var(--gta-flash,rgba(168,85,247,.32))}100%{background:transparent}}' +
      '.gta-caret{display:inline-block;width:2px;height:1.1em;margin:0 -1px;vertical-align:-0.16em;background:var(--gta-caret,var(--accent,#6a0dad))}' +
      '.gta-caret.gta-blink{animation:gta-blink 1.06s steps(1) infinite}@keyframes gta-blink{50%{opacity:0}}' +
      '.gta-key{display:inline-block;margin-left:0.4em;padding:0.1em 0.45em;font-size:0.62em;font-weight:600;line-height:1.4;vertical-align:0.18em;' +
      'border-radius:5px;border:1px solid currentColor;border-bottom-width:2px;opacity:0;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }

    function setup(el) {
      var raw = el.textContent.replace(/\s+/g, ' ').trim();
      var parts = [];
      raw.split(/(\[\[[\s\S]*?\]\])/).forEach(function (p) {
        if (!p) return;
        var ghost = /^\[\[/.test(p);
        parts.push({ ghost: ghost, text: ghost ? p.slice(2, -2) : p });
      });
      el.setAttribute('aria-label', parts.map(function (p) { return p.text; }).join(''));
      el.textContent = '';
      parts.forEach(function (p) {
        p.span = document.createElement('span');
        p.span.setAttribute('aria-hidden', 'true');
        if (p.ghost) p.span.className = 'gta-ghost';
        el.appendChild(p.span);
      });
      var caret = document.createElement('span');
      caret.className = 'gta-caret';
      caret.setAttribute('aria-hidden', 'true');
      var key = document.createElement('span');
      key.className = 'gta-key';
      key.setAttribute('aria-hidden', 'true');
      key.textContent = el.dataset.key || DEFAULTS.key;
      return { el: el, parts: parts, caret: caret, key: key, visible: false, wake: null };
    }

    function finished(s) {
      s.parts.forEach(function (p) {
        p.span.textContent = p.text;
        if (p.ghost) p.span.className = 'gta-ghost gta-show';
        if (p.ghost) p.span.style.opacity = '1';
      });
      s.el.appendChild(s.caret);
    }

    function whenVisible(s) {
      return s.visible ? Promise.resolve() : new Promise(function (r) { s.wake = r; });
    }

    function typeInto(s, p, speed) {
      return new Promise(function (done) {
        var n = 0;
        s.caret.classList.remove('gta-blink');
        (function tick() {
          p.span.textContent = p.text.slice(0, n);
          if (n++ >= p.text.length) { done(); return; }
          var ch = p.text.charAt(n - 1);
          setTimeout(tick, speed * (0.55 + Math.random() * 0.9) + (/[,.!?]/.test(ch) ? speed * 4 : 0));
        })();
      });
    }

    function suggest(s, p, d) {
      var showKey = (d.key || '') !== 'none';
      s.caret.classList.add('gta-blink');
      // The caret sits before the suggestion, the key hint after it.
      s.el.insertBefore(s.caret, p.span);
      return sleep(+d.think || DEFAULTS.think).then(function () {
        p.span.textContent = p.text;
        p.span.className = 'gta-ghost';
        void p.span.offsetWidth;
        p.span.classList.add('gta-show');
        if (showKey) {
          p.span.after(s.key);
          anime.animate(s.key, { opacity: [0, 0.55], scale: [0.7, 1], duration: 380, delay: 200, ease: 'outBack(2)' });
        }
        return sleep(+d.wait || DEFAULTS.wait);
      }).then(function () {
        if (!showKey) return;
        // Press the key.
        return new Promise(function (r) {
          anime.animate(s.key, { y: [0, 2, 0], scale: [1, 0.88, 1], duration: 260, ease: 'inOut(2)', onComplete: r });
        });
      }).then(function () {
        if (s.key.parentNode) s.key.remove();
        p.span.classList.add('gta-taken');
        p.span.after(s.caret);
        s.caret.classList.add('gta-blink');
        return sleep(500);
      });
    }

    function play(s) {
      var d = s.el.dataset;
      var speed = +d.speed || DEFAULTS.speed;
      s.parts.forEach(function (p) {
        p.span.textContent = '';
        p.span.style.opacity = '';
        if (p.ghost) p.span.className = 'gta-ghost';
      });
      s.el.appendChild(s.caret);
      s.caret.classList.add('gta-blink');
      var chain = sleep(500);
      s.parts.forEach(function (p) {
        chain = chain.then(function () {
          if (p.ghost) return suggest(s, p, d);
          s.el.insertBefore(s.caret, p.span.nextSibling);
          return typeInto(s, p, speed);
        });
      });
      chain.then(function () {
        s.caret.classList.add('gta-blink');
        if (d.loop === undefined || d.loop === 'false') return;
        sleep(+d.hold || DEFAULTS.hold).then(function () { return whenVisible(s); }).then(function () {
          return new Promise(function (r) {
            anime.animate(s.parts.map(function (p) { return p.span; }), { opacity: 0, duration: 350, ease: 'in(2)', onComplete: r });
          });
        }).then(function () { play(s); });
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._gta;
          s.visible = e.isIntersecting;
          if (s.visible && s.wake) { var w = s.wake; s.wake = null; w(); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-ghost-text-autocomplete]').forEach(function (el) {
        if (el._gta) return;
        var s = (el._gta = setup(el));
        finished(s);
        if (reduced()) { s.caret.remove(); return; }
        // Keep the finished text's height (in em) so nothing jumps as it types.
        var fs = parseFloat(getComputedStyle(el).fontSize) || 16;
        if (el.offsetHeight) el.style.minHeight = (el.offsetHeight / fs) + 'em';
        io.observe(el);
        whenVisible(s).then(function () { play(s); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<p data-ghost-text-autocomplete>
  Thanks for the notes! [[I'll send a new draft tonight.]]
</p>