Hogsend is brand new.Chat to Doug
Hogsend
Client-side SDK

Components

Pre-built React UI for in-app surfaces — NotificationBell, FeedPopover, NotificationFeed, Banner, Toast — 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";

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.

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

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.

On this page