Joy DOM

React

Install, embed, and reference for the @joy-dom/react renderer.

@joy-dom/react renders a Joy DOM JSON document into React elements. It powers the live previews you see on the spec page.

Install

bun add @joy-dom/react @joy-dom/spec

@joy-dom/spec is a peer dependency. It exports the JSON Schema and TypeScript types.

First render

import {  } from "@joy-dom/react";
import type { Spec } from "@joy-dom/spec";

const : Spec = {
  : 1,
  : {
    ".hello": { : "flex", : { : 16, : "px" } },
    : { : "flex" },
  },
  : [],
  : {
    : "div",
    : { : ["hello"] },
    : [{ : "p", : ["Hello Joy DOM"] }],
  },
};

export function () {
  return < ={} />;
}

Drop <JoyDom> anywhere you'd put a regular React component.

Custom components

If the document references custom nodes (kebab-case type), pass them via the components prop:

import { , type  } from "@joy-dom/react";
import type { Spec } from "@joy-dom/spec";

function ({ , , ,  }: ) {
  return (
    < ={} ={} ={}>
      {}
    </>
  );
}

export function ({  }: { : Spec }) {
  return < ={} ={{ "contact-button":  }} />;
}

The component receives the resolved children, className, style, id, and the original node. See §7.1 Component props.

Missing a registration for a custom node throws at render time.

Events and actions

A node binds an event to a named action in its props (see §8 Events and actions). Pass an actions map keyed by action name; each handler receives the DOM event and the binding's params:

import { , type  } from "@joy-dom/react";
import type { Spec } from "@joy-dom/spec";

const : Spec = {
  : 1,
  : { ".buy": { : "flex" } },
  : [],
  : {
    : "div",
    : {
      : ["buy"],
      : { : "action", : "checkout", : { : "joy-01" } },
    },
    : ["Buy now"],
  },
};

const :  = ({ ,  }) => {
  .();
  .("checkout", ?.);
};

export function () {
  return < ={} ={{  }} />;
}

A binding that names an action absent from the map throws at render, so a typo surfaces immediately. Pass missingAction="ignore" to leave such events unbound and render the rest of the tree.

The renderer wires bindings on built-in nodes only. A custom component receives the raw node, so it reads any bindings from node.props and decides how to use them. The interactive widgets use case walks through a card that mixes both.

Load documents at runtime

When the document isn't part of your build:

import { ,  } from "react";
import {  } from "@joy-dom/react";
import { , type Spec } from "@joy-dom/spec";

export function ({  }: { : string }) {
  const [, ] = <Spec | null>(null);

  (() => {
    ()
      .(() => .())
      .(() => .())
      .();
  }, []);

  if (!) return null;
  return < ={} />;
}

Calling SpecSchema.parse at the boundary makes malformed documents fail fast with a readable Zod error.

Server-side rendering

<JoyDom> renders into its own shadow root. On the server that's a declarative shadow root (<template shadowrootmode>); the browser attaches it while parsing the HTML, which means React can't hydrate that subtree afterwards. So pick one:

Static / string rendering. renderToStaticMarkup(<JoyDom spec={spec} />) produces the declarative shadow root; mount it with element.setHTMLUnsafe(html) so the browser attaches it. No hydration involved — ideal for static generation or a server function returning markup.

Inside a hydrated app (Next.js, Waku, Remix). Render <JoyDom> on the client so its shadow root is attached client-side, not hydrated. The simplest way is to gate it on a mounted flag (const [m, setM] = useState(false); useEffect(() => setM(true), [])) and render {m ? <JoyDom … /> : null}. Joy DOM's own website does exactly this for its previews.

Common integration patterns

Static documents bundled with the app. Import JSON directly: import card from "./card.json". Bundlers tree-shake; production payload only carries the documents you reference.

API-served documents. Fetch and validate via SpecSchema as above.

Per-user generated documents. Render on the server, pass to the client as initial props. Validate on the server boundary, not the client (cheaper, single source of truth).

API reference

<JoyDom> (component)

The primary entry point. Renders a Joy DOM document as React elements.

<JoyDom spec={spec} components={{ "contact-button": ContactButton }} />

Props (JoyDomProps)

PropTypeRequiredDescription
specSpecYesThe Joy DOM document.
componentsRecord<string, JoyDomComponent>NoCustom node registry, keyed by kebab-case type name.
actionsRecord<string, ActionHandler>NoHandlers for the document's event action bindings, keyed by action name.
missingAction"throw" | "ignore"NoWhat to do when a binding names an absent action. Defaults to "throw".
responsive"media" | "container"NoHow breakpoints resolve: viewport @media (default) or @container against its own box.

<JoyDom> renders into its own shadow root, so the document's global ids and classes stay isolated from the host page.

Rendering to a string

For non-component contexts — a server function, a test — render with react-dom/server:

const  = (< ={} />);

The markup contains a declarative shadow root (<template shadowrootmode>); mount it with element.setHTMLUnsafe(html) so the browser attaches the shadow.

Types

JoyDomProps

type JoyDomProps = JoyDomRenderOptions & {
  spec: Spec;
  /** Ref to the rendered content element inside the shadow root. */
  ref?: Ref<HTMLDivElement>;
};

JoyDomRenderOptions

The options <JoyDom> accepts alongside spec:

type JoyDomRenderOptions = {
  components?: Record<string, JoyDomComponent>;
  actions?: Record<string, ActionHandler>;
  missingAction?: "ignore" | "throw";
  responsive?: "media" | "container";
};

JoyDomComponent<N>

A custom component:

type JoyDomComponent<N extends Node = Node> = ComponentType<JoyDomComponentProps<N>>;

JoyDomComponentProps<N>

What custom components receive:

type JoyDomComponentProps<N extends Node = Node> = {
  children?: ReactNode;
  node: N;
  alt?: string;
  id?: string;
  className?: string;
  src?: string;
  style?: CSSProperties;
};
PropWhat's in it
childrenRendered child nodes and primitive values.
nodeThe raw Joy DOM node. Useful when the type lets you read more than the renderer surfaces.
idResolved node id (from props.id).
classNameJoined class string (renderer-generated + document classes).
styleResolved inline style for this platform.
altimg-only. The resolved alt attribute.
srcimg-only. The resolved src attribute.

RenderedNodeProps

The bag of props the renderer passes to every node (text, image, block, or custom):

type RenderedNodeProps = {
  alt?: string;
  id?: string;
  className?: string;
  src?: string;
  style?: CSSProperties;
};

Error behavior

  • A document fails SpecSchema validation at the boundary you control. @joy-dom/react does not call SpecSchema.parse itself, so validate before passing.
  • A document references a custom node with no entry in components. The renderer throws. This is intentional. Silent fallback hides the real bug.
  • Style values that don't map to a CSS property are ignored. The Zod schema would have caught these if you validated upstream.

Why doesn't the renderer validate?

The renderer trusts its input. Validation is a step you choose to run at the boundary (network, file load). Running it on every render would be wasteful. See Tutorial › Publishing for the recommended pattern.

Where next

On this page