Hogsend is brand new.Try it
Hogsend
Client-side SDK

Components

Pre-built React UI for in-app surfaces — NotificationBell, FeedPopover, NotificationFeed, SurveyBlockView, Banner, Toast, PreferenceCenter — with props, headless escape hatches, and granular imports.

@hogsend/react ships opt-in UI components for the feed, banner, and toast surfaces. Each component reads from the same store the hooks read (see ./hooks), emits the same first-party events (see ./events), and exposes a five-layer override surface: --hs-* CSS variables, className + per-slot classNames, data-* state attributes, asChild prop-merge via Slot, and render* full-markup replacement.

All components require a <HogsendProvider> ancestor (see ./provider).

CSS

Component CSS is opt-in. Import it once, at your app root:

import "@hogsend/react/styles.css";

A hooks-only or headless usage pulls no CSS. Theming tokens are documented in ./theming.

Imports

Components are exported from the barrel and from granular subpaths. Subpath imports keep the bundle to the one component you use:

// barrel
import { NotificationBell, FeedPopover, NotificationFeed, BannerView, ToastContainer } from "@hogsend/react";

// granular subpaths
import { NotificationBell } from "@hogsend/react/bell";
import { FeedPopover } from "@hogsend/react/popover";
import { NotificationFeed } from "@hogsend/react/feed";
import { BannerView } from "@hogsend/react/banner";
import { ToastContainer } from "@hogsend/react/toast";
import { PreferenceCenter } from "@hogsend/react/preferences";

The bare names Banner, FeedItem, and Toast are the re-exported @hogsend/js data types. The components are exported as BannerView, FeedItemView, and ToastView to avoid the clash. NotificationBell, NotificationFeed, FeedPopover, and ToastContainer keep their names.

<NotificationBell>

A button with an unread/unseen count badge. forwardRef<HTMLButtonElement>.

import { NotificationBell } from "@hogsend/react/bell";

<NotificationBell badgeCountType="unseen" onClick={() => setOpen((o) => !o)} />;
PropTypeDefaultNotes
feedIdstring"in_app"Feed the count reads from.
badgeCountType"unread" | "unseen" | "none""unseen"Which metadata count drives the badge.
renderIcon(state: { count: number }) => ReactNodeReplace the bell glyph.
asChildbooleanfalseMerge props onto the single child element via Slot.
onClick() => voidClick handler (toggle a popover).
isOpenbooleanSets aria-expanded.
popoverIdstringSets aria-controls.
classNamestringRoot class.
classNamesNotificationBellClassNamesPer-slot: { root?, badge?, icon? }.
aria-labelstringAccessible label.

The badge renders 99+ when the count exceeds 99. State attributes: data-unread, data-unseen, data-has-badge, data-state (open/closed).

<FeedPopover>

A controlled popover wrapping <NotificationFeed>, anchored to a trigger ref. Closes on Escape and outside-click, and restores focus to buttonRef.

import { useRef, useState } from "react";
import { NotificationBell } from "@hogsend/react/bell";
import { FeedPopover } from "@hogsend/react/popover";

function Inbox() {
  const buttonRef = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  return (
    <>
      <NotificationBell
        ref={buttonRef}
        isOpen={open}
        popoverId="hs-feed"
        onClick={() => setOpen((o) => !o)}
      />
      <FeedPopover
        id="hs-feed"
        isVisible={open}
        onClose={() => setOpen(false)}
        buttonRef={buttonRef}
        placement="bottom-end"
      />
    </>
  );
}
PropTypeDefaultNotes
isVisiblebooleanControlled open state. Required.
onClose() => voidCalled on Escape / outside-click. Required.
buttonRefRefObject<HTMLElement | null>Focus returns here on close.
placement"bottom-start" | "bottom-end" | "top-start" | "top-end""bottom-end"Anchor placement.
feedIdstring"in_app"Forwarded to the inner feed.
renderItemNotificationFeedProps["renderItem"]Forwarded.
renderHeaderNotificationFeedProps["renderHeader"]Forwarded.
renderEmptyNotificationFeedProps["renderEmpty"]Forwarded.
onItemClickNotificationFeedProps["onItemClick"]Forwarded.
onMarkAllAsReadClickNotificationFeedProps["onMarkAllAsReadClick"]Forwarded.
classNamestringRoot class.
classNamesFeedPopoverClassNames{ root? }.
idstringFor aria-controls pairing.
aria-labelstringAccessible label.

