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

email-visual-builder

v0.1.0

Published

A visual email builder for React: a versioned document model, a drag-and-drop editor, and deterministic email-safe HTML export. Also renders landing pages.

Readme

email-visual-builder

A visual email builder for React. Authors drag blocks onto a canvas, edit text in place, and you get back a versioned JSON document plus deterministic, email-safe HTML.

  • Owned document model. A versioned, validated JSON document with ordered migrations. No editor-internal state is ever persisted.
  • Headless core. Parsing, validation, commands, undo/redo and export run in Node with no React or DOM dependency, so a server can validate and render the same document the editor produced.
  • Deterministic export. The same document always renders byte-identical HTML. Email output is nested tables with inline styles; nothing depends on the canvas DOM.
  • React editor. Canvas, block library, layers, inspector, rich text with inline color and font, variables, image editing, and a device preview.
  • Two modes. Email is the primary one. A landing-page mode renders the same blocks as semantic HTML.

Install

pnpm add email-visual-builder
# or
npm install email-visual-builder

React 19 and react-dom 19 are the only peer dependencies. Then load the styles one of two ways.

Without daisyUI. Import the self-contained stylesheet. Nothing else is needed, whether or not your app uses Tailwind:

import "email-visual-builder/standalone.css";

Every rule in it is confined to the editor, so it does not restyle your page or collide with your own Tailwind build, and none of your classes need to match its names. It is about 23 kB gzipped.

With Tailwind CSS 4 and daisyUI 5. If your app already runs both, let them style the editor so it shares your theme, and import only the editor chrome:

@import "tailwindcss";
@plugin "daisyui";
@source "../node_modules/email-visual-builder/dist";
@import "email-visual-builder/styles.css";

Use one or the other, not both.

Entry points

| Import | Contents | React/DOM | | ------------------------------------- | ------------------------------------------------------------------------------------- | --------- | | email-visual-builder/core | Document types, parsing, migrations, registries, commands, the controller, validation | No | | email-visual-builder/renderers | Email and landing-page HTML export | No | | email-visual-builder/react | Provider, editor UI, presets, rich-text adapter | Yes | | email-visual-builder/standalone.css | All editor styles in one file, for hosts without daisyUI | — | | email-visual-builder/styles.css | Editor chrome only, for hosts that run Tailwind and daisyUI | — |

Usage

Registries and variables

Variables are supplied by the host; the package ships none of its own. Build one registry set and share it between the editor and the server, so the editor never accepts a document the server refuses.

// registries.ts
import {
  createBuilderRegistries,
  createVariableRegistry,
  type BuilderRegistries,
  type VariableDefinition,
} from "email-visual-builder/core";
import { DEFAULT_RENDERER_REGISTRIES } from "email-visual-builder/renderers";

export const variables: readonly VariableDefinition[] = [
  {
    key: "recipient.firstName",
    token: "%recipient.firstName%",
    label: "Recipient first name",
    sampleValue: "Alex",
    allowedContexts: ["text"],
  },
];

export function buildRegistries(): BuilderRegistries {
  const variableRegistry = createVariableRegistry(variables);
  if (!variableRegistry.ok) throw new Error("Invalid variable definitions");
  const registries = createBuilderRegistries({
    blocks: DEFAULT_RENDERER_REGISTRIES.blocks,
    modes: DEFAULT_RENDERER_REGISTRIES.modes,
    inspectorControls: DEFAULT_RENDERER_REGISTRIES.inspectorControls,
    variables: variableRegistry.value,
  });
  if (!registries.ok) throw new Error("Invalid registries");
  return registries.value;
}

Editor

import { useMemo } from "react";
import {
  BuilderController,
  createStarterDocument,
} from "email-visual-builder/core";
import { EmailVisualBuilder } from "email-visual-builder/react";
import "email-visual-builder/standalone.css";
import { buildRegistries, variables } from "./registries";

