# Construa um widget de feedback flutuante para o seu app React

> Um botão de feedback flutuante para um app React que abre um pequeno painel, envia a tela, a versão do app e a conta com cada resposta e nunca perde um rascunho.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/feedback-form/react-feedback-widget

Renderize um botão fixo que abre um painel feito com a Popover API do navegador, para que ele fique acima de todas as camadas do seu app sem briga de z-index e feche com Escape. No envio, adicione a rota atual, a versão do app, o id da conta e o viewport aos dados do formulário e envie a partir do navegador com o SDK do Ikiform. Mande o e-mail do usuário só quando ele marcar uma caixa pedindo resposta.

## O que muda num widget dentro do app

Um formulário na página espera as pessoas rolarem até ele. Um widget está em todas as telas, o que muda o que pode dar errado. Ele precisa ficar acima dos seus próprios modais, menus e toasts. Não pode perder uma mensagem escrita pela metade quando alguém clica fora dele. E como as pessoas estão logadas, você já sabe quem são e onde estão, então o widget deve enviar isso em vez de perguntar.

Diagrama: A vida de uma sessão do widget de feedback, do clique à resposta armazenada

1. O botão está sempre na tela, fixo no canto inferior direito.
2. Clicar nele abre o painel na top layer do navegador e gera uma nova chave de tentativa.
3. O usuário escolhe uma carinha e escreve uma nota. O estado do formulário continua montado enquanto o painel está aberto ou oculto.
4. No envio, o widget adiciona a rota, a versão do app, o id da conta e o viewport, e envia com o SDK.
5. O Ikiform armazena uma única vez, mesmo se o botão for clicado duas vezes, porque os dois envios levam a mesma chave de idempotência.
6. Se o usuário clicar fora ou apertar Escape antes, o painel se oculta e o rascunho continua lá na próxima vez.

O light dismiss é o padrão da Popover API. Como o painel só se oculta, o campo de texto mantém o que foi digitado.

## Campos para um app

Formulário: In-app feedback

- **Como está indo?** (`rating`, rating, obrigatório)
- **Conte mais** (`message`, textarea)
- **A equipe pode me responder por e-mail** (`may_reply`, checkbox)
- **E-mail** (`email`, email)
- **Tela** (`route`, text)
- **Versão do app** (`app_version`, text)
- **Conta** (`account_id`, text)
- **Viewport** (`viewport`, text)

Os usuários só veem os três primeiros campos. O resto é adicionado no código no envio. `may_reply` é um checkbox, armazenado como `true` ou `false`.

| Campo | Vem de | Por quê |
| --- | --- | --- |
| `rating` | O usuário, cinco carinhas | Um campo `rating` com `max: 5`. Carinhas são mais rápidas de escolher que números |
| `message` | O usuário | Opcional. Mais útil quando a carinha é ruim |
| `may_reply`, `email` | Um checkbox, depois a sua sessão | O e-mail só é enviado quando a pessoa pede resposta, e o Reply da notificação vai para ela |
| `route` | `window.location.pathname` | Qual tela. Lido no envio, depois de qualquer navegação no lado do cliente |
| `app_version` | Seu build | Mostra se uma reclamação é sobre código que você já mudou |
| `account_id` | Sua sessão | Permite consultar o plano e os dados da pessoa. Não é prova de identidade, veja abaixo |
| `viewport` | `window.innerWidth` e `innerHeight` | Reclamações de layout costumam depender do tamanho |

## O componente

Ele precisa de `@ikiform/sdk` e do React 19, que tipa `popover`, `popoverTarget` e o evento `toggle`. O `popoverTarget` do botão o liga ao painel, então o navegador cuida de abrir, do Escape e de devolver o foco ao botão.

