← All posts
February 14, 20265 min readFeedbackIQ team

Auto-detecting a site's theme so the widget doesn't look pasted-in

Reading CSS custom properties, sampling existing buttons, deriving panel chrome from background luminance. How the widget paints itself to match whichever site it's embedded on.

Auto-detecting a site's theme so the widget doesn't look pasted-in

The default feedback widget looked fine. Cyan button, bottom-right corner, friendly panel. It was also the single most common piece of feedback we got from the first ten customers: “the button doesn’t match our site.” They were right. A widget that looks pasted-in reads as low-trust, and no amount of functionality fixes that.

So we wrote a theme detector. When the widget mounts on a host page, it sniffs the page’s own design tokens and paints itself to match. It’s one of those features that is invisible when it works — which is exactly the goal.

What to sniff

Four things are enough for the button and panel to feel site-native:

  • Primary brand color. Check CSS custom properties on :root for anything that looks like --primary, --brand, --accent. Fall back to the page’s most common saturated color sampled from existing buttons.
  • Background tone. Read getComputedStyle(document.body).backgroundColor and derive whether we’re on a dark or light site. Our panel’s chrome adapts.
  • Body font. getComputedStyle(document.body).fontFamily. If the host uses Inter, we use Inter. If they use Georgia, fine, we’ll match.
  • Corner radius. Sampled from an existing <button> or <a> styled as a button. A site whose buttons are pill-shaped deserves a pill-shaped widget button.

The detection pass

function detectTheme(): Theme {
  const root = getComputedStyle(document.documentElement);
  const body = getComputedStyle(document.body);

  const primary =
    sampleCustomProperty(root, ["--primary", "--brand", "--accent"]) ??
    sampleButtonColor() ??
    "#22d3ee";

  const bg = body.backgroundColor || "#ffffff";
  const isDark = relativeLuminance(bg) < 0.5;

  const fontFamily = body.fontFamily.replace(/['"]/g, "").split(",")[0];

  const radius = sampleButtonRadius() ?? "0.5rem";

  return { primary, bg, isDark, fontFamily, radius };
}

sampleButtonColor walks the DOM for button, [role="button"], .btn, a.button, reads their computed backgroundColor, filters out neutrals (anything with low chroma), and takes the mode. It runs once on mount and is memoized for the session.

Server-side preview for the dashboard

We also run the same detection server-side. When a customer adds a site URL in the dashboard, we fetch the page, run it through a headless browser (Puppeteer on a serverless function), and show a preview of the themed widget next to the URL input. The preview matches what the user will see on their site, before they paste the script tag.

This is behind the /api/projects/[id]/detect-theme endpoint. It returns the same Theme object the client-side sniffer uses, so the dashboard preview and the production widget agree.

Escape hatches

Theme detection is opinionated, and that means sometimes it’s wrong. Every detected value is overridable in the dashboard: the customer can pin a hex, a font, a radius, and a dark/light preference. Detection is the default, not the law.

We also expose CSS custom properties on the widget host element so customers with strong opinions can style the button themselves from their own stylesheet:

#feedbackiq-root {
  --fiq-primary: #ff3366;
  --fiq-radius: 0px;
}

What we got wrong

First pass matched the body text color to the panel. Turns out a lot of marketing sites have near-black body text (#0a0a0a) even when their background is off-white, which made our panel headers look gunmetal gray. We switched to deriving panel text from contrast against our panel bg, not from sampling the host. Same shape of problem — the difference between “copy from the site” and “make a decision that respects the site.” The second one is almost always what you actually want.

Next up: the auto-tagging pipeline. Every submission gets a category, a priority, and a handful of tags — none of which the user has to pick.

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