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.
npm install svg-interactlit and dompurify are declared as dependencies and installed automatically.
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);
});| 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 |
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. |
| 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.
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;
}const el = document.querySelector("interactive-svg");
el.elementMap.get("lobby"); // the bound <rect id="lobby">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" } },
});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;
}<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 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}
/>
);
}<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><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}
/>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}
/>
);
}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.
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