Feedback form for a website or app6 min read
Build a floating feedback widget for your React app
A floating feedback button for a React app that opens a small panel, sends the screen, app version and account with each answer, and never loses a draft.
Render a fixed button that opens a panel built on the browser's Popover API, so it sits above every layer of your app without a z-index fight and closes on Escape. On submit, add the current route, app version, account id and viewport to the form data and post it from the browser with the Ikiform SDK. Send the user's email only when they tick a box asking for a reply.
What's different about an in-app widget
A page form waits for people to scroll to it. A widget is there on every screen, which changes what can go wrong. It has to sit above your own modals, menus and toasts. It must not lose a half-written message when someone clicks outside it. And because people are signed in, you already know who they are and where they are, so the widget should send that instead of asking.
- The button is always on screen, fixed to the bottom right corner.
- Clicking it opens the panel in the browser's top layer and starts a new attempt key.
- The user picks a face and writes a note. The form's state stays mounted while the panel is open or hidden.
- On submit, the widget adds the route, app version, account id and viewport, and posts with the SDK.
- Ikiform stores it once, even if the button is pressed twice, because both posts carry the same idempotency key.
- If the user clicks outside or presses Escape first, the panel hides and the draft is still there next time.
Fields for an app
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
may_reply is a checkbox, stored as true or false.| Field | Comes from | Why |
|---|---|---|
rating | The user, five faces | A rating field with max: 5. Faces are quicker to pick than numbers |
message | The user | Optional. Most useful when the face is a bad one |
may_reply, email | A checkbox, then your session | The email is sent only when they ask for a reply, and Reply in the notification goes to them |
route | window.location.pathname | Which screen. Read at submit, after any client-side navigation |
app_version | Your build | Tells you if a complaint is about code you've already changed |
account_id | Your session | Lets you look up their plan and data. Not proof of identity, see below |
viewport | window.innerWidth and innerHeight | Layout complaints often depend on size |
The component
It needs @ikiform/sdk and React 19, which types popover, popoverTarget and the toggle event. The button's popoverTarget ties it to the panel, so the browser handles opening, Escape and returning focus to the button.
import { submit } from "@ikiform/sdk";
import { type FormEvent, type ToggleEvent, useId, useRef, useState } from "react";
import "./feedback-widget.css";
const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const FACES = ["Awful", "Bad", "Okay", "Good", "Great"];
interface Props {
accountId: string;
email: string;
appVersion: string;
}
export function FeedbackWidget({ accountId, email, appVersion }: Props) {
const panelId = useId();
const attempt = useRef("");
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
function onToggle(event: ToggleEvent<HTMLDivElement>) {
if (event.newState !== "open") return;
attempt.current = crypto.randomUUID();
if (status === "sent") setStatus("idle");
}
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
if (data.get("may_reply") === "true") data.set("email", email);
data.set("route", window.location.pathname);
data.set("app_version", appVersion);
data.set("account_id", accountId);
data.set("viewport", `${window.innerWidth}x${window.innerHeight}`);
setStatus("sending");
setError("");
const result = await submit(FORM_ID, data, { idempotencyKey: attempt.current });
if (result.ok) {
setStatus("sent");
return;
}
setError(result.error.fields?.rating?.[0] ?? result.error.message);
setStatus("idle");
}
return (
<>
<button type="button" className="feedback-trigger" popoverTarget={panelId}>
Feedback
</button>
<div id={panelId} popover="auto" className="feedback-panel" onToggle={onToggle} aria-label="Send feedback">
{status === "sent" ? (
<p role="status">Thanks, that helps.</p>
) : (
<form onSubmit={onSubmit}>
<fieldset>
<legend>How is it going?</legend>
{FACES.map((face, index) => (
<label key={face}>
<input type="radio" name="rating" value={index + 1} required /> {face}
</label>
))}
</fieldset>
<label htmlFor={`${panelId}-message`}>Tell us more</label>
<textarea id={`${panelId}-message`} name="message" rows={4} maxLength={4000} />
<label>
<input type="checkbox" name="may_reply" value="true" /> The team can reply to me by email
</label>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error ? <p role="alert">{error}</p> : null}
<button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
</form>
)}
</div>
</>
);
}popover="auto"puts the panel in the top layer when open. It shows above your dialogs and menus with noz-index, and closes when the user clicks elsewhere.- The panel's children stay mounted when it hides, so a draft survives an accidental click outside. After a successful send, the thank-you line replaces the form, which clears it, and the next open shows an empty form.
onTogglemakes a fresh key every time the panel opens.submit()sends it as theIdempotency-Keyheader, so a double click stores one answer. See idempotency.- The email is only set when the box is ticked. Unticked, the answer carries the account id but no address.
The popover's default styles center it on screen. These rules pin it above the button instead:
.feedback-trigger {
position: fixed;
right: 16px;
bottom: 16px;
}
.feedback-panel {
inset: auto 16px 64px auto;
margin: 0;
width: min(360px, calc(100vw - 32px));
max-height: calc(100dvh - 96px);
overflow: auto;
}Mount it once
Render the widget in the layout that wraps signed-in screens, so it appears everywhere and survives navigation. In the Next.js App Router, the layout is a server component that reads the session and passes plain props to the client widget:
import type { ReactNode } from "react";
import { FeedbackWidget } from "@/components/feedback-widget";
import { getSession } from "@/lib/session";
export default async function AppLayout({ children }: { children: ReactNode }) {
const session = await getSession();
return (
<>
{children}
{session ? (
<FeedbackWidget
accountId={session.accountId}
email={session.email}
appVersion={process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}
/>
) : null}
</>
);
}Mark the widget file with "use client" in the App Router. In a Vite or React Router app, render it next to your router outlet. It works the same way.
What the browser can and can't prove
This widget posts straight from the browser with the form's public id. That's the right trade for feedback: no server route to maintain, and Ikiform's own meta.user_agent is the user's real browser. It also means anyone can post any account_id they like. Use it to look things up, never to grant anything.
Check it works
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Open the panel, type a note, click outside, and open it again. The note is still there.
- Open a modal in your app, then the widget. The panel shows above the modal.
- Press Escape. The panel closes and focus is back on the Feedback button.
- Pick a face and double-click Send. Submissions shows one answer, with
route,app_versionandaccount_idfilled. - Send once with the reply box ticked. The notification email's Reply goes to the user.
Gotchas
- Keep the button clear of your own floating elements, such as a chat bubble or cookie banner. Move one of them, or open the widget from a menu item instead.
- Don't show the button on every screen of a checkout or editor where people are concentrating. Hide it there with a prop.
- The Popover API is supported in current Chrome, Edge, Firefox and Safari. If you must support older browsers, check before you rely on it.
- Every field added in code must exist in the form's definition, or Ikiform refuses the post with an Unknown field error.
- Don't send the whole app state. Route, version, account and size answer most questions; anything else is a privacy question.
Troubleshooting
| Problem | What to do |
|---|---|
403 origin_not_allowed, or every post lands in spam | The page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed. |
403 challenge_required | The form's spam check is set to Ikiform and a fetch post had no token. Get one with challenge(formId) from @ikiform/sdk before each submit, or turn the check off. |
| The panel appears in the middle of the screen | The stylesheet didn't load, so the browser's default popover styles apply. Check the CSS import, or set inset and margin on the panel. |
422 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
Questions
Render a fixed button with popoverTarget pointing at a panel that has popover=auto. The browser handles opening, closing and focus. Post the panel's form with submit() from @ikiform/sdk and add the route and app version at submit.
Popovers render in the top layer, so they sit above your own dialogs without z-index rules, and light dismiss and Escape come for free. A feedback panel doesn't need to block the page the way a modal does.
No. Anything sent from the browser can be changed by the user. Use it to look up context, and post from your server with a secret key when the identity must be trusted.
No. It posts straight to Ikiform from the browser, and Allowed domains limits which sites can use the form. Add a server route only if you need trusted identity.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.