Pulses
Per-feature reactions (👍 / 👎) and scoped comments, wrapped around any region of your UI as JSX. Reactions roll up into a sentiment headline ("Checkout button: 78% positive across 412 reactions") on the dashboard; comments thread inside the same triage / board / umbrella pipeline your free-text feedback already lives in.
Quickstart
Install @opini-dev/[email protected] (Pulses ship with the React package; same install command as the existing widget). Five lines of JSX gets you reactions + a scoped comment input on any component:
import { OpiniPulse, OpiniReactions, OpiniComment } from "@opini-dev/react"
<OpiniPulse name="checkout-button" projectKey="pk_live_xxx">
<button>Buy now</button>
<OpiniReactions />
<OpiniComment placeholder="Tell us why" />
</OpiniPulse>The first reaction with a never-seen name auto-registers the pulse with a humanised default; it appears on the dashboard's /pulses page the next time the admin loads it. No dashboard setup required before shipping.
Tier 1 — drop-in
Default styles are on. Renders the 👍 / 👎 chips inline with your content, plus an optional comment textarea.
import { OpiniPulse, OpiniReactions, OpiniComment, DefaultStyles } from "@opini-dev/react"
export function ChartCard() {
return (
<article>
<DefaultStyles />
<h2>Engagement over time</h2>
<ChartPNG />
<OpiniPulse name="dashboard-engagement-chart" projectKey="pk_live_xxx">
<OpiniReactions />
<OpiniComment placeholder="What's missing here?" />
</OpiniPulse>
</article>
)
}Pass unstyled on the parent <OpiniPulse> to opt out of the default look entirely.
Tier 2 — slot classNames
Apply Tailwind / CSS-Modules / vanilla classNames to structural slots. Slot keys for the chip pair: reactions, reactionUp, reactionDown, reactionCount, reactionUpActive, reactionDownActive, reactionDisabled. For the comment form: comment, commentTextarea, commentEmailInput, commentNameInput, commentActions, commentSubmit, commentSuccess, commentStatus.
import { OpiniPulse, OpiniReactions, OpiniComment } from "@opini-dev/react"
export function PricingTable() {
return (
<OpiniPulse
name="pricing-table"
projectKey="pk_live_xxx"
className="mt-4 flex items-center gap-3 text-sm"
classNames={{
reactions: "inline-flex items-center gap-2",
reactionUp: "rounded-full border px-2 py-1 hover:bg-emerald-50 data-[active=true]:bg-emerald-100",
reactionDown: "rounded-full border px-2 py-1 hover:bg-rose-50 data-[active=true]:bg-rose-100",
reactionCount: "text-xs text-zinc-500",
comment: "ml-3 flex items-center gap-2",
commentTextarea: "rounded border px-2 py-1 text-xs",
commentSubmit: "rounded bg-zinc-900 px-2 py-1 text-xs text-white",
}}
>
<span className="text-zinc-500">Was this clear?</span>
<OpiniReactions />
<OpiniComment placeholder="What was unclear?" />
</OpiniPulse>
)
}Every chip carries data-state="idle | submitting | reacted-up | reacted-down | error | locked", data-active="true|false", and aria-pressed on the active chip. Tailwind users can drive the active visual with data-[active=true]:… without writing JS.
Tier 3 — composable parts
Reach for <OpiniReactionUp /> / <OpiniReactionDown /> when you want to lay the chips out yourself — one on each side of a card, only an upvote, branded buttons via asChild. Children-as-function gets you the active / count state for branded copy.
import {
OpiniPulse,
OpiniReactionUp,
OpiniReactionDown,
OpiniComment,
} from "@opini-dev/react"
import { ThumbsUp, ThumbsDown } from "lucide-react"
import { Button } from "@/components/ui/button"
export function ReleaseNotesEntry({ slug, title, body }: Props) {
return (
<OpiniPulse name={`release-${slug}`} projectKey="pk_live_xxx">
<header className="flex items-center justify-between">
<h3>{title}</h3>
<div className="flex gap-2">
<OpiniReactionUp asChild>
<Button variant="ghost" size="sm">
{({ active, count }) => (
<>
<ThumbsUp data-active={active} />
<span>{count}</span>
</>
)}
</Button>
</OpiniReactionUp>
<OpiniReactionDown asChild>
<Button variant="ghost" size="sm">
{({ active, count }) => (
<>
<ThumbsDown data-active={active} />
<span>{count}</span>
</>
)}
</Button>
</OpiniReactionDown>
</div>
</header>
<p>{body}</p>
<OpiniComment placeholder="Reply to the team">
<textarea name="message" rows={2} placeholder="Reply…" />
<input name="email" type="email" placeholder="email (optional)" />
<button type="submit">Send</button>
</OpiniComment>
</OpiniPulse>
)
}The comment form follows the same plain-input field contract as <OpiniForm>: name="message" is required; name="email" and name="name" are optional. Anything else on the form is dropped before the POST.
Tier 4 — useOpiniPulse (headless)
The headless hook gives you react, comment, and the full observable state — status, countUp, countDown, youReacted, sentiment, locked, error.
import { useOpiniPulse } from "@opini-dev/react"
export function MyCustomChip({ pulseKey }: { pulseKey: string }) {
const { react, status, countUp, countDown, youReacted, sentiment, locked } =
useOpiniPulse({ name: pulseKey, projectKey: "pk_live_xxx" })
if (locked) return <span className="text-zinc-400">Reactions disabled</span>
const total = countUp + countDown
return (
<div className="flex items-center gap-3">
<button
onClick={() => react("up")}
disabled={status === "submitting"}
aria-pressed={youReacted === "up"}
>
👍 {countUp}
</button>
<button
onClick={() => react("down")}
disabled={status === "submitting"}
aria-pressed={youReacted === "down"}
>
👎 {countDown}
</button>
<span>
{total > 0 ? `${Math.round((sentiment ?? 0) * 100)}% positive` : "no reactions yet"}
</span>
</div>
)
}Called inside an <OpiniPulse> provider, the hook subscribes to context (so two consumers in the same pulse share a single state machine). Called outside, it allocates its own state for the given (projectKey, name) pair.
Wire shape
The reactions endpoint is the only new public route. The existing /api/v1/ingest endpoint gains exactly one new optional field — pulse_key — so submitting a comment scoped to a pulse keeps using the same ingest path you already wired up.
// POST /api/v1/ingest/reactions
{
"project_key": "pk_live_xxx",
"pulse_key": "checkout-button",
"kind": "up", // "up" | "down"
"undo": false, // true cancels the user's prior reaction of the same kind
"email": "[email protected]", // optional; required when pulse.require_email
"context": { /* WidgetContext, optional */ }
}// 200 response
{
"pulse_public_id": "pl_2A6Q…",
"count_up": 412,
"count_down": 116,
"you_reacted": "up" // null when no de-dup hint available
}// POST /api/v1/ingest (existing endpoint, with one new optional field)
{
"project_key": "pk_live_xxx",
"text": "the new layout is great but the export button is hidden",
"email": "[email protected]",
"pulse_key": "dashboard-v2", // NEW, optional; when set, scopes the feedback to a pulse
"context": { /* … */ }
}The endpoint is rate-limited per IP + project + pulse key (burst 60, refill 300/min — 5× the feedback bucket since reactions are a one-tap action). Unknown-key probes consume a token too, so a bot scanning for valid keys can't bypass the limiter via 404 loops.
The optional undo flag implements same-chip-click-cancels: clicking 👍 again sends kind: "up", undo: true and the server clears the user's reaction; the response carries you_reacted: null. Opposite-chip click is a straight switch — undo: false with the new kind.
Locked mode
A pulse goes into locked mode when either of two things happens server-side:
- The project's
pulses_lockedsetting is on (admin opted out of auto-registration), and the JSX references a pulse key the server doesn't recognise. - The project has hit its per-day auto-registration cap (200 distinct new keys / day) and a never-seen key arrives.
The React lib treats both as a soft no-op:
- Both chips render with
data-state="locked"andaria-disabled="true". Clicks return early without dispatching. - A single
console.warnfires in development; silenced in production. Guarded per pulse instance so it never repeats per click. onErroron<OpiniPulse>still fires — customer telemetry captures the lock event.- No exception is thrown into the React tree. The flag is sticky for the lifetime of the React tree, so once we see a lock we don't keep retrying on every click.
Visit your dashboard /pulses/settings to flip pulses_locked off, register the key manually, or raise the per-day cap.
Require email
Pulse settings expose a require_email flag (per-project, or per-pulse override). When on:
- The reaction POST body must include an
email; submission without one returns 400 (mapped to{ ok:false, kind:"bad" }bypostReaction). <OpiniPulse requireEmail>(orrequireEmailinherited from project settings) shows the email input before the first reaction. After that, the email is cached so subsequent clicks don't re-prompt.- Server-side, reactions de-dup hard on
(pulse_id, email): one reaction per email per pulse, regardless of how many times the user clicks. Anonymous reactions (no email) use a best-effort per-IP/UA bucket — see the audit note in the design spec for what that does and doesn't guarantee.
Webhook events
Three new event kinds. They register alongside the existing webhook config (Discord-compat rendering supported via is_discord_compat).
// pulse.created — fires once on auto-registration (or admin create)
{
"pulse_id": "01J…",
"pulse_public_id": "pl_…",
"project_id": "01J…",
"key": "checkout-button",
"name": "Checkout button",
"auto_registered": true,
"created_at": "2026-05-04T14:31:18Z"
}// pulse.reacted — debounced 60 s, then emitted with the delta over that window
{
"pulse_id": "01J…",
"pulse_public_id": "pl_…",
"project_id": "01J…",
"key": "checkout-button",
"name": "Checkout button",
"window_started_at": "2026-05-04T14:30:18Z",
"window_ended_at": "2026-05-04T14:31:18Z",
"delta_up": 47,
"delta_down": 3,
"total_up": 412,
"total_down": 116
}// pulse.commented — fires per comment
{
"pulse_id": "01J…",
"pulse_public_id": "pl_…",
"project_id": "01J…",
"feedback_id": "01J…",
"feedback_public_id": "fb_…",
"submitted_in_triage": true,
"preview": "the new layout is great but the export button is hidden",
"submitter_email": "[email protected]",
"created_at": "2026-05-04T14:31:18Z"
}pulse.reacted is debounced — a viral chip clicked 1000× in one minute fires one webhook with the aggregated delta, not 1000. The debounce window is in-process (60 s), so on a hard crash the deltas to the webhook are lost (the rollup tables are durable). The preview on pulse.commented is the first 160 chars of the body, newline-collapsed; receivers that need the full body fetch via the API.
Self-hosting
Pulses ship unconditionally. There is no environment-variable flag, no separate enable step, no migration to opt into. Point your widget / @opini-dev/react at your self-hosted baseUrl and the reactions endpoint works the moment your binary is on a version ≥ 0.2.0.
<OpiniPulse
name="checkout-button"
projectKey="pk_live_xxx"
baseUrl="https://feedback.your-corp.com"
>
<button>Buy now</button>
<OpiniReactions />
</OpiniPulse>Per-project knobs (lock auto-registration, raise the daily cap, require email, exclude comments from triage) live on the /pulses/settings page in the dashboard.
Roadmap — v1.1
Coming in v1.1:
- No-code pulses. Tag a CSS selector in the dashboard — no JSX required. Useful for teams whose product surface is locked behind a separate sprint cycle from the Opini integration.
- Sparklines on the pulse-list page. 14-day trend at a glance. v1.0 ships totals + sentiment % only.
- Public sentiment badges. Optional
<OpiniSentiment />part for showing the rolled-up percentage to public visitors. Gated on the per-project "surfaces publicly visible" setting. - Script-tag pulse support. v1.0 is React-first. A
data-opini-pulse="…"attribute on the script-tag bundle is on the list once there's customer demand.