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 anddata-*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/embedand 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/embedThe 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.
| Slot | Where |
|---|---|
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 / extraTabs | Add 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
| Class | Element | State attributes |
|---|---|---|
cb-avatar / __img / __initials | Any avatar | data-has-image |
cb-presence | Presence dot (slot only) | data-state="online | away" |
cb-verified / __check | Verified mark (an SVG in currentColor) | |
cb-pill + --role / --tier / --you | Pills | role: data-founder; tier: data-tier, data-color, and --cb-tier-color |
cb-links / __item, cb-link-pill / __icon / __label | Link pills | data-network="linkedin | instagram | ... | link" |
cb-button + --primary / --secondary | Buttons and button-like links | disabled |
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.