Reference
React components

React components

Package: @cobuntu/embed-react.

Headless React components for a members directory, member profiles, event filters and the marketplace: the same experience as Cobuntu's community app, without its look. You get the structure, the behaviour, the copy and the accessibility; you write the CSS.

  • Headless. No stylesheet ships. Every element has a stable cb- class and data-* state attributes (class map).
  • No runtime dependencies besides React 18 or 19. No fetching, no router, no CSS framework.
  • Data via props. Load members on your server with @cobuntu/embed and pass the answers straight in: the types are compatible.
  • Your router. Every internal link is drawn by renderLink.
  • Platform slots. Message, Add friend, presence and notes belong to the Cobuntu app. They are optional slots that an embedded site leaves empty.

Install

npm install @cobuntu/embed-react @cobuntu/embed

The bundle is marked "use client", so in the Next.js app router you can render the components from a server component and pass them plain data.

Data from your server

The directory and profiles are for members only. Call me.directory() and me.member() with the visitor's session, on the server:

// lib/community.ts (server only)
import { isCobuntuError } from "@cobuntu/embed";
import { cobuntuFor } from "./cobuntu"; // createCobuntu(...) with the visitor's session
 
export async function loadDirectory(sessionToken: string | null) {
  if (!sessionToken) return { ok: false as const, gate: "signed-out" as const };
  try {
    return { ok: true as const, data: await cobuntuFor(sessionToken).me.directory({ limit: 100 }) };
  } catch (e) {
    if (isCobuntuError(e) && e.status === 401) return { ok: false as const, gate: "signed-out" as const };
    if (isCobuntuError(e) && e.code === "MEMBERS_ONLY") return { ok: false as const, gate: "not-member" as const };
    throw e;
  }
}

Platform slots

These render actions that need the Cobuntu app itself (messaging, friendships, live presence, matchmaking notes). An embedded site leaves them unset and nothing renders.

SlotWhere
renderCardActions(member)After Profile on each card (Message).
renderPeekActions(member)Before Profile in the peek (Message, Call, Video).
presenceOf(member)"online" | "away" | null: a .cb-presence dot on the avatar.
renderNotes(members)Above the filter bar while not searching.
MemberProfile.renderActions / renderStats / renderAvatar / extraTabsAdd friend, Message, counts, a note deck, more tabs.

Labels

Every string is English by default. Pass labels with any subset of CbLabels; functions take their values (noResults: (q) => ..., show: (n) => ...).

<MemberDirectory members={members} labels={{ people: "members", searchPlaceholder: "Search the community" }} />

Class map

Blocks use BEM: cb-block, cb-block__element, cb-block--modifier. State lives in data-* attributes, never in class names.

Each component page lists its own classes:

Shared

ClassElementState attributes
cb-avatar / __img / __initialsAny avatardata-has-image
cb-presencePresence dot (slot only)data-state="online | away"
cb-verified / __checkVerified mark (an SVG in currentColor)
cb-pill + --role / --tier / --youPillsrole: data-founder; tier: data-tier, data-color, and --cb-tier-color
cb-links / __item, cb-link-pill / __icon / __labelLink pillsdata-network="linkedin | instagram | ... | link"
cb-button + --primary / --secondaryButtons and button-like linksdisabled

A tier's colour arrives as the custom property --cb-tier-color on its pill. It paints nothing until your CSS uses it.

Hooks

useUnreadCount(fetchCount, { intervalMs?, refreshOnFocus?, enabled? })

Polls the member's unread notification count. fetchCount calls your route, which calls me.notifications.unreadCount() on the server. Default interval 45 s; paused while the tab is hidden; refreshes on focus. Returns { count, error, refresh } (count is null until the first answer). See Social and account settings.

Stylesheet skeleton

A starting point. Replace the values with your own tokens.

.cb-directory__grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: 1fr; }
@media (min-width: 720px)  { .cb-directory__grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1080px) { .cb-directory__grid { grid-template-columns: repeat(3, 1fr); } }
 
.cb-member-card { display: flex; flex-direction: column; height: 252px; padding: 16px; border: 1px solid #e5e5e5; }
.cb-member-card[data-interactive="true"] { cursor: pointer; }
.cb-member-card__identity { display: flex; gap: 12px; align-items: center; }
.cb-member-card__text { min-width: 0; }
.cb-member-card__name, .cb-member-card__handle, .cb-member-card__subline { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cb-member-card__pills { display: flex; gap: 6px; margin-top: 12px; overflow: hidden; }
.cb-member-card__signature { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.cb-member-card__spacer { flex: 1; }
.cb-member-card__actions { display: flex; gap: 8px; }
 
.cb-avatar { position: relative; display: inline-grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; overflow: hidden; }
.cb-avatar__img { width: 100%; height: 100%; object-fit: cover; }
.cb-presence[data-state="online"] { position: absolute; right: 0; bottom: 0; width: 12px; height: 12px; border-radius: 50%; background: #22c55e; }
 
.cb-pill--tier { color: var(--cb-tier-color, currentColor); }
 
.cb-filter__row { display: flex; gap: 8px; }
.cb-filter__search { flex: 1; display: flex; align-items: center; gap: 8px; }
.cb-filter__search-input { flex: 1; min-width: 0; border: 0; background: none; font: inherit; }
.cb-filter__backdrop, .cb-peek__backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); }
.cb-filter__backdrop { display: flex; align-items: flex-end; justify-content: center; z-index: 70; }
.cb-filter__sheet { width: 100%; max-width: 440px; max-height: 88vh; display: flex; flex-direction: column; background: #fff; }
.cb-filter__body { overflow-y: auto; }
.cb-filter__option { display: flex; width: 100%; align-items: center; gap: 12px; }
.cb-filter__option[data-state="off"] .cb-filter__check svg { visibility: hidden; }
 
.cb-peek { position: fixed; inset: 0; z-index: 80; }
.cb-peek__frame { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.cb-peek__surface { pointer-events: auto; width: 480px; max-width: 92vw; max-height: 88vh; overflow-y: auto; background: #fff; }
 
.cb-profile__header { display: flex; gap: 64px; align-items: flex-start; }
@media (max-width: 767px) { .cb-profile__header { gap: 16px; } }
.cb-tabs { display: flex; border-bottom: 1px solid #e5e5e5; }
.cb-tabs__tab { flex: 1; }
.cb-tabs__tab[data-state="active"] { border-bottom: 2px solid currentColor; }

Components by area

  • Members: Headless members directory, member profile, members gate and the smaller member pieces.
  • Events: Event filters, the mini calendar and event prices.
  • Marketplace: Product filters, cards, the product page, basket, checkout summary and purchases.
  • Account area: My events, profile events, the ledger and the library.
  • Join to Save: The Join to Save modal and hook for member prices.
  • Co-creation banner: The co-creation banner.