Skip to main content

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. 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:

  • Using the Cell.define() function.
  • Defining a shape as a new ES6 class that inherits from dia.Element or dia.Link.

While all shapes are descendants of dia.Cell class, when creating new shapes you should inherit either from dia.Element or dia.Link classes depending on your desired shape. In addition to the base classes you can also inherit from any shape in the predefined JointJS shape collections and even any custom element subtype you have previously defined.

We suggesting you to 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.

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.

Using Cell.define() function

You can use Cell.define() function to declare custom shapes. To use it properly you should call define method on the dia.Element or dia.Link class as well as on the already existing shape.

Here is how the parameters of the define() function map to familiar building blocks of elements:

  • type - the type of the subtype class.
  • defaultInstanceProperties - object that contains properties to be assigned to every constructed instance of the subtype. Used for specifying default attributes.
  • prototypeProperties - object that contains properties to be assigned on the subtype prototype. Intended for properties intrinsic to the subtype, not usually modified. Used for specifying shape markup and for any functionality that is shared by all instances of the subtype (e.g. property validation).
  • staticProperties - object that contains properties to be assigned on the subtype constructor. Not very common, used mostly for alternative constructor functions.

Let's use the familiar shapes.standard.Rectangle shape definition as an example:

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

dia.Element.define('standard.Rectangle', {
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)',
strokeWidth: 2,
stroke: '#000000',
fill: '#FFFFFF'
},
label: {
textVerticalAnchor: 'middle',
textAnchor: 'middle',
x: 'calc(0.5*w)',
y: 'calc(0.5*h)',
fontSize: 14,
fill: '#333333'
}
}
}, {
markup: [{
tagName: 'rect',
selector: 'body',
}, {
tagName: 'text',
selector: 'label'
}]
});

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 will return an object that contains the attributes for our model. It is possible to use an object for our defaults, but as objects are referenced, not copied in JavaScript, our function will return a different object each time.

In JavaScript classes, you may be used to working with super. In our use case, we want our child subtype to take attributes from its parent type. Using ...super.defaults, if an attribute is undefined in the child, the parent attribute will be assigned instead. Similarly, once a property is set in the child, additional values of the same property from the parent are replaced.

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 attributes.

Attributes

During your custom shape declaration you should specify default attributes for your model. These attributes will be used as a template for every instance of your custom shape. The attributes are defined in the defaults() function of your custom shape class.

Type

The first attribute we will look at is type. The type is a unique path identifier to help JointJS find our shape. The most important usage of a type is to provide cellNamespace for your graph. You can find more information about cellNamespaces in the following section.section.

Lets modify our MyShape class to include a type attribute:

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

export class MyShape extends dia.Element {

defaults() {
return {
type: 'myNamespace.MyShape',
...super.defaults,
}
}
}

The type path is very important, especially if you want to import a graph from JSON. graph would look at relevant namespace path to find the correct constructor. Let's use our custom shape type in the graph cell namespace:

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

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

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

Optional attributes

Besides the type attribute, you may want to specify default values for various attributes you may want to use with your shapes. Elements and links have their own special model attributes like size, source, router etc. You can learn about model attributes in dedicated element and link sections.

As we have already set up any parent attributes, we should now add some unique attributes for each instance of our custom shape. In order to do this, we create an attrs object with keys that correspond to our subelement selectors which are defined in markup. body and label are the respective keys in our example. The attributes can consist of standard SVG attributes, but also special JointJS presentation attributes.

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 defaultsDeep

There is one alternative way to return attributes in our custom shape, and you can see that in the following example:

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