Emits inapp.feed_opened with props { feedId } on the closed→open transition. The element is role="dialog" aria-modal="false". State attributes: data-placement, data-state="open".

<NotificationFeed>

The scrolling feed list with header, optional filter bar, items, and load-more.

import { NotificationFeed } from "@hogsend/react/feed";

<NotificationFeed
  feedId="in_app"
  initialFilterStatus="all"
  onItemClick={(item) => router.push(item.actionUrl ?? "/")}
/>;
PropTypeDefaultNotes
feedIdstring"in_app"Feed to render.
defaultFeedOptionsFeedFetchOptionsInitial fetch options.
initialFilterStatusFeedItemStatus | "all" | "unread""all"Starting filter tab.
renderItem(item: FeedItem, helpers: { onClick: () => void }) => ReactNodeReplace a row.
renderHeader(state: { metadata; markAllAsRead }) => ReactNodeReplace the header.
renderEmpty() => ReactNodeReplace the empty state.
renderFilterBar(state: { status; setStatus }) => ReactNodenoneNo filter bar by default.
onItemClick(item: FeedItem) => voidFired AFTER inapp.item_clicked + mark-read.
onItemRead(item: FeedItem) => voidFired when an item is marked read.
onMarkAllAsReadClick() => voidHeader action handler.
classNamestringRoot class.
classNamesNotificationFeedClassNamesSlots: root, header, headerTitle, markAllButton, filterBar, filterTab, list, item, empty, loadMore.
aria-labelstringAccessible label.

On item click the feed emits inapp.item_clicked (fire-and-forget) with props { feedItemId, feedId, actionUrl? } BEFORE your onItemClick, then calls markAsRead. State attributes: data-status (the network status), data-empty.

<FeedItemView>

The single-row component (exported name FeedItemView; props type FeedItemProps; forwardRef<HTMLDivElement>). Use it inside a renderItem to keep default markup while overriding behavior.

PropTypeDefaultNotes
itemFeedItemThe item to render. Required.
onClick(item: FeedItem) => voidRow click handler.
asChildbooleanfalseSlot prop-merge.
classNamestringRoot class.
classNamesFeedItemClassNamesSlots: root, unreadDot, content, title, body, timestamp, action.
formatTimestamp(iso: string) => stringrelative formatterOverride timestamp formatting.

State attributes: data-status, data-unread, data-unseen. unread is true when status is unseen or seen.

<SurveyBlockView>

The in-app survey renderer (exported name SurveyBlockView; props type SurveyBlockProps; forwardRef<HTMLDivElement>). One component covers scale, nps, yesno, and choice. FeedItemView auto-renders it for any item whose blocks contains a { type: "survey" } member — you rarely mount it directly.

import { SurveyBlockView } from "@hogsend/react";

// inside a custom renderItem, for the first survey block on an item:
<SurveyBlockView item={item} block={surveyBlock} />;
PropTypeDefaultNotes
itemFeedItemThe host feed item (for its id + mark-read). Required.
blockSurveyBlockThe narrowed { type: "survey" } block to render. Required.
asChildbooleanfalseSlot prop-merge.
classNamestringRoot class.
classNamesSurveyBlockClassNamesSlots: { root, prompt, options, option, optionSelected, thanks }.
renderSurvey(state: { block; answered; answer?; onAnswer }) => ReactNodeReplace the whole rendered survey.
onAnswer(answer: string | number) => voidFired AFTER capture + optimistic mark.

On answer it closes the loop on the existing spine: client.capture(block.event, { [property]: value, surveyId?, feedItemId, source: "in_app" }, { idempotencyKey: "inapp:survey:<itemId>:<event>" })property defaults to "value" (an NPS producer sets "score"). The answer lands in user_events.properties, so a journey reads it via ctx.waitForEvent → properties. State attributes: root data-mode, data-answered, data-value; per option data-selected + data-value.

Pair it with the engine producer sendSurvey() (modes scale / nps / yesno / choice), which publishes the feed item carrying the survey block. See the live loop in ./survey.

<BannerView>

Renders the single highest-priority non-dismissed banner for a slot. Component name BannerView; props type BannerProps.

import { BannerView } from "@hogsend/react/banner";

