Gimme Feedback Widget (Email-Ready, No Bloat)

December 10, 2025

This is a tiny "Gimme Feedback" widget you can drop onto almost any website without turning your project into an Intercom clone. It adds a small floating button that opens a clean mini panel where visitors can type a message and optionally leave an email. On submit, it sends the feedback to your inbox using a super small PHP endpoint. No dependencies, no frameworks, no heavy assets — just a few kilobytes.

How it feels for the visitor

  • They’re browsing your site
  • They want to say something quick
  • They tap a tiny feedback button
  • They type a message (+ optional email)
  • They hit submit and get a friendly "sent" state

1) Drop-in CSS + JS (auto-injects the widget)

Paste this into your page template footer or anywhere that loads site-wide. This version creates the button + panel automatically, so you don’t need to add HTML.

<style>
  /* Vibe Feedback Widget — ultra-light */
  .vibe-fb-btn {
    position: fixed;
    right: 14px;
    bottom: 14px;
    z-index: 9999;

    display: inline-flex;
    align-items: center;
    justify-content: center;

    width: 46px;
    height: 46px;
    border-radius: 999px;

    border: 1px solid rgba(0,0,0,0.14);
    background: #F9FAFF;
    color: #000;

    font: 700 14px/1 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
    cursor: pointer;

    box-shadow: 0 8px 18px rgba(0,0,0,0.08);
    transition: background .18s ease, border-color .18s ease, transform .18s ease, opacity .18s ease;
  }
  .vibe-fb-btn:hover {
    background: #F2F4FF;
    border-color: rgba(168,41,167,0.45);
  }
  .vibe-fb-btn:active { transform: translateY(1px); }
  .vibe-fb-btn:focus-visible {
    outline: 2px solid #A829A7;
    outline-offset: 3px;
  }

  .vibe-fb-panel {
    position: fixed;
    right: 14px;
    bottom: 70px;
    z-index: 9999;

    width: min(320px, 92vw);
    background: #FFFFFF;
    border: 1px solid #C4C6F5;
    box-shadow: 0 10px 24px rgba(0,0,0,0.10);
    border-radius: 12px;
    padding: 10px 10px 12px;

    opacity: 0;
    transform: translateY(8px);
    pointer-events: none;
    transition: opacity .18s ease, transform .18s ease;
  }
  .vibe-fb-panel.is-open {
    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;
  }

  .vibe-fb-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 8px;
  }
  .vibe-fb-title {
    font-weight: 700;
    font-size: 13px;
    margin: 0;
  }
  .vibe-fb-close {
    border: 0;
    background: transparent;
    font-size: 18px;
    line-height: 1;
    cursor: pointer;
    padding: 2px 6px;
  }

  .vibe-fb-label {
    display: block;
    font-size: 11px;
    font-weight: 700;
    margin: 8px 0 4px;
    color: #333;
  }
  .vibe-fb-input,
  .vibe-fb-textarea {
    width: 100%;
    box-sizing: border-box;
    font: 12px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
    border: 1px solid #C4C6F5;
    background: #fff;
    border-radius: 8px;
    padding: 7px 8px;
  }
  .vibe-fb-textarea {
    min-height: 90px;
    resize: vertical;
  }

  .vibe-fb-row {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 10px;
  }
  .vibe-fb-submit {
    border-radius: 999px;
    border: 1px solid #A829A7;
    background: #A829A7;
    color: #fff;
    font: 700 12px/1 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
    padding: 7px 12px;
    cursor: pointer;
  }
  .vibe-fb-submit:hover { background: #8c1f8b; }
  .vibe-fb-submit[disabled] {
    opacity: .6;
    cursor: default;
  }

  .vibe-fb-status {
    font-size: 11px;
    color: #555;
  }
  .vibe-fb-status.ok { color: #1b7a3a; }
  .vibe-fb-status.err { color: #b3261e; }

  /* Tiny privacy line */
  .vibe-fb-foot {
    margin-top: 8px;
    font-size: 10px;
    color: #666;
  }

  @media (prefers-reduced-motion: reduce) {
    .vibe-fb-btn, .vibe-fb-panel { transition: none; }
  }
</style>

<script>
  // Vibe Feedback Widget — no deps, tiny footprint
  (function () {
    // ===== CONFIG =====
    var ENDPOINT = "/feedback.php"; // <- create this file (see PHP below)
    var BUTTON_TEXT = "💬";
    var PANEL_TITLE = "Gimme feedback";
    var ALLOW_EMAIL = true;
    var MAX_CHARS = 1200;

    // Simple cooldown to cut spam bursts from same browser
    var COOLDOWN_MINUTES = 2;
    // ==================

    if (document.querySelector(".vibe-fb-btn") || document.querySelector(".vibe-fb-panel")) {
      return; // avoid double-inject
    }

    function el(tag, cls) {
      var n = document.createElement(tag);
      if (cls) n.className = cls;
      return n;
    }

    var btn = el("button", "vibe-fb-btn");
    btn.type = "button";
    btn.setAttribute("aria-label", "Send feedback");
    btn.title = "Send feedback";
    btn.textContent = BUTTON_TEXT;

    var panel = el("div", "vibe-fb-panel");
    panel.setAttribute("role", "dialog");
    panel.setAttribute("aria-label", "Feedback form");

    var head = el("div", "vibe-fb-head");
    var title = el("p", "vibe-fb-title");
    title.textContent = PANEL_TITLE;

    var close = el("button", "vibe-fb-close");
    close.type = "button";
    close.setAttribute("aria-label", "Close");
    close.textContent = "✕";

    head.appendChild(title);
    head.appendChild(close);

    var labelMsg = el("label", "vibe-fb-label");
    labelMsg.textContent = "Message";
    var textarea = el("textarea", "vibe-fb-textarea");
    textarea.maxLength = MAX_CHARS;
    textarea.placeholder = "What should I fix, add, or improve?";

    var emailWrap = el("div");
    var labelEmail = el("label", "vibe-fb-label");
    labelEmail.textContent = "Email (optional)";
    var emailInput = el("input", "vibe-fb-input");
    emailInput.type = "email";
    emailInput.placeholder = "you@example.com";

    if (ALLOW_EMAIL) {
      emailWrap.appendChild(labelEmail);
      emailWrap.appendChild(emailInput);
    }

    // Honeypot (bots tend to fill)
    var hp = el("input");
    hp.type = "text";
    hp.name = "website";
    hp.autocomplete = "off";
    hp.tabIndex = -1;
    hp.style.position = "absolute";
    hp.style.left = "-9999px";
    hp.style.height = "0";
    hp.style.opacity = "0";

    var row = el("div", "vibe-fb-row");
    var submit = el("button", "vibe-fb-submit");
    submit.type = "button";
    submit.textContent = "Submit";
    var status = el("span", "vibe-fb-status");
    row.appendChild(submit);
    row.appendChild(status);

    var foot = el("div", "vibe-fb-foot");
    foot.textContent = "No accounts, no tracking. Just a quick note to the site owner.";

    panel.appendChild(head);
    panel.appendChild(labelMsg);
    panel.appendChild(textarea);
    if (ALLOW_EMAIL) panel.appendChild(emailWrap);
    panel.appendChild(hp);
    panel.appendChild(row);
    panel.appendChild(foot);

    function openPanel() {
      panel.classList.add("is-open");
      setTimeout(function () { textarea.focus(); }, 0);
    }
    function closePanel() {
      panel.classList.remove("is-open");
      status.textContent = "";
      status.className = "vibe-fb-status";
    }

    btn.addEventListener("click", function () {
      if (panel.classList.contains("is-open")) closePanel();
      else openPanel();
    });
    close.addEventListener("click", closePanel);

    document.addEventListener("keydown", function (e) {
      if (e.key === "Escape") closePanel();
    });

    function cooldownKey() { return "vibe_fb_last_sent"; }

    function isCoolingDown() {
      try {
        var last = localStorage.getItem(cooldownKey());
        if (!last) return false;
        var lastMs = parseInt(last, 10);
        if (!lastMs) return false;
        var diffMin = (Date.now() - lastMs) / 60000;
        return diffMin < COOLDOWN_MINUTES;
      } catch (e) {
        return false;
      }
    }

    function setCooldownNow() {
      try { localStorage.setItem(cooldownKey(), String(Date.now())); } catch (e) {}
    }

    function setStatus(text, cls) {
      status.textContent = text;
      status.className = "vibe-fb-status " + (cls || "");
    }

    function send() {
      var msg = (textarea.value || "").trim();
      var em  = (emailInput.value || "").trim();

      if (isCoolingDown()) {
        setStatus("You just sent feedback. Try again in a minute.", "err");
        return;
      }
      if (!msg) {
        setStatus("Type a quick message first.", "err");
        return;
      }
      if (msg.length > MAX_CHARS) {
        setStatus("Message is too long.", "err");
        return;
      }
      if (hp.value) {
        setStatus("Blocked.", "err");
        return;
      }

      submit.disabled = true;
      setStatus("Sending…");

      var body = new URLSearchParams();
      body.append("message", msg);
      if (ALLOW_EMAIL && em) body.append("email", em);
      body.append("page", location.href);
      body.append("ua", navigator.userAgent || "");

      fetch(ENDPOINT, {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
          "X-Requested-With": "XMLHttpRequest"
        },
        body: body.toString()
      })
      .then(function (r) { return r.json().catch(function(){ return null; }); })
      .then(function (data) {
        if (!data || !data.ok) throw new Error("fail");
        setStatus("Sent. Thank you!", "ok");
        setCooldownNow();
        textarea.value = "";
        emailInput.value = "";
        setTimeout(closePanel, 900);
      })
      .catch(function () {
        setStatus("Couldn’t send right now.", "err");
      })
      .finally(function () {
        submit.disabled = false;
      });
    }

    submit.addEventListener("click", send);

    // Mount after DOM ready
    function mount() {
      document.body.appendChild(btn);
      document.body.appendChild(panel);
    }
    if (document.readyState === "loading") {
      document.addEventListener("DOMContentLoaded", mount);
    } else {
      mount();
    }
  })();
</script>

2) Tiny PHP endpoint (sends to your inbox)

Create /feedback.php on your server. This stays intentionally small, but includes basic safety: a honeypot check, simple rate limiting by IP, and short input validation. It returns JSON so the widget can show a clean “Sent!” state.

<?php
// feedback.php — ultra-light feedback endpoint (PHP 7+ recommended)
// Sends form feedback to your email. Minimal but sensible protections.

// 1) CONFIG
$TO_EMAIL = 'you@example.com';  // <-- change this
$SITE_NAME = 'Your Site';
$RATE_LIMIT_SECONDS = 30;       // per IP

header('Content-Type: application/json; charset=UTF-8');

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false]);
    exit;
}