defaults() {
return util.defaultsDeep({
size: {
width: 80,
// `height` will be taken from the parent class
},
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)
}

Earlier, we used the spread operator with super.defaults. That allowed us to replace any parent properties if we defined those properties on the child. In example above, we are using util.defaultsDeep(). This works a little differently, and recursively assigns default properties. That means if a property already exists on the child, the child property won't be replaced even if the parent property of same name has a different value.

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 preferred way to specify markup is to use util.svg() tagged template. This function is a helper that allows you to write SVG markup in a more readable way. The markup is treated as a prototype property and should exist before the element is instantiated. To achieve it you should declare your markup in special preinitialize() function. Let's add markup to our custom shape:

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

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

export class MyShape extends dia.Element {

preinitialize() {
this.markup = markup;
}

defaults() {
return {
...super.defaults,
type: 'myNamespace.MyShape',
size: { width: 100, height: 80 },
attrs: {
body: {
// Attributes
},
label: {
// Attributes
}
}
}
}
}
note

We are markup in a separate variable to avoid creating a new instance of the template for each new shape instance. This is a good practice to avoid unnecessary memory usage and increase performance.

You can use selectors you defined in your markup to target SVG subelements to get or set specific attributes:

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

Methods

Additionally, let's create several methods for our custom shape. We can create prototype methods to call on our constructor, or we can create static methods to call on the class itself.

export class MyShape extends dia.Element {

/**
*
*/

test(): void {
console.log(`A prototype method test for ${this.get('type')}`);
}

static staticTest(i: number): void {
console.log(`A static method test with an argument: ${i}`);
}
}

Let's look at a prototype method in a little more detail. If we wanted to create a custom text label for each instance of our shape, we could create a function called setText. This function will take a string as its only parameter, and set the label text using this value. Then, when we create our instance, we can pass our custom text as an argument to the constructor.

// Custom shape prototype method

setText(text: string): dia.Element {
return this.attr('label/text', text || '');
}

// Create instance using the method

const myNewShape = new MyShape().setText(text);

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 presentational 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, or apply some interesting effect to your elements, those are just a few of the reasons you may want to explore custom views. In the following example, we create a fade view effect to change the opacity of elements.

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

class MyShape extends dia.Element {
defaults() {
return {
...super.defaults,
type: 'myShapeGroup.MyShape',
size: { width: 100, height: 30 },
position: {x: 10, y: 10 },
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)'
}
}
}
}

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

const MyShapeView: dia.ElementView = 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;
}
});

const graph = new dia.Graph({}, { cellNamespace: shapes });
const paper = new dia.Paper({
model: graph,
width: 800,
height: 800,
gridSize: 1,
interactive: true,
async: true,
frozen: false,
background: { color: '#F3F7F6' },
cellViewNamespace: shapes,
});

document.getElementById('canvas').appendChild(paper.el);

Object.assign(shapes, {
myShapeGroup: {
MyShape,
MyShapeView
},
standard: {
Rectangle: shapes.standard.Rectangle
}
});

graph.fromJSON({
cells: [
{ type: 'myShapeGroup.MyShape'},
{
type: 'standard.Rectangle',
size: { width: 40, height: 40 },
position: {x: 40, y: 50 }
}
]
});

graph.getElements()[0].set('faded', true);

The custom view implementation looks quite similar to our custom namespace example from earlier, but with a few key differences. The first major difference is that we define our custom view using dia.ElementView.extend({}). 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.

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 setting in our Paper options. You can see that in action below.

const MyShapeView = dia.ElementView.extend({

// Custom view functionality

});

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

// Options

elementView: () => MyShapeView
});

Custom elements example

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.

Default label

When creating new links you may want to change default link label attributes. The second argument of the define() function (default instance properties) is also where defaultLabel for custom Link subtypes may be specified. This allows you to provide custom default markup, size, attrs and position for labels that are created on an instance of your custom Link type. The defaultLabel property is explained in separate section.

The shapes.standard.Link does not define its own custom default label, so the built-in default markup, attributes and position are used unless they are overridden by individual label markup, attrs and position.

The defaultLabel accepts four optional properties:

  • markup - sets the default markup of labels created on this Link subtype. Expected in the JSON markup format or as a util.svg ES6 tag template. (An SVG-parsable string is also accepted, but it is slower.).
  • size - sets the default dimensions of labels created on this Link subtype.
  • attrs - sets the default attributes of label subelements on this Link subtype. Uses selectors defined in markup.
  • position - sets the default position along the line at which labels will be added for instances of this Link subtype. The default is {distance: 0.5} (midpoint of the connection path).

Link labels are explained in depth in a separate section.

Example

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());
}
};

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:

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.