npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

canvu-react

v0.4.59

Published

Vector-first infinite canvas (SVG) with pan, zoom, React bindings, and optional plugins

Readme

canvu

canvu is a vector-first SVG canvas for the browser with pan, zoom, tools, persistence, plugins, and realtime collaboration.

Install

npm install canvu react react-dom lucide-react

Required peers:

  • react
  • react-dom
  • lucide-react

Optional peers:

  • @ai-sdk/react
  • ai

Entry points

| Import | Purpose | | --- | --- | | canvu | Core primitives: camera, scene, SVG renderer, input helpers, shape builders | | canvu/react | React runtime: VectorViewport, VectorToolbar, persistence, plugin system | | canvu/plugins/chatbot | Plug and play chatbot plugin | | canvu/plugins/realtime | Plug and play realtime collaboration plugin + advanced hooks | | canvu/plugins/tldraw | tldraw import helpers |

Quick start

import { useState } from "react";
import {
  useVectorCanvasDocument,
  VectorCanvas,
  VectorToolbar,
  VectorViewport,
} from "canvu/react";

export function Board() {
  const [toolId, setToolId] = useState("hand");
  const [toolLocked, setToolLocked] = useState(false);
  const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });

  if (!doc.isHydrated) return null;

  return (
    <VectorCanvas.Root style={{ height: "100dvh", width: "100%" }}>
      <VectorCanvas.Body>
        <VectorCanvas.Main>
          <VectorCanvas.ViewportSurface>
            <VectorViewport
              ariaLabel="Board"
              toolId={toolId}
              toolLocked={toolLocked}
              onToolChangeRequest={setToolId}
              items={doc.items}
              onItemsChange={doc.onItemsChange}
              interactive
              plugins={[]}
              toolbar={
                <VectorCanvas.Toolbar>
                  <VectorToolbar
                    value={toolId}
                    onChange={setToolId}
                    toolLocked={toolLocked}
                    onToolLockedChange={setToolLocked}
                    aria-label="Canvas tools"
                  />
                </VectorCanvas.Toolbar>
              }
            />
          </VectorCanvas.ViewportSurface>
        </VectorCanvas.Main>
      </VectorCanvas.Body>
    </VectorCanvas.Root>
  );
}

Plugin-first architecture

The recommended React DX is plugin-first:

import { chatbotPlugin } from "canvu/plugins/chatbot";
import { realtimeCollaborationPlugin } from "canvu/plugins/realtime";

<VectorViewport
  ariaLabel="Board"
  items={doc.items}
  onItemsChange={doc.onItemsChange}
  toolId={toolId}
  onToolChangeRequest={setToolId}
  interactive
  plugins={[
    chatbotPlugin({ chatApi: "/api/chat" }),
    realtimeCollaborationPlugin({
      url,
      roomId,
      peer: {
        id: peerId,
        displayName: "Kalmon",
        color: "#7c3aed",
        image: avatarUrl,
      },
    }),
  ]}
/>

No CanvasPluginHost is required. The plugin runtime lives inside VectorViewport.

Persisting built-in file uploads

If you want to keep the native VectorViewport file picker and drag-and-drop UX while also saving the original binary to your own backend or object storage, pass assetStore.

import { useState } from "react";
import {
	type VectorViewportAssetStore,
	useVectorCanvasDocument,
	VectorCanvas,
	VectorToolbar,
	VectorViewport,
} from "canvu/react";

export function Board() {
	const [toolId, setToolId] = useState("hand");
	const [toolLocked, setToolLocked] = useState(false);
	const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });

	const assetStore: VectorViewportAssetStore = {
		async upload({ file, kind }) {
			const form = new FormData();
			form.append("file", file);
			form.append("kind", kind);

			const response = await fetch("/api/canvu/assets", {
				method: "POST",
				body: form,
			});

			const asset = await response.json();
			return {
				pluginData: {
					assetId: asset.id,
					assetKey: asset.key,
					mimeType: file.type,
					originalFileName: file.name,
				},
			};
		},
		async resolve({ assetIds }) {
			const response = await fetch("/api/canvu/assets/resolve", {
				method: "POST",
				headers: { "content-type": "application/json" },
				body: JSON.stringify({ assetIds }),
			});

			return response.json();
		},
	};

	if (!doc.isHydrated) return null;

	return (
		<VectorCanvas.Root style={{ height: "100dvh", width: "100%" }}>
			<VectorCanvas.Body>
				<VectorCanvas.Main>
					<VectorCanvas.ViewportSurface>
						<VectorViewport
							ariaLabel="Board"
							toolId={toolId}
							toolLocked={toolLocked}
							onToolChangeRequest={setToolId}
							items={doc.items}
							onItemsChange={doc.onItemsChange}
							interactive
							assetStore={assetStore}
							toolbar={
								<VectorCanvas.Toolbar>
									<VectorToolbar
										value={toolId}
										onChange={setToolId}
										toolLocked={toolLocked}
										onToolLockedChange={setToolLocked}
										aria-label="Canvas tools"
									/>
								</VectorCanvas.Toolbar>
							}
						/>
					</VectorCanvas.ViewportSurface>
				</VectorCanvas.Main>
			</VectorCanvas.Body>
		</VectorCanvas.Root>
	);
}

