svg-interact
v0.1.0
Published
Framework-agnostic, data-bound interactive SVG custom element built with Lit.
Maintainers
Readme
svg-interact
A framework-agnostic, data-bound interactive SVG custom element built with Lit.
Render a designer-authored SVG string, bind each shape to your domain data, and listen for normalized interaction events — from React, Vue, Svelte, Solid or plain HTML using the same browser primitive.
Install
npm install svg-interactlit and dompurify are declared as dependencies and installed automatically.
Quick start
import "svg-interact/register";
const el = document.querySelector("interactive-svg");
el.svg = `<svg xmlns="http://www.w3.org/2000/svg"><rect id="lobby" /></svg>`;
el.sourceMap = { lobby: { name: "Lobby", occupancy: 4 } };
el.addEventListener("interactive-svg-click", (event) => {
console.log(event.detail.key, event.detail.data);
});Public API
Entry points
| Import | Provides |
| ------------------------- | ------------------------------------------------------------------------------------------------- |
| svg-interact | InteractiveSvgElement, TAG_NAME, InteractiveSvgEventName, element types, defaultSanitizer |
| svg-interact/register | Side-effect import that defines <interactive-svg> once |
| svg-interact/controller | InteractiveSvgController, controller config types, resolveSanitizer, headless binding types |
<interactive-svg> properties
Complex values are assigned as JavaScript properties, not HTML attributes.
| Property | Type | Description |
| ------------ | --------------------------------- | -------------------------------------------- |
| svg | string | SVG markup to render (sanitized by default). |
| sourceMap | Record<string, unknown> | Source data keyed by resolved element key. |
| options | BindingOptions | Binding, sanitizer, and metadata options. |
| elementMap | ReadonlyMap<string, SVGElement> | Read-only view of bound keys to elements. |
BindingOptions
| Option | Type | Default | Description |
| ----------------- | --------------------------------------------- | ------- | ---------------------------------------------------------- |
| getElementKey | (el: SVGElement) => string | null | undefined |
| keyAttribute | string | "id" | Attribute read for the key when getElementKey is absent. |
| sanitizer | Sanitizer | false | default sanitizer |
| staticMetadata | { classNames?, attributes? } | — | Metadata applied to every bound element. |
| resolveMetadata | (data, key) => { classNames?, attributes? } | — | Metadata derived per bound element. |
Key resolution precedence: getElementKey → keyAttribute → id. An element is
skipped when no key resolves or no sourceMap entry matches.
Events
The element dispatches CustomEvents that bubble and cross the shadow boundary:
| Event | Fired on |
| ----------------------- | ------------- |
| interactive-svg-click | click |
| interactive-svg-enter | pointer enter |
| interactive-svg-leave | pointer leave |
Each event.detail is an InteractionEventDetail:
interface InteractionEventDetail<T = unknown> {
key: string;
data: T;
element: SVGElement;
originalEvent: Event;
}Element map access
const el = document.querySelector("interactive-svg");
el.elementMap.get("lobby"); // the bound <rect id="lobby">Headless controller
Use svg-interact/controller when integrating outside the Lit custom element (e.g. a framework wrapper or imperative host):
import { InteractiveSvgController } from "svg-interact/controller";
const container = document.getElementById("svg-root")!;
const controller = new InteractiveSvgController({
container,
onInteraction: (type, detail) => {
console.log(type, detail.key, detail.data);
},
});
controller.update({
svg: `<svg xmlns="http://www.w3.org/2000/svg"><rect id="lobby" /></svg>`,
sourceMap: { lobby: { name: "Lobby" } },
});Framework usage
The examples below implement the same real-world pattern: bind data to SVG elements, highlight clicked elements via resolveMetadata, and track the selected keys. They assume an SVG whose elements carry ids matching the sourceMap keys, plus a selection style:
.is-selected {
stroke: #00203b;
stroke-width: 4;
}Plain HTML
<interactive-svg></interactive-svg>
<script type="module">
import "svg-interact/register";
const el = document.querySelector("interactive-svg");
const svgString = `<svg xmlns="http://www.w3.org/2000/svg"><rect id="lobby" /></svg>`;
const sourceMap = { lobby: { name: "Lobby" } };
const selected = new Set();
function render() {
el.svg = svgString;
el.sourceMap = sourceMap;
el.options = {
resolveMetadata: (_data, key) => ({
classNames: selected.has(key) ? ["is-selected"] : [],
}),
};
}
el.addEventListener("interactive-svg-click", (event) => {
const { key } = event.detail;
if (selected.has(key)) selected.delete(key);
else selected.add(key);
render();
});
render();
</script>React
React doesn't bind custom-element events through JSX props, so wrap the element
once with [@lit/react](https://www.npmjs.com/package/@lit/react). The wrapper
exposes element fields as props and maps the event to an onElementClick prop —
a fully declarative, typed API.
import "svg-interact/register";
import { createComponent } from "@lit/react";
import * as React from "react";
import { useState } from "react";
import { InteractiveSvgElement, TAG_NAME } from "svg-interact";
const InteractiveSvg = createComponent({
tagName: TAG_NAME,
elementClass: InteractiveSvgElement,
react: React,
events: { onElementClick: "interactive-svg-click" },
});
function FloorPlan({ svg, sourceMap }) {
const [selected, setSelected] = useState(new Set<string>());
const options = {
resolveMetadata: (_data, key) => ({
classNames: selected.has(key) ? ["is-selected"] : [],
}),
};
function onElementClick(event) {
const { key } = event.detail;
const next = new Set(selected);
if (next.has(key)) next.delete(key);
else next.add(key);
setSelected(next);
}
return (
<InteractiveSvg
svg={svg}
sourceMap={sourceMap}
options={options}
onElementClick={onElementClick}
/>
);
}Vue
<script setup>
import "svg-interact/register";
import { computed, ref } from "vue";
const props = defineProps(["svg", "sourceMap"]);
const selected = ref(new Set());
const options = computed(() => ({
resolveMetadata: (_data, key) => ({
classNames: selected.value.has(key) ? ["is-selected"] : [],
}),
}));
function onElementClick(event) {
const { key } = event.detail;
const next = new Set(selected.value);
if (next.has(key)) next.delete(key);
else next.add(key);
selected.value = next;
}
</script>
<template>
<interactive-svg
:svg.prop="props.svg"
:sourceMap.prop="props.sourceMap"
:options.prop="options"
@interactive-svg-click="onElementClick"
/>
</template>Svelte
<script>
import "svg-interact/register";
let { svg, sourceMap } = $props();
let selected = $state(new Set());
const options = $derived({
resolveMetadata: (_data, key) => ({
classNames: selected.has(key) ? ["is-selected"] : [],
}),
});
function onElementClick(event) {
const { key } = event.detail;
if (selected.has(key)) selected.delete(key);
else selected.add(key);
selected = new Set(selected);
}
</script>
<interactive-svg
svg={svg}
sourceMap={sourceMap}
options={options}
oninteractive-svg-click={onElementClick}
/>Solid
import "svg-interact/register";
import { createSignal } from "solid-js";
function FloorPlan({ svg, sourceMap }) {
const [selected, setSelected] = createSignal(new Set<string>());
const options = () => ({
resolveMetadata: (_data, key) => ({
classNames: selected().has(key) ? ["is-selected"] : [],
}),
});
function onElementClick(event) {
const { key } = event.detail;
const next = new Set(selected());
if (next.has(key)) next.delete(key);
else next.add(key);
setSelected(next);
}
return (
<interactive-svg
prop:svg={svg}
prop:sourceMap={sourceMap}
prop:options={options()}
on:interactive-svg-click={onElementClick}
/>
);
}Security: semi-trusted SVG threat model
This component targets semi-trusted SVG — strings produced by your own application code or known design tools — not fully hostile, user-uploaded SVG.
By default, every SVG string is sanitized with a DOMPurify SVG-profile policy that:
- removes
<script>elements, - strips inline event handler attributes (
onclick, …), - neutralizes unsafe URL protocols (
javascript:), - forbids high-risk constructs such as
<foreignObject>.
Limits and responsibilities:
- The default policy is not a substitute for sandboxing fully untrusted input. Iframe sandboxing and remote-resource isolation are out of scope for v1.
- Custom sanitizers (
options.sanitizer) fully replace the default behavior — you own the resulting safety guarantees. options.sanitizer: falsedisables sanitization entirely. Only use it for SVG you fully control.
Development
pnpm install # install dependencies
pnpm hooks:install # install lefthook git hooks (once after clone)
pnpm test # Vitest behavior tests
pnpm typecheck # library tsc --noEmit
pnpm build # Vite library build + declarations
pnpm lint # oxlint
pnpm format:check # biome
pnpm storybook # interactive demos / manual test bench
pnpm --filter @svg-interact/storybook typecheck # Storybook/framework demos