# React アプリにフローティングのフィードバックウィジェットを作る

> React アプリ用のフローティングのフィードバックボタン。小さなパネルを開き、回答ごとに画面、アプリのバージョン、アカウントを送り、下書きを失いません。

公開日 2026-10-25 · https://ikiform.com/ja/guides/feedback-form/react-feedback-widget

固定ボタンを表示し、ブラウザーの Popover API で作ったパネルを開きます。z-index と格闘せずにアプリのどのレイヤーよりも上に表示され、Escape で閉じます。送信時に現在のルート、アプリのバージョン、アカウント ID、ビューポートをフォームデータに加え、Ikiform SDK でブラウザーから送信します。ユーザーのメールアドレスは、返信を希望するチェックボックスにチェックが入ったときだけ送ります。

## アプリ内ウィジェットならではの違い

ページのフォームは、人がそこまでスクロールするのを待ちます。ウィジェットはすべての画面にあるので、起こりうる問題が変わります。自分のアプリのモーダル、メニュー、トーストより上に表示しなければなりません。誰かが外側をクリックしても、書きかけのメッセージを失ってはいけません。そしてユーザーはサインインしているので、誰がどこにいるかはすでにわかっています。ウィジェットは尋ねる代わりにそれを送るべきです。

図: フィードバックウィジェットの 1 セッション、クリックから保存まで

1. ボタンは常に画面右下に固定表示されています。
2. クリックするとブラウザーのトップレイヤーにパネルが開き、新しい試行キーが作られます。
3. ユーザーが顔を選んでメモを書きます。フォームの状態は、パネルが開いていても隠れていてもマウントされたままです。
4. 送信時に、ウィジェットがルート、アプリのバージョン、アカウント ID、ビューポートを加え、SDK で送信します。
5. ボタンが 2 回押されても、両方の送信に同じ冪等性キーが付くので、Ikiform は 1 回だけ保存します。
6. ユーザーが先に外側をクリックするか Escape を押すと、パネルは隠れ、下書きは次回もそのまま残ります。

ライトディスミスは Popover API のデフォルトです。パネルは隠れるだけなので、テキスト欄には入力した内容が残ります。

## アプリ向けのフィールド

フォーム: In-app feedback

- **使い心地はいかがですか？** (`rating`, rating, 必須)
- **詳しく教えてください** (`message`, textarea)
- **チームからメールで返信してもよい** (`may_reply`, checkbox)
- **メールアドレス** (`email`, email)
- **画面** (`route`, text)
- **アプリのバージョン** (`app_version`, text)
- **アカウント** (`account_id`, text)
- **ビューポート** (`viewport`, text)

ユーザーに見えるのは最初の 3 つのフィールドだけです。残りは送信時にコードで追加します。`may_reply` はチェックボックスで、`true` か `false` として保存されます。

| フィールド | 取得元 | 理由 |
| --- | --- | --- |
| `rating` | ユーザー、5 つの顔 | `max: 5` の `rating` フィールド。顔は数字より素早く選べます |
| `message` | ユーザー | 任意。顔の評価が悪いときに特に役立ちます |
| `may_reply`, `email` | チェックボックス、次にセッション | メールアドレスは返信を希望したときだけ送られ、通知の Reply は本人宛てになります |
| `route` | `window.location.pathname` | どの画面か。クライアント側の遷移のあと、送信時に読み取ります |
| `app_version` | 自分のビルド | 不満がすでに変更したコードについてのものかどうかがわかります |
| `account_id` | 自分のセッション | プランやデータを調べられます。本人確認の証明にはなりません。下記参照 |
| `viewport` | `window.innerWidth` と `innerHeight` | レイアウトの不満はサイズに左右されがちです |

## コンポーネント

`@ikiform/sdk` と、`popover`、`popoverTarget`、`toggle` イベントに型がある React 19 が必要です。ボタンの `popoverTarget` がパネルと結び付けるので、開く操作、Escape、ボタンへのフォーカスの戻りはブラウザーが処理します。