canvu still owns the conversion from File to VectorSceneItem. Your assetStore.upload(...) only intercepts the original browser file and returns persistable pluginData, which canvu merges into every created item from that file. resolve(...) is optional and is intended for custom persistence adapters that need to rehydrate signed URLs after loading a snapshot.

Reusing the same ingestion pipeline outside the viewport

When files originate outside the VectorViewport UI, such as a board creation wizard, a custom import modal, or a migration routine, use ingestAssetFilesToSceneItems(...).

import { useRef, useState } from "react";
import {
	ingestAssetFilesToSceneItems,
	type VectorViewportAssetStore,
	useVectorCanvasDocument,
	VectorViewport,
} from "canvu/react";

export function BoardImport() {
	const [toolId, setToolId] = useState("hand");
	const inputRef = useRef<HTMLInputElement>(null);
	const doc = useVectorCanvasDocument({ persistenceKey: "board.v1" });

	const assetStore: VectorViewportAssetStore = {
		async upload({ file, kind }) {
			const form = new FormData();
			form.append("file", file);
			form.append("kind", kind);

			const response = await fetch("/api/canvu/assets", {
				method: "POST",
				body: form,
			});

			const asset = await response.json();
			return {
				pluginData: {
					assetId: asset.id,
					assetKey: asset.key,
				},
			};
		},
	};

	async function handleImport(files: FileList | null) {
		if (!files) return;

		const result = await ingestAssetFilesToSceneItems({
			files: Array.from(files),
			worldCenter: { x: 0, y: 0 },
			assetStore,
			pdfScale: 1.15,
			pdfPageConcurrency: 2,
			onItemsReady(nextItems) {
				doc.onItemsChange([...doc.items, ...nextItems]);
			},
			decorateItem(item) {
				return {
					...item,
					locked: true,
				};
			},
		});

		doc.onItemsChange([...doc.items, ...result.items]);
	}
	if (!doc.isHydrated) return null;

	return (
		<>
			<button type="button" onClick={() => inputRef.current?.click()}>
				Import files
			</button>

			<input
				ref={inputRef}
				type="file"
				accept="image/*,application/pdf"
				multiple
				hidden
				onChange={(event) => {
					void handleImport(event.target.files);
					event.target.value = "";
				}}
			/>

			<VectorViewport
				ariaLabel="Board"
				items={doc.items}
				onItemsChange={doc.onItemsChange}
				toolId={toolId}
				onToolChangeRequest={setToolId}
				interactive
				assetStore={assetStore}
			/>
		</>
	);
}

canvu can now stream PDF pages into the canvas progressively through onItemsReady(...), skip PDF thumbnails during ingest, and use a lower initial raster scale by default to improve time-to-first-render.

The native file tool now accepts multiple images and PDFs from the picker and stacks every imported asset vertically. When the board already has uploaded images or PDFs, new imports are inserted immediately after the existing stack instead of overlapping previous content.

This helper is the same ingestion layer used internally by the native file tool, so external imports do not need to reimplement PDF rasterization, local blob persistence, or pluginData attachment.

Custom tools

Use createToolPlugin(...) for isolated tools.

import { createToolPlugin } from "canvu/react";
import { Pin } from "lucide-react";

const reviewPinPlugin = createToolPlugin({
  id: "review-pin",
  label: "Review",
  shortcutHint: "R",
  icon: <Pin aria-hidden />,
  createItem: ({ id, bounds }) => createReviewPinItem(id, bounds),
});

<VectorViewport plugins={[reviewPinPlugin]} ... />

If the tool belongs to a larger feature, keep it inside that feature plugin.

Advanced hooks

Low-level hooks remain public for advanced customization:

  • useRealtimeSession(...)
  • useRealtimeComments(...)
  • useCanvuPluginContext()
  • useCanvuViewportContext()
  • useCanvuDocumentContext()
  • useCanvuPluginContribution(...)
  • createCanvuPlugin(...)

Use them for custom plugins and bespoke UIs, not for the common path.

CSS requirement

The interactive surface must use touch-action: none so the browser does not steal gestures.

Security

childrenSvg is injected via innerHTML. Only pass trusted or sanitized SVG.