Skip to content
EditorStackPlayground

Custom blocks and component types in GrapesJS, packaged as a plugin

Build a call-to-action component type with traits, recognition of existing markup and scoped CSS, then expose it as blocks, all inside a typed GrapesJS plugin. Includes the parser detail that breaks the documented isParsedNode example in the default browser parser.

Updated · Tested with GrapesJS 0.23.5

TL;DR

  • A block is what users drag; a component type is what the dropped element is. Define the type first (tag, attributes, traits, styles), then make blocks whose content references it.
  • GrapesJS 0.23.1 introduced isParsedNode as the preferred recognition hook, but with the default browser parser the node's tagName arrives upper-case ('A'), so compare case-insensitively.
  • Put a type's CSS in its styles default rather than calling Css.addRules in the plugin: the rules then travel with the component into the export.

Blocks, component types and plugins

Three concepts, often blurred:

  • A component type defines what an element is: its tag, default attributes, the traits users can edit in the settings panel, its CSS, and how existing markup is recognised as that type.
  • A block is what appears in the Blocks panel. Its content is inserted on drop.
  • A plugin is a function (editor, options) => void that registers both, so the same set can be reused across editors and versioned like any other module.

The example builds a CTA button type with three traits and two blocks. Blocks like these are the "block library" line in our build versus buy estimate, the part users judge an editor on.

The plugin

cta-plugin.ts
import type { Plugin } from 'grapesjs';

export type CtaPluginOptions = {
  /** Block category label in the Blocks panel. */
  category?: string;
};

const VARIANTS = [
  { id: 'primary', label: 'Primary' },
  { id: 'secondary', label: 'Secondary' },
];

export const ctaPlugin: Plugin<CtaPluginOptions> = (editor, options) => {
  const category = options.category ?? 'Marketing';

  // 1. A component type: what the element is, how it is recognised, what the user can edit.
  editor.Components.addType('cta-button', {
    // Recognise existing markup (pasted HTML, loaded templates) as this type.
    // With the default browser parser, tagName arrives upper-case ("A"): compare case-insensitively.
    isParsedNode: (node) =>
      node.tagName?.toLowerCase() === 'a' && (node.attributes?.class ?? '').split(/\s+/).includes('cta')
        ? { type: 'cta-button' }
        : undefined,
    model: {
      defaults: {
        tagName: 'a',
        attributes: { class: 'cta', href: '#', 'data-variant': 'primary' },
        components: 'Start free trial',
        // Component-scoped CSS: added with the first instance, exported with the page.
        styles: `
          .cta { display: inline-block; padding: 12px 22px; border-radius: 6px; font: 600 16px/1 system-ui, sans-serif; text-decoration: none; }
          .cta[data-variant="primary"] { background: #1f6feb; color: #fff; }
          .cta[data-variant="secondary"] { background: transparent; color: #1f6feb; border: 2px solid #1f6feb; }
        `,
        traits: [
          { type: 'text', name: 'href', label: 'Link' },
          { type: 'select', name: 'data-variant', label: 'Style', options: VARIANTS },
          { type: 'checkbox', name: 'target', label: 'New tab', valueTrue: '_blank', valueFalse: '' },
        ],
      },
    },
  });

  // 2. Blocks: what the user drags. One uses the component type, one is a composed section.
  editor.Blocks.add('cta-button', {
    label: 'CTA button',
    category,
    media: '<svg viewBox="0 0 24 24" width="32" height="32"><rect x="3" y="8" width="18" height="8" rx="2" fill="currentColor"/></svg>',
    content: { type: 'cta-button' },
  });

  editor.Blocks.add('cta-section', {
    label: 'CTA section',
    category,
    select: true,
    content: `<section class="cta-section" style="padding:48px 24px;text-align:center">
      <h2>Ready to start?</h2>
      <p>One sentence that removes the last objection.</p>
      <a class="cta" href="/signup" data-variant="primary">Create an account</a>
    </section>`,
  });
};

