Skip to main content
Version: 4.1

Custom shapes

JointJS comes with several collections of built-in element shapes. You can find more information on using these built-in shapes in this section.

However, even though there are many default shapes to choose from, you might find that you are missing one and want to create your own shape definition. Creating new shapes is very simple in JointJS if you already know SVG. The most important SVG elements are rect, text, circle, ellipse, image and path. Their thorough description with examples and illustrations can be found elsewhere on the Internet, e.g. on MDN. What is important for us is to realize that combining the basic SVG shapes, we can define any 2D shape we need.

If you like video, the following YouTube content will show you how to create custom JointJS elements using SVG via the Cell.define() function. This tutorial will still provide you with a comprehensive guide to defining custom shapes, even if you decide to watch the video or not.

A basic shape​

There are two primary ways to create a basic shape in JointJS:

Although all shapes are ultimately descendants of the dia.Cell class, you should inherit from the more specific dia.Element or dia.Link classes, which provide basic element and link functionality out-of-the-box, respectively. In practice, it usually makes the most sense for your shapes to inherit from a shape in one JointJS's predefined shape collections (e.g. shapes.standard.Rectangle). Of course, your shapes can also inherit from any other of your previously-defined custom shapes.

note

JointJS is fully compatible with TypeScript and you can create your custom shapes using TypeScript classes. The following examples are written in JavaScript, but can be used in TypeScript as well. We provide TypeScript definitions for all JointJS classes and functions, so you can use them in your projects without any issues.

We suggest that you use ES6 classes for defining new shapes, as it is more flexible and allows you to use the full power of JavaScript or TypeScript. However, if you prefer the Cell.define() function, you can use it as well. We explain the differences of this approach separately for each section of this tutorial - look for details boxes like the following:

Using Cell.define()

You can use Cell.define() function to declare custom shapes. Similarly to the ES6 class approach, you can call the define() function on dia.Element or dia.Link classes, or on another shape definition (e.g. a predefined JointJS shape like shapes.standard.Rectangle, or any custom shape you have previously defined).

The Cell.define() function can be called with up to four parameters. Here is how they map to building blocks of ES6 shapes:

  • [required] type - a string specifying the type of the new shape.

  • [optional] defaultInstanceProperties - an object that contains properties to be assigned to every constructed instance of the shape:

  • [optional] prototypeProperties - an object that contains properties to be assigned on the shape's prototype. Intended for properties which are intrinsic to the shape definition itself and not usually changed on individual instances:

  • [optional] staticProperties - an object that contains properties to be assigned on the shape's constructor (not very common):

Let's define a basic custom shape using ES6 classes:

import { dia } from '@joint/core';

export class MyShape extends dia.Element {

defaults() {
return {
...super.defaults
};
}
}

The defaults function returns an object with the default model attributes of our shape. It is possible to use an object for our defaults, but since objects are referenced - not copied - in JavaScript, our function will return a different object each time.

In JavaScript classes, the super keyword allows us to access the properties of the parent class definition. Using ...super.defaults (with a spread operator) thus makes it possible for our shape definition (MyShape) to inherit all optional model attributes from its parent shape definition (dia.Element). More precisely, if a model attribute is left undefined on the child, the definition of the attribute from the parent class is assigned instead, and so on up the inheritance chain. Conversely, providing a value to a model attribute on the child completely overrides any value of the same property from the parent. If you need parent properties to be merged with child properties instead, use the util.defaultsDeep() function:

Using util.defaultsDeep()

Instead of replacing parent properties outright, the util.defaultsDeep() function recursively assigns parent properties as default properties. This makes a difference for object properties - if the parent's object has some extra properties which are not defined inside the child's object, those are merged into the child object.

Our custom shape definition would look like the following:

import { dia, util } from '@joint/core';

export class MyShape extends dia.Element {

defaults() {
return util.defaultsDeep({}, super.defaults);
}
}

This is the most basic boilerplate for a custom shape, but you have to agree it's not very exciting, so let's add some more default model attributes to our shape.

Model attributes​

You most probably need some default model attributes to be specified for your custom shape. The default model attributes are used as a template for every instance of your custom shape. They are defined inside the defaults() function of your custom shape class.

Type​

The most important model attribute is type. It specifies a unique path identifier within a cellNamespace object. Cell namespaces allow JointJS to find the correct constructor for each encountered object, which becomes crucially important when importing diagrams from JSON - JointJS references the Graph's cellNamespace object according to each saved object's type path identifier. You can find more information about cell namespaces in the Cell namespaces section of our documentation.

Let's modify our MyShape class to include a type attribute:

import { dia, shapes } from '@joint/core';

export class MyShape extends dia.Element {

defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape'
};
}
}
Using util.defaultsDeep()

With the util.defaultsDeep() function, our modified custom shape definition would look like the following:

import { dia, util } from '@joint/core';

export class MyShape extends dia.Element {

defaults() {
return util.defaultsDeep({
type: 'myNamespace.MyShape'
}, super.defaults);
}
}
Using Cell.define()

With the Cell.define() approach, our modified custom shape definition would look like the following:

