Interactive PlantUML SVG: click-through, tooltips, JS wiring

puml.online

PlantUML’s output SVG is “dead” by default — view-only. But with map directives or post-process JS injection, the diagram becomes a navigation map: click a node to open docs, hover for a tooltip, call an API for live data.

Three levels of interaction

Level Implementation Use case
Static default SVG docs, slides
Click-through [[url]] or post-process <a> injection wiki navigation
Hover tooltip post-process JS architecture overview
JS wiring post-process JS monitoring dashboards, designers

Method 1: map directive + click-through (PlantUML native)

1
2
3
4
5
6
7
8
@startuml
component "User Service" as us [[https://wiki.company.com/user-service]]
component "Order Service" as os [[https://wiki.company.com/order-service]]
component "Payment Service" as ps [[https://wiki.company.com/payment-service]]

us --> os
os --> ps
@enduml

[[url]] is PlantUML’s native link syntax — the rendered SVG nodes are wrapped in <a>, clicking jumps to the URL.

Use cases:

  • Architecture diagram clickable to wiki
  • Class diagram clickable to source
  • Flow diagram clickable to runbook

Method 2: map directive for clickable index

1
2
3
4
5
6
7
@startuml
map "Architecture Map" {
us => https://wiki/user-service
os => https://wiki/order-service
ps => https://wiki/payment-service
}
@enduml

map is PlantUML’s directory index — each entry is a small card with a link. Perfect for README / docs landing pages.

Advanced syntax:

1
2
3
4
5
map "Service Directory" {
us => https://wiki/user
us => [User Service] => https://wiki/user
}
@enduml

Method 3: %%tooltip (experimental)

1
2
3
4
5
@startuml
component "User Service" as us %%tooltip "Handles user registration, login, profile"
component "Order Service" as os %%tooltip "Manages shopping cart and checkout"
us --> os
@enduml

%%tooltip is PlantUML 1.2024+ experimental — rendered SVG nodes carry <title> elements — hover shows the browser’s native tooltip.

Note: %%tooltip is a comment-level directive, appended to the same line as the element. Not !define, not skinparam.

Method 4: post-process SVG to inject JS

For built-in interactions PlantUML doesn’t support, post-process with Python/Node to inject:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# inject_tooltip.py
import re, sys, pathlib

svg = pathlib.Path(sys.argv[1]).read_text()
metadata = pathlib.Path(sys.argv[2]).read_text()
# metadata is JSON: {"us": "User Service handles ...", "os": "Order Service manages ..."}

svg = svg.replace(
'<svg ',
f'<svg data-tooltips="{pathlib.Path(sys.argv[2]).name}" '
)

svg = re.sub(
r'data-node-id="(\w+)"',
lambda m: f'data-node-id="{m.group(1)}" data-tooltip="{json.loads(metadata).get(m.group(1), "")}"',
svg,
)

# inject JS
js = """
<script>
document.querySelectorAll('[data-tooltip]').forEach(el => {
const t = el.getAttribute('data-tooltip');
if (!t) return;
el.addEventListener('mouseenter', () => {
const tip = document.createElement('div');
tip.className = 'plantuml-tooltip';
tip.textContent = t;
document.body.appendChild(tip);
const r = el.getBoundingClientRect();
tip.style.left = (r.left + r.width/2) + 'px';
tip.style.top = (r.top - tip.offsetHeight - 5) + 'px';
el._tip = tip;
});
el.addEventListener('mouseleave', () => {
if (el._tip) el._tip.remove();
});
});
</script>
"""

# insert JS before </svg>
svg = svg.replace('</svg>', f'{js}</svg>')
pathlib.Path(sys.argv[1]).write_text(svg)

PlantUML’s default SVG output has data-node-id or id attributes on node elements — post-process uses querySelector to pick targets and inject events.

Method 5: live data wiring

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!DOCTYPE html>
<html>
<head>
<style>
.status-ok { fill: green !important; }
.status-warn { fill: orange !important; }
.status-down { fill: red !important; }
</style>
</head>
<body>
<object data="architecture.svg" type="image/svg+xml" id="diagram"></object>

<script>
const statuses = {
'us': 'ok', // user-service
'os': 'warn', // order-service
'ps': 'down', // payment-service
};

document.getElementById('diagram').addEventListener('load', () => {
const svgDoc = document.getElementById('diagram').contentDocument;
for (const [nodeId, status] of Object.entries(statuses)) {
const node = svgDoc.querySelector(`[data-node-id="${nodeId}"]`);
if (node) node.classList.add(`status-${status}`);
}
});

// periodic refresh
setInterval(async () => {
const r = await fetch('/api/service-status');
const data = await r.json();
const svgDoc = document.getElementById('diagram').contentDocument;
for (const [id, status] of Object.entries(data)) {
const node = svgDoc.querySelector(`[data-node-id="${id}"]`);
if (node) {
node.classList.remove('status-ok', 'status-warn', 'status-down');
node.classList.add(`status-${status}`);
}
}
}, 10000);
</script>
</body>
</html>

Architecture diagram showing live service health — green/orange/red monitoring dashboard.

<object> embeds SVG, JS accesses SVG DOM via contentDocument — cross-origin needs SVG server to send Access-Control-Allow-Origin.

Method 6: node click triggers detail panel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<!DOCTYPE html>
<html>
<head>
<style>
.detail-panel {
position: fixed;
right: 0; top: 0;
width: 300px;
height: 100vh;
background: white;
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
padding: 20px;
display: none;
}
.detail-panel.open { display: block; }
</style>
</head>
<body>
<object data="architecture.svg" type="image/svg+xml" id="diagram"></object>
<div class="detail-panel" id="panel"></div>

<script>
document.getElementById('diagram').addEventListener('load', () => {
const svgDoc = document.getElementById('diagram').contentDocument;
svgDoc.querySelectorAll('[data-node-id]').forEach(node => {
node.style.cursor = 'pointer';
node.addEventListener('click', async () => {
const id = node.getAttribute('data-node-id');
const r = await fetch(`/api/services/${id}/details`);
const detail = await r.json();
document.getElementById('panel').innerHTML = `
<h2>${detail.name}</h2>
<p>${detail.description}</p>
<ul>
<li>Owner: ${detail.owner}</li>
<li>Repo: ${detail.repo}</li>
<li>On-call: ${detail.oncall}</li>
</ul>
`;
document.getElementById('panel').classList.add('open');
});
});
});
</script>
</body>
</html>

Click a service in the diagram, the right side slides out with details — the architecture diagram becomes the wiki navigation entry point.

Field example: visual CI pipeline with PlantUML

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@startuml
component "Git Push" as git
component "Lint" as lint
component "Test" as test
component "Build" as build
component "Deploy Staging" as staging
component "Deploy Prod" as prod

git --> lint
lint --> test : "fail"
test --> build : "pass"
build --> staging
staging --> prod : "manual approve"
@enduml

CI pipeline visualization, each node is a CI stage. Frontend post-process colors nodes by stage state (running=blue, passed=green, failed=red, pending=gray).

Field example: architecture in Confluence with live status

1
2
3
4
5
6
<ac:structured-macro ac:name="html">
<ac:plain-text-body><![CDATA[
<iframe src="https://wiki.internal/diagram-viewer?svg=architecture.svg"
width="100%" height="600"></iframe>
]]></ac:plain-text-body>
</ac:structured-macro>

diagram-viewer is an internal page:

  1. Load PlantUML-rendered SVG
  2. Inject post-process JS (click a node to open wiki page)
  3. Periodically poll Prometheus API for service status, update node colors

Security considerations

SVG with embedded JS has XSS risk:

  • PlantUML’s generated SVG does not include <script> by default — safe
  • But when post-process injects JS, deploy the SVG to trusted domain (internal CDN)
  • Don’t let users upload .puml then render SVG for others to view — attackers can inject malicious JS

Production approach:

  • SVG uploads to cdn.internal.company.com
  • JS post-processing generates at CI, deploys to internal wiki
  • Sanitize SVG with DOMPurify

Decision tree

1
2
3
4
5
6
Need interaction?
├─ No → default SVG, done
├─ Click-through to wiki → [[url]] syntax
├─ Hover tooltip → %%tooltip (simple) / post-process (complex)
├─ Live status display → post-process JS + API
└─ Complex interaction (detail panel / edit) → Vue/React wrapping SVG

Simplest uses [[url]] — zero JS. Most complex uses React/Vue to treat SVG as a SVG-as-component, fully programmable — but loses PlantUML’s conciseness.

  • Title: Interactive PlantUML SVG: click-through, tooltips, JS wiring
  • Author: puml.online
  • Created at : 2026-07-30 17:10:00
  • Updated at : 2026-08-14 21:34:29
  • Link: https://puml.online/blog/plantuml-interactive-webview-en/
  • License: This work is licensed under CC BY-NC-SA 4.0.