// 2) Basic IP rate limit (file-based, tiny)
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$ipKey = preg_replace('/[^a-zA-Z0-9\.\-_:]/', '_', $ip);
$tmpDir = sys_get_temp_dir();
$rlFile = $tmpDir . '/vibe_fb_' . $ipKey . '.txt';

$now = time();
if (is_file($rlFile)) {
    $last = (int)@file_get_contents($rlFile);
    if ($last > 0 && ($now - $last) < $RATE_LIMIT_SECONDS) {
        echo json_encode(['ok' => false, 'error' => 'rate']);
        exit;
    }
}
@file_put_contents($rlFile, (string)$now);

// 3) Inputs
$message = trim((string)($_POST['message'] ?? ''));
$email   = trim((string)($_POST['email'] ?? ''));
$page    = trim((string)($_POST['page'] ?? ''));
$ua      = trim((string)($_POST['ua'] ?? ''));
$honeypot= trim((string)($_POST['website'] ?? ''));

// Honeypot: if filled, likely bot
if ($honeypot !== '') {
    echo json_encode(['ok' => false]);
    exit;
}

if ($message === '' || mb_strlen($message, 'UTF-8') > 1200) {
    echo json_encode(['ok' => false]);
    exit;
}

// Optional email sanity
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $email = ''; // silently drop invalid
}

