Skip to main content
opini

React package

Embed Opini directly into your React app. Four styling tiers, from drop-in to fully headless. Plain inputs, no field components — your layout stays yours.

Install

bash
# pnpm
pnpm add @opini-dev/react

# npm
npm install @opini-dev/react

React 18 or 19 peer dep. ESM-only. Server-rendering safe — floating-mode portals only mount after the first effect on the client.

Tier 1 — drop-in

One component. Default styles ship on so it looks like a feedback widget out of the box.

tsx
import { OpiniWidget } from "@opini-dev/react"

export default function App() {
  return <OpiniWidget projectKey="pk_live_xxx" />
}

Pass unstyled if you want to start from a blank slate.

Tier 2 — slot classNames

Apply Tailwind / CSS Module / vanilla classNames to structural slots. Field styling stays in your form HTML — see Tier 3.

tsx
<OpiniWidget
  projectKey="pk_live_xxx"
  unstyled
  className="fixed bottom-6 right-6"
  classNames={{
    trigger: "rounded-full bg-black px-5 py-2.5 text-sm text-white",
    popover: "w-96 rounded-2xl border bg-white p-5 shadow-xl",
    submit: "rounded-md bg-violet-600 px-3 py-2 text-white hover:bg-violet-700",
  }}
/>

For CSS-only transitions, every slot exposes data-state="open|closed" and data-loading="true|false". Tailwind users can wire entrance/exit with data-[state=open]:scale-100.

Tier 3 — composable parts

Bring your own button, your own copy, your own field layout. Plain <input> / <textarea> with name="message" / "email" / "name" are the field contract.

tsx
import {
  OpiniWidgetRoot,
  OpiniTrigger,
  OpiniPopover,
  OpiniForm,
  OpiniSubmit,
  OpiniSuccess,
} from "@opini-dev/react"

<OpiniWidgetRoot projectKey="pk_live_xxx">
  <OpiniTrigger asChild>
    <MyBrandedButton>Send feedback →</MyBrandedButton>
  </OpiniTrigger>
  <OpiniPopover className="my-popover">
    <h2>Pitch a chapter.</h2>
    <OpiniForm className="space-y-3">
      <textarea
        name="message"
        placeholder="Albums that auto-curate themselves on a trip…"
      />
      <input name="name" placeholder="name (optional)" />
      <input name="email" type="email" placeholder="email" />
      <OpiniSubmit asChild>
        {({ status }) => (
          <MyBrandedButton variant="primary" disabled={status === "submitting"}>
            {status === "submitting" ? "Sending…" : "SEND IT →"}
          </MyBrandedButton>
        )}
      </OpiniSubmit>
    </OpiniForm>
    <OpiniSuccess>You'll hear back. Promise.</OpiniSuccess>
  </OpiniPopover>
</OpiniWidgetRoot>

Tier 4 — useOpiniSubmit (headless)

When you want the API client + state machine without any chrome at all.

tsx
import { useOpiniSubmit } from "@opini-dev/react"

function MyOwnForm() {
  const { submit, status, error } = useOpiniSubmit({ projectKey: "pk_live_xxx" })
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const fd = new FormData(e.currentTarget)
        void submit({
          text: String(fd.get("message") ?? ""),
          email: fd.get("email") ? String(fd.get("email")) : undefined,
        })
      }}
    >
      <textarea name="message" />
      <input name="email" />
      <button disabled={status === "submitting"}>Send</button>
      {error ? <p>{error.message}</p> : null}
    </form>
  )
}

Props

Every prop is typed, so your editor lists them as you type. There is no customCss prop — that field on the dashboard targets the script-tag widget bundle only. In React you style with whatever pipeline you already have.

Slot keys

The exhaustive list: trigger, popover, arrow, close, form, actions, submit, success, msg. Every slot also carries a data-opini-part="…" attribute for CSS-in-JS users.

Field-name contract

<OpiniForm> reads the form via FormData on submit. The allowlist is exactly three fields:

  • name="message" — required, the feedback body.
  • name="email" — optional, validated when present.
  • name="name" — optional, free-form.

Anything else is silently dropped before the POST, so you can add your own form-state fields without polluting the payload.

SSR

Floating-mode portal contents render only after the first effect on the client; you'll see no portal output in the initial HTML. Hydration is a no-op. Inline mode renders straight into your tree on both server and client.