Skip to main content
opini

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:

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

jsonc
// 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 */ }
}
jsonc
// 200 response
{
  "pulse_public_id": "pl_2A6Q…",
  "count_up": 412,
  "count_down": 116,
  "you_reacted": "up"           // null when no de-dup hint available
}
jsonc
// 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_locked setting 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" and aria-disabled="true". Clicks return early without dispatching.
  • A single console.warn fires in development; silenced in production. Guarded per pulse instance so it never repeats per click.
  • onError on <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" } by postReaction).
  • <OpiniPulse requireEmail> (or requireEmail inherited 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).

jsonc
// 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"
}
jsonc
// 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
}
jsonc
// 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.

tsx
<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.