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
- Paste the snippet just before
</head>. - Add
data-ghost-text-autocompleteto a paragraph. Put the suggested parts inside[[ ]]; everything else is typed.
Options
Add any of these attributes to the same element.
data-key | The key shown beside a suggestion. Default Tab; none hides it. |
data-speed | Typing time per character. Default 55 ms. |
data-think | Pause before a suggestion appears. Default 450 ms. |
data-wait | How long a suggestion waits before it is accepted. Default 1100 ms. |
data-loop | Fade out and play again after a pause. |
data-hold | How long the finished text stays before looping. Default 2600 ms. |
--gta-flash, --gta-caret | CSS 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.
<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><p data-ghost-text-autocomplete>
Thanks for the notes! [[I'll send a new draft tonight.]]
</p>