Notes on the choices:

  • isParsedNode with a case-insensitive tag check. The GrapesJS documentation's example compares node.tagName === 'input'. When we logged the node in 0.23.5 with the default browser parser, tagName was "A" and our first version of this check never matched: loaded HTML came in as the built-in link type. The lower-cased comparison works; the attributes object had the class string as expected.
  • styles on the type. The documentation describes component-scoped styles under "Components and CSS". The rules are added with the component and appear in editor.getCss(), which our test checks after editing.
  • Traits are attributes. href, data-variant and target are edited directly; the CSS keys its variants off data-variant, so switching the select restyles the button with no JavaScript.
  • content: { type: 'cta-button' } inserts the type with its defaults, while the section block uses HTML that the type recognises. Both paths end at the same component type.

Registering it

main.ts
import grapesjs from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
import { ctaPlugin } from './cta-plugin';

const editor = grapesjs.init({
  container: '#gjs',
  height: '100vh',
  storageManager: false,
  plugins: [ctaPlugin],
  projectData: {
    pages: [{ component: '<main><h1>Pricing</h1><a class="cta" href="/buy">Buy now</a></main>' }],
  },
});

// Show the Blocks panel on load instead of the default Style Manager.
editor.onReady(() => editor.runCommand('core:open-blocks'));

// Exposed for the test harness.
Object.assign(window, { editor });

plugins: [ctaPlugin] works because the plugin needs no options. To pass options with type checking, the GrapesJS documentation recommends the usePlugin helper; the email builder guide uses it with a third-party plugin.

What our test checks

The verifier bundles the example and drives it in headless Chromium. It asserts that:

  1. both blocks render in the Blocks panel under the "Marketing" category;
  2. <a class="cta"> in the initial project is recognised as cta-button;
  3. the CTA block's content inserts a cta-button with data-variant="primary";
  4. with the component selected, typing into the Link trait input, choosing "secondary" in the Style select and ticking New tab update href, data-variant and target="_blank";
  5. the CTA inside the section block is recognised as cta-button too;
  6. getHtml() and getCss() contain the edited attributes and the variant rule.

The drop gesture is simulated by inserting the block's content through the API, which is what a drop inserts; the drag interaction itself is GrapesJS core behaviour and not what this guide teaches. You can try real dragging in the GrapesJS playground.

What this guide does not cover

  • Custom views. The type uses the default view. Components with their own rendering logic in the canvas need a view definition.
  • Scripts inside components. Interactive components (tabs, sliders) need component scripts, documented separately under "Components and JS".
  • Migrations. GrapesJS leaves values equal to a type's defaults out of stored project JSON (the avoidDefaults option, on by default), so changing a type's defaults later can change how existing projects load. We did not test that path; test it against saved projects before changing a shipped type. Stored projects are covered in saving to a database.
  • React components as blocks. GrapesJS blocks produce HTML. If your design-system components must be the blocks, GrapesJS vs Craft.js explains the alternative model.
  • Tailwind-styled blocks. See GrapesJS with Tailwind CSS.

Frequently asked questions

What is the difference between a block and a component in GrapesJS?
A block is an entry in the Blocks panel with a label, a category and some content. A component is a node in the page, with a type that decides its tag, attributes, traits and behaviour. Dropping a block inserts its content, which can be plain HTML or a component definition such as { type: 'cta-button' }.
Should I use isComponent or isParsedNode?
The GrapesJS documentation recommends isParsedNode for new component types from 0.23.1 onwards, and it takes priority when both exist. In the default browser parser the node it receives is a plain object with tagName in upper case and attributes as an object, so write checks that do not depend on case.
How do traits map to HTML?
By default a trait's name is the attribute it edits. A text trait named href edits the href attribute; a select trait named data-variant edits data-variant; a checkbox trait writes valueTrue or valueFalse. Set changeProp: true to edit a component property instead of an attribute.

Sources

  1. GrapesJS documentation: Component Manager (isParsedNode, styles) — accessed 2026-09-17
  2. GrapesJS documentation: Traits — accessed 2026-09-17
  3. GrapesJS documentation: Block Manager — accessed 2026-09-17
  4. GrapesJS documentation: Plugins (usePlugin) — accessed 2026-09-17

Written by the EditorStack Research Team. How we test and what we refuse to publish is on the methodology page.