← All posts
February 2, 20266 min readFeedbackIQ team

A one-line embed: rebuilding the feedback widget in vanilla JS

Shadow DOM, 7KB gzipped, lazy-loaded screenshot capture, offline queue, and zero framework runtime. The smallest piece of code and the most scrutinized-at-3am piece of code.

A one-line embed: rebuilding the feedback widget in vanilla JS

The widget is one line of code that a customer pastes on their site. That one line has to: mount a button, not leak styles into the host page, not inherit styles from the host page, open a panel that supports screenshot attachments, stream submissions to our API, recover gracefully on offline, and stay under 10 KB gzipped. It’s small, but the constraints are the entire point.

No framework

The widget is vanilla JavaScript. No React, no Svelte, no bundler preamble. Every framework I tried added 30-40 KB of runtime that a marketing page doesn’t need to load. The final widget.js is ~7 KB gzipped with a flat event system and hand-rolled DOM. It turns out that if you don’t need reactivity, you don’t need a framework to give you reactivity.

<script
  src="https://cdn.feedbackiq.app/widget.js"
  data-site-key="pk_live_your_key"
></script>

That’s it. No config object, no init call, no.mount(). The script reads its own data-* attributes, registers a listener on DOMContentLoaded, and injects a root element just before </body>.

Shadow DOM so nothing leaks in either direction

The worst thing a third-party widget can do is pick up a * { box-sizing: border-box } rule from the host page and explode its own layout. Shadow DOM solves this with one line:

const host = document.createElement("div");
host.id = "feedbackiq-root";
document.body.appendChild(host);

const root = host.attachShadow({ mode: "open" });
root.innerHTML = `<style>${styles}</style>${markup}`;

Inside the shadow root our styles don’t see host-page CSS and the host page doesn’t see ours. This also means we can ship aggressive resets (all: initial) on our root elements without affecting the host. The widget looks identical on a Tailwind site, a Bootstrap site, and a pile of inline styles from 2014.

Screenshots via the browser, no extensions

Users can attach a screenshot to their feedback. We could have required a browser extension (users hate this) or asked them to paste in a dataURL (users will never do this). Instead, the widget uses html2canvas lazy-loaded on first use to snapshot the current viewport, then renders a small annotation layer where the user can drag to highlight the problem area. The resulting PNG is posted to /api/v1/attachments with a presigned URL scheme so the file never lands in our application database.

Lazy-loading matters: screenshot code is ~40 KB. Including it up front for users who never take screenshots is a 6× increase in widget weight. We dynamically import() it the first time the screenshot button is clicked. If the user never clicks, it’s never loaded.

Network: optimistic, retrying, honest

Submissions go to /api/v1/feedback with a simple POST. We never block the UI on the embedding/dedupe work — the server responds as soon as the row is written, then kicks off dedupeFeedback(feedback.id).catch(...) fire-and-forget. On the client, we show “Sent, thanks!” in ~200ms regardless of what’s happening downstream.

Offline is handled by a tiny queue: failed submissions are written to localStorage, and we retry on the next online event. This is one of those features that is invisible when it works and is the reason we won’t ever lose a user’s carefully typed bug report to a flaky hotel WiFi.

What I got wrong the first time

First draft had the widget render into a regular div with high-specificity class names. A customer’s site had a rule div > * { color: inherit } that cascaded through our panel and turned every label dark blue. That was the bug that taught me Shadow DOM was non-optional. Switched in the next commit; never had the issue again.

Next post: detecting the host site’s theme so our widget button matches the page instead of looking like a pasted-in tooltip.

Try FeedbackIQ

Drop a widget on your site, ship PRs from feedback

Claude reads the report, writes the fix, opens the PR on your repo. Dedupe with pgvector so the backlog doesn’t drown in duplicates.

Start for free