Design System: Tokens, Primitives, and the Shell Contract
Developer Guide Free & ProThe contract a theme author needs to restyle, override, or extend BuddyNext's frontend: the three-layer --bn-* token cascade, the component primitives, the shell model (who owns what markup), the mobile bottom nav, RTL and dark-mode rules, and the buddynext_part_* hooks for overriding markup. This page is for theme developers and bridge authors.

Overview / Contract#
BuddyNext renders inside your theme's chrome and styles itself entirely from CSS custom properties. You do not need to fork a template or write a stylesheet to re-skin it. The contract is:
- Tokens are the single source of truth. Every color, type size, weight, spacing, radius, and shadow is a
--bn-*custom property. No frontend CSS hardcodes a hex, px, or font-family value. Re-theming is overriding tokens, not rewriting CSS. - Components are attribute-driven primitives. A small set of
.bn-*classes (button, card, input, badge, page, empty state) carry all variant logic throughdata-*attributes, so you style by token, not by reimplementing components. - Your theme owns the document; BuddyNext owns
.bn-app.get_header()andget_footer()are yours (your header IS the top navigation). BuddyNext renders only the rail, main column, optional right sidebar, and mobile bottom nav between them. - Markup is overridable two ways. Copy a template part into
{theme}/buddynext/, or hook thebuddynext_part_*actions and filters - no fork required.
This page covers the contract. Canonical token values live in assets/css/bn-base.css (:root). Read that file for exact values - this page does not duplicate the value table, which drifts.
The three-layer token model#
A single --bn-hue cascades through an OKLCH accent ramp, so the whole product re-tints from one hue change. TokenService (includes/Theme/TokenService.php) injects the resolved values inline on the bn-base style handle. Values resolve through two layers, in order:
| Layer | Source | Wins when |
|---|---|---|
| 1. Your theme's preset | theme.json --wp--preset--color--*, --wp--preset--font-size--*, etc. |
The preset slug is defined - the host theme always wins. |
| 2. BuddyNext OKLCH default | The --bn-* ramp in assets/css/bn-base.css, driven by --bn-hue / --bn-chroma. |
No matching theme preset is set. This is the baseline. |
Concretely, each legacy alias resolves to var(--wp--preset--*, var(--bn-*)). For example --brand is var(--wp--preset--color--primary, var(--bn-accent)): if your theme.json defines a primary color it wins; otherwise the OKLCH accent is used. Nothing in this chain pins a hex value.
There is no plugin
theme.jsonlayer. WordPress only readstheme.jsonfrom core, the active theme (parent + child) and Global Styles - never from a plugin directory. BuddyNext used to ship a roottheme.jsonthat an earlier version of this table described as a merged "layer 3" baseline; it was inert, and it could not have worked as described anyway. Had it been merged,--wp--preset--color--primarywould always have been defined, so layer 1 would always resolve and the OKLCH accent in layer 2 would have been permanently unreachable. The OKLCH ramp is the baseline; there is nothing beneath it.
Token families#
All tokens are --bn- prefixed. The families:
| Family | Tokens |
|---|---|
| Surfaces | --bn-bg, --bn-canvas, --bn-surface, --bn-sunken, --bn-raised |
| Ink (text) | --bn-ink, --bn-ink-2, --bn-ink-3, --bn-ink-4 |
| Lines / focus | --bn-line, --bn-line-faint, --bn-line-strong, --bn-ring |
| Accent ramp | --bn-accent, --bn-accent-50 ... --bn-accent-900, --bn-accent-fg |
| Semantic | --bn-success(-bg), --bn-danger(-bg), --bn-info(-bg), --bn-warn(-bg) |
| Integration accents | --bn-jetonomy, --bn-media, --bn-paid, --bn-events, each with -bg and -border variants (hues derive from --bn-hue-{jetonomy,media,paid,events}) |
| Type | --bn-font-{body,display,ui,mono}, --bn-text-{2xs..4xl,base,md}, --bn-fw-{normal..extrabold}, --bn-leading-{tight,snug,normal,body} |
Spacing (4px grid, scaled by --bn-density) |
--bn-s1 through --bn-s6, --bn-s8, --bn-s10, --bn-s12, --bn-s16 |
| Radius | --bn-r-{sm,md,lg,xl,full} |
| Shadow | --bn-shadow-{xs,sm,md,lg} |
| Motion | --bn-dur, --bn-dur-fast, --bn-dur-slow, --bn-ease, --bn-ease-out |
| Layering | --bn-z-menu, --bn-z-modal, --bn-z-lightbox, --bn-z-toast |
| Layout | --bn-container, --bn-content-max, --bn-content-wide, --bn-railw |
| Toasts and dialogs | --bn-toast-min-w, --bn-toast-max-w, --bn-dialog-max-w, --bn-dialog-btn-h |
Bare-named aliases (--bg, --text-1, --s4, ...) exist only for back-compat. Always author against the --bn-* names.
Overriding a token#
Two ways, both supported. The cleanest is a theme.json preset - it slots into layer 1 with no PHP:
{
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#0a66c2", "name": "Brand" }
]
}
}
}
That single primary slug re-tints every BuddyNext surface that resolves --brand / --bn-accent, because BuddyNext prefers --wp--preset--color--primary over its own default.
For finer control - or to override a token that has no theme.json preset - use the buddynext_css_vars filter. It receives the resolved token map (TokenService::get_defaults()) before it is injected:
add_filter( 'buddynext_css_vars', function ( array $vars ) {
$vars['--bn-hue'] = '255'; // shift the whole OKLCH accent ramp
$vars['--bn-r-md'] = '12px'; // rounder cards and buttons
$vars['--bn-surface'] = 'var(--wp--preset--color--base, #fff)';
return $vars;
} );
A matching buddynext_css_vars_dark filter overrides the dark-mode token map (TokenService::get_dark_overrides()).
Warning: Do not add a stylesheet that hardcodes hex/px to restyle BuddyNext. Override the tokens. Hardcoded values break dark mode and RTL, which the token path handles automatically. BuddyNext's own CSS is held to the same rule.
Partner plugins: matching the community brand#
A plugin that draws its own UI on BuddyNext pages (a popup, a badge, a celebration effect) should take its colour from the community's brand instead of a colour of its own, so it looks native on every site. The accent ramp is the public seam for that: --bn-accent and --bn-accent-50 ... --bn-accent-900 are generated from the one --bn-hue, are defined on :root by bn-base (which loads on every BuddyNext page), and are re-derived for dark mode, so a value read through var() follows the community's colour and the theme with no colour maths in JavaScript. Read them with a fallback for pages where BuddyNext is not loaded:
.my-plugin-accent { background: var( --bn-accent-500, var( --my-plugin-accent ) ); }
Take shades from the ramp (for example --bn-accent-300, --bn-accent-500, --bn-accent-700) rather than converting the accent in script. Do not redefine any --bn-* variable from a plugin.
Component primitives#
BuddyNext composes its UI from a small primitive vocabulary. Variants are expressed as data-* attributes, not extra classes, so you re-skin by styling the base class plus its attribute selectors.
| Primitive | Class | Variant API | Notes |
|---|---|---|---|
| Page shell | .bn-page |
- | Outer page container for a hub surface. |
| Card | .bn-card |
data-interactive |
Surface container; data-interactive adds hover affordance. |
| Button | .bn-btn |
data-variant (primary, secondary, danger, ghost, ai), data-size (sm, md) |
Single button primitive; never style a bare <button>. |
| Input | .bn-input |
- | Text inputs, selects (.bn-select), textareas. |
| Badge | .bn-badge |
data-tone |
Count and status pills. |
| Empty state | .bn-empty-state |
data-tone |
Empty/zero-result card. Rendered via the parts/empty-state.php part. |
Note: Interactive states (
:hover,:focus,:active,[aria-selected]) are scoped under.bn-appso your theme's bare-element chrome cannot bleed into BuddyNext controls, and BuddyNext's styles cannot leak out into your header. Focus rings use--bn-ring.
The navigation components are a separate, conflict-free set with their own namespaces (.bn-nav-metrics, .bn-tabs, .bn-subnav) so a change to one never bleeds into another.
The shell model#
Every BuddyNext-mapped slug (activity, members, spaces, messages, notifications, auth, onboarding) renders inside your active theme's chrome. There is no shell-takeover mode and no opt-out filter - theme chrome is the only mode.
The render sequence (PageRouter -> templates/shell/hub-shell.php):
get_header() <- your theme: DOCTYPE, <head>, wp_head(),
<body>, site header / nav / branding.
THIS HEADER IS THE TOP NAVIGATION.
<div class="bn-app"> <- BuddyNext owns this subtree only.
rail + main + right-sidebar? + mobile bottom nav
</div>
get_footer() <- your theme: site footer, wp_footer().
What this means for a theme author:
- BuddyNext does not render a topbar inside
.bn-app. Your site header is the top nav. Style it as you normally would. .bn-appbursts to100vwviabn-shell.cssso it stays edge-to-edge regardless of your theme's content containermax-width. You do not need a full-width page template, though one looks best.- The left rail and mobile bottom nav are gated by the owner's "Show community navigation" setting. When off, navigation is driven entirely from your theme menus and BuddyNext renders neither.
- The right sidebar auto-renders when anything is hooked onto
buddynext_right_sidebar(and the Sidebar feature is enabled). Register widgets withadd_action( 'buddynext_right_sidebar', ... ).
Mobile bottom navigation#
Below 640px the left rail hides and the .bn-mobile-nav bottom tab bar (templates/partials/nav.php) becomes the primary navigation. It is rendered by hub-shell.php on every hub, so it appears consistently without each hub template including it. Item classes follow the BEM pattern: .bn-mobile-nav__item, with --create, --more, and --active modifiers. The active item also carries aria-current="page"; after a client-side navigation the active marker is re-synced in JS.
RTL and dark mode#
Both are handled by the token system and logical CSS, so token-driven overrides inherit them for free.
RTL. BuddyNext CSS uses logical properties throughout - margin-inline-start / -end, padding-inline, inset-inline - never physical left / right. WordPress flips direction from the site locale; you do not ship an RTL stylesheet. If you add overriding CSS, use logical properties too.
Dark mode. Tokens flip under any of these selectors on an ancestor:
[data-bn-theme="dark"], /* BuddyNext's own toggle (v2 canonical) */
[data-theme="dark"], /* legacy alias still honored */
[data-bx-mode="dark"] /* bridges BuddyX / Reign's .bx-color-mode toggle */
The third selector means that if your theme is BuddyX or Reign, BuddyNext follows the host theme's dark toggle automatically. A prefers-color-scheme: dark block also applies when the user has set no explicit theme. Because dark mode flips the --bn-* values, anything authored against tokens is dark-safe with no extra work. Verify dark via the real theme toggle, not a hand-set attribute.
Overriding markup: the buddynext_part_* hooks#
BuddyNext ships a library of reusable template parts under templates/parts/ (empty state, pagination, sidebar card, section head, stat strip, filter strip, and feature panels). Each wraps a primitive so you describe state instead of duplicating markup. There are two ways to override a part.
1. Copy the part into your theme#
buddynext_get_template() resolves through the child theme, then the parent theme, then the plugin default. So copying a file overrides it:
{your-theme}/buddynext/parts/empty-state.php <- wins over the plugin's copy
This is the right tool when you want to change the part's structure wholesale.
2. Hook the part's actions and filters#
Every part fires the same four hooks (replace {name} with the part's filename, underscored - empty_state, sidebar_card, section_head, stat_strip, filter_strip, pagination):
| Hook | Type | Purpose |
|---|---|---|
buddynext_part_{name}_args |
filter | Filter the resolved arg array before render. Receives and returns array. |
buddynext_part_{name}_classes |
filter | Filter the root element's class list. Receives and returns array<int,string>. |
buddynext_part_{name}_before |
action | Fires before the part emits markup. Receives array $args. |
buddynext_part_{name}_after |
action | Fires after the closing tag. Receives array $args. |
This is the right tool for additive changes - inject a button, add a tab, append a class - without forking the file.
Overriding a template part - worked example#
Add a CTA to the empty-state card on a specific surface, and inject a moderator-only tab into a filter strip:
// Append a "Resume setup" button to the onboarding-incomplete empty state.
add_action( 'buddynext_part_empty_state_after', function ( array $args ) {
if ( 'onboarding-incomplete' !== ( $args['tone'] ?? '' ) ) {
return;
}
printf(
'<a class="bn-btn" data-variant="primary" data-size="sm" href="%s">%s</a>',
esc_url( home_url( '/getting-started/' ) ),
esc_html__( 'Resume setup', 'my-theme' )
);
} );
// Add a moderator-only "Flagged" tab to any filter strip.
add_filter( 'buddynext_part_filter_strip_tabs', function ( array $tabs, array $args ) {
if ( ! current_user_can( 'edit_posts' ) ) {
return $tabs;
}
$tabs[] = array(
'key' => 'flagged',
'label' => __( 'Flagged', 'my-theme' ),
'icon' => 'flag',
'href' => add_query_arg( 'filter', 'flagged' ),
);
return $tabs;
}, 10, 2 );
The same _args / _classes / _before / _after pattern applies to every part, so a theme or bridge never needs to ship a forked hub template. Each part documents its $args and the hooks it fires in its own PHP file header under templates/parts/.
Icons on community pages#
On BuddyNext's community pages each icon is drawn once: IconService::render() returns the usual
<svg class="bn-icon bn-icon--{slug}"> holding a <use href="#bn-i-{slug}">, and the shapes are
printed once in a hidden sprite at the end of the page. Size, colour and stroke still come from the
outer <svg>, so .bn-icon { color: ... } and size rules work as before. Elsewhere (REST responses,
wp-admin, emails, blocks on other pages) icons stay inline.
CSS that styles the inside of an icon (.bn-icon path { fill: ... }) cannot reach a shape drawn
through <use>. If your theme does that, keep icons inline:
add_filter( 'buddynext_icon_sprite', '__return_false' );
Notes / gotchas#
- Icons are SVG, never emoji or icon fonts. Use
buddynext_icon( 'slug' )in templates; the slugs are Lucide-style SVGs inassets/icons/. Custom icons follow the samestroke="currentColor",viewBox="0 0 24 24", no width/height shape. - Never hardcode values to restyle. Hardcoded hex/px breaks dark mode and RTL. Override tokens via
theme.jsonorbuddynext_css_vars. .bn-appscoping is deliberate. It isolates BuddyNext's interactive states from your theme and vice versa. Keep any custom rules scoped under.bn-appso they do not leak.- Free and Pro share the token system. Pro adds no second token set; it reuses the same
--bn-*families and the samebuddynext_css_varsoverride point, so one theme override covers both plans.