Prompt Box Glow
A gradient glow circles the prompt box. Click into it and a thin ring of colour turns slowly round the border with a soft halo; send a prompt and it spins faster and brighter while the answer is worked out, then fades away.
How to use it
- Paste the snippet just before
</head>. - Add
data-prompt-box-glowto the box that wraps your input or textarea. Give it a border radius; the glow follows it. - Add
data-workingwhile your answer is on its way and remove it when it arrives. Pressing Enter or clicking a button in the box also shows the working glow for a moment.
Options
Add any of these attributes to the same element.
data-colors | Comma-separated colours. Default #8b5cf6,#ec4899,#f59e0b,#22d3ee. |
data-width | Thickness of the ring. Default 2 px. |
data-working | Spin fast and glow bright, as if working. Add or remove it any time. |
data-work | How long the working glow lasts after Enter or a button click. Default 2800 ms. |
data-autoplay | Type a pretend prompt, send it and clear it, over and over, until someone really clicks into the box. |
data-prompts | Prompts for the autoplay to type, separated by |. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Prompt Box Glow: a gradient glow circles a prompt box. When the box has
// focus a thin ring of colour turns slowly round its border with a soft halo;
// while it's working (after sending) the ring spins faster and the halo
// brightens, then it all fades away. Follows the box's rounded corners.
// Usage: <div data-prompt-box-glow><textarea placeholder="Ask anything"></textarea></div>
// Optional: data-colors (comma-separated, default "#8b5cf6,#ec4899,#f59e0b,#22d3ee"),
// data-width (ring thickness in px, default 2), data-working (glow as if
// working; add or remove the attribute any time), data-autoplay (types a
// pretend prompt, sends it, works for a moment and clears it, over and over;
// stops once someone really uses the box), data-prompts (prompts to type,
// separated by |), data-work (ms the pretend work lasts, default 2800).
// Pressing Enter in the box, or clicking a button inside it, shows the
// working glow for data-work ms.
var DEFAULTS = {
colors: '#8b5cf6,#ec4899,#f59e0b,#22d3ee', width: 2, work: 2800,
prompts: 'Plan a weekend in Lisbon|Explain black holes simply|Write a haiku about coffee'
};
var css =
'@property --pbg-a{syntax:"<angle>";inherits:true;initial-value:0deg}' +
'[data-prompt-box-glow]{position:relative;transition:border-color .4s}' +
'.pbg-fx{position:absolute;inset:0;border-radius:inherit;pointer-events:none;opacity:0;' +
'transition:opacity .5s ease;animation:pbg-spin 4s linear infinite}' +
'.pbg-focus>.pbg-fx{opacity:1}' +
'.pbg-work>.pbg-fx{opacity:1;animation-duration:1.3s}' +
'.pbg-ring,.pbg-halo{position:absolute;border-radius:inherit;' +
'background:conic-gradient(from var(--pbg-a),var(--pbg-stops));' +
'-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;' +
'mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)}' +
'.pbg-ring{inset:calc(var(--pbg-w) * -1);padding:var(--pbg-w);border-radius:inherit}' +
// The halo is blurred, then masked so it only glows outside the box.
'.pbg-blur{position:absolute;inset:-32px;padding:32px;filter:blur(12px);opacity:.5;transition:opacity .5s,filter .5s;' +
'-webkit-mask:var(--pbg-hole) center/100% 100% no-repeat content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;' +
'mask:var(--pbg-hole) center/100% 100% no-repeat content-box exclude,linear-gradient(#000 0 0)}' +
'.pbg-work .pbg-blur{opacity:.85;filter:blur(16px)}' +
'.pbg-halo{inset:24px;padding:8px;border-radius:24px}' +
'@keyframes pbg-spin{to{--pbg-a:360deg}}' +
'@media (prefers-reduced-motion: reduce){.pbg-fx{animation:none}}';
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) {
var d = el.dataset;
var cols = (d.colors || DEFAULTS.colors).split(',').map(function (c) { return c.trim(); });
var fx = document.createElement('span');
fx.className = 'pbg-fx';
fx.setAttribute('aria-hidden', 'true');
fx.style.setProperty('--pbg-stops', cols.concat(cols[0]).join(','));
fx.style.setProperty('--pbg-w', (+d.width || DEFAULTS.width) + 'px');
// A rounded hole the shape of the box, so the halo never shows inside it.
var r = parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0;
fx.style.setProperty('--pbg-hole', 'url("data:image/svg+xml,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" rx="' + r + '"/></svg>') + '")');
fx.innerHTML = '<span class="pbg-blur"><span class="pbg-halo"></span></span><span class="pbg-ring"></span>';
el.insertBefore(fx, el.firstChild);
var input = el.querySelector('textarea, input, [contenteditable], [data-pbg-text]');
var st = { input: input, timers: [], real: false, workTimer: null };
el._pbg = st;
var work = +d.work || DEFAULTS.work;
function sync() {
el.classList.toggle('pbg-focus', el.contains(document.activeElement) || st.fakeFocus);
el.classList.toggle('pbg-work', d.working !== undefined || st.fakeWork);
}
st.sync = sync;
st.send = function () {
st.fakeWork = true;
sync();
clearTimeout(st.workTimer);
st.workTimer = setTimeout(function () {
st.fakeWork = false;
if (st.input && 'value' in st.input && st.real) st.input.value = '';
sync();
}, work);
};
function takeOver() {
if (st.real) return;
st.real = true;
st.timers.forEach(clearTimeout);
st.fakeFocus = st.fakeWork = false;
if (st.input) {
if ('value' in st.input) st.input.value = '';
else if (st.input.hasAttribute('data-pbg-text')) st.input.textContent = '';
}
sync();
}
el.addEventListener('focusin', function () { takeOver(); sync(); });
el.addEventListener('focusout', function () { setTimeout(sync, 0); });
el.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey && st.input && 'value' in st.input) {
e.preventDefault();
if (st.input.value.trim()) st.send();
}
});
el.addEventListener('click', function (e) {
var b = e.target.closest('button');
if (b && el.contains(b)) { takeOver(); st.send(); }
});
new MutationObserver(sync).observe(el, { attributes: true, attributeFilter: ['data-working'] });
sync();
}
// The pretend user: focus, type a prompt, send it, wait, clear, repeat.
function autoplay(el) {
var st = el._pbg, d = el.dataset;
var prompts = (d.prompts || DEFAULTS.prompts).split('|');
var work = +d.work || DEFAULTS.work;
var k = 0;
function write(text) {
if (!st.input) return;
if ('value' in st.input) st.input.value = text;
else st.input.textContent = text;
}
function later(fn, ms) { st.timers.push(setTimeout(function () { if (!st.real) fn(); }, ms)); }
function round() {
st.timers = [];
var text = prompts[k++ % prompts.length], i = 0;
st.fakeFocus = true;
st.sync();
function type() {
write(text.slice(0, ++i));
if (i < text.length) later(type, 45 + Math.random() * 70);
else later(function () {
st.fakeFocus = false;
st.send();
later(function () { write(''); }, 250);
later(round, work + 1600);
}, 600);
}
later(type, 700);
}
round();
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
autoplay(e.target);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-prompt-box-glow]').forEach(function (el) {
if (el._pbg) return;
setup(el);
if (el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false' && !reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-prompt-box-glow style="border-radius: 20px">
<textarea placeholder="Ask anything"></textarea>
<button type="button">Send</button>
</div>