Skip to content
EditorStackPlayground

GrapesJS with Tailwind CSS v4: utilities in the canvas, compiled CSS on export

Run Tailwind's in-browser compiler inside the GrapesJS canvas so utility classes render while editing, then compile only the CSS a published page uses on the server. Tested with Tailwind 4.1.14 and GrapesJS 0.23.5, including class names with colons and slashes.

Updated · Tested with GrapesJS 0.23.5

TL;DR

  • Load @tailwindcss/browser into the canvas iframe with canvas.scripts: utility classes render while editing, and the script is not part of the exported HTML.
  • Tailwind documents the in-browser build as development-only, so never ship it on published pages; compile real CSS from the exported HTML instead, which our test does and compares against the canvas.
  • Class names like md:py-24, w-1/2 and hover:bg-blue-700 survived GrapesJS's parser and getHtml() unchanged in 0.23.5; the Style Manager, which writes its own CSS rules, is a separate problem this guide does not solve.

Two jobs, two tools

A Tailwind page needs CSS generated from the classes it uses. In an editor that has to happen twice:

  1. While editing, inside the canvas iframe, every time a class appears.
  2. When publishing, once, for exactly the classes on the page.

Tailwind's in-browser build (@tailwindcss/browser, the file behind its Play CDN) does the first job well and is documented by Tailwind as "designed for development purposes only, and is not intended for production." An editor canvas is a development surface; your published pages are not. So the browser build goes into the canvas, and the published CSS is compiled on the server.

The editor

editor.ts
import grapesjs from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';

const editor = grapesjs.init({
  container: '#gjs',
  height: '100vh',
  storageManager: false,
  canvas: {
    // Tailwind's in-browser compiler, self-hosted (copied from @tailwindcss/browser).
    // It runs inside the canvas iframe only and is not part of the exported HTML.
    scripts: ['/vendor/tailwindcss-browser.js'],
  },
  projectData: {
    pages: [
      {
        component: `
          <section class="bg-slate-900 px-6 py-16 md:py-24 text-center">
            <h1 class="text-5xl font-bold tracking-tight text-white">Ship pages faster</h1>
            <p class="mx-auto mt-4 w-1/2 text-lg text-slate-300">Tailwind classes, edited visually.</p>
            <a href="#" class="mt-8 inline-block rounded-md bg-blue-600 px-5 py-3 font-semibold text-white hover:bg-blue-700">Get started</a>
          </section>`,
      },
    ],
  },
});

editor.Blocks.add('tw-card', {
  label: 'Card',
  category: 'Tailwind',
  content: `<div class="max-w-sm rounded-xl bg-white p-6 shadow-lg">
    <h3 class="text-xl font-semibold text-slate-900">Card title</h3>
    <p class="mt-2 text-slate-600">Card body text.</p>
  </div>`,
});

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

canvas.scripts appends scripts to the head of the canvas iframe only. GrapesJS's typings state that these scripts are not printed in the export code, and our test confirms the exported HTML has no reference to the compiler. Self-host the file (copy it from node_modules/@tailwindcss/browser/dist/index.global.js) rather than pointing at a CDN, so the editor keeps working under a strict Content Security Policy and does not depend on a third party.

npm install grapesjs@0.23.5 tailwindcss@4.1.14 @tailwindcss/browser@4.1.14

Compiling CSS for the published page

build-css.mjs
import fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { compile } from 'tailwindcss';

const require = createRequire(import.meta.url);

/** Resolves `@import "tailwindcss"` (and its sub-imports) from node_modules. */
async function loadStylesheet(id, base) {
  const file = id.startsWith('.') ? path.resolve(base, id) : require.resolve(id === 'tailwindcss' ? 'tailwindcss/index.css' : id);
  return { path: file, base: path.dirname(file), content: await fs.readFile(file, 'utf8') };
}

/** Every token inside a class attribute. Tailwind ignores tokens that are not utilities. */
function classCandidates(html) {
  const candidates = new Set();
  for (const match of html.matchAll(/\sclass="([^"]*)"/g)) {
    for (const token of match[1].split(/\s+/)) if (token) candidates.add(token);
  }
  return [...candidates];
}

