WordPress Block Development: Understanding Block Validation and Adding Dynamic Classes the Right Way

Fixing Block Validation in WordPress: Add Dynamic Classes the Right Way

Introduction

If you’ve ever tried to build a custom Gutenberg block for WordPress and suddenly ran into the dreaded message —

“This block contains unexpected or invalid content”

— you’re not alone.

For new block developers, this error can be confusing and discouraging. You tweak a small thing — maybe add a new <div>, or dynamically set a CSS class — and suddenly your block breaks.

In this article, I’ll explain why that happens, what’s going on behind the scenes with WordPress block validation, and how to correctly add dynamic classes and conditional markup to your blocks — without breaking the validation system.

To make this practical, we’ll explore a real-world example: building an offcanvas drawer block with a customizable handle (button or image) and configurable placement (left, top, right, or bottom).

The Goal

Our goal is simple on paper:

  • Create a custom offcanvas drawer block in the WordPress block editor.
  • Allow the user to choose where the drawer opens from (left, right, top, or bottom).
  • Let them choose between a button handle or an image handle.
  • Allow nested (inner) blocks inside the drawer content.

Sounds straightforward, right?
But as anyone who’s worked with Gutenberg knows, the devil hides in the validation logic.

Understanding Block Validation

Gutenberg blocks are declarative. This means WordPress compares the output generated in the editor (from the edit() function) with the output of the save() function — the static HTML that gets stored in the database.

If the two outputs don’t match exactly, Gutenberg thinks the content has been tampered with and throws a validation error.

So, whenever you see a message like:

“This block has been modified externally and may not be editable.”

…it means your edit() and save() markup are out of sync.

Even a small change — like adding a new wrapper <div>, or using a component that renders differently between editor and frontend — can trigger this.

The Problem in Our Example

Let’s say we’ve defined our block attributes like this:

"attributes": {
  "handleType": { "type": "string", "default": "button" },
  "handleImage": { "type": "object", "default": null },
  "handleText": { "type": "string", "default": "Open Drawer" },
  "placement": { "type": "string", "default": "right" }
}

And inside the editor, we’re trying to do something like this:

<Button className="offcanvas-drawer-handle">
  <img src={attributes.handleImage.url} alt="" />
</Button>

But the moment you uncomment that <Button>, the block becomes invalid.


Why It Happens

Here are the two main reasons:

  1. Mismatch between editor and frontend HTML.
    The <Button> from @wordpress/components is a React UI component that renders differently in the editor and in saved HTML.
    For example, Gutenberg’s <Button> may render a <div> in the editor but not match your <button> in the save() output.
  2. Dynamic class manipulation inside the editor.
    Trying to modify blockProps.className manually like this: blockProps.className = `${blockProps.className} offcanvas-drawer-${attributes.placement}`; will also confuse Gutenberg, since it changes how WordPress tracks classes in both editor and frontend contexts.

The Correct Way to Add Dynamic Classes

Instead of manually editing className, Gutenberg gives us a helper called useBlockProps().

Here’s how to correctly apply your dynamic classes:

const blockProps = useBlockProps({
  className: `offcanvas-drawer offcanvas-drawer-${attributes.placement}`,
});

And do the same inside your save.js file:

const blockProps = useBlockProps.save({
  className: `offcanvas-drawer offcanvas-drawer-${attributes.placement}`,
});

This keeps the markup consistent between editor and frontend, and avoids the validation issue.


The Correct Way to Render the Handle

In the editor (edit.js), you can use Gutenberg’s <Button> component for user convenience.
But in the saved markup (save.js), you must use plain HTML elements (<button> or <img>) because React components don’t exist on the frontend.

Editor (edit.js):

<Button className="offcanvas-drawer-handle" variant="primary">
  {attributes.handleType === 'image' && attributes.handleImage ? (
    <img src={attributes.handleImage.url} alt="" />
  ) : (
    attributes.handleText
  )}
</Button>

Frontend (save.js):

<button className="offcanvas-drawer-handle">
  {attributes.handleType === 'image' && attributes.handleImage ? (
    <img src={attributes.handleImage.url} alt="" />
  ) : (
    attributes.handleText
  )}
</button>

This ensures the structure and attributes match perfectly, so the block remains valid.


Should the Handle Be Inside the Same Wrapper?

This is a design decision — and it depends on what you’re trying to achieve.

For simple offcanvas drawers, keeping the handle inside the same wrapper <div> is totally fine. You can easily position it with absolute positioning:

.offcanvas-drawer-handle {
  position: absolute;
  top: 50%;
  right: -20px;
}

