← Overlays

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

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-feedback-tab></div> anywhere in the page. The script builds the tab and the form.
  3. Listen for its feedback event and send e.detail.rating (1–5) and e.detail.comment wherever you keep them.

Options

Add any of these attributes to the same element.

data-questionThe question. Default “How was this page?”
data-thanksThe thank-you line. Default “Thanks for the feedback!”
data-labelThe tab’s text. Default “Feedback”.
data-commentAdds an optional comment box.
data-sideright or left. Default right.
data-widthWidth of the panel. Default 260 px.
data-colorColour of the tab, button and tick. Default #6a0dad.
data-containedSit on the edge of its parent instead of the window. Give the parent position: relative and overflow: hidden.
data-autoplayOpens, 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.

1 · Snippet
<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>
2 · Your HTML
<div data-feedback-tab data-comment></div>

<script>
  document.addEventListener('feedback', e => {
    fetch('/feedback', { method: 'POST', body: JSON.stringify(e.detail) });
  });
</script>