Viewer and editor for isometric infrastructure diagrams, as a single Lit web component — a React-free port of Isoflow / FossFLOW.
The model format (JSON) is interchangeable with Isoflow/FossFLOW exports: diagrams created there render here, and vice versa.
Two of them: the editor (the full component) and a stress test (up to 10 000 nodes, with the numbers).
The upstream Isoflow is an excellent piece of work, but it is a React application: React, MUI, zustand, immer, gsap, chroma-js, react-quill — over 1 MB of dependencies before your own code. If your app is not a React app, you cannot embed it.
lit-isoflow keeps the parts that matter — the isometric projection, the A*
connector routing, the model format — and rewrites the view layer as a standard
web component. Three runtime dependencies (lit, zod, pathfinding), roughly
140 kB bundled, and it drops into any framework, or none.
The editor-mode attribute selects what <lit-isoflow> is:
| Mode | What you get | Typical use |
|---|---|---|
EXPLORABLE_READONLY (default) |
Viewer — pan, zoom, fit-to-view; the model is never mutated | embedding a diagram in docs, dashboards, read-only apps |
EDITABLE |
Editor — everything below: tools, selection, drag, drawing, undo/redo, property API | diagram authoring UI |
NON_INTERACTIVE |
Static rendering — no listeners at all | screenshots, PDF/PNG export pipelines, thumbnails |
<!-- Viewer -->
<lit-isoflow fit-to-view></lit-isoflow>
<!-- Editor -->
<lit-isoflow editor-mode="EDITABLE" fit-to-view></lit-isoflow>Editing capabilities (EDITABLE): select & drag items, rubber-band selection
(drag on empty canvas to select a group, then move it as one; Shift adds to the
selection — click or band), click-through to elements buried under others
(clicking the selected element again descends the tile's stack), draw connectors
(anchored to items or tiles), re-anchor or bend connectors by dragging their
anchors/path, draw & resize rectangles, place icons, add text boxes, delete
selection, gesture-level undo/redo, transient pan (hold Ctrl/Space).
Property panels (name, color, description…) are provided by the host app —
see “Wiring a property panel”.
Rendering (all modes): grid, nodes (isometric & flat icons), connectors (A* routing, solid/dashed/dotted, direction arrows, labels), rectangles, text boxes, node labels.
npm install lit-isoflow<lit-isoflow fit-to-view style="width: 100%; height: 600px"></lit-isoflow>
<script type="module">
import 'lit-isoflow';
const diagram = document.querySelector('lit-isoflow');
diagram.model = {
title: 'My diagram',
icons: [{ id: 'server', name: 'Server', url: '...', isIsometric: true }],
colors: [{ id: 'blue', value: '#a5b8f3' }],
items: [{ id: 'srv1', name: 'Server 1', icon: 'server' }],
views: [
{
id: 'main',
name: 'Main view',
items: [{ id: 'srv1', tile: { x: 0, y: 0 } }],
connectors: [],
rectangles: [],
textBoxes: []
}
]
};
</script>Putting it into a real app — bundling, icon packs, saving, theming, rendering diagrams into PDFs and Word files — is covered in the integration guide.
| Property | Attribute | Default | Description |
|---|---|---|---|
model |
— | null |
Diagram model (Isoflow/FossFLOW JSON) |
viewId |
view-id |
first view | View to display |
editorMode |
editor-mode |
EXPLORABLE_READONLY |
EDITABLE, EXPLORABLE_READONLY (pan/zoom) or NON_INTERACTIVE |
showGrid |
show-grid |
true |
Show the isometric grid |
theme |
theme |
auto |
light, dark, or auto (follows the OS) — see Theming |
backgroundColor |
background-color |
— | Overrides the theme's background |
fitToView |
fit-to-view |
false |
Fit the view in the viewport on load |
strings |
— | English | Overrides for the component's two strings — see Internationalisation |
zoomIn()/zoomOut()— multiplicative steps (×1.25), from 0.01 to 4. The floor is that low on purpose: a diagram of several thousand nodes only fits on screen at a few percent.fit()— fit the whole view inside the viewport, measured on what is actually painted (icons, labels, connector paths) rather than the tile bounding box, which overestimates by roughly 3×setTool(tool, options?)— activate an editing tool:'CURSOR','PAN','PLACE_ICON'(options.iconId),'CONNECTOR','RECTANGLE','TEXTBOX'tool(getter) — currently active tooldeleteSelection()— delete the selected item (also bound to the Delete key)clearSelection()— clear the selection (also bound to Escape); the host can use it to close its property panelundo()/redo()— gesture-level history (also bound to Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z);canUndo/canRedogettersgetModel()— deep snapshot of the current (possibly edited) modelgetSelectedItem()/updateItem()/updateViewItem()/updateConnector()/updateRectangle()/updateTextBox()— property-panel API, see “Wiring a property panel” belowgetSelectedItems()— the rubber-band selection, as[{ type, id }](ornull). Exclusive withgetSelectedItem(): a group has no property panel, a plain click on a member collapses the group to that single elementcreateRectangle({ from, to, color?, id? })/deleteRectangle(id)— place a zone programmatically (importing, templating, generating), rather than only by drawing it with the mouse. Returns the new id.exportSvg(options?)— renders the view to vector SVG. Returns{ svg, width, height }. Options:showGrid(default false),background(default'transparent'),margin(default 0.15 tiles). A thin wrapper aroundrenderToSvg()— see “Rendering without a browser”.exportPng(options?)— renders the view to a PNG with no extra dependency (off-screen clone → SVGforeignObject→ canvas), cropped tightly to the rendered content (icons, labels, connectors — not the tile bounding box). Returns{ blob, dataUrl, width, height }. Options:scale(default 2),showGrid(default false),background(any CSS color, including'transparent'),margin(tiles around the content, default 0.15). Icon URLs must be data URIs or same-origin (external images would taint the canvas); isopack icons are data URIs, so they just work.
| Key | Action |
|---|---|
| Hold Ctrl or Space | Pan; the active tool and selection are restored on release |
| Shift+click | Add an element to the selection, or remove it (toggle) |
| Shift+drag (anywhere) | Rubber band that adds to the selection instead of replacing |
| Click the selected element | Select the next element below it in the tile's stack |
| Delete / Backspace | Delete the selection |
| Escape | Clear the selection |
| Ctrl+Z | Undo |
| Ctrl+Y / Ctrl+Shift+Z | Redo |
Shortcuts are ignored while typing in an input, including inside a shadow root.
diagram-ready— model parsed and scene renderedzoom-changed—detail.zoommodel-error—detail.error(zod validation error)item-selected—detail.item({ type, id }ornull)selection-changed—detail.items(rubber-band selection,[{ type, id }]ornull)model-updated—detail.model(debounced snapshot after each edit)tool-changed—detail.toolhistory-changed—detail.canUndo/detail.canRedo
<lit-isoflow> deliberately ships no property panel and no rich text
editor: the canvas stays lean (lit + zod + pathfinding) and the host app
brings its own UI kit. The wiring contract is three parts:
-
Listen to
item-selected, then callgetSelectedItem()for the full data of the selection:diagram.addEventListener('item-selected', () => { const selected = diagram.getSelectedItem(); // ITEM → { type, id, modelItem: { name, description, icon }, viewItem: { tile, labelHeight } } // CONNECTOR → { type, id, connector: { description, color, style, width, anchors } } // RECTANGLE → { type, id, rectangle: { color, from, to } } // TEXTBOX → { type, id, textBox: { content, fontSize, orientation, tile } } renderMyPanel(selected); // null when the selection is cleared });
-
Write changes back through the update methods — each call re-renders the scene, feeds the undo history and emits
model-updated:diagram.updateItem(id, { name, description, icon }); diagram.updateViewItem(id, { tile, labelHeight }); diagram.updateConnector(id, { description, color, style, width }); diagram.updateRectangle(id, { color }); diagram.updateTextBox(id, { content, fontSize, orientation });
-
Persist by listening to
model-updated(debounced) or callinggetModel()whenever you save.
The editor demo (demo/editor/index.html) implements a complete panel with plain HTML inputs — no dependency — and is the reference example.
Rich descriptions: modelItem.description is an HTML string
(≤ 1000 chars; upstream Isoflow edits it with Quill and uses <p><br></p> as
its empty value). Any editor that produces HTML plugs in. With tiptap:
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
let editor;
diagram.addEventListener('item-selected', () => {
const selected = diagram.getSelectedItem();
editor?.destroy();
if (selected?.type !== 'ITEM') return;
editor = new Editor({
element: document.querySelector('#description-editor'),
extensions: [StarterKit],
content: selected.modelItem.description ?? '',
onUpdate: ({ editor }) => {
diagram.updateItem(selected.id, { description: editor.getHTML() });
}
});
});Notes:
- Each keystroke burst (pauses < 250 ms) collapses into one undo step; debounce
onUpdateyourself if you want coarser steps. - Descriptions are rendered as-is in node labels — sanitize if models come from untrusted sources.
Icons are plain image URLs (SVG/PNG, data URIs welcome), declared in model.icons.
isIsometric: true renders the image as-is (pre-projected isometric artwork);
isIsometric: false projects a flat image onto the isometric ground plane.
The official icon packs work unchanged — the demo loads all five @isoflow/isopacks (Isoflow basic, AWS, Azure, GCP, Kubernetes — 1000+ icons) and exposes them in a searchable gallery:
import isoflowIsopack from '@isoflow/isopacks/dist/isoflow';
const icons = isoflowIsopack.icons.map((icon) => ({ ...icon, collection: 'Isoflow' }));
diagram.model = { ...model, icons };Icon artwork belongs to its respective owners (AWS, Microsoft, Google, CNCF,
Isoflow); check the isopacks repository for per-collection licences. That is
why @isoflow/isopacks is a dev-dependency of the demo, not a dependency of
this package.
<lit-isoflow theme="dark"></lit-isoflow>
<!-- light | dark | auto -->auto (the default) follows the OS via prefers-color-scheme and repaints when
it changes. In an app that has its own theme toggle, drive the property instead —
auto would ignore it:
diagram.theme = app.isDark ? 'dark' : 'light';The theme only repaints the chrome — background, grid, label boxes, leader
lines, connector halos, controls. Node, connector and zone colours come from
model.colors: they belong to the diagram, not to the interface, and a diagram
that means something in red still means it at night.
The same applies to the headless renderer, which defaults to light — an export
is a document, not a screen, so it must not depend on the machine that generated it:
renderToSvg(model, { theme: 'dark' });Both worlds read the same palettes (src/theme.js), so the canvas and the exported SVG cannot drift apart.
The SVG renderer is pure JavaScript with no DOM, so a diagram can be turned into vector output anywhere JavaScript runs — a CLI, a build step, a server, a PDF pipeline:
import { renderToSvg } from 'lit-isoflow/render'; // no lit, no DOM
const { svg, width, height } = renderToSvg(model, {
theme: 'light', // 'light' (default) or 'dark'
background: '#ffffff', // overrides the theme's background; 'transparent' by default
showGrid: false,
margin: 0.5 // tiles of padding around the content
});This entry point pulls in neither Lit nor the component, so it stays light in a
Node process. It shares the geometry engine with <lit-isoflow> — the component's
exportSvg() is exactly this function — so what you get is what the editor shows.
Notes:
- Node descriptions (HTML) are flattened to plain text, since SVG has no rich text.
- Text width is measured with a canvas in the browser, and estimated from font metrics in Node, so text-box layouts can differ by a pixel or two.
- Icon aspect ratios are read from their data-URI
viewBox. For raster icons, passiconSizes: { [iconId]: { width, height } }.
The component is a canvas: it owns exactly two user-facing strings, so there is no i18n layer to configure — just override them:
diagram.strings = {
untitledItem: 'Sans titre', // name given to a newly placed node
invalidModel: 'Modèle invalide' // prefix of the model validation error
};Everything else on screen comes from your model (node names, labels) or from your
own UI. The demo ships a ~40-line, dependency-free i18n module
(demo/shared/i18n.js) — dictionary per language, t()
helper, browser detection, localStorage persistence — as a reference for host
apps.
Nodes are real DOM subtrees (roughly nine elements each, three without a label), which buys hit-testing, CSS, and accessibility — and sets a ceiling. Two things keep that ceiling far away:
- Off-screen nodes are not mounted. Only what the viewport shows is in the DOM.
- A node's template is rebuilt only when that node changed. Its rendering depends on exactly four things — view item, model item, resolved icon, theme — and mutations replace those objects rather than editing them in place, so identity is a sound change signal.
Together, they make interaction independent of model size. Measured with the stress demo at the working zoom, 1 000 and 10 000 nodes both hold ~60 fps while panning and under live mutation (~3 000 icon swaps per second).
The honest limit: zoom all the way out to fit a huge diagram and every node is on screen, so every node is mounted. Reconciling 10 000 live DOM subtrees costs ~120 ms per frame no matter how little has changed — around 2 fps. If you need to fly over diagrams that big, this is the wall, and the answer is level-of-detail (paint a dot, not a subtree, when an icon is 7 px wide), not a faster renderer.
Run the numbers yourself: the stress demo generates up to 10 000 nodes and reports first render, DOM size, and frame rate — including the bad ones.
Node description fields hold HTML (that is what Isoflow's rich text editor
produces) and are rendered as-is inside node labels. Sanitize them if your models
come from untrusted sources.
git clone https://github.com/eviltik/lit-isoflow
cd lit-isoflow
pnpm install
pnpm run demo # the demo doubles as the manual test bench
pnpm run check # lint + formatting + unit testsThere is no build step: src/ is plain ES modules with JSDoc types, and that is
what gets published. See CONTRIBUTING.md — and if you are an
AI coding agent (or pair with one), AGENTS.md maps the source,
lists the invariants that must not break, and documents the e2e testing traps.
- No React, MUI, zustand, immer, gsap, chroma-js or dom-to-image. The geometry
engine (projection, A* connector routing, fit-to-view) is ported as-is; the view
layer is rewritten with Lit templates, animations use CSS transitions, colour
variants use an HSL approximation of chroma's brighten/darken, and PNG export
goes through an SVG
foreignObject. - The scene (connector paths, textbox sizes) is derived from the model as a pure
function (
deriveScene) instead of being kept in sync inside a store. - Selected-connector anchors render above the nodes layer and take hit-test priority, so endpoints sitting on a node can be grabbed and re-anchored (upstream hides them behind nodes and always drags the node).
- Gesture-level undo/redo — absent upstream.
- Invalid connectors are skipped rather than deleted from the model.
- The rubber-band selection is lit-isoflow's own, not a port: upstream's Lasso mode is entirely commented out. The band is a screen-aligned rectangle, and capture follows the eye: an element is selected when it falls inside the rectangle on screen.
This is a port, and the hard thinking — the isometric projection, the connector routing, the model format, the icon packs — comes from the original work:
- Isoflow by Mark Mankarious (MIT)
- FossFLOW by Stan Smith (MIT), which keeps the community edition alive
- @isoflow/isopacks for the icons
MIT — see LICENSE. Portions derived from Isoflow (MIT) and FossFLOW (MIT). Icon artwork in the demo belongs to its respective owners.
