⚠️ Design system CSS not found. Check the path in cms/docs.config.js → designSystemPath, then re-run npm run docgen.

React

Consuming the design system from React and Next.js

Docs / React
Download .md file
Open .md in new tab
On this page
  • Two module families
  • The route-change shim
  • What the shim does not cover
  • Loading the system in Next.js
  • The React adapters (Tier 2)
  • Dialogs and drawers from React
  • Rules for sharing the DOM

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.js builds 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 on bd: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 checks
document.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 calls
window.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 dispatches
bd: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.container is part of the event's contract. The docs site
    passes the swapped container; the shim passes document, 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 Suspense boundary is for useSearchParams, which Next requires
    under a boundary during static rendering. If no component reads the query
    string, usePathname alone 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.js or 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 React key produces 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 in
node_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 every
npm 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
    the open prop; every user-driven close (Escape, backdrop, close button,
    drag) surfaces as onClose, so state follows the user. Setting open to
    false is a programmatic close and bypasses the dialog-hide/drawer-hide
    guard deliberately — route a close that should respect the guard through
    window.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 a name (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 without BindDesignSystem. 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.js binds 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. onClose fires for
    user-driven closes — Escape, the backdrop, the close button, a drag —
    and not for the close that follows setting open to 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 on format + 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-autofocus is the adapter's focus hook. Mark the control the
    reader should land on and <Dialog> / <Sheet> focus it after opening.
    React's autoFocus prop 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> / error sets
    aria-invalid, but colour is never the whole message: render the outcome
    in a role="status" region and point aria-describedby at it (the
    adapter forwards it).
  • showToast is 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 title renders 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, and
    Sheet is the app-facing name for its bottom-docked shape, which is why
    placement and 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 the data-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 autoFocus prop 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 emit autofocus, 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 control data-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 passive useEffect cleanup runs, React has
    already detached the ref and removed the node, so
    useEffect(() => () => ref.current?.close(), []) reads null and 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.js reads
    data-format once at bind; bar.js indexes 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 React key derived
    from their config so a config change remounts, then let BindDesignSystem
    pick up the fresh node. Everywhere else, do not re-key gratuitously —
    every remount is a rebind you have to arrange.
  • Dispatch bd:after-nav only. The legacy studio:after-nav name 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's h1 (given tabindex="-1") on pathname
    change — so keyboard and screen-reader users land where reading begins.
On this page
  • Two module families
  • The route-change shim
  • What the shim does not cover
  • Loading the system in Next.js
  • The React adapters (Tier 2)
  • Dialogs and drawers from React
  • Rules for sharing the DOM
Previous Source of Truth
Next Setup

Was this page helpful?

We use this feedback to improve our documentation.

Thanks for your feedback

Send feedback

© 2026 By Default