Nodes & Shapes
This guide covers creating nodes, choosing shapes, styling them, and embedding media.
Shape gallery
VizCraft ships with 22 built-in shapes plus a custom-path escape hatch. Here they are at a glance:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(700, 960);
builder
.node('circ').at(60, 80).circle(30).fill('#89b4fa').label('circle')
.node('rec').at(180, 80).rect(80, 50, 8).fill('#a6e3a1').label('rect')
.node('dia').at(310, 80).diamond(70, 70).fill('#f9e2af').label('diamond')
// ... (18 more shapes)
.done();
builder.mount(document.getElementById('container'));
Shape reference
| Shape | Builder method | Parameters |
|---|---|---|
| Circle | .circle(r) | radius |
| Rectangle | .rect(w, h, rx?) | width, height, corner radius |
| Diamond | .diamond(w, h) | width, height |
| Cylinder | .cylinder(w, h, arcHeight?) | database symbol |
| Hexagon | .hexagon(r, orientation?) | 'pointy' (default) or 'flat' |
| Ellipse | .ellipse(rx, ry) | radii |
| Arc | .arc(r, startAngle, endAngle, closed?) | pie slice |
| Block Arrow | .blockArrow(len, bodyW, headW, headLen, dir?) | directional arrow |
| Callout | .callout(w, h, opts?) | speech bubble with pointer |
| Cloud | .cloud(w, h) | thought bubble |
| Cross | .cross(size, barWidth?) | plus sign |
| Cube | .cube(w, h, depth?) | 3D isometric |
| Document | .document(w, h, waveHeight?) | wavy bottom |
| Note | .note(w, h, foldSize?) | folded corner |
| Parallelogram | .parallelogram(w, h, skew?) | I/O symbol |
| Star | .star(points, outerR, innerR?) | star/badge |
| Trapezoid | .trapezoid(topW, bottomW, h) | manual operation |
| Triangle | .triangle(w, h, direction?) | 'up'/'down'/'left'/'right' |
| Custom Path | .path(d, w, h) | any SVG path data |
Labels
Simple labels
Add a text label to any node with .label(text, opts?):
builder.node('a').at(100, 100).rect(80, 40).label('Hello');
builder
.node('b')
.at(200, 100)
.circle(30)
.label('World', { fontSize: 14, fill: 'white' });
Multi-line and word-wrap
Use \n for manual line breaks, or maxWidth for automatic word wrapping:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 160);
builder
.node('n1').at(100, 60).rect(140, 80)
.label('Manual\nLine\nBreaks', { lineHeight: 1.4, fontSize: 13, fontWeight: 'bold' })
.node('n2').at(300, 60).circle(50)
.label('This long text automatically wraps to fit the shape', { maxWidth: 80, fontSize: 11 })
.node('n3').at(500, 60).diamond(80, 80)
.label('Top Align\nText', { verticalAlign: 'top', dy: -20, fontSize: 12 });
builder.mount(document.getElementById('container'));
| Option | Type | Description |
|---|---|---|
maxWidth | number | Triggers automatic word wrapping |
lineHeight | number | Vertical spacing multiplier (default: 1.2) |
verticalAlign | 'top' | 'middle' | 'bottom' | Alignment (default: 'middle') |
fontSize | number | string | Font size |
fontWeight | number | string | Font weight |
fill | string | Text color |
Rich text labels
Use .richLabel((l) => ...) for mixed-format labels — bold, italic, code, links, superscripts:
- Preview
- Code
import { viz } from 'vizcraft';
builder
.node('api').at(140, 120).rect(140, 64, 12)
.richLabel((l) => l.text('API ').bold('v2').newline().code('GET /users'))
.node('db').at(520, 120).cylinder(140, 64)
.richLabel((l) => l.text('Postgres ').italic('14'))
.edge('api', 'db').arrow()
.richLabel((l) => l.text('p').sup('95').text(' ').bold('12ms'));
Styling
Fill and stroke
builder
.node('a')
.at(100, 100)
.rect(80, 40)
.fill('#2196F3')
.stroke('#1565C0', 2);
Border dash patterns
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(620, 180);
builder
.node('solid').at(80, 90).rect(120, 60).stroke('#6c7086').label('solid (default)')
.node('dashed').at(230, 90).rect(120, 60).stroke('#6c7086').dashed().label('dashed')
.node('dotted').at(380, 90).rect(120, 60).stroke('#6c7086').dotted().label('dotted')
.node('dashDot').at(530, 90).rect(120, 60).stroke('#6c7086').dash('dash-dot').label('dash-dot');
builder.mount(document.getElementById('container'));
| Preset | stroke-dasharray |
|---|---|
'solid' | none |
'dashed' | 8, 4 |
'dotted' | 2, 4 |
'dash-dot' | 8, 4, 2, 4 |
Drop shadows
Add a drop shadow with .shadow() (defaults) or .shadow({ dx, dy, blur, color }):
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(620, 200);
builder
.node('default').at(100, 100).rect(130, 60, 8)
.fill('#ffffff').stroke('#e0e0e0').shadow().label('default shadow')
.node('custom').at(310, 100).rect(130, 60, 8)
.fill('#ffffff').stroke('#e0e0e0')
.shadow({ dx: 4, dy: 4, blur: 10, color: 'rgba(0,0,0,0.35)' })
.label('custom shadow')
.node('none').at(520, 100).rect(130, 60, 8)
.fill('#ffffff').stroke('#e0e0e0').label('no shadow');
builder.mount(document.getElementById('container'));
Sketch / hand-drawn mode
Apply a hand-drawn effect with .sketch() on individual nodes, edges, or globally:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(420, 200)
.sketch() // global sketch mode
.node('a').at(110, 100).rect(130, 60, 8).fill('#ffffff').stroke('#333').label('Node A')
.node('b').at(310, 100).rect(130, 60, 8).fill('#ffffff').stroke('#333').label('Node B')
.edge('a', 'b').arrow();
builder.mount(document.getElementById('container'));
| Scope | API | Description |
|---|---|---|
| Per-node | .sketch() / .sketch({ seed }) | Sketchy rendering for one node |
| Per-edge | .sketch() | Sketchy rendering for one edge |
| Global | viz().sketch() | Apply to all nodes and edges |
Embedded media
Images
Embed images with .image(href, w, h, opts?). Position options: 'center', 'above', 'below', 'left', 'right'.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 150);
builder
.node('n1').at(100, 75).circle(40)
.image('/info-icon.svg', 24, 24, { position: 'above' }).label('Info')
.node('n2').at(300, 75).rect(120, 50, 8)
.image('/activity-icon.svg', 24, 24, { position: 'left' }).label('Activity')
.node('n3').at(500, 75).diamond(80, 80)
.image('/dollar-icon.svg', 30, 30, { position: 'center' }).label('Centered', { dy: 30 });
builder.mount(document.getElementById('container'));
Icons and inline SVG
Use .icon(id, opts) with the icon registry, or .svgContent(markup, opts) for inline SVG:
- Preview
- Code
import { viz, registerIcon } from 'vizcraft';
registerIcon('custom-check', '<svg viewBox="0 0 24 24" ...>...</svg>');
builder
.node('img').at(140, 85).rect(170, 70, 10).fill('#a6e3a1')
.image('camera.svg', 28, 28, { position: 'left', dx: -6 }).label('image()', { dx: 16 })
.node('icon').at(380, 85).rect(170, 70, 10).fill('#89b4fa')
.icon('custom-check', { size: 28, color: '#111', position: 'center' }).label('icon()', { dy: 30 })
.node('svg').at(620, 85).rect(170, 70, 10).fill('#f9e2af')
.svgContent('<svg ...>...</svg>', { w: 28, h: 28, position: 'center' }).label('svgContent()', { dy: 30 });
Built-in icons: 'database', 'server', 'cloud', 'person'.
Positioning
Two ways to position nodes:
.at(x, y)— absolute coordinates in the viewBox..cell(col, row)— grid-based positioning (requires.grid()on the builder).
Text badges
Attach 1–2 character indicators (class kind, status icon, etc.) pinned to each corner of a node.
- Preview
- Code
viz()
.node('cls').at(100, 60).rect(120, 50, 6)
.label('UserService').fill('#2d2d3d').stroke('#555')
.badge('C', { position: 'top-left', fill: '#fff', background: '#4a9eff', fontSize: 10 })
.done()
.node('iface').at(300, 60).rect(120, 50, 6)
.label('IRepository').fill('#2d2d3d').stroke('#555')
.badge('I', { position: 'top-left', fill: '#fff', background: '#e5c07b', fontSize: 10 })
.badge('ƒ', { position: 'top-right', fill: '#98c379' })
.done();
Options
| Option | Type | Default | Description |
|---|---|---|---|
position | BadgePosition | 'top-left' | Corner to pin the badge to |
fill | string | — | Text colour |
background | string | — | Pill background colour (omit for none) |
fontSize | number | 10 | Font size in px |
BadgePosition is one of 'top-left' · 'top-right' · 'bottom-left' · 'bottom-right'.
Declarative form
viz().node('n', {
badges: [
{ text: 'C', position: 'top-left', fill: '#fff', background: '#4a9eff' },
{ text: '⚡', position: 'top-right', fill: '#e64553' },
],
});
Compartmented nodes (UML-style sections)
Divide a node into horizontal compartments separated by divider lines — ideal for UML class boxes.
Use .compartment(id, cb?) on a NodeBuilder to define each section. The builder auto-sizes the node height to fit all compartments.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 220);
builder
.node('user')
.at(250, 110)
.rect(200, 0, 6)
.fill('#f5f5f5')
.stroke('#333')
.compartment('name', (c) => c.label('User').height(36))
.compartment('attrs', (c) =>
c.label('- id: number\n- name: string', { fontSize: 12, textAnchor: 'start' })
)
.compartment('methods', (c) =>
c.label('+ getName()\n+ setName()', { fontSize: 12, textAnchor: 'start' })
)
.done();
builder.mount(document.getElementById('container'));
When compartments are present, the main .label() is suppressed — each compartment carries its own label instead.
Declarative compartments
builder.node('cls', {
at: { x: 250, y: 110 },
rect: { w: 200, h: 0, rx: 6 },
fill: '#f5f5f5',
stroke: '#333',
compartments: [
{ id: 'name', label: 'MyClass', height: 36 },
{ id: 'attrs', label: '- field: string' },
{ id: 'methods', label: '+ doWork()' },
],
});
Per-entry interactivity
Use .entry(id, text, opts?) inside a compartment callback to make each line independently interactive — with its own click handler, tooltip, hover highlight, and text styling.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 250);
builder
.node('service')
.at(250, 125)
.rect(220, 0, 6)
.fill('#f5f5f5')
.stroke('#333')
.compartment('name', (c) => c.label('UserService').height(36))
.compartment('methods', (c) => {
c.entry('create', '+ createUser()', {
style: { fontWeight: 'bold' },
tooltip: 'Creates a new user record',
});
c.entry('find', '+ findById(id)', {
tooltip: 'Looks up user by primary key',
});
c.entry('delete', '- deleteUser()', {
style: { fill: '#c00', fontStyle: 'italic' },
});
})
.done();
builder.mount(document.getElementById('container'));
Entries are rendered as individual SVG <text> elements with data-viz-role="compartment-entry" and data-entry attributes. Hovered entries receive the CSS class viz-entry-hover.
Entry padding and custom CSS class
Control vertical spacing around each entry with padding, and attach a custom CSS class with className for targeted styling.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 280);
builder
.node('svc')
.at(250, 140)
.rect(240, 0, 6)
.fill('#f5f5f5')
.stroke('#333')
.compartment('name', (c) => c.label('UserService').height(36))
.compartment('methods', (c) => {
c.entry('create', '+ createUser()', {
padding: 6, // 6 px top + 6 px bottom
className: 'entry-public',
style: { fontWeight: 'bold' },
});
c.entry('find', '+ findById(id)', {
padding: { top: 2, bottom: 8 }, // asymmetric padding
});
c.entry('delete', '- deleteUser()', {
padding: 6,
className: 'entry-danger',
style: { fill: '#c00', fontStyle: 'italic' },
});
})
.done();
builder.mount(document.getElementById('container'));
padding accepts a single number (uniform top & bottom) or an object { top?: number; bottom?: number } for asymmetric spacing. The entry height grows to include the padding, and text remains vertically centered within the content area.
The className value is appended to the entry's internal CSS class string, so you can target entries with standard CSS:
.entry-danger text {
fill: #c00;
}
Declarative entries
builder.node('cls', {
at: { x: 250, y: 110 },
rect: { w: 200, h: 0, rx: 6 },
compartments: [
{ id: 'name', label: 'MyClass', height: 36 },
{
id: 'methods',
entries: [
{ id: 'get', text: '+ getUser()' },
{ id: 'set', text: '+ setUser()', style: { fontWeight: 'bold' } },
{
id: 'rm',
text: '- removeUser()',
padding: 4,
className: 'entry-danger',
},
],
},
],
});
Compartment hit-testing
hitTest() returns a compartmentId when the pointer lands inside a specific compartment, so you can build context menus or selection per-section.
When the compartment uses entries, hitTest() also returns an entryId identifying the specific entry that was hit.
Collapsed / compact mode
Use .collapsed() to render only the first compartment (the header) while preserving all compartment data. This is useful for compact UML views where you want to hide attributes and methods until the user expands.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(500, 200);
// Expanded (default)
builder.node('expanded')
.at(130, 100).rect(200, 0, 6).fill('#f5f5f5').stroke('#333')
.compartment('name', (c) => c.label('UserService').height(36))
.compartment('attrs', (c) => c.label('- id: number\n- name: string', { fontSize: 12, textAnchor: 'start' }))
.compartment('methods', (c) => c.label('+ create()\n+ find()', { fontSize: 12, textAnchor: 'start' }));
// Collapsed — only header visible, triangle indicator shown
builder.node('collapsed')
.at(370, 100).rect(200, 0, 6).fill('#f5f5f5').stroke('#333')
.compartment('name', (c) => c.label('UserService').height(36))
.compartment('attrs', (c) => c.label('- id: number\n- name: string', { fontSize: 12, textAnchor: 'start' }))
.compartment('methods', (c) => c.label('+ create()\n+ find()', { fontSize: 12, textAnchor: 'start' }))
.collapsed()
.done();
builder.mount(document.getElementById('container'));
When collapsed:
- Only the first compartment (header) is rendered; dividers and subsequent compartments are hidden.
- A small triangle indicator appears in the header, signaling the collapsed state.
- The node auto-sizes its height to the first compartment only.
- All compartment data is preserved in the scene — toggling back to expanded restores the full view.
- The node group receives the CSS class
viz-node-collapsedfor styling hooks.
Interactive collapse toggle
Use .onClick(handler) on a compartment to wire up collapse/expand behavior. The handler receives a CompartmentClickContext with a toggle() helper:
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 250);
builder.node('cls1')
.at(150, 125).rect(200, 0, 6).fill('#f5f5f5').stroke('#333')
.compartment('name', (c) =>
c.label('UserService').height(36).onClick((ctx) => {
ctx.toggle({ animate: 200 });
})
)
.compartment('attrs', (c) =>
c.label('- db: Database\n- cache: Cache', { fontSize: 12, textAnchor: 'start' })
)
.compartment('methods', (c) =>
c.label('+ create()\n+ find()', { fontSize: 12, textAnchor: 'start' })
)
.done();
builder.node('cls2')
.at(450, 125).rect(200, 0, 6).fill('#f5f5f5').stroke('#333')
.compartment('name', (c) =>
c.label('Database').height(36).onClick((ctx) => {
ctx.toggle({ animate: 200 });
})
)
.compartment('attrs', (c) =>
c.label('- host: string\n- port: number', { fontSize: 12, textAnchor: 'start' })
)
.compartment('methods', (c) =>
c.label('+ connect()\n+ query()', { fontSize: 12, textAnchor: 'start' })
)
.done();
builder.edge('cls1', 'cls2').label('uses').done();
builder.mount(document.getElementById('container'));
Click the triangle indicator in the header to toggle between collapsed and expanded states.
When the first compartment has an onClick handler, a clickable collapse indicator appears in the header — even when the node is expanded. Clicking the indicator fires the first compartment's onClick.
The CompartmentClickContext provides:
| Field | Type | Description |
|---|---|---|
nodeId | string | The id of the node |
compartmentId | string | The id of the compartment |
collapsed | boolean | Current collapsed state (before toggling) |
collapseAnchor | CollapseAnchor | Current collapse anchor ('center' when unset) |
toggle | (opts?: { animate?: number; anchor?: CollapseAnchor }) => void | Toggle collapse with optional animation and anchor override |
Declarative collapsed
builder.node('cls', {
at: { x: 200, y: 200 },
rect: { w: 160, h: 0, rx: 6 },
collapsed: true,
compartments: [
{
id: 'name',
label: 'ClassName',
height: 36,
onClick: (ctx) => ctx.toggle(),
},
{ id: 'attrs', label: '- field: string' },
{ id: 'methods', label: '+ doWork()' },
],
});
Customising the collapse indicator
The collapse chevron can be styled, hidden, or replaced with custom SVG:
// Hide the indicator entirely
builder
.node('no-chevron')
.at(100, 100)
.rect(160, 0)
.compartment('header', (c) => c.label('Title').onClick((ctx) => ctx.toggle()))
.compartment('body', (c) => c.label('Content'))
.collapseIndicator(false)
.done();
// Custom colour
builder
.node('red-chevron')
.at(300, 100)
.rect(160, 0)
.compartment('header', (c) => c.label('Title').onClick((ctx) => ctx.toggle()))
.compartment('body', (c) => c.label('Content'))
.collapseIndicator({ color: 'crimson' })
.done();
// Custom SVG via render callback
builder
.node('custom')
.at(500, 100)
.rect(160, 0)
.compartment('header', (c) => c.label('Title').onClick((ctx) => ctx.toggle()))
.compartment('body', (c) => c.label('Content'))
.collapseIndicator({
render: (collapsed) =>
`<text font-size="12">${collapsed ? '▶' : '▼'}</text>`,
})
.done();
The same options work declaratively:
builder.node('cls', {
at: { x: 200, y: 200 },
rect: { w: 160, h: 0 },
compartments: [
{ id: 'name', label: 'Title', onClick: (ctx) => ctx.toggle() },
{ id: 'body', label: 'Content' },
],
collapseIndicator: { color: 'blue' },
});
See CollapseIndicatorOptions for all options.
Collapse anchor
Control which edge stays fixed during collapse/expand animation with .collapseAnchor():
'top'— top edge stays fixed; the node shrinks/grows downward.'center'— the node shrinks/grows symmetrically (default).'bottom'— bottom edge stays fixed; the node shrinks/grows upward.
Select an anchor, then click the collapse indicator on either node to see how the animation direction changes.
You can also pass the anchor per-toggle via ctx.toggle({ animate: 300, anchor: 'top' }), which overrides the node-level default.
- Preview
- Code
import { viz } from 'vizcraft';
const builder = viz().view(600, 300);
builder.node('svc')
.at(200, 150).rect(200, 0, 6).fill('#f5f5f5').stroke('#333')
.collapseAnchor('top') // top edge stays fixed
.compartment('name', (c) =>
c.label('UserService').height(36).onClick((ctx) => {
ctx.toggle({ animate: 300 });
})
)
.compartment('attrs', (c) =>
c.label('- db: Database\n- cache: Cache', { fontSize: 12, textAnchor: 'start' })
)
.compartment('methods', (c) =>
c.label('+ create()\n+ find()', { fontSize: 12, textAnchor: 'start' })
)
.done();
builder.mount(document.getElementById('container'));
CSS classes
Apply a CSS class with .class('my-class') for theming:
builder.node('a').at(100, 100).rect(80, 40).class('highlight');
Then style via CSS:
.highlight .viz-node-shape {
fill: gold;
stroke: orange;
}
Found a problem? Open an issue on GitHub.