import { dia } from '@joint/core';

export const MyShape = dia.Element.define('myNamespace.MyShape');

Here is an example of how our custom shape could then be added to our Graph's cellNamespace object:

import { shapes, dia } from '@joint/core';
import { MyShape } from './shapes';

const namespace = { ...shapes, myNamespace: { MyShape }};

const graph = new dia.Graph({}, { cellNamespace: namespace });

Optional model attributes​

Besides the type attribute, you may want to specify default values for various optional model attributes of your custom shape. We have already set up our shape to receive parent attributes as defaults, so we should now add some unique attributes which would set our custom shape apart from its parent and other shapes.

Elements and Links have their own special model attributes like size (illustrated below), source and router (both specific to Links), and others. You can learn more about model attributes in dedicated element and link sections of our documentation. However, the most interesting model attribute to talk about here is attrs - an object which allows us to specify presentation attributes for our shape.

Within the attrs object, the keys identify different subelement selectors, as defined in the shape markup - in our example, the respective keys are body and label. For each of these subelements, we may provide the values for different presentation attributes - those can be standard SVG attributes, special JointJS presentation attributes that provide additional functionality, or custom special presentation attributes that allow us to define custom behavior exclusive to our shape.

Let's modify our MyShape class again to include some additional model attributes:

import { dia, util } from '@joint/core';

export class MyShape extends dia.Element {

defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
cx: 'calc(0.5*w)',
cy: 'calc(0.5*h)',
rx: 'calc(0.5*w)',
ry: 'calc(0.5*h)',
strokeWidth: 2,
stroke: '#333333',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
};
}
}
Using util.defaultsDeep()

With the util.defaultsDeep() function, our modified custom shape definition would look like the following:

import { dia, util } from '@joint/core';

export class MyShape extends dia.Element {

defaults() {
return util.defaultsDeep({
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
cx: 'calc(0.5*w)',
cy: 'calc(0.5*h)',
rx: 'calc(0.5*w)',
ry: 'calc(0.5*h)',
strokeWidth: 2,
stroke: '#333333',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}, super.defaults);
}
}
Using Cell.define()

With the Cell.define() approach, our modified custom shape definition would look like the following:

import { dia } from '@joint/core';

