# Crea un widget flotante de feedback para tu app React

> Un botón flotante de feedback para una app React que abre un panel pequeño, envía la pantalla, la versión de la app y la cuenta con cada respuesta, y nunca pierde un borrador.

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

Renderiza un botón fijo que abre un panel basado en la Popover API del navegador, para que quede por encima de todas las capas de tu app sin pelearte con el z-index y se cierre con Escape. Al enviar, añade a los datos del formulario la ruta actual, la versión de la app, el id de la cuenta y el viewport, y envíalo desde el navegador con el SDK de Ikiform. Envía el email del usuario solo cuando marque una casilla pidiendo respuesta.

## Qué cambia en un widget dentro de la app

Un formulario en una página espera a que la gente llegue hasta él. Un widget está en todas las pantallas, y eso cambia lo que puede salir mal. Tiene que quedar por encima de tus propios modales, menús y toasts. No debe perder un mensaje a medio escribir cuando alguien hace clic fuera. Y como la gente ha iniciado sesión, ya sabes quién es y dónde está, así que el widget debería enviarlo en lugar de preguntarlo.

Diagrama: El recorrido de una sesión del widget de feedback, del clic a la respuesta guardada

1. El botón está siempre en pantalla, fijo en la esquina inferior derecha.
2. Al hacer clic se abre el panel en la top layer del navegador y se genera una nueva clave de intento.
3. El usuario elige una cara y escribe una nota. El estado del formulario sigue montado mientras el panel está abierto u oculto.
4. Al enviar, el widget añade la ruta, la versión de la app, el id de la cuenta y el viewport, y envía con el SDK.
5. Ikiform lo guarda una sola vez, aunque se pulse el botón dos veces, porque ambos envíos llevan la misma clave de idempotencia.
6. Si el usuario hace clic fuera o pulsa Escape antes, el panel se oculta y el borrador sigue ahí la próxima vez.

El cierre ligero (light dismiss) es el comportamiento por defecto de la Popover API. Como el panel solo se oculta, el campo de texto conserva lo escrito.

## Campos para una app

Formulario: In-app feedback

- **¿Qué tal va?** (`rating`, rating, obligatorio)
- **Cuéntanos más** (`message`, textarea)
- **El equipo puede responderme por email** (`may_reply`, checkbox)
- **Email** (`email`, email)
- **Pantalla** (`route`, text)
- **Versión de la app** (`app_version`, text)
- **Cuenta** (`account_id`, text)
- **Viewport** (`viewport`, text)

Los usuarios solo ven los tres primeros campos. El resto se añade en código al enviar. `may_reply` es una casilla, guardada como `true` o `false`.

| Campo | Viene de | Por qué |
| --- | --- | --- |
| `rating` | El usuario, cinco caras | Un campo `rating` con `max: 5`. Las caras se eligen más rápido que los números |
| `message` | El usuario | Opcional. Más útil cuando la cara es mala |
| `may_reply`, `email` | Una casilla, y luego tu sesión | El email solo se envía cuando piden respuesta, y Responder en la notificación va a ellos |
| `route` | `window.location.pathname` | Qué pantalla. Se lee al enviar, después de cualquier navegación en el cliente |
| `app_version` | Tu build | Te dice si una queja trata de código que ya has cambiado |
| `account_id` | Tu sesión | Te permite consultar su plan y sus datos. No prueba la identidad; mira más abajo |
| `viewport` | `window.innerWidth` e `innerHeight` | Las quejas de diseño suelen depender del tamaño |

## El componente

Necesita `@ikiform/sdk` y React 19, que tipa `popover`, `popoverTarget` y el evento `toggle`. El `popoverTarget` del botón lo vincula al panel, así que el navegador se encarga de abrirlo, de Escape y de devolver el foco al botón.