If, however, you ever need to place the handle outside the drawer (e.g., in a separate column), it might be better to make it a separate block<OffcanvasHandle /> — that references the drawer by id.

But for most use cases, a single self-contained block with both handle and drawer content is simpler and works great.


Full Working Example

Here’s a working simplified example.

edit.js

import { __ } from '@wordpress/i18n';
import { useBlockProps, InnerBlocks, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleGroupControl, ToggleGroupControlOption, Button, TextControl, MediaUpload } from '@wordpress/components';

export default function Edit({ attributes, setAttributes }) {
  const { handleType, handleImage, handleText, placement } = attributes;

  const blockProps = useBlockProps({
    className: `offcanvas-drawer offcanvas-drawer-${placement}`,
  });

  return (
    <>
      <InspectorControls>
        <PanelBody title={__("General Settings", "wow-themes")}>
          <ToggleGroupControl
            label="Placement"
            value={placement}
            onChange={(value) => setAttributes({ placement: value })}
          >
            <ToggleGroupControlOption value="left" label="Left" />
            <ToggleGroupControlOption value="top" label="Top" />
            <ToggleGroupControlOption value="right" label="Right" />
            <ToggleGroupControlOption value="bottom" label="Bottom" />
          </ToggleGroupControl>

          <ToggleGroupControl
            label="Handle Type"
            value={handleType}
            onChange={(value) => setAttributes({ handleType: value })}
          >
            <ToggleGroupControlOption value="button" label="Button" />
            <ToggleGroupControlOption value="image" label="Image" />
          </ToggleGroupControl>

          {handleType === 'button' && (
            <TextControl
              label="Handle Text"
              value={handleText}
              onChange={(value) => setAttributes({ handleText: value })}
            />
          )}

          {handleType === 'image' && (
            <MediaUpload
              onSelect={(value) => setAttributes({ handleImage: value })}
              allowedTypes={["image"]}
              render={({ open }) => (
                <Button onClick={open} variant="primary">
                  {handleImage ? "Replace Image" : "Upload Image"}
                </Button>
              )}
            />
          )}
        </PanelBody>
      </InspectorControls>

      <div {...blockProps}>
        <InnerBlocks />
        <Button className="offcanvas-drawer-handle" variant="primary">
          {handleType === 'image' && handleImage ? (
            <img src={handleImage.url} alt="" className="offcanvas-drawer-handle-image" />
          ) : (
            handleText
          )}
        </Button>
      </div>
    </>
  );
}

save.js

import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';

export default function save({ attributes }) {
  const { handleType, handleImage, handleText, placement } = attributes;

  const blockProps = useBlockProps.save({
    className: `offcanvas-drawer offcanvas-drawer-${placement}`,
  });

  return (
    <div {...blockProps}>
      <InnerBlocks.Content />
      <button className="offcanvas-drawer-handle">
        {handleType === 'image' && handleImage ? (
          <img src={handleImage.url} alt="" className="offcanvas-drawer-handle-image" />
        ) : (
          handleText
        )}
      </button>
    </div>
  );
}

Can We Still Use the “Checkbox Trick” for CSS-Only Animation?

Yes — you can technically implement the CSS-only open/close toggle using the good old “checkbox hack”:

<input type="checkbox" id="drawer-toggle" />
<label for="drawer-toggle">Open Drawer</label>
<div class="drawer"> ... </div>

…but it’s not recommended inside Gutenberg because React re-renders may reset checkbox states.
Instead, use React state (useState) or a small JS script for open/close animations. It’s more reliable and flexible.


Key Takeaways

  • Use useBlockProps() to safely add dynamic classes.
  • Make sure edit() and save() render identical HTML.
  • Only use WordPress <Button> component in the editor, not in the saved markup.
  • Keep handle and drawer together for simple use cases; separate them for advanced layouts.
  • Don’t fear block validation — understand it, and it becomes your best friend for stability.

Conclusion

Once you understand how WordPress validates block markup, it all starts to make sense.
The editor (edit.js) is for React-driven interactions, while the save.js defines your block’s static, reproducible HTML.

By respecting that boundary and using useBlockProps() correctly, you can confidently build flexible, dynamic blocks without fighting the validation system.

Building something like an offcanvas drawer in vanilla HTML and CSS might take 20 minutes — but building it as a reusable Gutenberg block that integrates with the editor, supports nested blocks, and survives version upgrades? That’s where the real power of WordPress block development shines.


Have questions or got stuck with block development?
Drop a comment below or reach out — I love discussing modern WordPress engineering challenges.

Related Post

Leave a Reply

Your email address will not be published. Required fields are marked *

Loading...