/**
 * Compiles the CSS a published page needs from the HTML GrapesJS exported: only the utilities
 * that appear in it, plus Tailwind's preflight and theme variables.
 */
export async function buildTailwindCss(html, input = '@import "tailwindcss";') {
  const compiler = await compile(input, { base: process.cwd(), loadStylesheet });
  return compiler.build(classCandidates(html));
}

classCandidates collects every token from class attributes. Tailwind discards tokens that are not utilities, so over-collecting is harmless. The result contains preflight, the theme variables and only the utilities present. In our test it was 9 kB for the example page, and it did not contain grid-cols-12, which the page never uses.

A caveat: compile() is exported by the tailwindcss package with types, but Tailwind's documentation covers its CLI, Vite and PostCSS integrations rather than this function. Pin the version. The documented alternative is to write the exported HTML to a file and run the Tailwind CLI with that file as a source.

What our test checks

The editor is bundled with esbuild and loaded in headless Chromium with the self-hosted browser build. The test asserts that:

  1. inside the canvas, the heading's computed font size is 48px (text-5xl) and the button has a background colour (bg-blue-600);
  2. w-1/2 makes the paragraph half its container's content width;
  3. a block with classes not present on load, including shadow-lg, gets a computed box shadow after it is added;
  4. md:py-24, w-1/2 and hover:bg-blue-700 appear unchanged in editor.getHtml(), and the compiler script does not;
  5. CSS compiled by build-css.mjs from that HTML, loaded on a standalone page, gives the heading the same computed font size, colour, background and border radius as in the canvas, the button the same background, and the paragraph the same w-1/2 ratio.

If you want pre-built Tailwind blocks

Hand-writing blocks is covered in custom blocks and component types. The community grapesjs-tailwind plugin bundles the Tailblocks set; see the FAQ for what we found in its package metadata. We have not tested it, nor the premium plugin below.

Limitations

  • The Style Manager fights utility classes. GrapesJS's Style Manager writes CSS rules against selectors; it does not add or remove Tailwind classes. Teams usually hide it or replace it with a class editor. We did not test style editing on Tailwind classes, and the grapesjs-tailwind plugin's README recommends a custom selectorManager.escapeName for class names with slashes, which suggests selector handling is where problems appear.
  • Canvas performance. The in-browser compiler observes the canvas document. We did not measure its cost on large pages.
  • Tailwind configuration. Custom theme values go in the CSS passed to compile(), for example an @theme block, and must also reach the canvas; keeping the two in sync is not covered.
  • Tailwind v3. Everything here is v4. The v3 Play CDN and configuration model differ.

For the bigger decision of whether GrapesJS fits a Tailwind-based product at all, the GrapesJS review and GrapesJS vs Puck cover the architecture, and the playground shows the default editing experience.

Frequently asked questions

Should I use the grapesjs-tailwind plugin?
We did not test it. Version 1.0.13 (MIT, published October 2025) describes itself as a work in progress, loads Tailwind from the Play CDN at cdn.tailwindcss.com by default, requires grapesjs-plugin-forms, and was developed against grapesjs ^0.22.13. Its value is the Tailblocks block set; evaluate it against your GrapesJS version before adopting it.
Do new classes typed in the editor get styled?
In the canvas, yes: the in-browser compiler watches the document. Our test drops a block with shadow-lg, which does not appear in the initial page, and waits until its computed box-shadow is set. On the published page, only if you recompile the CSS from the saved HTML.
Is the tailwindcss compile() function a public API?
It is exported by the tailwindcss package and its types ship with it, but Tailwind's documentation describes the CLI, Vite and PostCSS integrations, not compile(). Pin your Tailwind version if you use it, or write the exported HTML to disk and run the Tailwind CLI over it instead.

Sources

  1. Tailwind CSS documentation: Play CDN — accessed 2026-09-17
  2. Tailwind CSS documentation: Detecting classes in source files — accessed 2026-09-17
  3. GrapesJS documentation: Canvas — accessed 2026-09-17
  4. npm registry metadata for @tailwindcss/browser — accessed 2026-09-17
  5. npm registry metadata for grapesjs-tailwind (1.0.13, MIT) — accessed 2026-09-17

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