`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"` pone el panel en la top layer cuando está abierto. Se muestra por encima de tus diálogos y menús sin `z-index`, y se cierra cuando el usuario hace clic en otro sitio.
- Los hijos del panel siguen montados cuando se oculta, así que un borrador sobrevive a un clic fuera accidental. Tras un envío correcto, la línea de agradecimiento sustituye al formulario, lo que lo vacía, y la siguiente apertura muestra un formulario vacío.
- `onToggle` crea una clave nueva cada vez que se abre el panel. `submit()` la envía como cabecera `Idempotency-Key`, así que un doble clic guarda una sola respuesta. Consulta [idempotencia](https://ikiform.com/es/docs/frameworks.md#idempotency).
- El email solo se rellena cuando la casilla está marcada. Sin marcar, la respuesta lleva el id de la cuenta pero ninguna dirección.

Los estilos por defecto del popover lo centran en la pantalla. Estas reglas lo fijan encima del botón:

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

## Móntalo una vez

Renderiza el widget en el layout que envuelve las pantallas con sesión iniciada, para que aparezca en todas partes y sobreviva a la navegación. En el App Router de Next.js, el layout es un server component que lee la sesión y pasa props simples al widget de 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}
    </>
  );
}
```

Marca el archivo del widget con `"use client"` en el App Router. En una app con Vite o React Router, renderízalo junto al outlet del router. Funciona igual.

## Lo que el navegador puede y no puede probar

Este widget envía directamente desde el navegador con el id público del formulario. Para el feedback es el equilibrio correcto: no hay ruta de servidor que mantener, y el `meta.user_agent` de Ikiform es el navegador real del usuario. También significa que cualquiera puede enviar el `account_id` que quiera. Úsalo para consultar cosas, nunca para conceder nada.

> Si necesitas que la cuenta sea fiable, por ejemplo para mostrar el feedback en una herramienta de administración, envía desde tu servidor con la clave secreta y añade allí la cuenta a partir de la sesión. Las [versiones de servidor](https://ikiform.com/es/guides/feedback-form.md#form) de la guía principal muestran el patrón; elige el límite de frecuencia Relaxed o Custom, porque entonces todos los envíos llegan desde la IP de tu servidor.

## Comprueba que funciona

1. En **Allowed domains** del formulario, añade el dominio de tu sitio, y `localhost` mientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar.
2. Abre el panel, escribe una nota, haz clic fuera y vuelve a abrirlo. La nota sigue ahí.
3. Abre un modal en tu app y luego el widget. El panel aparece por encima del modal.
4. Pulsa Escape. El panel se cierra y el foco vuelve al botón de feedback.
5. Elige una cara y haz doble clic en Enviar. **Submissions** muestra una sola respuesta, con `route`, `app_version` y `account_id` rellenos.
6. Envía una vez con la casilla de respuesta marcada. El Responder del email de notificación va al usuario.

## Errores comunes

- Mantén el botón separado de tus propios elementos flotantes, como una burbuja de chat o un banner de cookies. Mueve uno de ellos, o abre el widget desde un elemento de menú.
- No muestres el botón en cada pantalla de un checkout o un editor donde la gente está concentrada. Ocúltalo ahí con una prop.
- La Popover API es compatible con las versiones actuales de Chrome, Edge, Firefox y Safari. Si tienes que dar soporte a navegadores antiguos, compruébalo antes de depender de ella.
- Cada campo añadido en código debe existir en la definición del formulario, o Ikiform rechaza el envío con un error de campo desconocido.
- No envíes todo el estado de la app. Ruta, versión, cuenta y tamaño responden a la mayoría de preguntas; cualquier otra cosa es una cuestión de privacidad.

## Solución de problemas

| Problema | Qué hacer |
| --- | --- |
| `403 origin_not_allowed`, o todos los envíos acaban en spam | El dominio de la página no está en **Allowed domains** del formulario. Añádelo, y `localhost` para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |
| `403 challenge_required` | La verificación antispam del formulario está en **Ikiform** y un envío con `fetch` no llevaba token. Consigue uno con `challenge(formId)` de `@ikiform/sdk` antes de cada envío, o desactiva la verificación. |
| El panel aparece en el centro de la pantalla | La hoja de estilos no se cargó, así que se aplican los estilos por defecto del popover del navegador. Revisa el import del CSS, o define `inset` y `margin` en el panel. |
| `422 validation_failed` | El envío no coincide con la definición de campos del formulario. `error.fields` indica cada campo y el motivo. Haz que el `name` del input coincida con el id del campo. |

## Preguntas

### ¿Cómo añado un botón de feedback a una app React?

Renderiza un botón fijo con popoverTarget apuntando a un panel con popover=auto. El navegador se encarga de abrir, cerrar y del foco. Envía el formulario del panel con submit() de @ikiform/sdk y añade la ruta y la versión de la app al enviar.

### ¿Por qué usar la Popover API en lugar de una librería de modales?

Los popovers se renderizan en la top layer, así que quedan por encima de tus propios diálogos sin reglas de z-index, y el cierre ligero y Escape vienen gratis. Un panel de feedback no necesita bloquear la página como lo hace un modal.

### ¿Es fiable el id de cuenta del widget?

No. Todo lo que se envía desde el navegador lo puede cambiar el usuario. Úsalo para consultar contexto, y envía desde tu servidor con una clave secreta cuando la identidad tenga que ser fiable.

### ¿El widget necesita un servidor?

No. Envía directamente a Ikiform desde el navegador, y Allowed domains limita qué sitios pueden usar el formulario. Añade una ruta de servidor solo si necesitas una identidad fiable.

## Más guías

- [Formulario de feedback para un sitio web o una app](https://ikiform.com/es/guides/feedback-form.md): Pon un formulario de feedback en cualquier página o dentro de tu app: una valoración, un comentario, la página de origen, filtro antispam y avisos cuando la nota es baja.
- [Formulario de reporte de bugs](https://ikiform.com/es/guides/feedback-form/bug-report.md): Un formulario de reporte de bugs que pide a la gente los pasos y la gravedad, y deja que la página añada por su cuenta la URL, el navegador, el viewport y los errores recientes.
- [Encuesta NPS](https://ikiform.com/es/guides/feedback-form/nps-survey.md): Haz una encuesta NPS con una puntuación de 0 a 10 en el email, una pregunta de seguimiento en tu propia página que cambia según la puntuación y un script que calcula el NPS.
- [Feedback de beta testers](https://ikiform.com/es/guides/feedback-form/beta-tester-feedback.md): Un formulario de feedback para betas con un enlace personal por tester y build, dos escalas de valoración y un resumen semanal que muestra si cada build mejoró.
- [Formulario de peticiones de funcionalidades](https://ikiform.com/es/guides/feedback-form/feature-request.md): Un formulario de peticiones de funcionalidades que pregunta por el problema en lugar de la solución, guarda las peticiones por tema, cuenta personas y no filas, y les avisa por email cuando sale.
- [Encuesta de cancelación](https://ikiform.com/es/guides/feedback-form/cancellation-survey.md): Pregunta por qué cancelan tus clientes dentro del flujo de baja de tu SaaS, deja Omitir a un clic, guarda el motivo en Stripe e Ikiform y nunca retrases la baja.