<BannerView slot="default" placement="top" onClick={(b) => router.push(b.actionUrl ?? "/")} />;
PropTypeDefaultNotes
slotstring"default"Banner slot (feed category banner:<slot>).
placement"top" | "bottom" | "inline""top"Layout placement.
renderBanner(banner: Banner, helpers: { onClick; onDismiss }) => ReactNodeFull markup replacement.
onClick(banner: Banner) => voidFired AFTER banner.clicked.
onDismiss(banner: Banner) => voidFired AFTER banner.dismissed.
autoCapturebooleantrueEmit banner.shown once per banner id on first render.
asChildbooleanfalseSlot prop-merge.
classNamestringRoot class.
classNamesBannerClassNamesSlots: root, content, title, body, action, dismiss.
aria-labelstringAccessible label.

The element is role="status". State attributes: data-placement, data-state (visible/dismissed). Author journeys on banner.shown / banner.clicked / banner.dismissed — not the internal inapp.* mark events (see ./events).

<ToastView> and <ToastContainer>

Toasts are ephemeral: not persisted and not in HogsendState. They originate from explicit useToast().show(...) calls or from realtime feed items where type === "toast" (routed once client.connect() runs). Mount one <ToastContainer> at the app root; it renders nothing when there are no toasts.

import { ToastContainer } from "@hogsend/react/toast";

<ToastContainer placement="top-right" onToastClick={(t) => router.push(t.actionUrl ?? "/")} />;

<ToastContainer> props

PropTypeDefaultNotes
placement"top-left" | "top-right" | "bottom-left" | "bottom-right""top-right"Stack position.
renderToast(toast: Toast) => ReactNodeReplace each toast's markup.
onToastClick(toast: Toast) => voidFired AFTER inapp.toast_clicked.
onToastDismiss(toast: Toast) => voidFired AFTER inapp.toast_dismissed.
classNamestringContainer class.
toastClassNamesToastClassNamesPer-toast slots: root, content, title, body, action, dismiss.
aria-labelstringAccessible label.

State attribute: data-placement.

<ToastView> props

The single-toast component (exported name ToastView; props type ToastProps; forwardRef<HTMLDivElement>). Used internally by <ToastContainer>; render it directly only for a custom container.

PropTypeDefaultNotes
toastToastThe toast to render. Required.
onClick(toast: Toast) => voidClick handler.
onDismiss(toast: Toast) => voidDismiss handler.
renderToast(toast: Toast) => ReactNodeFull markup replacement.
asChildbooleanfalseSlot prop-merge.
classNamestringRoot class.
classNamesToastClassNamesSlots: root, content, title, body, action, dismiss.

State attribute: data-type.

<PreferenceCenter>

The list/category opt-in surface. Renders one row per code-defined list with an on/off switch; supply channels to render a category×channel matrix instead. When the catalog carries kind: "channel" lists it auto-sections into a Channels group (a synthetic Email master row + the delivery channels) above a Topics group. Usable standalone or as a tab inside <FeedPopover preferences>.

import { PreferenceCenter } from "@hogsend/react";
// or the granular subpath:
import { PreferenceCenter } from "@hogsend/react/preferences";

<PreferenceCenter title="Email preferences" />;
PropTypeDefaultNotes
channelsPreferenceChannel[]Channel columns → a category×channel matrix. Omit → a single switch column.
resolveCategoryId(listId: string, channelId: string) => stringprimary → listId, else ${listId}.${channelId}Maps a (list, channel) cell to the engine categoryId it toggles.
titlestringOptional header.
layout"auto" | "flat""auto""auto" sections Channels/Topics when the catalog carries kind: "channel" lists (and no channels matrix is set). "flat" forces the legacy single column.
emailTogglefalse | { label?: string; description?: string }{ label: "Email" }The synthetic Email master row in the Channels section (wired to unsubscribedAll). Pass false to hide it. Rendered only in sectioned mode.
sectionLabels{ channels?: string; topics?: string }{ channels: "Channels", topics: "Topics" }Section heading text.
asChildbooleanfalseSlot prop-merge.
classNamestringRoot class.
classNamesPreferenceCenterClassNamesSlots: { root, header, section, sectionHeader, row, rowLabel, rowDescription, control, switch, switchThumb, cell, matrixHeader, loading, empty }.
renderRow(state: { list; channels?; toggle; isOn }) => ReactNodeReplace a whole row.
renderControl(state: { categoryId; checked; onChange; label }) => ReactNodeReplace a single switch / matrix cell.
renderEmpty() => ReactNodeReplace the empty state.
onPreferenceChange(categoryId: string, subscribed: boolean) => voidFired AFTER the toggle dispatches.
aria-labelstringAccessible label.

