Skip to main content
Version: 4.3

Custom Elements & Styling

Here is where @joint/react pays off: an element is just a React component. No element-type registry, no base class. If you can build a card in React, you can put it on the canvas.

Here are the same workflow steps, now with real shape: an icon, label, and description, plus ports, the small connection points the next chapter wires together.

import {
  GraphProvider,
  Paper,
  HTMLHost,
  type ElementPort,
} from '@joint/react';
import '@joint/react/styles.css';
import './example.css';

type NodeData = {
  label: string;
  description: string;
  icon: string;
  color: string;
};

// Two ports: an input on the left, an output on the right.
const ports: Record<string, ElementPort> = {
  in: { cx: 0, cy: '50%', width: 10, height: 10, passive: true },
  out: { cx: '100%', cy: '50%', width: 10, height: 10 },
};

const initialCells = [
  {
    id: 'trigger',
    type: 'element',
    position: { x: 60, y: 60 },
    size: { width: 160, height: 52 },
    portMap: ports,
    data: { label: 'New Ticket', description: 'Listens for events', icon: 'âš¡', color: '#f59e0b' },
  },
  {
    id: 'agent',
    type: 'element',
    position: { x: 60, y: 170 },
    size: { width: 160, height: 52 },
    portMap: ports,
    data: { label: 'Classify', description: 'Runs an AI model', icon: '🤖', color: '#3b82f6' },
  },
];

// One component, driven entirely by `data`.
function NodeCard({ label, description, icon, color }: NodeData) {
  return (
    <HTMLHost useModelGeometry className="node">
      <span className="node__icon" style={{ color }}>
        {icon}
      </span>
      <div className="node__body">
        <div className="node__label">{label}</div>
        <div className="node__desc">{description}</div>
      </div>
    </HTMLHost>
  );
}

export default function App() {
  return (
    <GraphProvider initialCells={initialCells}>
      <Paper renderElement={NodeCard} className="canvas" />
    </GraphProvider>
  );
}


This is the element shape the rest of the guide builds on, all the way to the final demo. Your element component never changes between editions; that is the whole point.

An element is a component​

renderElement receives the cell's data as props. From there you write ordinary React:

type NodeData = { label: string; description: string; icon: string; color: string };

function NodeCard({ label, description, icon, color }: NodeData) {
return (
<HTMLHost useModelGeometry className="node">
<span className="node__icon" style={{ color }}>{icon}</span>
<div className="node__body">
<div className="node__label">{label}</div>
<div className="node__desc">{description}</div>
</div>
</HTMLHost>
);
}

Anything goes inside: your component library, conditional styling, icons, local state. The element is a real React component tree, and it re-renders only when its own data changes.

Fully typed

Type the props with your data type and label, icon, etc. are type-checked with autocomplete. To avoid repeating the type, you can infer it from your cells with InferElement<typeof initialCells>['data'].

Accessibility

JointJS renders the diagram as SVG and does not add ARIA roles, focus order, or keyboard handling on its own. Because each element is your React component, you can add role, aria-label, tabIndex, and key handlers directly in renderElement. For a fully accessible diagram, also expose the same information in a non-visual form, for example a list or table of the elements and their connections.

Styling: plain CSS, your call​

The element above is styled with a hand-written class in example.css. No CSS framework is required or assumed. Use plain CSS, CSS Modules, your own design tokens, or a utility framework if you already have one.

.node {
display: flex;
align-items: flex-start;
gap: 8px;
width: 160px;
height: 52px;
box-sizing: border-box;
padding: 10px 12px;
border: 2px solid var(--jj-color-shape-border);
border-radius: 8px;
background: var(--jj-color-shape-surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
font-family: system-ui, sans-serif;
}

In this CSS, we use the library's own CSS variables for colors, so the node automatically matches the default component palette. In this case, your custom element will blend with the default port and paper color setup. You can change the colors by overriding the variables in your own CSS, or use your own color values directly. See Theming for the full variable list and styling guidelines.

HTML vs SVG elements​

JointJS draws on SVG by default, so renderElement output lands inside an SVG node. Two paths:

  • HTML elements: wrap content in HTMLHost (style-neutral) or HTMLBox (themed via --jj-box-*). It places your <div> in an SVG <foreignObject> (see Sizing for how it handles dimensions).
  • SVG elements: return SVG directly (<rect>, <circle>, <SVGText>) for maximum performance and crisp scaling.

Most app-style diagrams use HTMLHost / HTMLBox. Reach for SVG when you have thousands of elements or want to use complex shapes. See Elements for the full rendering reference.

Sizing: measured or fixed​

An element can size itself in two ways, and you choose per element:

  • Measured (no size): the default, and what every earlier chapter used. HTMLHost measures your rendered content and syncs the size back to the element, so the node always fits exactly. Ideal when content varies (labels, dynamic text). You write no dimensions at all.
  • Fixed (size + useModelGeometry): set size: { width, height } on the cell and pass useModelGeometry to HTMLHost; the element renders at exactly that size and skips content measurement. Use it when you need a predictable footprint. It's also a good idea when performance matters and you plan to render thousands of elements, since it skips the measurement pass entirely.
// Measured: the element fits its content
{ id: 'a', type: 'element', position: { x, y }, data }
<HTMLHost className="node">…</HTMLHost>

// Fixed: the element is exactly 160 × 52
{ id: 'b', type: 'element', position: { x, y }, size: { width: 160, height: 52 }, data }
<HTMLHost useModelGeometry className="node">…</HTMLHost>

(Drawing as raw SVG? You can set size on the cell yourself, or let the SVG content drive the size with the useMeasureElement() hook, covered in Measuring Elements.)

Ports​

Ports are named connection points on an element: the dots a link can attach to. Add them with portMap on the cell. By default, the port is an SVG <ellipse> element, but it can be changed using the shape parameter. Each port is placed with cx (x position) and cy (y position) in the element's coordinate system, given as a number or a percentage:

const ports = {
in: { cx: 0, cy: '50%', width: 10, height: 10, passive: true },
out: { cx: '100%', cy: '50%', width: 10, height: 10 },
};

{ id: 'trigger', type: 'element', size: { width: 160, height: 52 }, portMap: ports, data: {…} }

passive: true makes a port a link target only. Ports are how the next chapter connects elements precisely. Full reference: Ports.

Your elements look right. Now connect them.