export const MyShape = dia.Element.define('myNamespace.MyShape', {
size: { width: 100, height: 80 },
attrs: {
body: {
cx: 'calc(0.5*w)',
cy: 'calc(0.5*h)',
rx: 'calc(0.5*w)',
ry: 'calc(0.5*h)',
strokeWidth: 2,
stroke: '#333333',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
});

Markup​

The markup is a special prototype property which is specified as a JSON array. Each member of the array is taken to define one subelement of the new shape. Subelements are defined with objects containing a tagName (a string with the SVG tag name of the subelement) and a selector (a string identifier for this subelement in the shape). Although JointJS can also understand SVG markup in string form, that approach is noticeably slower due to the need for parsing and lack of capacity for custom selectors.

The recommended way to specify markup is via the util.svg() tagged template. This function is a helper that allows you to write SVG markup in a more readable way while keeping the performance benefits of markup specified as a JSON array.

The markup is treated as a prototype property and should exist before the element is instantiated. To achieve this, you should declare your markup in special preinitialize() function. Let's add markup to our custom shape:

note

We are declaring markup in a separate variable first in order to avoid creating a new instance of the template for each new instance of our shape. This is a good practice which avoids unnecessary memory usage and increases performance.

import { dia, util } from '@joint/core';

const markup = util.svg/* xml */`
<ellipse @selector="body"/>
<text @selector="label"/>
`;

export class MyShape extends dia.Element {

preinitialize() {
this.markup = markup;
}

// defaults() {}
}
Using Cell.define()

With the Cell.define() approach, markup is added inside the prototypeProperties parameter:

import { dia, util } from '@joint/core';

const markup = util.svg/* xml */`
<ellipse @selector="body"/>
<text @selector="label"/>
`;

export const MyShape = dia.Element.define('myNamespace.MyShape', {
// Optional model attributes
}, {
markup: markup
});

As mentioned above, the selectors defined in the markup can be used in the shape definition to specify values of different presentation attributes, but they can also be referenced later during program execution to target SVG subelements in order to get or set specific presentation attributes on an instance of our shape:

// create namespace, graph and paper
// ...

// create an instance of MyShape
const myShape = new MyShape();
myShape.addTo(graph);

// set the <rect/> color to red
myShape.attr('body/fill', 'red');

Methods​

Sometimes, the built-in functionality of JointJS is not enough, and custom behavior needs to be supported by your shape. In most of these cases, your use case can be addressed by prototype methods. Prototype methods are useful for custom initialization logic and for custom logic that reacts to user interaction (e.g. property validation) - in general, for any logic which needs access to the current instance of your shape.

For example, if we wanted to create a shortcut to assign a text label value to instances of our shape, we could create a setText() prototype method taking a string as its parameter and setting the label text attribute accordingly. Returning this is a good practice, as it allows chaining of prototype methods (in this example, the cell.attr() setter returns this):

import { dia } from '@joint/core';

export class MyShape extends dia.Element {

// preinitialize() {}

// defaults() {}

setText(text = '') {
return this.attr('label/text', text);
}
}
Using Cell.define()

With the Cell.define() approach, prototype methods are added inside the prototypeProperties parameter (next to markup):

import { dia } from '@joint/core';

export const MyShape = dia.Element.define('myNamespace.MyShape', {
// Optional model attributes
}, {
// Markup

setText(text = '') {
return this.attr('label/text', text);
}
});

Here is an example of how the setText() prototype method could be used to initialize the label text of an instance of our shape:

// create namespace, graph and paper
// ...

// create an instance of MyShape and set its text
const myShape = new MyShape().setText(text);

Custom special presentation attributes​

Custom special presentation attributes, defined in a static attributes object on your shape, allow you to define your own special presentation attributes, which can be used inside your shape's attrs model attribute. Custom special presentation attributes work as shortcuts that can set multiple native SVG attributes to the rendered SVGElement representing a subelement of your shape, dynamically (based on the provided value, bounding box of the subelement, other values inside the attrs object, and/or the values of model attributes).

For example, if we wanted to have a shortcut to set the 'stroke-dasharray' SVG attribute based on the provided value and the value of the 'stroke-width' presentation attribute, we could create a custom 'line-style' special presentation attribute to do so:

import { dia } from '@joint/core';

export class MyShape extends dia.Element {

// preinitialize() {}

// defaults() {}

// Prototype methods

static attributes = {
'line-style': {
set: function(value, _refBBox, _node, attrs, _cellView) {
const n = attrs['stroke-width'] || 1;
switch (value) {
case 'dashed':
return { 'stroke-dasharray': `${4 * n},${2 * n}` }
case 'dotted':
return { 'stroke-dasharray': `${n},${n}` }
default:
return { 'stroke-dasharray': 'none' }
}
},
unset: 'stroke-dasharray'
}
};
}
Using pure ES6

Static class properties were introduced in the ES2022 (ES13) version of JavaScript. If you need to support custom special presentation attributes in an earlier version (e.g. pure ES6), you can define them outside the class block:

import { dia } from '@joint/core';

export class MyShape extends dia.Element {

// preinitialize() {}

// defaults() {}

// Prototype methods
}
MyShape.attributes = {
'line-style': {
set: function(value, _refBBox, _node, attrs, _cellView) {
const n = attrs['stroke-width'] || 1;
switch (value) {
case 'dashed':
return { 'stroke-dasharray': `${4 * n},${2 * n}` }
case 'dotted':
return { 'stroke-dasharray': `${n},${n}` }
default:
return { 'stroke-dasharray': 'none' }
}
},
unset: 'stroke-dasharray'
}
};
Using Cell.define()

With the Cell.define() approach, the attributes object is added inside the staticProperties parameter:

import { dia } from '@joint/core';

export const MyShape = dia.Element.define('myNamespace.MyShape', {
// Optional model attributes
}, {
// Markup

// Prototype methods
}, {
attributes = {
'line-style': {
set: function(value, _refBBox, _node, attrs, _cellView) {
const n = attrs['stroke-width'] || 1;
switch (value) {
case 'dashed':
return { 'stroke-dasharray': `${4 * n},${2 * n}` }
case 'dotted':
return { 'stroke-dasharray': `${n},${n}` }
default:
return { 'stroke-dasharray': 'none' }
}
},
unset: 'stroke-dasharray'
}
}
});

We can then use the custom 'line-style' special presentation attribute like any other attribute, so let's specify it via the cell.attr() method:

// create namespace, graph and paper
// ...

// create an instance of MyShape and set its lineStyle
const myShape = new MyShape().attr('body/lineStyle', 'dotted');

Static methods​

Static methods are occasionally useful to implement functionality which does not need a specific instance of a shape to operate.

For example, if we wanted to have a shortcut to check if an object of an unknown type is an instance of MyShape, we could create a static method MyShape.isMyShape():

import { dia } from '@joint/core';

export class MyShape extends dia.Element {

// preinitialize() {}

// defaults() {}

// Prototype methods

// Custom special presentation attributes

static isMyShape(obj) {
return obj instanceof MyShape;
}
}
Using Cell.define()

With the Cell.define() approach, static methods are added inside the staticProperties parameter (next to attributes):

import { dia } from '@joint/core';

export const MyShape = dia.Element.define('myNamespace.MyShape', {
// Optional model attributes
}, {
// Markup

// Prototype methods
}, {
// Custom special presentation attributes

isMyShape(obj) {
return obj instanceof MyShape;
}
});

Here is an example of how the MyShape.isMyShape() static method could be used to get an array of all MyShape instances in our graph:

// create namespace, graph and paper
// ...

// populate graph with various elements
// ...

// filter graph's contents to get an array of MyShape instances
const myShapes = graph.getCells().filter((cell) => MyShape.isMyShape(cell));

Custom Views​

Custom views provide some extra flexibility when it comes to working with our models. If we want some additional behavior, but don't believe that behavior should live alongside the presentation attributes on our models, custom views can provide this for us. Maybe you want to capture user input, see a minimal version of the same graph, apply some interesting effect to your elements, or implement a custom special presentation attribute which reacts to changes in a model attribute; those are just a few of the reasons you may want to explore custom views.

As with custom cell models, custom views can be defined in two ways:

  • Using the View.extend() function with dia.ElementView or dia.LinkView classes.
  • Defining a view as a new ES6 class that inherits from dia.ElementView or dia.LinkView.

We are suggesting you to use ES6 classes for defining new views, as it is more flexible and allows you to use the full power of JavaScript or TypeScript classes. However, if you prefer the View.extend() function, you can use it as well.

In the following example, we create a fade view effect to change the opacity of MyShape elements.

First, let's create our custom shape model as usual:

import { dia, util } from '@joint/core';

const markup = util.svg/* xml */`
<rect @selector="body" cursor="pointer"/>
`

class MyShape extends dia.Element {

preinitialize() {
this.markup = markup;
}

defaults() {
return {
...super.defaults,
type: 'MyShape',
size: { width: 100, height: 30 },
position: { x: 150, y: 50 },
attrs: {
body: {
fill: 'red',
width: 'calc(w)',
height: 'calc(h)'
}
}
};
}
}

Then we can create a custom view for the MyShape model:

class MyShapeView extends dia.ElementView {

preinitialize() {
this.presentationAttributes = dia.ElementView.addPresentationAttributes({
faded: 'flag:opacity'
});
}

confirmUpdate(flags, ...args) {
let flags = super.confirmUpdate(flags, ...args);
if (this.hasFlag(flags, 'flag:opacity')) this.toggleFade();
}

toggleFade() {
this.el.style.opacity = this.model.get('faded') ? 0.5 : 1;
}
}
Using View.extend()

When using the extend() method, you can specify presentationAttributes with an object property instead of specifying it in preinitialize() method.

const MyShapeView = dia.ElementView.extend({

// Make sure that all super class presentation attributes are preserved
presentationAttributes: dia.ElementView.addPresentationAttributes({
// mapping the model attributes to flag labels
faded: 'flag:opacity'
}),

confirmUpdate(flags, ...args) {
dia.ElementView.prototype.confirmUpdate.call(this, flags, ...args);
if (this.hasFlag(flags, 'flag:opacity')) this.toggleFade();
},

toggleFade() {
this.el.style.opacity = this.model.get('faded') ? 0.5 : 1;
}
});

With this view, we can now toggle the opacity of our element by setting the faded attribute on the model. The confirmUpdate() method is called whenever the model changes, and we can use it to update the view accordingly.

const shape = new MyShape();
graph.addCell(shape);

// this will set the opacity of the shape to 0.5 via the custom view
shape.set('faded', true);

The custom view implementation looks quite similar to our custom namespace example from earlier, but with a few key differences. When the custom view is defined, it will listen to the underlying model changes, and update itself. When we are satisfied with the functionality we have created in our view, we then need to set up the namespace correctly once more. In our example, we do this by extending our namespace with MyShapeView. The Paper will search for any model types with a suffix of 'View' in our namespace. As we have completed the set up, that means the behavior defined in our custom view is now available for us to use how we want. With this approach you can also listen for the DOM events in your custom view. For example you can listen for the click event on the element and change the model attributes accordingly. You can find the example of such use later in the tutorial.

Default view​

A more powerful alternative is to override the default view found in the namespace. Which approach you use will depend on your specific needs. To override the default view, we use the elementView and linkView settings respectively in our Paper options. You can see that in action below:

class MyShapeView extends dia.ElementView {

// Custom view functionality

};

const graph = new dia.Graph({}, { cellNamespace: namespace });
const paper = new dia.Paper({

// Options

elementView: () => MyShapeView
});

Full control rendering using custom view​

In some cases, you may want to create a shape with the full control over its markup and rendering. For example, you may want to create a shape with a variable number of subelements or change the markup depending on some custom attribute. In this case, you can use following technique to create custom element markup.

To use this approach you should create your shape as usual extending base class. But you should omit markup property and attrs attribute is not used as we are not using selectors.

Let's illustrate this approach with a small example which implements tabs behavior for the custom shape. First, we will create our custom shape model as usual, but we will use only some custom attributes which will be used in our custom view:

class MyShape extends joint.dia.Element {

defaults() {
return {
...super.defaults,
type: 'MyShape',
fillColor: 'red',
outlineColor: 'blue',
activeTab: 0,
numberOfTabs: 1,
faded: false
};
}
}

Then we can create a custom view for the MyShape model. First, let's specify some attributes we will listen for in our custom view. This part is the same as in our previous custom view example:

class MyShapeView extends dia.ElementView {

preinitialize() {
this.tabHeight = 20;
this.inactiveTabColor = 'gray';

this.presentationAttributes = dia.ElementView.addPresentationAttributes({
activeTab: ['UPDATE'],
fillColor: ['UPDATE'],
outlineColor: ['UPDATE'],
faded: ['FADE'],
numberOfTabs: ['RENDER']
});
}

confirmUpdate(...args) {
let flags = super.confirmUpdate(...args);
if (this.hasFlag(flags, 'FADE')) {
this.toggleFade();
flags = this.removeFlag(flags, 'FADE');
}
return flags;
}

toggleFade() {
this.vel.attr('opacity', this.model.prop('faded') ? 0.2 : 1);
}
};

Then we will specify our custom rendering routine. This is the main part of our custom view. We will use the render() method to render our custom shape. In this method, we will create a custom SVG markup for our shape. We will use the attributes specified in our model to create a custom shape with tabs.

info

This approach overrides JointJS's built-in process of applying attributes to subelements' SVGElements. Consequently, none of the abstractions added by JointJS on top of that functionality can be used with a fully custom view. This prevents you from using special presentation attributes and all related functionality (camelCase attribute names, relative dimensions with calc(), and defining custom special presentation attributes).

class MyShapeView extends dia.ElementView {

// previous code

render() {
const { vel, model } = this;
const body = this.vBody = V('rect').addClass('body');
const tabs = this.vTabs = [];
const texts = this.vTexts = [];
for (let i = 0, n = model.prop('numberOfTabs'); i < n; i++) {
tabs.push(V('rect').addClass('tab').attr('cursor', 'pointer'));
texts.push(V('text').addClass('label').text(`Tab ${i + 1}`));
}
vel.empty().append([
body,
...tabs
]);
this.translate();
this.update();
}

update() {
this.updateBody();
this.updateText();
this.updateTabs();
this.toggleFade();
}

updateBody() {
const { model, vBody } = this;
const { width, height } = model.size();
const bodyAttributes = {
width,
height,
fill: model.prop('fillColor'),
stroke: model.prop('outlineColor')
};
vBody.attr(bodyAttributes);
}

updateTabs() {
const { model, vTabs, inactiveTabColor } = this;
const numberOfTabs = model.prop('numberOfTabs');
const length = model.prop('size/width') / numberOfTabs;
const activeTab = model.prop('activeTab');
for (let i = 0; i < numberOfTabs; i++) {
const isActive = (activeTab === i);
const offset = Number(isActive);
vTabs[i].attr({
'width': length - 2 * offset,
'x': i * length + offset,
'y': offset,
'height': this.tabHeight,
'stroke': (isActive) ? 'none' : model.prop('outlineColor'),
'fill': (isActive) ? model.prop('fillColor') : inactiveTabColor,
'data-index': i
});
}
}

updateText() {
const { model, vTexts, vel, tabHeight } = this;
const activeTab = model.prop('activeTab');
const numberOfTabs = model.prop('numberOfTabs');
for (let i = 0; i < numberOfTabs; i++) {
const vText = vTexts[i];
if (i === activeTab) {
const tx = model.prop(['size', 'width']) / 2;
const ty = tabHeight + 10;
vText.attr({
'transform': `translate(${tx}, ${ty})`,
'text-anchor': 'middle'
});
vel.append(vText);
} else {
vText.remove();
}
}
}
}

Note that in the previous part we set several attributes to enable UPDATE and RENDER flags. These are built-in flags which trigger update() and render() function respectively. So, when one of the attributes changes, the update() or render() method will be called to update the view.

Last, we will set up events in our custom view to add interactivity:

class MyShapeView extends dia.ElementView {

// previous code

events() {
return {
'dblclick': () => this.onDblClick(),
'mousedown .tab': (evt) => this.onTabClick(evt)
};
}

toggleFade() {
this.vel.attr('opacity', this.model.prop('faded') ? 0.2 : 1);
}

onTabClick(evt) {
evt.stopPropagation();
const index = Number(evt.target.dataset.index);
this.model.prop('activeTab', index);
}
}

You can look at the full code and the example in action here:

For the more advanced use of this approach you can look at our content driven shape example.

Examples​

Custom elements​

Let's apply everything we learned so far and create two custom elements: Node class based on dia.Element and ButtonNode element based on Node shape, which contains HTML element inside (more on this topic in our HTML inside shapes section). Keep in mind that the provided instance properties are mixined with the parent definition, but prototype and static properties are not. This means that it is enough for us to record only the attributes that changed in the definition of the custom element subtype (however, if we wanted to change the markup, we would have to do so explicitly).

import { dia, shapes, util } from '@joint/core';

const nodeMarkup = util.svg/* xml */`
<rect @selector="nodeBody"/>
<clipPath @selector="clipPath"><rect @selector="clipPathRect"/></clipPath>
<rect @selector="nodeHeader"/>
<text @selector="nodeHeaderLabel"/>
<text @selector="label"/>
`

class Node extends dia.Element {
preinitialize() {
this.markup = nodeMarkup;
}

defaults() {
const clipId = util.uuid();

return {
...super.defaults,
type: 'examples.Node',
size: {
width: 150,
height: 80
},
attrs: {
root: {
cursor: 'move'
},
nodeBody: {
width: 'calc(w)',
height: 'calc(h)',
fill: '#023047',
rx: 6
},
nodeHeaderLabel: {
x: 15,
y: 20 / 2 + 1,
textAnchor: 'start',
textVerticalAnchor: 'middle',
fill: '#023047',
text: 'Node',
fontSize: 10
},
nodeHeader: {
x: 0,
y: 0,
width: 'calc(w)',
height: 20,
clipPath: `url(#${clipId})`,
fill: '#ffb703'
},
clipPath: {
id: clipId
},
clipPathRect: {
width: 'calc(w)',
height: 'calc(h)',
rx: 6,
},
label: {
x: 'calc(w/2)',
y: 'calc(h/2+10)',
textAnchor: 'middle',
textVerticalAnchor: 'middle',
fill: 'white'
}
}
}
}
}

We add our shapes to the graph in the same manner described in our quick start tutorial. In the code below, we add a examples.Node shape with custom color and a multiline label. The multiline label respects the x and y values that we calculated with the calc() function earlier.

const n2 = new Node();
n2.position(50, 125);
n2.resize(200, 100);
n2.attr({
nodeBody: {
fill: '#219ebc'
},
label: {
text: 'Node\nanother color'
}
});
n2.addTo(graph);

To create ButtonNode shape, we will extend our Node class and add a button element to the markup. Notice, that we concatenate the markup property of the parent class with the new markup for the button element. To style the HTML inside the element we are using CSS classes which we can setup in the markup or in the attrs property.

import { dia, shapes, util } from '@joint/core';

const buttonNodeMarkup = util.svg/* xml */`
<foreignObject @selector="foreignObject">
<div class="button-container" @selector="buttonContainer">
<button class="button" @selector="button"></button>
</div>
</foreignObject>
`;

class ButtonNode extends Node {
preinitialize() {
super.preinitialize();

this.markup = this.markup.concat(buttonNodeMarkup);
}

defaults() {
const clipId = util.uuid();

return util.defaultsDeep({
type: 'examples.ButtonNode',
buttonTextVisible: 'Hide text',
buttonTextHidden: 'Show text',
attrs: {
nodeHeaderLabel: {
text: 'Button node'
},
label: {
y: 'calc(h/2+25)',
visibility: 'hidden'
},
foreignObject: {
x: 0,
y: 0,
width: 'calc(w)',
height: 'calc(h)',
}
}
}, super.defaults());
}
}

To implement listen to button events we are creating custom view for our ButtonNode shape:

export class ButtonNodeView extends dia.ElementView {

render() {
super.render();
this.el.getElementsByTagName('button')[0].textContent =
this.model.get('buttonTextHidden');
}

events() {
return {
'click button': (evt) => { this.onButtonClick(evt) }
}
}

onButtonClick(evt) {
const visibility = this.model.attr('label/visibility');
if (visibility === 'hidden') {
this.model.attr('label/visibility', 'visible');
evt.target.textContent = this.model.get('buttonTextVisible');
} else {
this.model.attr('label/visibility', 'hidden');
evt.target.textContent = this.model.get('buttonTextHidden');
}
}
}

Here we are listening for a click event on a button element. When the button is clicked, we change the visibility of the label text and the text of the button.

note

If you want to learn more about creating custom shapes using Typescript, you can find more information in the Typescript integration section.

The following example shows the default look of our Node and ButtonNode shapes with different attributes.

Let's apply our knowledge and create two custom links: Connection link based on standard.Link class and TwoWayConnection link based on the Connection shape.

const defaultLabelMarkup = util.svg/* xml */`
<rect @selector="body"/>
<text @selector="label"/>
`;

class Connection extends shapes.standard.Link {

defaults() {
return util.defaultsDeep({
type: 'examples.Connection',
defaultLabel: {
markup: defaultLabelMarkup,
attrs: {
label: {
fill: 'black', // default text color
fontSize: 12,
textAnchor: 'middle',
yAlignment: 'middle',
pointerEvents: 'none'
},
body: {
ref: 'label',
fill: 'white',
stroke: 'cornflowerblue',
strokeWidth: 2,
width: 'calc(1.2*w)',
height: 'calc(1.2*h)',
x: 'calc(x-calc(0.1*w))',
y: 'calc(y-calc(0.1*h))'
}
},
position: {
distance: 100, // default absolute position
args: {
absoluteDistance: true
}
}
},
attrs: {
line: {
stroke: 'cornflowerblue',
strokeWidth: 5,
targetMarker: {
'type': 'rect',
'width': 10,
'height': 20,
'y': -10,
'stroke': 'none'
}
}
},
}, super.defaults);
}
};

class TwoWayConnection extends Connection {

defaults() {
return util.defaultsDeep({
type: 'examples.TwoWayConnection',
attrs: {
line: {
sourceMarker: {
'type': 'rect',
'width': 10,
'height': 20,
'y': -10,
'stroke': 'none'
}
}
},
}, super.defaults());
}
};

Here we specified special model attribute defaultLabel, which is used to determine the markup of a default label. We did not specify any defaultLabel.size object in our example, so JointJS was missing overarching reference width and height dimensions for use in the various attrs/body calc() operations. In order to use calc() operations anyways - specifically on attrs/body - we instead provided a ref special attribute to identify a reference subelement from which the dimensions could be determined. In our example, the reference subelement was the label SVGTextElement, so JointJS used its dimensions for the calc() operations inside attrs/body. (If we had specified neither defaultLabel.size nor an individual ref attribute on attrs/body, however, the calculations would have used 0 as the reference width and height, which would have been unexpected.)

Additionally, note that in our example the x and y position of the body subelement is also determined by reference to the label subelement - via calc() operations which refer to the x and y variables.

The following example shows our Connection and TwoWayConnection links in action:

Content driven shape​

To create a shape with using this approach you should define a custom shape as usual. Here we are specifying several additional attribute which will be used later:

class Shape extends joint.dia.Element {
defaults() {
return {
...super.defaults,
type: 'custom.Shape',
fillColor: 'red',
outlineColor: 'blue',
label: '',
image: ''
};
}
}

The main magic of our content driven element happens in a layout function. As we want our layout to have access to certain model properties during calculation, we should preinitialize() our instances with those properties.

preinitialize() is a specialized method for use with models as ES classes. If we define it, it is invoked when the model is first created, before any instantiation logic is run for the model, including constructor call.

preinitialize() {
this.spacing = 10;
this.labelAttributes = {
'font-size': 14,
'font-family': 'sans-serif',
};
this.imageAttributes = {
'width': 50,
'height': 50,
'preserveAspectRatio': 'none'
};
this.cache = {};
}

When we initialize() our shape instance, we accomplish a number of things. We add an event listener to detect any attribute changes in our model, and we also set the size of our element based on the values returned from our layout calculation.

The onAttributeChange() method checks if attributes that affect the size of our element have changed. If there is no label present in the changes, that means we don't need to recalculate the label size, and can use the dimensions stored in the cache.

If either the image or label are present in the changes, we then need to recalculate the size of our element. We achieve this in the setSizeFromContent() method which derives the width and height from our layout.

initialize() {
super.initialize();
this.on('change', this.onAttributeChange);
this.setSizeFromContent();
}

/* Attributes that affects the size of the model. */
onAttributeChange() {
const {
changed,
cache
} = this;
if ('label' in changed) {
// invalidate the cache only if the text of the `label` has changed
delete cache.label;
}
if ('label' in changed || 'image' in changed) {
this.setSizeFromContent();
}
}

setSizeFromContent() {
delete this.cache.layout;
const {
width,
height
} = this.layout();
this.resize(width, height);
}

As we mentioned earlier, most of the action relating to the dimensions of our content driven element happens in the layout. In the following code, you will see how we utilize properties introduced in the preinitialize() method to create a flexible layout.

The layout() method first determines if there are any layout metrics already present in the cache, and if not, calls the calcLayout() method to create them.

layout() {
const { cache } = this;
let { layout } = cache;
if (layout) {
return layout;
} else {
const layout = this.calcLayout();
cache.layout = layout;
return layout;
}
}

calcLayout() {
const {
attributes,
labelAttributes,
imageAttributes,
spacing,
cache
} = this;
let width = spacing * 2;
let height = spacing * 2;
let x = spacing;
let y = spacing;
// image metrics
let imageMetrics;
if (attributes.image) {
const { width: w, height: h } = imageAttributes;
imageMetrics = {
x,
y,
width: w,
height: h
};
height += spacing + h;
y += spacing + h;
width += w;
} else {
imageMetrics = null;
}
// label metrics
let labelMetrics;
{
let w, h;
if ('label' in cache) {
w = cache.label.width;
h = cache.label.height;
} else {
const { width, height } = measureText(svg, attributes.label, labelAttributes);
w = width;
h = height;
cache.label = {
width,
height
};
}
width = Math.max(width, spacing + w + spacing);
height += h;
if (!h) {
// no text
height -= spacing;
}
labelMetrics = {
x,
y,
width: w,
height: h
};
}
// root metrics
return {
x: 0,
y: 0,
width,
height,
imageMetrics,
labelMetrics
};
}

When calculating the label dimensions, if no label is present in the cache, we use a helper function measureText() to get the dimensions for us. While a little unorthodox, we need to temporarily render a text element in the DOM to get the correct measurement, and it proves to work nicely.

const svg = paper.svg;

function measureText(svgDocument, text, attrs) {
const vText = V('text').attr(attrs).text(text);
vText.appendTo(svgDocument);
const bbox = vText.getBBox();
vText.remove();
return bbox;
}

Custom view​

The other important aspect of our content driven element is a custom element view. The view is responsible for rendering our shape, and working with our element visually. Our custom view also listens to underlying model changes, and updates itself.

The first thing we notice in our view is the presentationAttributes. The attributes property of our model contains the presentationAttributes. You can see we are extending the existing presentation attributes, while making sure the original CellView attributes are preserved.

An important note about the view is that, when it needs an update, it first requests that update from the paper. Update requests are sent to the paper via flags. presentationAttributes is simply an object that maps attributes to flag labels.

confirmUpdate() receives all scheduled flags, and based on them updates the view. In our example, it isn't necessary to perform updates for resizing DOM elements if the received flag is '@color'.

const ElementView = dia.ElementView;

const ShapeView = ElementView.extend({

presentationAttributes: ElementView.addPresentationAttributes({
// attributes that changes the position and size of the DOM elements
label: [ElementView.Flags.UPDATE],
image: [ElementView.Flags.UPDATE],
// attributes that do not affect the size
outlineColor: ['@color'],
fillColor: ['@color'],
}),

confirmUpdate: function(...args) {
let flags = ElementView.prototype.confirmUpdate.call(this, ...args);
if (this.hasFlag(flags, '@color')) {
// if only a color is changed, no need to resize the DOM elements
this.updateColors();
flags = this.removeFlag(flags, '@color');
}
// must return 0
return flags;
}

// Other Methods
});

The render() function runs once during initialization. It is responsible for creating the DOM elements, and updates during the initial render.

update() and updateColors() are methods responsible for updating our view, and will run when the appropriate flags have been received by confirmUpdate().

/* Runs only once while initializing */
render: () => {
const { vel, model } = this;
const body = this.vBody = V('rect').addClass('body');
const label = this.vLabel = V('text').addClass('label').attr(model.labelAttributes);
this.vImage = V('image').addClass('image').attr(model.imageAttributes);
vel.empty().append([
body,
label
]);
this.update();
this.updateColors();
this.translate(); // default element translate method
},

update: function() {
const layout = this.model.layout();
this.updateBody();
this.updateImage(layout.$image);
this.updateLabel(layout.$label);
},

updateColors: function() {
const { model, vBody } = this;
vBody.attr({
fill: model.get('fillColor'),
stroke: model.get('outlineColor')
});
},

updateBody: function() {
const { model, vBody } = this;
const { width, height } = model.size();
const bodyAttributes = {
width,
height
};
vBody.attr(bodyAttributes);
},

updateImage: function($image) {
const { model, vImage, vel } = this;
const image = model.get('image');
if (image) {
if (!vImage.parent()) {
vel.append(vImage);
}
vImage.attr({
'xlink:href': image,
x: $image.x,
y: $image.y
});

} else {
vImage.remove();
}
},

updateLabel: function($label) {
const { model, vLabel } = this;
vLabel.attr({
'text-anchor': 'middle',
x: $label.x + $label.width / 2,
y: $label.y + $label.height / 2
});
vLabel.text(model.get('label'), {
textVerticalAnchor: 'middle'
});
}

That's it for configuration, and now it's finally time to see our content driven element on screen. We do this by creating an instance of our shape, and setting some attribute values with techniques you are already familiar with.

Once we add our element to the graph, you'll notice that our content, including a rather long label, doesn't extend beyond the bounds of its element. A dynamic approach may not be required for your use case, but I'm sure you'll agree it has its advantages after seeing it in action.

const customShape4 = new Shape();
customShape4
.set('image', 'https://via.placeholder.com/150/FF0000')
.set('label', 'Lorem ipsum dolor sit amet,\nconsectetur adipiscing elit.\nInteger vehicula.')
.set('outlineColor', 'red')
.position(200, 50)
.prop('fillColor', 'lightgray')
.addTo(graph);

You can look at the example in action below:

Frequently asked questions​

Can I have two labels on an element?​

Absolutely. You can have as many labels as you need on an element - see below. Similarly, you can also have any number of labels on links and ports.

How can I give multiple labels to an element?​

You can give multiple labels to an element by creating a new element type with extra label subelements inside its markup property.

Learn more...
info

Element labels are part of the element type definition. This is a difference from links, which have a built-in API for adding and removing labels (to any link type).

In this example, the new element type is called TwoLabelsElement, and the two <text/> label subelements have selectors label1 and label2:

It is possible to define custom JointJS elements with hyperlinks pointing to external sites - by including the <a> SVG element in the markup of a custom element. The navigation path is then specified in individual elements' attributes of the custom type.

Learn more...

The two most important XLink attributes in this context are the following:

  • xlink:href - set the target resource URL.
  • xlink:show - how should the linked content be presented? Possible values are 'replace' (show in current window; default) and 'new' (show in new window).

In the built-in link types, the arrowhead does not capture pointer events because it's made using <marker/> SVGElements (which are not a part of the DOM tree). You can get around this limitation by specifying a custom link arrowhead as part of the link's markup - see below.

You can make a link's arrowhead clickable by creating a new link type with an extra <path/> subelement inside its markup property (instead of the usual approach which relies on JointJS marker attributes, like sourceMarker, targetMarker, vertexMarker).

Learn more...

The example utilizes the atConnectionRatio attribute to position the arrowhead subelement (selector: targetArrowhead) and event attribute to fire a custom event.

You can give a link two colors by defining a new link type with custom logic for the stroke-dasharray attribute.

Learn more...

In this example, we define a custom link type with two extra subelements (selectors: first and second) to be colored, differentiated by the custom index property. We refer to these index values inside the custom indexAttributeSetter() function, which calculates the stroke-dasharray value for each subelement based on the current length of the Link.

tip

To find out more about defining and using custom attributes, see the Working with model attributes section in the JointJS documentation.