# 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.

Published 2026-10-25 · https://ikiform.com/guides/feedback-form/react-feedback-widget

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.

Diagram: The life of one feedback widget session, from click to stored answer

1. The button is always on screen, fixed to the bottom right corner.
2. Clicking it opens the panel in the browser's top layer and starts a new attempt key.
3. The user picks a face and writes a note. The form's state stays mounted while the panel is open or hidden.
4. On submit, the widget adds the route, app version, account id and viewport, and posts with the SDK.
5. Ikiform stores it once, even if the button is pressed twice, because both posts carry the same idempotency key.
6. If the user clicks outside or presses Escape first, the panel hides and the draft is still there next time.

Light dismiss is the Popover API's default. Because the panel only hides, the text field keeps what was typed.

## Fields for an app

Form: In-app feedback

- **How is it going?** (`rating`, rating, required)
- **Tell us more** (`message`, textarea)
- **The team can reply to me by email** (`may_reply`, checkbox)
- **Email** (`email`, email)
- **Screen** (`route`, text)
- **App version** (`app_version`, text)
- **Account** (`account_id`, text)
- **Viewport** (`viewport`, text)

Users only see the first three fields. The rest are added in code at submit. `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.

`components/feedback-widget.tsx`:

```tsx
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 no `z-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.
- `onToggle` makes a fresh key every time the panel opens. `submit()` sends it as the `Idempotency-Key` header, so a double click stores one answer. See [idempotency](https://ikiform.com/docs/frameworks.md#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:

`components/feedback-widget.css`:

```text
.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:

`app/(app)/layout.tsx`:

```tsx
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.

> If you need the account to be trustworthy, for example to show feedback inside an admin tool, post from your server with the secret key and add the account from the session there. The pillar's [server versions](https://ikiform.com/guides/feedback-form.md#form) show the pattern; choose the Relaxed or Custom rate limit, because every post then comes from your server's IP.

## Check it works

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. Open the panel, type a note, click outside, and open it again. The note is still there.
3. Open a modal in your app, then the widget. The panel shows above the modal.
4. Press Escape. The panel closes and focus is back on the Feedback button.
5. Pick a face and double-click Send. **Submissions** shows one answer, with `route`, `app_version` and `account_id` filled.
6. 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

### How do I add a feedback button to a React app?

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.

### Why use the Popover API instead of a modal library?

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.

### Is the account id in the widget trustworthy?

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.

### Does the widget need a server?

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.

## More guides

- [Feedback form for a website or app](https://ikiform.com/guides/feedback-form.md): Put a feedback form on any page or inside your app: a rating, a comment, the page it came from, spam filtering, and alerts when a score is low.
- [Bug report form](https://ikiform.com/guides/feedback-form/bug-report.md): A bug report form that asks people for steps and severity, and lets the page add the URL, browser, viewport and recent errors on its own.
- [NPS survey](https://ikiform.com/guides/feedback-form/nps-survey.md): Run an NPS survey with a 0 to 10 score in the email, a follow-up question on your own page that changes with the score, and a script that calculates NPS.
- [Beta tester feedback](https://ikiform.com/guides/feedback-form/beta-tester-feedback.md): A beta feedback form with a personal link per tester and build, two rating scales and a weekly summary that shows whether each build got better.
- [Feature request form](https://ikiform.com/guides/feedback-form/feature-request.md): A feature request form that asks for the problem instead of the solution, stores requests by theme, counts people not rows, and emails them when it ships.
- [Cancellation survey](https://ikiform.com/guides/feedback-form/cancellation-survey.md): Ask why customers cancel inside your SaaS cancel flow, keep Skip one click away, save the reason to Stripe and Ikiform, and never delay the cancel.
