Prompt Expand
A short prompt grows into a detailed one. New words open up between the old ones, pushing the text apart as they slide up into place with a soft highlight that fades once they’ve settled, the way an “enhance prompt” button rewrites a quick idea into something richer.
A fluffy ginger cat curled up asleep on a sunny windowsill, soft morning light through lace curtains, a steaming mug beside it, shallow depth of field, warm film photo
How to use it
- Paste the snippet just before
</head>. - Add
data-prompt-expandto an element holding the full, detailed prompt. - Wrap every added part in
<ins>. It starts as just the words outside them.
Options
Add any of these attributes to the same element.
data-delay | How long the short prompt shows first. Default 1400 ms. |
data-stagger | Gap between new words. Default 70 ms. |
data-color | Colour of the highlight on new words. Default #7c3aed. |
data-loop | Shrink back and expand again. |
data-hold | How long the full prompt shows before looping. Default 2800 ms. |
While it expands the element has the class prx-busy, and prx-done after, so you can style a button next to it. Call animacious.promptExpand(el, true) to expand it from your own button, and false to shrink it back.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Prompt Expand: a short prompt grows into a detailed one. New words slide
// in between the old ones, pushing the text apart as they open up, with a
// soft highlight that fades once they've settled, like an "enhance prompt"
// button at work.
// Usage: <p data-prompt-expand>A <ins>fluffy ginger</ins> cat <ins>curled up</ins> on a windowsill</p>
// Write the full, detailed prompt and wrap every added part in <ins>; it
// starts as just the words outside the <ins> tags.
// Optional: data-delay (ms the short prompt shows first, default 1400),
// data-stagger (ms between new words, default 70), data-color (highlight
// colour, default #7c3aed), data-loop (shrink back and expand again),
// data-hold (ms the full prompt shows before looping, default 2800).
// While it expands the element has the class prx-busy, and prx-done after.
// Script use: animacious.promptExpand(el, true) expands it, false shrinks it.
var DEFAULTS = { delay: 1400, stagger: 70, color: '#7c3aed', hold: 2800 };
var css =
'.prx ins{text-decoration:none;color:inherit;background:none}' +
'.prx-w{display:inline-block;vertical-align:bottom;overflow:hidden;white-space:pre;max-width:0;opacity:0;border-radius:.25em;' +
'background:color-mix(in srgb,var(--prx-c) 0%,transparent)}' +
'.prx-w>span{display:inline-block}' +
'.prx-fresh{background:color-mix(in srgb,var(--prx-c) 16%,transparent);color:color-mix(in srgb,var(--prx-c) 75%,currentColor)}' +
'.prx-w.prx-settle{transition:background-color 1.2s ease,color 1.2s ease}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
el.classList.add('prx');
el.style.setProperty('--prx-c', el.dataset.color || DEFAULTS.color);
var words = [];
// Each added word (with the space in front of it) becomes its own clipped
// box, so it can open from nothing and push the text along.
[].slice.call(el.querySelectorAll('ins')).forEach(function (ins) {
var text = ins.textContent;
ins.textContent = '';
// Keep the space between the old words inside the new part, so the short
// prompt doesn't show a double space where the new words will go.
var prev = ins.previousSibling, next = ins.nextSibling;
if (prev && prev.nodeType === 3 && /\s$/.test(prev.textContent) && !/^\s/.test(text)) {
prev.textContent = prev.textContent.replace(/\s+$/, '');
text = ' ' + text;
} else if ((!prev || (prev.nodeType === 3 && !prev.textContent.trim() && !prev.previousSibling)) &&
next && next.nodeType === 3 && /^\s/.test(next.textContent) && !/\s$/.test(text)) {
next.textContent = next.textContent.replace(/^\s+/, '');
text = text + ' ';
}
(text.match(/\s*\S+|\s+/g) || []).forEach(function (chunk) {
var w = document.createElement('span');
w.className = 'prx-w';
var inner = document.createElement('span');
inner.textContent = chunk;
w.appendChild(inner);
ins.appendChild(w);
words.push(w);
});
});
el._prx = { words: words, open: false, run: 0, timers: [], anims: [] };
}
function stop(st) {
st.run++;
st.timers.forEach(clearTimeout);
st.anims.forEach(function (a) { a.pause(); });
st.timers = [];
st.anims = [];
}
// Once open, the words go back to being plain inline text so they wrap normally.
function inline(w) {
w.style.maxWidth = 'none';
w.style.whiteSpace = 'normal';
w.style.display = 'inline';
w.firstChild.style.display = 'inline';
}
function full(el) {
el._prx.words.forEach(function (w) { inline(w); w.style.opacity = 1; });
el._prx.open = true;
el.classList.add('prx-done');
}
function expand(el, onDone) {
var st = el._prx, d = el.dataset;
var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
stop(st);
var run = st.run;
el.classList.add('prx-busy');
el.classList.remove('prx-done');
st.words.forEach(function (w, i) {
// Measure the word's natural width, then open its box up to it.
var inner = w.firstChild;
w.classList.remove('prx-settle');
w.classList.add('prx-fresh');
var target = inner.getBoundingClientRect().width + 1;
var delay = i * stagger;
st.anims.push(anime.animate(w, { maxWidth: [0, target], opacity: [0, 1], duration: 520, delay: delay, ease: 'out(3)' }));
st.anims.push(anime.animate(inner, { y: ['0.7em', 0], filter: ['blur(4px)', 'blur(0px)'], duration: 560, delay: delay + 60, ease: 'out(3)' }));
st.timers.push(setTimeout(function () {
if (run !== st.run) return;
// Settled: drop the highlight and let the word wrap like normal text.
w.classList.add('prx-settle');
w.classList.remove('prx-fresh');
}, delay + 1100));
});
var total = (st.words.length - 1) * stagger + 1100;
st.timers.push(setTimeout(function () {
if (run !== st.run) return;
st.words.forEach(inline);
st.open = true;
el.classList.remove('prx-busy');
el.classList.add('prx-done');
if (onDone) onDone();
}, total + 300));
}
function shrink(el, onDone) {
var st = el._prx;
stop(st);
var run = st.run;
el.classList.remove('prx-done', 'prx-busy');
var n = st.words.length;
st.words.forEach(function (w, i) {
w.classList.remove('prx-fresh', 'prx-settle');
w.style.display = '';
w.style.whiteSpace = '';
w.firstChild.style.display = '';
var width = w.firstChild.getBoundingClientRect().width + 1;
w.style.maxWidth = width + 'px';
st.anims.push(anime.animate(w, { maxWidth: [width, 0], opacity: [1, 0], duration: 420, delay: (n - 1 - i) * 18, ease: 'inOut(2)' }));
});
st.timers.push(setTimeout(function () {
if (run !== st.run) return;
st.open = false;
if (onDone) onDone();
}, (n - 1) * 18 + 500));
}
function cycle(el) {
var st = el._prx, d = el.dataset;
var delay = d.delay !== undefined ? +d.delay : DEFAULTS.delay;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
function wait(fn, ms) { var r = st.run; st.timers.push(setTimeout(function () { if (r === st.run) fn(); }, ms)); }
wait(function () {
expand(el, function () {
if (d.loop !== undefined) wait(function () { shrink(el, function () { cycle(el); }); }, hold);
});
}, delay);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
cycle(e.target);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-prompt-expand]').forEach(function (el) {
if (el._prx) return;
setup(el);
if (reduced()) full(el);
else io.observe(el);
});
}
window.animacious = window.animacious || {};
window.animacious.promptExpand = function (el, open) {
if (!el || !el._prx) return;
if (open === undefined) open = !el._prx.open;
if (reduced()) { if (open) full(el); return; }
if (open) expand(el); else shrink(el);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><p data-prompt-expand>
A <ins>fluffy ginger</ins> cat <ins>curled up</ins> on a windowsill<ins>,
soft morning light, film photo</ins>
</p>