The design system is framework-free by design: tokens are CSS custom
properties, components are markup contracts, and behaviour lives in small
modules that find their components by data attributes. A React product
consumes all three the same way any other product does — pin the package,
sync the artifacts, load the stylesheet and the modules for the components it
uses. See Source of Truth for the distribution
mechanics; this page covers what is different when React owns the DOM.
Two tiers, and both have arrived. Tier 1 is one small client module
that lets the existing design system modules work unmodified under Next.js
navigation, plus the rules that keep both DOM owners apart. Tier 2 is the
React adapters — thin 'use client' wrappers over the same markup
contracts, shipped in the dist package under ./react — see
The React adapters below. The adapters sit on
top of Tier 1, not instead of it: the stylesheet, the modules and the shim
still arrive the same way, and everything on this page stays true with or
without them.
Two module families
The modules split by how they bind
(see JavaScript), and the split decides how much
React has to do:
- Delegation modules never need re-initialising — they bind one
document-level listener at load, or own the DOM they create outright
(toast.jsbuilds its own live region):dialog.js(which is also the
drawer),toast.js,dropdown.js,password-toggle.js,number-input.js. They work under
Next.js unmodified — render the markup contract and the behaviour is
there, on nodes that exist now or arrive later. Half the system needs
nothing from you. - Per-element modules bind to the elements they find at initialisation:
tabs.js,accordion.js,rating.js,cell-input.js,bar.js. On the
docs site these re-initialise onbd:after-nav, the event the router
fires after swapping page content — each re-run binds only new elements,
because every module marks what it has already bound. These five are the
reason the shim below exists.
Every module initialises on load regardless of when it loads — each checksdocument.readyState rather than waiting on DOMContentLoaded — so loading
them after hydration is fine.
bd-video sits outside this page. It initialises on load only and does
not re-initialise on bd:after-nav; on the docs site it survives
navigation by a router mechanism React does not have, and it has no safe
per-instance teardown. A React product that needs it callswindow.initBdVideo(scope) from a mount effect per screen and accepts that
unmounting players leaks their observers. Tier 2 ships no bd-video adapter —
the module has no safe per-instance teardown to wrap, so an adapter would
only hide the leak behind a component boundary.
Never render data-bd-deferred-autoplay from React: it is router-written
state, and rendering it plays the video on the next dispatch.
The route-change shim
One client component, mounted once in the root layout. It dispatchesbd:after-nav after every route commit, so the per-element modules bind the
new page's components.
// app/brandos-after-nav.jsx
'use client';
import { useEffect } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
export function BrandOSAfterNav() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
document.dispatchEvent(
new CustomEvent('bd:after-nav', {
detail: { container: document },
})
);
}, [pathname, searchParams]);
return null;
}
// app/layout.jsx — inside <body>, once
<Suspense fallback={null}>
<BrandOSAfterNav />
</Suspense>
Three details worth knowing rather than rediscovering:
detail.containeris part of the event's contract. The docs site
passes the swapped container; the shim passesdocument, which every
listener accepts and scopes to. By the time the effect runs the old page
is unmounted, so the event only ever finds live nodes — the two-page
moment the docs site's router has to defend against does not exist here.- The effect also fires on first mount. Harmless: the modules already
initialised on load, and the bind guards absorb the duplicate. - The
Suspenseboundary is foruseSearchParams, which Next requires
under a boundary during static rendering. If no component reads the query
string,usePathnamealone narrows the shim further.
What the shim does not cover
The shim fires when the URL changes. DOM that changes without a URL
change never gets the event:
- Streamed content. With
loading.jsor any pending Suspense boundary,
the navigation commits the fallback — the shim fires then — and the real
content streams in afterwards, unbound. router.refresh()and Server Action revalidation. Both re-render at
the same URL; new elements in the reconciled payload are unbound.- Conditional mounts and re-keys.
{open && <div className="tabs">…}or
a changed Reactkeyproduces fresh DOM on a state change, with no event.
The escape hatch is the same in all three cases, and it is safe because
binding is idempotent — over-firing costs a scan, never a double bind. Mount
this inside the streamed subtree, beside the conditional mount, or in the
revalidated region:
// app/bind-design-system.jsx
'use client';
import { useEffect } from 'react';
export function BindDesignSystem() {
useEffect(() => {
document.dispatchEvent(
new CustomEvent('bd:after-nav', {
detail: { container: document },
})
);
}, []);
return null;
}
A searchParams-driven filter UI re-fires the shim on every query change;
that is a harmless document-wide rescan, not a bug.
Loading the system in Next.js
// app/layout.jsx
import './design-system.css'; // synced by bd-sync
import Script from 'next/script';
// one Script tag per module the product uses
<Script src="/design-system/js/tabs.js" strategy="afterInteractive" />
<Script src="/design-system/js/accordion.js" strategy="afterInteractive" />
Include only the modules for components the product uses — the package ships
them as separate files precisely so nothing else comes along. The stylesheet,
sprite and module files arrive via the standard pin + bd-sync flow and stay
gitignored vendored copies; upgrade by bumping the pin, never by editing them.
The React adapters (Tier 2)
The dist package ships a react/ directory of 'use client' components —
plain ES modules using React.createElement, no build step, readable innode_modules. Import them from the package; they are not vendored copies:
import { Sheet, Tabs, showToast } from '@bydefaultstudio/design-system/react';
The adapters render the documented markup contracts byte-for-byte and stop
there. Behaviour still belongs to the modules: an adapter bridges a module's
events to React props and hands React state to the module's public API, and
it never reimplements what the module already does. Load the module scripts
for the components you use, exactly as above — an adapter without its module
renders correctly and warns once in the console that the behaviour is
missing. The exceptions are the two segmented forms, which have no module to
load: the flat form's selection state is the adapter's own (the contract
assigns it to the consumer's script, and under React that script is React),
and the thumb form is native radios.
React is a peer dependency (>=18), optional so a non-React consumer never
installs it — the range is open at the top on purpose, because an optional
peer still has its range checked when React is present, and a CSS-only
consumer should never fail to install over a React version it does not use. The files are ESM () — every bundler consumes them;
plain require() does not.
TypeScript declarations ship with the adapters. Every component has a.d.mts sibling and the ./react export declares a types condition, so a
TypeScript product gets prop completion and errors with no configuration —placement="botom" is caught in the editor, not in the browser. Union types
name their legal values (placement, variant, mode, verdict, size),
and the events carry real payload types, so event.detail.source on a<Sheet onHide> narrows to the four documented sources.
The declarations are compiled against a realistic consumer on everynpm test (npm run typecheck), because a declaration file that has never
been checked breaks the build of every project that installs it.
| Adapter | Wraps | Owns state? | Bridges |
|---|---|---|---|
<Dialog> |
Dialog | open prop → showModal()/close() |
native close → onClose; dialog-hide → onHide |
<Sheet> |
Drawer | open prop, same machinery |
close → onClose; drawer-hide → onHide (detail.source includes "drag") |
<Tabs> |
Tabs | No — tabs.js switches | activation click → onChange(index, item) |
<SegmentedControl> |
Segmented Control | Flat: yes (controlled or uncontrolled). Thumb: native radios | onChange(value) on both forms |
<Dropdown> family |
Dropdown | No — dropdown.js opens, closes, resolves placement | dropdown-select → onSelect(detail) |
showToast() |
Toast | No — toast.js owns its DOM outright, so Toast is a function, not a component | calls window.showToast |
<Rating> |
Rating | No — rating.js paints; defaultValue seeds, readOnly renders pure markup |
rating-change → onChange(value) |
<CellInput> |
Cell Input | No — cell-input.js builds cells and owns the value | cell-input:change → onChange; cell-input:complete → onComplete |
Worth knowing rather than rediscovering:
- Dialog and Sheet are controlled. React opens and closes them through
theopenprop; every user-driven close (Escape, backdrop, close button,
drag) surfaces asonClose, so state follows the user. Settingopento
false is a programmatic close and bypasses thedialog-hide/drawer-hide
guard deliberately — route a close that should respect the guard throughwindow.bdRequestClose(ref.current, 'programmatic')instead. Both close
themselves before unmounting. <SegmentedControl variant="thumb">switches contract, not styling.
One value API —options,value/defaultValue,onChange— renders
the flat button group by default and the radio-backed thumb markup with
the prop. The thumb form takes aname(one is generated when absent)
and posts with a form like any radio group.- The per-element adapters self-register.
<Tabs>,<Rating>and<CellInput>call their module's public init from a mount effect when
they arrive unbound, so a streamed, conditionally mounted or re-keyed
instance binds withoutBindDesignSystem. The escape hatch is still the
answer for contract markup rendered outside the adapters. One consequence
worth knowing: a module's init takes a scope, and the smallest scope
containing the adapter's own element is its parent — and these modules
select by role or class (tabs.jsbinds every[role="tablist"]it
finds). Don't put a second, non-By-Default tab set, rating or cell input
in the same parent element, or ours will bind it too. - A close React asked for is not reported back.
onClosefires for
user-driven closes — Escape, the backdrop, the close button, a drag —
and not for the close that follows settingopento false, which the
consumer already knows about. That also keeps React's StrictMode
double-invoke from driving a dialog closed on mount. <CellInput>re-keys itself onformat+mode. The module reads
both once at bind; the adapter remounts on a change and the fresh block
self-registers — the re-key rule from the sharing rules below, handled.- Uncontrolled means uncontrolled.
<Tabs defaultActive>and<Rating defaultValue>seed the initial render; afterwards the module
owns the state and React must not write it back — changing either prop
after mount warns rather than silently fighting the module. To display
app-owned values, render<Rating readOnly value={n}>— pure markup, no
module. data-autofocusis the adapter's focus hook. Mark the control the
reader should land on and<Dialog>/<Sheet>focus it after opening.
React'sautoFocusprop cannot do this (see Dialogs and drawers below),
and on a destructive dialog the contract puts it on Cancel.- Naming is yours to supply.
<Dialog>,<Sheet>,<Tabs>,<SegmentedControl>,<Rating>and<CellInput>each warn once in the
console when the accessible name is missing, rather than inventing one.<Rating readOnly>needs particular care: the filled stars are decoration
and the group's label is the only thing carrying the score, so write the
value into it —label="Rating: 4 out of 5", the form the contract uses. - A verdict needs its own text.
<CellInput verdict>/errorsetsaria-invalid, but colour is never the whole message: render the outcome
in arole="status"region and pointaria-describedbyat it (the
adapter forwards it). showToastis a client-side function. Imported into a Server
Component it becomes a non-callable client reference — call it from a
client component.- A dialog with no
titlerenders no header, and the close button lives
in the header. Escape and the backdrop still close it, but supply your
own visible dismiss control in the body when you go headerless. <Drawer>and<Sheet>are the same component, exported under both
names: the system component is the Drawer, andSheetis the app-facing name for its bottom-docked shape, which is whyplacementand the drag handle default the way they do.
Dialogs and drawers from React
Both ride the native <dialog> element, so a ref plus showModal() /close() gives React full control. Be precise about what comes from where:
- Native, no module needed: the page behind goes inert (which is what
contains focus), Escape closes, focus returns on close, the::backdrop
pane paints, and the drawer's scroll lock ships in the stylesheet. - From
dialog.js: clicking the backdrop to dismiss — including the
guards that stop a drag-select ending on the backdrop or a keyboard
Enter/Space registering as an outside click — plus thedata-static
opt-out and the guarded-close contract. The module is delegation-based
and React-safe, so the recommended path is to load it and let it supply
light dismiss. Without it, backdrop clicks do nothing and Escape or an
explicit close button are the only ways out — acceptable, but choose it
knowingly, and never re-add light dismiss with a naive click handler.
Two React-specific traps:
React's
autoFocusprop does nothing inside a closed<dialog>— it
calls.focus()at mount, before the dialog opens, and React never writes
the attribute client-side. (Server rendering does emitautofocus, so
the prop appears to work in a server-rendered dialog and silently fails in
a client-mounted one. Don't rely on it either way.) Focus the intended
control yourself in the same handler:ref.current.showModal(); ref.current.querySelector('[data-autofocus]')?.focus().
The adapters do exactly this for you — mark the controldata-autofocus
and<Dialog>/<Sheet>focus it after opening. On destructive dialogs
the contract is focus on Cancel, never the destructive action — without
this, focus lands on the first focusable instead.Close before unmount, from a layout effect. A dialog that unmounts
while open strands focus on<body>with no announcement. The obvious fix
does not work: by the time a passiveuseEffectcleanup runs, React has
already detached the ref and removed the node, souseEffect(() => () => ref.current?.close(), [])readsnulland closes
nothing. Capture the node in a layout effect, whose cleanup runs while
the dialog is still connected and still modal:useLayoutEffect(() => { const el = ref.current; return () => { if (el?.open) el.close(); }; }, []);Verified in Chromium: with the passive version focus lands on
<body>;
with the layout version it returns to the trigger.<Dialog>and<Sheet>do this for you. Closing before a route push is still the
tidier move where you control the navigation.
Rules for sharing the DOM
- React renders the markup contract; the module supplies the behaviour.
The HTML a component renders must match its documented contract exactly —
the contract is the API, and drift between a React render and the
styleguide is a bug in the render. - Never mutate React-rendered elements from outside React beyond what a
module's documented behaviour does (class toggles, ARIA state). If a
widget needs framework-owned state — a controlled tab set, a toast queue
driven by app logic — reach for the app's own state and plain markup. The
Tier 2 adapters give you event bridging and
lifecycle, not framework-owned state; see Uncontrolled means uncontrolled
there. - Key config-at-bind components on their config.
cell-input.jsreadsdata-formatonce at bind;bar.jsindexes its children once at setup.
A re-render that changes those attributes on the same node leaves the
module running the old configuration — the bind guard means even a fresh
event will not re-read it. Give these components a Reactkeyderived
from their config so a config change remounts, then letBindDesignSystem
pick up the fresh node. Everywhere else, do not re-key gratuitously —
every remount is a rebind you have to arrange. - Dispatch
bd:after-navonly. The legacystudio:after-navname is
consumed by one module for one internal purpose; no new product dispatches
it. - Exit animations reopen the two-page window. A library that keeps the
outgoing page mounted while the new one enters (AnimatePresence,
view-transition wrappers) recreates the overlap the shim otherwise avoids;
document-scoped binds stay correct, but anything first-match will find the
dying page. Prefer animating within a mounted page. - Route changes move DOM, not focus. Next announces the new route but
leaves focus where it was, or on<body>. Pair the shim with a focus
reset — focus the new page'sh1(giventabindex="-1") onpathname
change — so keyboard and screen-reader users land where reading begins.