Feedback form for a website or app

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.

Preet SutharFounder, Ikiform

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 life of one feedback widget session, from click to stored answerFeedbackbuttonfixed,bottomrightpanelopenstop layer,newattemptkeyuserratesandwritesstatelives inthe panelsubmitwithcontextroute,version,account,viewportstoredoncesame keyon adoubleclickpanelhidesdraft keptfor nextopenclickSDKclick outside or EscapeThe life of one feedback widget session, from click to stored answerFeedback buttonfixed, bottom rightpanel openstop layer, new attemptkeyuser rates andwritesstate lives in thepanelsubmit withcontextroute, version,account, viewportstored oncesame key on a doubleclickpanelhidesdraft keptfor nextopenclickSDKclick outside or Escape
  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

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

How is it going?
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.
FieldComes fromWhy
ratingThe user, five facesA rating field with max: 5. Faces are quicker to pick than numbers
messageThe userOptional. Most useful when the face is a bad one
may_reply, emailA checkbox, then your sessionThe email is sent only when they ask for a reply, and Reply in the notification goes to them
routewindow.location.pathnameWhich screen. Read at submit, after any client-side navigation
app_versionYour buildTells you if a complaint is about code you've already changed
account_idYour sessionLets you look up their plan and data. Not proof of identity, see below
viewportwindow.innerWidth and innerHeightLayout 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
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.
  • 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
.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
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 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

ProblemWhat to do
403 origin_not_allowed, or every post lands in spamThe 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_requiredThe 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 screenThe 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_failedThe 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

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.