Reads usePreferences()GET /v1/lists (the catalog) + GET /v1/lists/preferences. Toggling emits inapp.preference_changed from the SDK store (do NOT add a capture). State attributes: root data-loading / data-empty / data-matrix / data-sectioned; section data-section="channels|topics"; row data-list-id (the Email master row is data-list-id="$email") + data-kind="channel|topic"; switch data-state="on|off". GET /v1/lists is reachable with an anonymous pk_ key, which degrades each list's subscribed to its defaultOptInwrites need an identified user with a userToken (see identifying-users). The ./preferences page is a live anonymous read.

Sectioned layout

layout defaults to "auto": when the engine's GET /v1/lists catalog carries any kind: "channel" list, the panel splits into a Channels section (a synthetic Email master row wired to unsubscribedAll, then the delivery channels — in_app, telegram, discord, …) above a Topics section (your defineList lists). The channel kind is set server-side; an older engine that omits kind surfaces no channel rows, so the body is byte-identical to the flat layout — existing consumers need zero changes. Supplying the channels matrix prop always wins over sectioning, and layout: "flat" forces the legacy single column.

The synthetic Email master row is not backed by a real ListSummary, so it bypasses renderRow — but its control still flows through renderControl with categoryId: ALL_EMAILS_CATEGORY ("$all"), and a flip inverts to setUnsubscribedAll(!next) (subscribed-to-email ≡ NOT unsubscribedAll). ListSummary.kind ("channel" | "topic" | undefined) is exported from @hogsend/js; treat undefined as "topic". ALL_EMAILS_CATEGORY is the exported "$all" sentinel emitted on the Email master's inapp.preference_changed.

Minimal pre-built inbox

Bell, popover, banner, and toast container together:

import { useRef, useState } from "react";
import "@hogsend/react/styles.css";
import { HogsendProvider } from "@hogsend/react";
import { NotificationBell } from "@hogsend/react/bell";
import { FeedPopover } from "@hogsend/react/popover";
import { BannerView } from "@hogsend/react/banner";
import { ToastContainer } from "@hogsend/react/toast";

function App() {
  const buttonRef = useRef<HTMLButtonElement>(null);
  const [open, setOpen] = useState(false);

  return (
    <>
      <BannerView slot="default" />
      <NotificationBell
        ref={buttonRef}
        isOpen={open}
        popoverId="hs-feed"
        onClick={() => setOpen((o) => !o)}
      />
      <FeedPopover id="hs-feed" isVisible={open} onClose={() => setOpen(false)} buttonRef={buttonRef} />
      <ToastContainer placement="top-right" />
    </>
  );
}

export default function Root() {
  return (
    <HogsendProvider apiUrl="https://api.acme.com" publishableKey="pk_…" userToken={token}>
      <App />
    </HogsendProvider>
  );
}

Headless

Skip the components and drive your own markup from the feed state. <FeedStateProvider> is a render-prop wrapper over useHogsendFeed (see ./hooks); it runs the initial fetch and opens realtime once per feedId.

import { FeedStateProvider } from "@hogsend/react";

<FeedStateProvider feedId="in_app">
  {({ items, metadata, markAsRead, loading }) => (
    <ul aria-busy={loading}>
      <li>{metadata.unread_count} unread</li>
      {items.map((item) => (
        <li key={item.id} onClick={() => markAsRead([item.id])}>
          {item.title}
        </li>
      ))}
    </ul>
  )}
</FeedStateProvider>;

FeedStateProviderProps is { feedId?: string; defaultFeedOptions?: FeedFetchOptions; children: (state: UseHogsendFeed) => ReactNode }. The state argument is the full UseHogsendFeed shape, including every markAs* method, fetchNextPage, refetch, networkStatus, and store.

The same data is available from the useHogsendFeed hook directly when you do not need a wrapping component:

import { useHogsendFeed } from "@hogsend/react";

function Inbox() {
  const { items, markAllAsRead } = useHogsendFeed({ feedId: "in_app" });
  return (
    <div>
      <button onClick={markAllAsRead}>Mark all read</button>
      {items.map((i) => (
        <div key={i.id}>{i.title}</div>
      ))}
    </div>
  );
}

Realtime runs over polling today (GET /v1/feed every 12s). A native EventSource cannot send Authorization: Bearer pk_…, so SSE is a non-default seam; the components and headless hooks work over polling unchanged.

Identified surfaces (a concrete userId) require a userToken minted server-side via generateUserToken — never in the browser. Anonymous mode works without one. See ./provider.

pk_ publishable keys are anon-only without a userToken and enforce a per-key origin allowlist fail-closed: a missing allowlist, missing Origin header, or unlisted origin returns 403. See ./provider.