Install
Create a widget in Team settings → Widget (team owners only; every plan includes at least one). Each config gets a public expw_ key, a domain allowlist (submissions are only accepted from pages on domains you list), and the board its reports land on. Every submission becomes an issue there.
Then paste the snippet before </head> on your site. It's the GA-style async pattern: a tiny queue stub loads the real script lazily, so it never blocks your page.
<script>
// Exponential feedback widget. Full docs — init options, identify,
// setCustomData, setTheme (dark/light/auto), setLauncherHidden, labels,
// headless submit:
// https://exponential.at/docs/widget/
(function (w, d, u) {
if (w.ExponentialWidget) return;
var q = [], api = { q: q };
["init","identify","setCustomData","setTheme","setLauncherHidden","open","close","submit"].forEach(function (m) {
api[m] = function () { q.push([m, [].slice.call(arguments)]); };
});
w.ExponentialWidget = api;
var s = d.createElement("script");
s.async = true; s.src = u;
d.head.appendChild(s);
})(window, document, "https://app.exponential.at/widget/v1/loader.js");
ExponentialWidget.init({ key: "expw_YOUR_KEY" });
</script>That's the whole install. A floating feedback button appears, and calls made before the script loads are queued and replayed.
expw_ keys ship in page source, like an analytics ID. The domain allowlist plus server-side rate limits are what gate submissions, so no secret ever has to live in the page.On the cloud, a Free team takes 60 submissions an hour across all its widgets (settings shows the bar and the upgrade link); the Team plan is unlimited. Self-hosted has no plan ceiling at all — only the per-IP abuse buckets, which apply everywhere.
JS API
The snippet exposes window.ExponentialWidget with eight calls:
// Call this once to boot the widget with your public key.
// Optional init overrides: theme ("dark" | "light" | "auto"),
// launcher, color, label, showButton, zIndex, host.
ExponentialWidget.init({ key: "expw_YOUR_KEY" });
// Every init option, in full:
ExponentialWidget.init({
key: "expw_YOUR_KEY",
// Where the launcher sits, per device (desktop / mobile split at a
// 767px viewport). mode: "fab" (floating pill) or "tab" (edge square);
// position: top|middle|bottom - left|right. Defaults: desktop
// fab bottom-right, mobile tab middle-right. Whatever you set here
// wins over the widget's configured launcher.
launcher: {
desktop: { mode: "fab", position: "bottom-right" },
mobile: { mode: "tab", position: "middle-right" },
},
color: "#7c5cff", // accent for the button and primary actions
label: "Feedback", // "" renders an icon-only button
showButton: true, // false = headless, see below
zIndex: 2147483000,
theme: "auto",
// Only for a self-hosted instance whose loader you serve from a
// different origin than the API. Defaults to the loader's own origin.
host: "https://issues.example.com",
});
// Attach your signed-in user, so reports arrive with a
// real reporter (and your replies to them reach their inbox).
ExponentialWidget.identify({
email: "ada@example.com",
name: "Ada Lovelace",
userId: "usr_123",
});
// Arbitrary context stamped onto every submission:
// plan, build, feature flags, tenant…
ExponentialWidget.setCustomData({
plan: "business",
version: "1.42.0",
});
// Hook the widget to your site's dark/light toggle — launcher
// and panel restyle live. "auto" follows the visitor's system.
ExponentialWidget.setTheme("light");
// Hide the launcher while your own UI covers its corner (a bottom
// sheet, a mobile action bar). Only the button goes away: an open
// panel keeps rendering and open()/close()/submit() keep working.
ExponentialWidget.setLauncherHidden(true);
// Open / close the panel programmatically. Wire your own
// "Report a bug" menu item to open().
ExponentialWidget.open();
ExponentialWidget.close();
// Submit without the panel. See Headless mode below.
ExponentialWidget.submit({ message: "The Save button does nothing." });All calls are safe to make before the script has loaded. The loader queues and replays them in order. (A queued submit runs fire-and-forget; call it after load, e.g. from a click handler, to get its Promise.)
position option ("bottom-right" / "bottom-left") still works on its own, but it is ignored entirely once launcher is present. New installs should use launcher.Form fields
The form asks one thing: what happened? The first line of the answer becomes the issue's title. Everything else is configured per widget in Team settings → Widget:
- Email: shown by default and optional; make it required, or hide it entirely for internal tools where nobody wants follow-up emails. With an email, the reporter gets a private link to their report and you can reply to them from the issue.
- Name: off by default. Turn it on (optionally required) when a plain name is all you need to walk over and ask “what did you mean?” without collecting an email.
- Custom fields: up to 8 extra text inputs (e.g. “Which page?”, “Order number”). Responses land in the submission's custom-data block, alongside your
setCustomDatapayload. A typed response wins over a host-set key of the same name. - Labels: expose up to 10 of your team's labels (“Bug”, “Idea”, …) as toggle chips, and the reporter's picks arrive on the created issue — triage done at the source.
- Appearance: dark (default), light, or match-the-visitor's-system theme, plus an accent color — both with a live preview in settings.
- Launcher: the button's shape and corner, set separately for desktop and mobile — a floating pill or an edge tab, in any of six positions — plus its Icon and Button label. The snippet's
launcheroption overrides all of it per install.
The settings dialog splits these across General, Form and Appearance tabs, with the real panel previewed beside them.
Visitors attached via identify() skip the email and name fields. Their identity rides along invisibly. The email is how you reach the reporter: a report without one lands on the board like any other, but a reply to the reporter has nowhere to go.
Headless mode
Want your own feedback UI? Boot the widget without its button and submit programmatically. You keep the key + domain gating, rate limits, and issue creation, and skip the panel entirely:
ExponentialWidget.init({ key: "expw_YOUR_KEY", showButton: false });
ExponentialWidget.identify({ email: "ada@example.com", name: "Ada" });
// Later, from your own form's submit handler:
const result = await ExponentialWidget.submit({
message: "The upload spinner runs forever when I attach a screenshot.",
title: "Upload never finishes", // optional: defaults to the message's first line
name: "dani", // overrides identify()
customData: { page: "checkout" }, // merged over setCustomData()
screenshot: myBlob, // optional: you capture it
images: [pictureBlob], // up to 3 extra pictures, 10 MB each
labels: ["<label-id>"], // ids from the widget's configured labels
});
if (result.ok) {
console.log("Filed as", result.identifier); // e.g. "EXP-42"
} else {
console.error(result.error, result.code);
}
// Leave an email and the reporter gets a link to follow the conversation:
const sent = await ExponentialWidget.submit({
message: "I can't log in. The form loops back after I press Sign in.",
email: "ada@example.com",
});
sent.emailDelivered; // true, false (the mail failed), or null (no email given)submit() resolves with { ok, identifier, url, emailDelivered } on success and { ok: false, error, code } on failure. It never throws. Hosts written before the one-form widget keep working: description is read as the message when message is absent, and a mode is ignored. Screenshots are yours to capture in headless mode; pass a Blob (PNG, JPEG, or WebP) and it's attached like a panel screenshot. Server-side validation (required fields, rate limits) applies exactly as it does to the panel.
Not going fully headless? setLauncherHidden(true) hides just the button while your own UI covers its corner, and open() still brings the real panel up.
Screenshots & annotation
Screenshots are captured client-side, in the browser. The visitor's viewport is rendered locally and nothing is fetched by a server-side browser, so what's on their screen (including logged-in state) is what you see.
On desktop browsers Take screenshot uses the browser's native screen sharing to grab a single frame — it captures content a page snapshot can't render (canvas/WebGL, video, cross-origin iframes). The visitor picks the surface in the browser's own dialog, one frame is taken, and sharing stops immediately. On mobile (and if the visitor dismisses that dialog) the widget falls back to a local page snapshot automatically.
Menus, dropdowns and popups that close on click can be captured too: the delay chip next to Take screenshot cycles Off → 3s → 5s and holds the shot for that long, with a countdown in the launcher's corner, so the visitor can open whatever should be in the picture first. On desktop the countdown starts once the share dialog is confirmed.
Before submitting, the visitor can annotate the screenshot in a full-screen editor: Rectangle, Arrow, Free line and Crop, with undo. Annotations are flattened into the image on submit.
What lands in Exponential
Each submission becomes, atomically:
- An issue on the widget's board, titled from the first line of the message, with the message as the description and the reporter's label picks applied.
- The screenshot and pictures as attachments, embedded in the issue.
- A metadata block: reporter email (from
identifyor the form), the page URL, browser and viewport details, and yoursetCustomDatapayload.
The reporter is auto-subscribed to the issue. Resolve it and they're notified.
If the reporter left an email, they also get a confirmation with a private link to their report, and the issue's comment composer grows a Reply to reporter toggle. How that conversation runs is on the Feedback & reporters page.
Try it
This site runs the real widget. The feedback button in the corner of this page is a live install of exactly the snippet above. Click it, annotate a screenshot, submit, and your report lands on the Exponential team's own feedback board.