`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"` は、開いたパネルをトップレイヤーに置きます。`z-index` なしでダイアログやメニューの上に表示され、ユーザーがほかの場所をクリックすると閉じます。
- パネルの子要素は隠れてもマウントされたままなので、うっかり外側をクリックしても下書きは残ります。送信に成功すると、お礼の一文がフォームに置き換わってフォームがクリアされ、次に開くと空のフォームが表示されます。
- `onToggle` はパネルを開くたびに新しいキーを作ります。`submit()` はそれを `Idempotency-Key` ヘッダーとして送るので、ダブルクリックしても保存される回答は 1 件です。[冪等性](https://ikiform.com/ja/docs/frameworks.md#idempotency)を参照してください。
- メールアドレスは、チェックボックスにチェックが入ったときだけ設定されます。チェックがなければ、回答にはアカウント ID が入りますが、アドレスは入りません。

ポップオーバーのデフォルトのスタイルでは画面中央に表示されます。次のルールで、代わりにボタンの上に固定します:

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

## 1 回だけマウントする

ウィジェットはサインイン後の画面を包むレイアウトで表示します。そうすればどこにでも表示され、ページ遷移でも消えません。Next.js の App Router では、レイアウトはセッションを読み取ってクライアントのウィジェットにプレーンな props を渡すサーバーコンポーネントです:

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

App Router では、ウィジェットのファイルに `"use client"` を付けます。Vite や React Router のアプリでは、ルーターのアウトレットの隣に表示します。動作は同じです。

## ブラウザーが証明できること、できないこと

このウィジェットは、フォームの公開 ID でブラウザーから直接送信します。フィードバックにはこれが適したトレードオフです。保守するサーバールートがなく、Ikiform の `meta.user_agent` はユーザーの本当のブラウザーになります。ただし、誰でも好きな `account_id` を送れるということでもあります。情報を調べるために使い、何かの権限を与えるためには決して使わないでください。

> アカウントを信頼できる必要があるなら、たとえば管理ツール内にフィードバックを表示するなら、シークレットキーを使ってサーバーから送信し、そこでセッションからアカウントを加えてください。そのパターンはピラーの[サーバー版](https://ikiform.com/ja/guides/feedback-form.md#form)にあります。すべての送信がサーバーの IP から来るので、レート制限は Relaxed か Custom を選んでください。

## 動作を確認する

1. フォームの **Allowed domains** に自分のサイトのドメインを追加し、開発中は `localhost` も追加します。空のままにすると、どこからの送信も受け付けます。
2. パネルを開いてメモを入力し、外側をクリックしてから、もう一度開きます。メモはそのまま残っています。
3. アプリでモーダルを開いてから、ウィジェットを開きます。パネルはモーダルの上に表示されます。
4. Escape を押します。パネルが閉じ、フォーカスが Feedback ボタンに戻ります。
5. 表情を 1 つ選んで送信をダブルクリックします。**Submissions** には回答が 1 件だけ表示され、`route`、`app_version`、`account_id` が埋まっています。
6. 返信のチェックボックスにチェックを入れて 1 回送信します。通知メールの返信先はユーザーになります。

## ハマりどころ

- ボタンは、チャットのバブルや Cookie バナーなど、自分のサイトの浮いている要素と重ならないようにします。どちらかを動かすか、代わりにメニュー項目からウィジェットを開いてください。
- チェックアウトやエディターなど、人が集中している画面すべてにボタンを出さないでください。そこでは prop で非表示にします。
- Popover API は現行の Chrome、Edge、Firefox、Safari で使えます。古いブラウザーに対応する必要があるなら、頼る前に確認してください。
- コードで追加するフィールドはすべてフォームの定義に存在している必要があります。そうでないと Ikiform は Unknown field エラーで送信を拒否します。
- アプリの状態をまるごと送らないでください。ルート、バージョン、アカウント、サイズでほとんどの疑問に答えられます。それ以外はプライバシーの問題になります。

## トラブルシューティング

| 問題 | 対処法 |
| --- | --- |
| `403 origin_not_allowed` が返る、またはすべての送信がスパムになる | ページのドメインがフォームの **Allowed domains** にありません。ドメインと、ローカルテスト用の `localhost` を追加してください。登録したドメインのサブドメインは許可されます。 |
| `403 challenge_required` | フォームのスパムチェックが **Ikiform** に設定されていて、`fetch` の送信にトークンがありませんでした。送信のたびに `@ikiform/sdk` の `challenge(formId)` でトークンを取得するか、チェックをオフにしてください。 |
| パネルが画面の中央に表示される | スタイルシートが読み込まれず、ブラウザーのデフォルトの popover スタイルが適用されています。CSS の import を確認するか、パネルに `inset` と `margin` を設定してください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |

## よくある質問

### React アプリにフィードバックボタンを追加するには？

固定配置のボタンを描画し、popoverTarget で popover=auto を持つパネルを指定します。開閉とフォーカスはブラウザーが処理します。パネルのフォームは @ikiform/sdk の submit() で送信し、送信時にルートとアプリのバージョンを加えます。

### モーダルのライブラリではなく Popover API を使うのはなぜですか？

popover はトップレイヤーに描画されるので、z-index のルールなしで自分のダイアログより上に表示され、外側クリックや Escape で閉じる動作も最初から付いてきます。フィードバックパネルは、モーダルのようにページを塞ぐ必要がありません。

### ウィジェットのアカウント ID は信頼できますか？

いいえ。ブラウザーから送られるものはすべてユーザーが書き換えられます。文脈を調べるために使い、本人確認を信頼する必要がある場合はサーバーからシークレットキー付きで送信してください。

### ウィジェットにサーバーは必要ですか？

いいえ。ブラウザーから直接 Ikiform に送信し、Allowed domains でフォームを使えるサイトを制限します。信頼できる本人確認が必要な場合にだけ、サーバールートを追加してください。

## その他のガイド

- [ウェブサイトやアプリのフィードバックフォーム](https://ikiform.com/ja/guides/feedback-form.md): どのページにもアプリ内にもフィードバックフォームを設置。評価、コメント、送信元ページ、スパム対策、低評価時の通知まで。
- [バグ報告フォーム](https://ikiform.com/ja/guides/feedback-form/bug-report.md): 手順と深刻度は本人に尋ね、URL、ブラウザー、ビューポート、直近のエラーはページが自動で追加するバグ報告フォームです。
- [NPS アンケート](https://ikiform.com/ja/guides/feedback-form/nps-survey.md): メールに 0〜10 のスコアを載せ、スコアに応じて変わる追加質問を自分のページで尋ね、スクリプトで NPS を計算する NPS アンケートを実施します。
- [ベータテスターのフィードバック](https://ikiform.com/ja/guides/feedback-form/beta-tester-feedback.md): テスターとビルドごとの個別リンク、2 つの評価スケール、各ビルドが良くなったかがわかる週次サマリーを備えたベータ版フィードバックフォームです。
- [機能要望フォーム](https://ikiform.com/ja/guides/feedback-form/feature-request.md): 解決策ではなく課題を尋ね、要望をテーマ別に保存し、件数ではなく人数で数え、リリース時にメールで知らせる機能要望フォームです。
- [解約アンケート](https://ikiform.com/ja/guides/feedback-form/cancellation-survey.md): SaaS の解約フローで解約理由を尋ね、スキップはワンクリックのまま、理由を Stripe と Ikiform に保存し、解約を決して遅らせない方法。