// 4) Compose email
$subject = '[' . $SITE_NAME . '] New feedback';
$lines = [];
$lines[] = "Message:";
$lines[] = $message;
$lines[] = "";
if ($email !== '') $lines[] = "Email: " . $email;
if ($page !== '')  $lines[] = "Page: " . $page;
$lines[] = "IP: " . $ip;
if ($ua !== '')    $lines[] = "UA: " . $ua;

$body = implode("\n", $lines);

// Basic headers
$headers = [];
$headers[] = 'Content-Type: text/plain; charset=UTF-8';
$headers[] = 'X-Mailer: VibeFeedback';

// If user left an email, set Reply-To (nice quality-of-life)
if ($email !== '') {
    $headers[] = 'Reply-To: ' . $email;
}

// 5) Send
$ok = @mail($TO_EMAIL, $subject, $body, implode("\r\n", $headers));

echo json_encode(['ok' => $ok ? true : false]);

Optional: “Daily digest” without building a whole system

If you want a dead-simple digest approach, you’ve got two easy paths:

  • Let your email provider do it. Many providers let you filter messages with a subject like [Your Site] New feedback into a folder and create a daily summary rule.
  • Swap mail() for file logging. Instead of emailing every message, append to a log file and run a simple cron job that emails the file once per day.

Why this is “vibe-coded” friendly

  • No dependencies, no build step
  • Auto-inject UI — one paste-and-go block
  • Tiny payload for visitors
  • Works great for small tools, indie sites, and personal projects

Quick tweak ideas

Want text instead of an icon? Change BUTTON_TEXT to "Feedback" and bump the button width in CSS.
Want it on the left? Change right to left for both the button and panel.
Want stricter spam control? Increase RATE_LIMIT_SECONDS and consider blocking empty emails if your use case needs it.

Comments (0)

No comments yet — be the first.

← Back to all scripts