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
# pnpm
pnpm add @opini-dev/react
# npm
npm install @opini-dev/reactReact 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.
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.
<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.
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.
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.