Feedback Tab
A slim Feedback tab waits on the edge of the screen. Click it and a panel slides out with a quick five-face rating: the face you pick pops, Send turns into a thank-you tick, and the panel tucks itself away again.
How to use it
- Paste the snippet just before
</head>. - Add an empty
<div data-feedback-tab></div>anywhere in the page. The script builds the tab and the form. - Listen for its
feedbackevent and sende.detail.rating(1–5) ande.detail.commentwherever you keep them.
Options
Add any of these attributes to the same element.
data-question | The question. Default “How was this page?” |
data-thanks | The thank-you line. Default “Thanks for the feedback!” |
data-label | The tab’s text. Default “Feedback”. |
data-comment | Adds an optional comment box. |
data-side | right or left. Default right. |
data-width | Width of the panel. Default 260 px. |
data-color | Colour of the tab, button and tick. Default #6a0dad. |
data-contained | Sit on the edge of its parent instead of the window. Give the parent position: relative and overflow: hidden. |
data-autoplay | Opens, rates and sends by itself while in view, until someone touches it. Handy for demos. |
The faces are real buttons with labels like “4 of 5: Good” and aria-pressed, the tab keeps aria-expanded up to date, and Esc closes the panel. From code: animacious.feedbackTab(el) opens it.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Feedback Tab: a slim "Feedback" tab waits on the edge of the screen.
// Click it and a panel slides out with a quick five-face rating; the face
// you pick pops, Send turns into a thank-you tick, and it tucks itself away.
// Usage: <div data-feedback-tab></div> (the script builds the tab and form)
// Optional: data-question (default "How was this page?"), data-thanks
// (default "Thanks for the feedback!"), data-label (the tab text, default
// "Feedback"), data-comment (adds a box for a comment), data-side ("right",
// the default, or "left"), data-width (panel px, default 260), data-color
// (default #6a0dad), data-contained (sits on the edge of its parent instead
// of the window; give the parent position: relative and overflow: hidden),
// data-autoplay (it opens, rates and sends by itself while in view, until
// someone touches it, handy for demos).
// Event: "feedback" on the element, e.detail = { rating: 1-5, comment }.
// Script use: animacious.feedbackTab(el) opens it, animacious.feedbackTab(el, false) closes it.
var DEFAULTS = {
question: 'How was this page?', thanks: 'Thanks for the feedback!', label: 'Feedback',
width: 260, color: '#6a0dad', side: 'right'
};
var FACES = [['😞', 'Terrible'], ['🙁', 'Bad'], ['😐', 'Okay'], ['🙂', 'Good'], ['😍', 'Great']];
var css =
'.fbk{position:fixed;top:50%;right:0;z-index:8900;display:flex;align-items:center;--fbk-c:#6a0dad;font:500 14px/1.4 system-ui,sans-serif;color:#1a1625;text-align:left;margin-top:-90px}' +
'.fbk.fbk-l{right:auto;left:0;flex-direction:row-reverse}' +
'.fbk.fbk-c{position:absolute;z-index:4}' +
'.fbk-tab{flex:none;writing-mode:vertical-rl;transform:rotate(180deg);padding:14px 9px;border:0;border-radius:0 10px 10px 0;background:var(--fbk-c);color:#fff;font:inherit;font-size:13px;font-weight:700;letter-spacing:.04em;cursor:pointer;box-shadow:0 6px 18px rgba(14,10,28,.18)}' +
'.fbk-l .fbk-tab{transform:none;border-radius:0 10px 10px 0}' +
'.fbk-tab:focus-visible,.fbk-face:focus-visible,.fbk-send:focus-visible{outline:2px solid var(--fbk-c);outline-offset:2px}' +
'.fbk-panel{position:relative;box-sizing:border-box;padding:18px;background:#fff;border-radius:16px 0 0 16px;box-shadow:0 18px 50px rgba(14,10,28,.22)}' +
'.fbk-l .fbk-panel{border-radius:0 16px 16px 0}' +
'.fbk-q{margin:0 0 12px;font-weight:700;font-size:15px}' +
'.fbk-rate{display:flex;justify-content:space-between;gap:4px;margin-bottom:12px}' +
'.fbk-face{display:grid;place-items:center;width:38px;height:38px;padding:0;border:0;border-radius:50%;background:#f4f1f8;font-size:20px;line-height:1;cursor:pointer;transition:opacity .25s,filter .25s,background .25s}' +
'.fbk-dim .fbk-face{opacity:.4;filter:grayscale(1)}' +
'.fbk-dim .fbk-face.fbk-on{opacity:1;filter:none;background:color-mix(in srgb,var(--fbk-c) 16%,#fff)}' +
'.fbk-note{display:block;box-sizing:border-box;width:100%;min-height:58px;margin-bottom:12px;padding:8px 10px;border:1px solid #e4dff0;border-radius:10px;font:inherit;font-size:13px;resize:vertical}' +
'.fbk-send{display:block;box-sizing:border-box;width:100%;padding:9px;text-align:center;border:0;border-radius:10px;background:var(--fbk-c);color:#fff;font:inherit;font-weight:700;cursor:pointer;transition:opacity .2s}' +
'.fbk-send[disabled],.fbk-send.fbk-off{opacity:.35;cursor:default}' +
'.fbk-thanks{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;gap:10px;padding:16px;text-align:center;font-weight:700;visibility:hidden}' +
'.fbk-tick{display:grid;place-items:center;width:46px;height:46px;border-radius:50%;background:var(--fbk-c);color:#fff}' +
'.fbk-tick svg{width:24px;height:24px}';
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 deco = !!el.closest('[aria-hidden="true"]');
var f = { el: el, open: false, rating: 0, left: (d.side || DEFAULTS.side) === 'left', contained: on(d.contained), deco: deco };
el._fbk = f;
// Real controls on a page; spans inside decorative previews.
function mk(tag, cls) {
var n = document.createElement(deco && tag === 'button' ? 'span' : tag);
if (n.tagName === 'BUTTON') n.type = 'button';
n.className = cls;
return n;
}
el.classList.add('fbk');
if (f.left) el.classList.add('fbk-l');
if (f.contained) el.classList.add('fbk-c');
el.style.setProperty('--fbk-c', d.color || DEFAULTS.color);
el.textContent = '';
var tab = mk('button', 'fbk-tab');
tab.textContent = d.label || DEFAULTS.label;
var panel = mk('div', 'fbk-panel');
panel.id = 'fbk-panel-' + Math.random().toString(36).slice(2, 8);
var q = mk('p', 'fbk-q');
q.textContent = d.question || DEFAULTS.question;
q.id = panel.id + '-q';
var rate = mk('div', 'fbk-rate');
var faces = FACES.map(function (fc, i) {
var b = mk('button', 'fbk-face');
b.textContent = fc[0];
if (!deco) {
b.setAttribute('aria-label', (i + 1) + ' of 5: ' + fc[1]);
b.setAttribute('aria-pressed', 'false');
}
b.addEventListener('click', function () { pick(f, i + 1); });
rate.appendChild(b);
return b;
});
panel.appendChild(q);
panel.appendChild(rate);
if (on(d.comment)) {
var note = mk('textarea', 'fbk-note');
note.placeholder = 'Anything else? (optional)';
note.setAttribute('aria-label', 'Comment');
panel.appendChild(note);
f.note = note;
}
var send = mk('button', 'fbk-send');
send.textContent = 'Send';
if (send.tagName === 'BUTTON') send.disabled = true; else send.classList.add('fbk-off');
send.addEventListener('click', function () { submit(f); });
panel.appendChild(send);
var thanks = mk('div', 'fbk-thanks');
thanks.innerHTML = '<span class="fbk-tick"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" pathLength="1" stroke-dasharray="1" stroke-dashoffset="0"/></svg></span>';
var tt = document.createElement('span');
tt.textContent = d.thanks || DEFAULTS.thanks;
thanks.appendChild(tt);
if (!deco) thanks.setAttribute('role', 'status');
panel.appendChild(thanks);
el.appendChild(tab);
el.appendChild(panel);
f.tab = tab; f.panel = panel; f.faces = faces; f.send = send; f.thanks = thanks; f.rate = rate;
f.parts = [q, rate].concat(f.note ? [f.note] : []).concat([send]);
if (!deco) {
panel.setAttribute('role', 'group');
panel.setAttribute('aria-labelledby', q.id);
tab.setAttribute('aria-expanded', 'false');
tab.setAttribute('aria-controls', panel.id);
}
tab.addEventListener('click', function () { toggle(f, !f.open, true); });
el.addEventListener('keydown', function (e) { if (e.key === 'Escape' && f.open) { toggle(f, false, true); tab.focus(); } });
size(f);
anime.utils.set(el, { x: shut(f) });
panel.style.visibility = 'hidden';
if ('ResizeObserver' in window) new ResizeObserver(function () { size(f); if (!f.open) anime.utils.set(el, { x: shut(f) }); }).observe(f.contained ? el.parentNode : document.documentElement);
return f;
}
function size(f) {
var want = +f.el.dataset.width || DEFAULTS.width;
var room = (f.contained ? f.el.parentNode.clientWidth : document.documentElement.clientWidth) - f.tab.offsetWidth - 12;
f.panel.style.width = Math.max(150, Math.min(want, room)) + 'px';
// Keep it centred on the edge.
f.el.style.marginTop = -f.el.offsetHeight / 2 + 'px';
}
function shut(f) { return (f.left ? -1 : 1) * f.panel.offsetWidth; }
function toggle(f, open, byUser) {
if (open === f.open) return;
f.open = open;
var el = f.el;
if (!f.deco) f.tab.setAttribute('aria-expanded', open);
anime.utils.remove([el].concat(f.parts));
if (open) {
reset(f);
f.panel.style.visibility = 'visible';
if (reduced()) { anime.utils.set(el, { x: 0 }); anime.utils.set(f.parts, { opacity: 1, x: 0 }); }
else {
anime.animate(el, { x: 0, ease: anime.spring({ bounce: 0.25, duration: 560 }) });
anime.animate(f.parts, { opacity: [0, 1], x: [f.left ? -18 : 18, 0], duration: 420, delay: anime.stagger(60, { start: 120 }), ease: 'out(3)' });
anime.animate(f.faces, { scale: [0.4, 1], duration: 500, delay: anime.stagger(40, { start: 200 }), ease: 'outBack(2)' });
}
if (byUser && !f.deco) setTimeout(function () { if (f.open) f.faces[0].focus({ preventScroll: true }); }, 200);
return;
}
if (f.panel.contains(document.activeElement)) f.tab.focus({ preventScroll: true });
var done = function () { if (!f.open) f.panel.style.visibility = 'hidden'; };
if (reduced()) { anime.utils.set(el, { x: shut(f) }); return done(); }
anime.animate(el, { x: shut(f), duration: 420, ease: 'inOut(3)', onComplete: done });
}
function reset(f) {
f.rating = 0;
f.rate.classList.remove('fbk-dim');
f.faces.forEach(function (b) { b.classList.remove('fbk-on'); if (!f.deco) b.setAttribute('aria-pressed', 'false'); });
if (f.send.tagName === 'BUTTON') f.send.disabled = true; else f.send.classList.add('fbk-off');
if (f.note) f.note.value = '';
f.thanks.style.visibility = 'hidden';
anime.utils.set(f.parts, { opacity: 1 });
}
function pick(f, n) {
if (!f.open) return;
f.rating = n;
f.rate.classList.add('fbk-dim');
f.faces.forEach(function (b, i) {
b.classList.toggle('fbk-on', i === n - 1);
if (!f.deco) b.setAttribute('aria-pressed', i === n - 1);
});
if (f.send.tagName === 'BUTTON') f.send.disabled = false; else f.send.classList.remove('fbk-off');
if (!reduced()) {
var b = f.faces[n - 1];
anime.utils.remove(b);
anime.animate(b, { scale: [1, 1.35, 1.15], rotate: [0, -12, 0], duration: 520, ease: 'out(3)' });
f.faces.forEach(function (o) { if (o !== b) anime.animate(o, { scale: 1, duration: 200 }); });
}
}
function submit(f) {
if (!f.open || !f.rating) return;
f.el.dispatchEvent(new CustomEvent('feedback', { bubbles: true, detail: { rating: f.rating, comment: f.note ? f.note.value : '' } }));
var path = f.thanks.querySelector('path');
f.thanks.style.visibility = 'visible';
var later = function () { setTimeout(function () { toggle(f, false); }, 1600); };
if (reduced()) { anime.utils.set(f.parts, { opacity: 0 }); return later(); }
anime.animate(f.parts, { opacity: 0, y: [0, -6], duration: 200, ease: 'in(2)' });
anime.animate(f.thanks.children, { opacity: [0, 1], scale: [0.6, 1], duration: 520, delay: anime.stagger(80, { start: 140 }), ease: 'outBack(2)' });
anime.animate(path, { strokeDashoffset: [1, 0], duration: 420, delay: 300, ease: 'out(2)', onComplete: function () { anime.utils.set(f.parts, { y: 0 }); } });
later();
}
// Demo mode: open, pick a face, send, close, again.
function autoplay(f) {
var timers = [], visible = false;
f.auto = true;
function later(fn, ms) { timers.push(setTimeout(function () { if (f.auto && visible) fn(); }, ms)); }
function run() {
timers.forEach(clearTimeout); timers = [];
later(function () { toggle(f, true); }, 700);
later(function () { pick(f, 4); }, 1700);
later(function () { pick(f, 5); }, 2300);
later(function () { anime.animate(f.send, { scale: [1, 0.95, 1], duration: 220 }); submit(f); }, 3100);
later(run, 6400);
}
var host = f.contained ? f.el.parentNode : f.el;
host.addEventListener('pointerdown', function (e) { if (e.isTrusted) { f.auto = false; timers.forEach(clearTimeout); } });
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible && f.auto && !f.open) run();
else if (!visible) timers.forEach(clearTimeout);
}, { threshold: 0.4 }).observe(host);
}
function init() {
document.querySelectorAll('[data-feedback-tab]').forEach(function (el) {
if (el._fbk) return;
var f = setup(el);
if (on(el.dataset.autoplay) && !reduced()) autoplay(f);
});
}
window.animacious = window.animacious || {};
window.animacious.feedbackTab = function (el, open) {
var f = el._fbk || setup(el);
toggle(f, open !== false, true);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-feedback-tab data-comment></div>
<script>
document.addEventListener('feedback', e => {
fetch('/feedback', { method: 'POST', body: JSON.stringify(e.detail) });
});
</script>