Custom Renderers
Crank’s custom renderer API allows you to render components to any target
environment beyond the DOM. Whether you’re building games with Canvas/WebGL,
creating terminal UIs, generating images, building emails, or interfacing with
any other graphics system, Crank’s RenderAdapter interface provides the
foundation.
Overview #
A custom renderer consists of two main parts:
- RenderAdapter: A set of functions which determine how elements map to your target environment
- Renderer() class: A class which you subclass which orchestrates the rendering process and manages the component tree
import {Renderer, type RenderAdapter} from "@b9g/crank";export const adapter: RenderAdapter<MyNode, MyScope, MyRoot> = {create: ({tag, props}) => new MyNode(tag, props),patch: ({node, props}) => node.update(props),arrange: ({node, children}) => node.replaceChildren(children),// ... other methods};export class MyRenderer extends Renderer<MyNode, MyScope, MyRoot> {constructor() {super(adapter);}}export const renderer = new MyRenderer();
A module which implements a custom renderer should by convention export an adapter, the subclass, and an instance of the subclass for convenience.
RenderAdapter Interface #
The RenderAdapter interface defines how Crank elements are turned into nodes
in your target environment. Each method handles a specific part of the element
lifecycle.
Type Parameters #
It’s highly recommended to use TypeScript to write a custom renderer, as this
will help you understand the types of values which are passed to your
RenderAdapter methods. The Renderer class takes the following type
parameters.
TNode: The type representing nodes in your target environment.TScope: Context data passed down the component tree (e.g., coordinate systems, themes). The DOM renderer passes down xmlns info so that SVG elements are properly created.TRoot: The root container type (defaults toTNode).TResult: The type returned when reading element values (defaults toElementValue<TNode>).
Core Methods #
All methods except read and finalize receive a data object. Every data
object includes root: TRoot | undefined, giving access to the root container.
create(data): TNode #
Creates a new node when an element is rendered for the first time.
create({tag, props, scope}) {switch (tag) {case "sprite":return new PIXI.Sprite(props.texture);case "container":return new PIXI.Container();default:throw new Error(`Unknown tag: ${tag}`);}}
Parameters: tag, tagName, props, scope, root
patch(data): void #
Updates a node’s properties when props change. This is where you implement prop-to-attribute mapping, event listener binding, and property synchronization.
patch({node, props, oldProps}) {for (const [key, value] of Object.entries(props)) {if (oldProps?.[key] !== value) {if (key.startsWith("on")) {const eventName = key.slice(2).toLowerCase();node.removeAllListeners(eventName);if (value) node.on(eventName, value);} else {node[key] = value;}}}}
Parameters: tag, tagName, node, props, oldProps, scope, root,
copyProps, isHydrating, quietProps
copyProps: A set of props to skip because the user provided a copy with meta-prop syntax.isHydrating: Whether we are currently hydrating.quietProps: A set of props to suppress hydration warnings for because the user provided a hydrate with meta-prop syntax.
arrange(data): void #
Organizes child nodes within their parent after child elements are rendered.
The remove method handles node removal, so this method is primarily about
re-ordering existing nodes in the tree.
arrange({node, children}) {node.removeChildren();children.forEach((child, index) => node.addChildAt(child, index));}
Parameters: tag, tagName, node, props, children, oldProps,
root
remove(data): void #
Removes a node when an element is unmounted.
remove({node, parentNode, isNested}) {node.destroy?.();// Remove from parent (unless nested removal)if (!isNested && parentNode.children.includes(node)) {parentNode.removeChild(node);}}
Parameters: node, parentNode, isNested, root
isNested: Whether this removal is nested in another removal. Depending on your target environment, you may only need to remove the top-level node from its parent and leave the remaining nodes untouched.
text(data): TNode #
Creates or updates text nodes.
text({value, oldNode}) {if (oldNode && oldNode.text !== value) {oldNode.text = value;return oldNode;}return new TextNode(value);}
Parameters: value, scope, oldNode, hydrationNodes, root
raw(data): ElementValue<TNode> #
Handles raw values that bypass normal element processing.
raw({value}) {if (typeof value === "string") {return parseMarkup(value);}return value;}
Parameters: value, scope, hydrationNodes, root
adopt(data): Array<TNode> | undefined #
Adopts existing nodes during hydration (for server-side rendering or state
restoration). Should return an array of child nodes if the provided node
matches the expected tag, or undefined if hydration should fail.
adopt({tag, node}) {if (node && node.tagName.toLowerCase() === tag) {return Array.from(node.children);}return undefined;}
Parameters: tag, tagName, props, node, scope, root
scope(data): TScope | undefined #
Computes scope context for child elements. Useful for passing coordinate systems, themes, or namespaces down the tree. Called once when elements are created.
scope({tag, props, scope}) {if (tag === "viewport") {return {...scope,transform: new Transform(props.x, props.y, props.scale),};}return scope;}
Parameters: tag, tagName, props, scope, root
read(value): TResult #
Transforms the internal node representation into the public API.
read: (value) => {if (Array.isArray(value)) {return value.map(node => node.getPublicAPI());}return value?.getPublicAPI();}
finalize(root): void #
Performs final rendering operations (e.g., triggering a render pass).
finalize: (root) => {if (root instanceof PIXI.Application) {root.render();}}
JSX Type Definitions #
To get proper TypeScript support, define JSX types for your custom elements:
declare global {namespace JSX {interface IntrinsicElements {// Canvas elementsrect: {x?: number;y?: number;width?: number;height?: number;fill?: string;children?: any;};text: {x?: number;y?: number;children?: string | number;};group: {x?: number;y?: number;scale?: number;children?: any;};}}}
Advanced Patterns #
Bridge Components #
Create components that bridge between different renderers:
function* PixiApplication({children, ...props}) {const app = new PIXI.Application(props);document.body.appendChild(app.view);for ({children, ...props} of this) {pixiRenderer.render(children, app.stage, this);}}
The third parameter to render() allows you to connect the contexts of your
child renderer with your parent renderer.
function* GameUI() {const gameState = new GameState();// You can add provision for child Canvas components to readthis.provide("gameState", gameState);for ({} of this) {yield (<>{/* DOM UI */}<div class="ui"><Score value={gameState.score} /><button onclick={() => gameState.restart()}>Restart</button></div>{/* Canvas Game components will read the provisions */}<GameCanvas /></>);}}