`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"` coloca o painel na top layer quando aberto. Ele aparece acima dos seus dialogs e menus sem `z-index`, e fecha quando o usuário clica em outro lugar.
- Os filhos do painel continuam montados quando ele se oculta, então um rascunho sobrevive a um clique acidental fora. Depois de um envio bem-sucedido, a frase de agradecimento substitui o formulário, o que o limpa, e a próxima abertura mostra um formulário vazio.
- `onToggle` gera uma chave nova toda vez que o painel abre. `submit()` a envia como o header `Idempotency-Key`, então um clique duplo armazena uma única resposta. Veja [idempotência](https://ikiform.com/pt-br/docs/frameworks.md#idempotency).
- O e-mail só é definido quando a caixa está marcada. Desmarcada, a resposta leva o id da conta, mas nenhum endereço.

Os estilos padrão do popover o centralizam na tela. Estas regras o fixam acima do botão:

`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;
}
```

## Monte uma vez

Renderize o widget no layout que envolve as telas logadas, para que ele apareça em todo lugar e sobreviva à navegação. No App Router do Next.js, o layout é um server component que lê a sessão e passa props simples para o widget no cliente:

`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}
    </>
  );
}
```

Marque o arquivo do widget com `"use client"` no App Router. Num app Vite ou React Router, renderize-o ao lado do outlet do router. Funciona do mesmo jeito.

## O que o navegador consegue e não consegue provar

Este widget envia direto do navegador com o id público do formulário. Essa é a troca certa para feedback: nenhuma rota de servidor para manter, e o `meta.user_agent` do próprio Ikiform é o navegador real do usuário. Também significa que qualquer um pode enviar o `account_id` que quiser. Use-o para consultar dados, nunca para conceder algo.

> Se você precisa que a conta seja confiável, por exemplo para mostrar o feedback dentro de uma ferramenta de admin, envie a partir do seu servidor com a chave secreta e adicione a conta da sessão lá. As [versões de servidor](https://ikiform.com/pt-br/guides/feedback-form.md#form) do guia principal mostram o padrão; escolha o limite de taxa Relaxed ou Custom, porque todo envio passa a vir do IP do seu servidor.

## Confira se funciona

1. Em **Allowed domains** do formulário, adicione o domínio do seu site, mais `localhost` enquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar.
2. Abra o painel, digite uma nota, clique fora e abra de novo. A nota continua lá.
3. Abra um modal no seu app e depois o widget. O painel aparece acima do modal.
4. Aperte Escape. O painel fecha e o foco volta para o botão Feedback.
5. Escolha uma carinha e dê clique duplo em Enviar. **Submissions** mostra uma única resposta, com `route`, `app_version` e `account_id` preenchidos.
6. Envie uma vez com a caixa de resposta marcada. O Reply do e-mail de notificação vai para o usuário.

## Pegadinhas

- Mantenha o botão longe dos seus próprios elementos flutuantes, como uma bolha de chat ou um banner de cookies. Mova um deles, ou abra o widget a partir de um item de menu.
- Não mostre o botão em todas as telas de um checkout ou editor onde as pessoas estão concentradas. Oculte-o ali com uma prop.
- A Popover API é suportada nas versões atuais do Chrome, Edge, Firefox e Safari. Se você precisa suportar navegadores mais antigos, verifique antes de depender dela.
- Todo campo adicionado no código precisa existir na definição do formulário, ou o Ikiform recusa o envio com um erro Unknown field.
- Não mande o estado inteiro do app. Rota, versão, conta e tamanho respondem à maioria das perguntas; qualquer outra coisa é uma questão de privacidade.

## Solução de problemas

| Problema | O que fazer |
| --- | --- |
| `403 origin_not_allowed`, ou todo envio cai no spam | O domínio da página não está em **Allowed domains** do formulário. Adicione-o, mais `localhost` para testes locais. Subdomínios de um domínio listado são permitidos. |
| `403 challenge_required` | A verificação de spam do formulário está como **Ikiform** e um envio por `fetch` não tinha token. Obtenha um com `challenge(formId)` de `@ikiform/sdk` antes de cada envio, ou desligue a verificação. |
| O painel aparece no meio da tela | A folha de estilos não carregou, então os estilos padrão de popover do navegador se aplicam. Confira o import do CSS, ou defina `inset` e `margin` no painel. |
| `422 validation_failed` | O envio não corresponde à definição de campos do formulário. `error.fields` aponta cada campo e o motivo. Faça o `name` do input bater com o id do campo. |

## Perguntas

### Como adiciono um botão de feedback a um app React?

Renderize um botão fixo com popoverTarget apontando para um painel com popover=auto. O navegador cuida de abrir, fechar e do foco. Envie o formulário do painel com submit() de @ikiform/sdk e adicione a rota e a versão do app no envio.

### Por que usar a Popover API em vez de uma biblioteca de modais?

Popovers são renderizados na top layer, então ficam acima dos seus próprios dialogs sem regras de z-index, e o light dismiss e o Escape vêm de graça. Um painel de feedback não precisa bloquear a página como um modal faz.

### O id da conta no widget é confiável?

Não. Qualquer coisa enviada do navegador pode ser alterada pelo usuário. Use-o para consultar contexto, e envie a partir do seu servidor com uma chave secreta quando a identidade precisar ser confiável.

### O widget precisa de um servidor?

Não. Ele envia direto ao Ikiform a partir do navegador, e Allowed domains limita quais sites podem usar o formulário. Adicione uma rota de servidor só se você precisar de identidade confiável.

## Mais guias

- [Formulário de feedback para um site ou app](https://ikiform.com/pt-br/guides/feedback-form.md): Coloque um formulário de feedback em qualquer página ou dentro do seu app: uma nota, um comentário, a página de origem, filtro de spam e alertas para notas baixas.
- [Formulário de relato de bug](https://ikiform.com/pt-br/guides/feedback-form/bug-report.md): Um formulário de relato de bug que pede às pessoas os passos e a gravidade, e deixa a página adicionar sozinha a URL, o navegador, o viewport e os erros recentes.
- [Pesquisa NPS](https://ikiform.com/pt-br/guides/feedback-form/nps-survey.md): Rode uma pesquisa NPS com a nota de 0 a 10 no e-mail, uma pergunta de acompanhamento na sua própria página que muda com a nota e um script que calcula o NPS.
- [Feedback de beta testers](https://ikiform.com/pt-br/guides/feedback-form/beta-tester-feedback.md): Um formulário de feedback de beta com um link pessoal por tester e build, duas escalas de nota e um resumo semanal que mostra se cada build melhorou.
- [Formulário de pedido de funcionalidade](https://ikiform.com/pt-br/guides/feedback-form/feature-request.md): Um formulário de pedido de funcionalidade que pergunta pelo problema em vez da solução, armazena pedidos por tema, conta pessoas, não linhas, e avisa quando sair.
- [Pesquisa de cancelamento](https://ikiform.com/pt-br/guides/feedback-form/cancellation-survey.md): Pergunte por que os clientes cancelam dentro do fluxo de cancelamento do seu SaaS, deixe o Pular a um clique, salve o motivo no Stripe e no Ikiform e nunca atrase o cancelamento.