export function TemplateEditor() {
  const registries = useMemo(buildRegistries, []);
  const controller = useMemo(
    () =>
      new BuilderController(createStarterDocument("email", registries), {
        registries,
      }),
    [registries],
  );

  return (
    <div style={{ height: "100dvh" }}>
      <EmailVisualBuilder
        controller={controller}
        registries={registries}
        variables={variables}
      />
    </div>
  );
}

The editor fills its container, so give the container a height.

Export

import { exportVisualDocument } from "email-visual-builder/renderers";
import { buildRegistries } from "./registries";

const result = exportVisualDocument(savedDocument, buildRegistries());

if (result.errors.length > 0) {
  // The document is invalid; `result.html` is null.
} else {
  await send({ html: result.html });
  await store({ json: result.json });
}

exportVisualDocument accepts untrusted input: it migrates, parses and validates before rendering, and returns errors and warnings instead of throwing. Run it on the server against what the client submitted rather than trusting HTML produced in the browser.

Guide

How it fits together

  1. The document is plain JSON: a flat map of nodes with a root, a mode (email or landing-page), and settings. This is what you store.
  2. The controller holds a document in memory. Every edit is a validated command, so the document can never reach a state the parser would reject, and undo/redo comes for free.
  3. The editor is a React UI bound to a controller.
  4. The renderer turns a document into HTML. It never reads the editor.

Store the JSON as the source of truth and render HTML from it when you send. Do not store HTML and try to edit it later.

Saving and loading

// Save: validate and serialize what the editor holds.
const result = controller.export();
if (result.errors.length === 0) {
  await api.save({ document: result.json, html: result.html });
}

// Load: parse what you stored, then hand it to a controller.
const parsed = parseVisualDocument(JSON.parse(stored), registries);
if (parsed.ok) controller.loadDocument(parsed.value);

controller.subscribe(listener) fires on every change, which is the place to track unsaved changes. controller.undo(), redo(), canUndo() and canRedo() drive your own history buttons if you are not using the built-in toolbar.

Blocks

| Block | Purpose | | ----------- | ---------------------------------------------------------- | | section | A full-width band with background, padding and width | | columns | Side-by-side columns that stack on narrow screens | | heading | A heading, levels 1 to 6 | | rich-text | Paragraphs, lists, links, inline color and font, variables | | image | An image, uploaded or by URL, optionally linked | | cta | A button with a destination | | divider | A horizontal rule | | spacer | Vertical space | | social | A row of social profile icons |

Blocks obey structural rules: the document holds sections, and a section holds content blocks or columns. The editor will not let an author drop a block where it is not allowed, and the parser rejects a stored document that breaks the rules.

Variables

A variable is a placeholder your sending system replaces per recipient. The builder treats it as one atomic token: an author cannot half-delete it or mistype it, and the export writes the exact token you configured.

const variables: VariableDefinition[] = [
  {
    key: "recipient.firstName",
    token: "%recipient.firstName%",
    label: "Recipient first name",
    sampleValue: "Alex",
    allowedContexts: ["text"],
  },
  {
    key: "unsubscribe_url",
    token: "%unsubscribe_url%",
    label: "Unsubscribe link",
    allowedContexts: ["url", "email-system-link"],
  },
];

allowedContexts decides where a variable is offered:

| Context | Offered in | | ------------------- | ---------------------------------------- | | text | Headings, rich text, button labels | | url | Link and button destinations | | image-url | Image sources | | email-system-link | Links the email must carry, e.g. opt-out |

sampleValue is what the editor shows when the author turns on sample values. The token syntax is yours to choose; the builder never substitutes values.

Images

The builder does not know where your files live. Pass an assetAdapter and it calls you with the file to upload:

const assetAdapter: AssetAdapter = {
  maxImageBytes: 1024 * 1024,
  async uploadImage(file, context) {
    const response = await uploadToYourStorage(file);
    if (!response.ok) return { ok: false, error: "Upload failed. Try again." };
    return {
      ok: true,
      asset: {
        url: response.url,
        filename: file.name,
        mimeType: file.type,
      },
    };
  },
};

Return { ok: false } for expected failures instead of throwing, so the author can retry. Every upload must produce a new URL: undo restores the previous asset, so the old URL has to keep working. Without an adapter, authors can still set an image by pasting its URL.

