Skip to content

rejth/svg-interact

Repository files navigation

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-interact

lit 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: getElementKeykeyAttributeid. 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: false disables 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

About

Framework-agnostic interactive SVG web component built with Lit

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors