lit-isoflow
v1.1.0
Published
Isometric infrastructure diagrams as a Lit web component. A React-free port of Isoflow/FossFLOW.
Maintainers
Readme
lit-isoflow
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.
▶ Try the live demos
Two of them: the editor (the full component) and a stress test (up to 10 000 nodes, with the numbers).
Why
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.
One component, three modes
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.
Install
npm install lit-isoflowUsage
<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.
Properties / attributes
| 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 |
Methods
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.
Keyboard (EDITABLE)
| 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.
Events
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
Wiring a property panel / rich text editor
<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
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.
Theming (light / dark)
<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.
Rendering without a browser
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 } }.
Internationalisation
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.
Performance
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.
Security
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.
Development
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.
Design notes / deviations from Isoflow
- 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.
Credits
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
Licence
MIT — see LICENSE. Portions derived from Isoflow (MIT) and FossFLOW (MIT). Icon artwork in the demo belongs to its respective owners.