Authors can crop, rotate and resize an image before it is uploaded.

Email metadata

EmailVisualBuilder frames the canvas as a message window. Sender, subject and template name are yours; the builder only displays and edits them.

<EmailVisualBuilder
  controller={controller}
  registries={registries}
  variables={variables}
  assetAdapter={assetAdapter}
  templateName={name}
  onTemplateNameChange={setName}
  senderName="Donativus"
  senderEmail="[email protected]"
  subject={subject}
  onSubjectChange={setSubject}
  hasUnsavedChanges={dirty}
  toolbarActions={<button onClick={save}>Save</button>}
/>

Layout

The editor adapts to the width of its container, not the window. Below 900px the side panels collapse into toggles. Force one with layout="wide" or layout="narrow"; the default is "auto".

Translations

Every string in the editor comes from a label. Override any subset:

<EmailVisualBuilder
  controller={controller}
  labels={{ formatBold: "Negrita", addVariable: "Añadir variable" }}
/>

DEFAULT_BUILDER_LABELS lists every key with its English default.

Theming

The editor is drawn with semantic color tokens (--color-base-100, --color-base-content, --color-primary and so on).

  • With standalone.css it ships a light and a dark theme. Set data-theme="dark" on any ancestor, such as <html>, to switch. To match your brand, override the tokens on .donativus-vb-scope:

    .donativus-vb-scope {
      --color-primary: #e75480;
      --color-primary-content: #ffffff;
      --radius-field: 0.5rem;
    }
  • With your own daisyUI build it follows whatever theme your page sets.

Colors an author picks for the email itself are stored in the document and are never affected by the host theme.

Building your own editor

The presets are assembled from exported parts. Compose them yourself when you need a different arrangement:

<BuilderProvider controller={controller} mode="email" registries={registries}>
  <Toolbar />
  <Workspace library={<BlockLibrary />} inspector={<Inspector />}>
    <Canvas />
  </Workspace>
</BuilderProvider>

useBuilderSelector, useNode, useSelectedNodeId and useNodeActions read and change the document from your own components.

Importing from Unlayer

importUnlayerEmailDesign(design) converts the supported subset of an Unlayer email design into a document. Unsupported tools are reported in warnings rather than carried over as raw HTML.

Fonts

Font choices are system font stacks only. No font file is ever fetched, so an exported email makes no third-party request and loses nothing in clients that strip remote fonts. The face a recipient sees is the first installed family in the stack, which varies by platform.

Email compatibility

The email renderer targets a constrained, tested table and inline-style subset rather than claiming identical output in every client.

  • Layout is nested role="presentation" tables with explicit width, padding and border attributes and inline styles. No grid, flexbox or external stylesheet.
  • Columns stack through one @media (max-width:480px) rule. Clients that ignore <style> render the fixed desktop layout instead.
  • Image and column dimensions are also set as HTML attributes, because Outlook desktop ignores max-width.
  • letter-spacing and border-radius are ignored by Outlook desktop; a button renders there as a plain rectangular link.
  • No script, iframe, form, embedded image, @font-face or animation is ever emitted. The document schema has no way to express them.

Document format identifiers

Stored documents carry kind: "donativus.visual-document" and rich text carries kind: "donativus.rich-text". These are stable format identifiers, kept for compatibility with documents saved by Donativus, the fundraising platform by Aatsin this package was extracted from. CSS classes use the donativus-vb- prefix for the same reason, including .donativus-vb-scope, the wrapper the editor and its floating menus render inside.

Used by

Built and maintained with the support of Aatsin. Using it in production? Open a pull request to add your project.

Development

pnpm install
pnpm test          # node:test through tsx
pnpm check-types
pnpm build
pnpm storybook     # interactive editor at http://localhost:6007
pnpm sandbox       # the editor with standalone.css only, after pnpm build

License

Apache-2.0. Bundled social platform glyphs come from Simple Icons (CC0); the marks themselves remain trademarks of their owners.