Custom Gutenberg blocks that editors actually enjoy using

Small block.json files, dynamic rendering, content-only patterns and a locked-down theme.json: how we build blocks editors enjoy using.

block
On this page

Start from the editor’s job

Most block libraries are designed from the design file: every section of the mock-up becomes a block, every visual option becomes a toggle. Editors then face forty blocks and a sidebar full of switches, and the site slowly drifts off brand.

We start from the other end. Before we design a single block we ask the people who publish what they actually do each week: which pages they create, what they copy from old pages, and what they are afraid to touch. The answers usually fit on one page, and they decide what becomes a block, what becomes a pattern and what stays a core block with a style.

Much of this comes from our sister brand, ANPS Themes: 14 years of selling and supporting WordPress themes for more than 30,000 customers shows you very quickly which options people use and which ones they break layouts with.

Keep block.json small and honest

Every block starts with a block.json file on API version 3, the current version since WordPress 6.3. The title, description and keywords are what editors see in the inserter, so we write them for editors, not for developers. Attributes get defaults that already look right, and supports switches on only the controls the design can handle.

blocks/callout/block.jsonJSON
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "acme/callout",
	"title": "Callout",
	"category": "text",
	"description": "Short highlighted note inside an article.",
	"attributes": {
		"text": { "type": "string", "default": "", "role": "content" },
		"tone": { "type": "string", "enum": [ "info", "warning" ], "default": "info" }
	},
	"supports": { "html": false, "color": { "text": false }, "spacing": { "padding": true } },
	"example": { "attributes": { "text": "Updated for WordPress 7.0." } },
	"editorScript": "file:./index.js",
	"render": "file:./render.php"
}

A few details matter more than they look. "html": false removes “Edit as HTML”, the easiest way for an editor to break a block by accident. Declaring color turns on text and background controls by default, so we switch off the ones the design does not need. The example object feeds the preview editors see when they hover over the block in the inserter. Without one, they insert the block just to find out what it is.

Dynamic blocks where markup must stay in sync

A static block saves its HTML into the post. Change the markup later and every existing copy keeps the old HTML on the front end and shows a validation error in the editor, until you write a deprecation to migrate it. For anything whose markup we expect to change, or that has to match data elsewhere, we build a dynamic block: the post stores only the attributes, and PHP renders the HTML on every request.

blocks/callout/render.phpPHP
<?php
$acme_tone = 'warning' === ( $attributes['tone'] ?? '' ) ? 'warning' : 'info';
?>
<aside <?php echo get_block_wrapper_attributes( array( 'class' => 'is-tone-' . $acme_tone ) ); ?>>
	<p><?php echo wp_kses_post( $attributes['text'] ?? '' ); ?></p>
</aside>

The file receives $attributes, $content and $block, and it runs once per block instance, so shared functions belong in another file. get_block_wrapper_attributes() adds the classes and styles that supports generates, which keeps editor settings and front end in step.

This site works the same way. The FAQ block at the end of this article is dynamic: its render.php outputs native <details> elements, so one answer opens at a time even without JavaScript, and the same items feed the FAQPage structured data. When we change the markup, every page that uses it changes with it. Clean markup and structured data from one source is also half of our technical SEO work.

InnerBlocks templates and template locking

When a block holds other blocks, give it a template so it arrives filled in, and a lock that matches how much freedom the editor needs. A pricing card, for example, always has a heading, a short line, a list and a button.

blocks/plan/edit.jsJavaScript
import { useBlockProps, useInnerBlocksProps } from '@wordpress/block-editor';

const TEMPLATE = [
	[ 'core/heading', { level: 3, placeholder: 'Plan name' } ],
	[ 'core/paragraph', { placeholder: 'Who this plan is for' } ],
	[ 'core/list' ],
	[ 'core/buttons', {}, [ [ 'core/button', { text: 'Choose plan' } ] ] ],
];

export default function Edit() {
	const innerBlocksProps = useInnerBlocksProps( useBlockProps(), {
		template: TEMPLATE,
		templateLock: 'contentOnly',
	} );
	return <span class="xml"><div { ...innerBlocksProps } />;
}

The lock options are worth knowing by heart:

  • false: no lock. Editors can add, move and remove anything.
  • 'insert': blocks can be moved, but not added or removed.
  • 'all': nothing can be added, moved or removed.
  • 'contentOnly': only text and media can be edited. Blocks without content disappear from List View, and children cannot override the lock.

To limit what goes inside a container without locking it, list the permitted children in the allowedBlocks field of block.json. For a single block inside a template, a lock attribute with move and remove keeps it in place.

Content-only editing is now the default for patterns

WordPress 7.0 made contentOnly the default for unsynced patterns and template parts. Editors insert a pattern and can change its text and media straight away. To change the structure they choose “Edit pattern” or double-click it. For editors this is the behaviour we used to build by hand. For block authors it adds one rule.

Inside a contentOnly area, a block with no attribute marked "role": "content" is hidden from List View and cannot be selected. So every custom block that editors should be able to fill in needs that role on its content attributes, like text in the example above. A block whose content lives only in its inner blocks can declare "contentRole": true in supports instead. The details are in the WordPress 7.0 pattern editing dev note.

Patterns instead of mega-blocks

The mega-block, one custom block with twenty settings that renders a whole section, is the most common mistake we inherit. It hides core blocks behind a custom sidebar, and every new layout needs a developer. A pattern does the same job with blocks editors already know.

In a block theme, any file in /patterns with a valid header is registered automatically:

patterns/pricing-three-plans.phpPHP
<?php
/**
 * Title: Pricing: three plans
 * Slug: acme/pricing-three-plans
 * Categories: acme-pricing
 * Keywords: pricing, plans, compare
 * Viewport Width: 1200
 */
?>
<!-- block markup exported from the editor -->

Register your own categories with register_block_pattern_category(), so editors browse “Pricing” and “Heroes” instead of one long list. Use Inserter: no for patterns that only templates should use, and Block Types to offer a pattern where it fits, for example as a starting layout for a template part. On this site we also remove the core patterns and switch off remote patterns from the WordPress.org directory, so the inserter shows only what belongs to the brand.

Keep the brand in theme.json, not in training

If the colour picker offers every colour, someone will use one that is off brand. theme.json lets you remove the free choices and keep only your presets:

theme.jsonJSON
{
	"version": 3,
	"settings": {
		"color": {
			"custom": false,
			"customGradient": false,
			"defaultPalette": false,
			"defaultGradients": false
		},
		"typography": { "customFontSize": false, "defaultFontSizes": false },
		"spacing": { "customSpacingSize": false, "defaultSpacingSizes": false }
	}
}

Add your own palette, fontSizes and spacingSizes next to these, and use settings.blocks when one block needs a narrower set than the rest. This site’s own theme.json switches off custom colours, gradients and font sizes and offers only its brand palette and type scale.

Variations and styles instead of toggles

When one block needs a few preset looks, a block style is simpler than a setting. Our FAQ block has three styles, Light, Dark and Post, and this article uses the Post style. When a preset also changes attributes or inner blocks, register a variation. It appears in the inserter under its own name, so editors pick “Warning callout” instead of inserting a callout and hunting for a dropdown.

blocks/callout/block.json (excerpt)JSON
{
	"variations": [
		{
			"name": "warning",
			"title": "Warning callout",
			"attributes": { "tone": "warning" },
			"isActive": [ "tone" ],
			"scope": [ "inserter", "transform" ]
		}
	]
}

isActive tells the editor which variation a block is, so the block toolbar and List View show “Warning callout” rather than “Callout”.

Clear labels, help text and honest previews

Every inspector control gets a label that says what it changes and, where it is not obvious, one line of help text. Our logo marquee block is a small example: each item has a name, a bundled logo and an optional uploaded image, and the name field explains itself as “Shown as text when there is no logo; always the accessible name.” The screen reader label sits in its own Accessibility panel, so nobody has to guess what it is for.

The editor view should look like the front end. We load the same stylesheet in both and keep editor-only CSS to the handles and placeholders editors need. If the preview lies, editors stop trusting it and start publishing to check.

Which tool we reach for, by editor problem
Editor problemTool
Layouts drift from the designPattern with content-only editing
Off-brand colours and sizesPresets only in theme.json
Markup must change site-wideDynamic block with render.php
Wrong blocks inside a containerallowedBlocks or templateLock
Same block, a few looksBlock styles or variations

If an editor needs a style guide open to use a block, the block is not finished.

Our checklist for a new block

  1. Could it be a pattern? If core blocks can build it, it is a pattern.
  2. Does it look right on insert? Defaults, a template and an example preview.
  3. Is the markup likely to change? Then it is dynamic, with a render.php.
  4. Does it work inside a pattern? Content attributes carry "role": "content", and we test it in content-only mode.
  5. Is every option on brand? Only the supports the design allows, and presets from theme.json.
  6. Would a new editor understand the sidebar? Plain labels, short help text, no unexplained toggles.

Summary

Blocks that editors enjoy are mostly about what you leave out: fewer blocks, fewer options and no off-brand choices, with patterns, locking and theme.json doing the policing. That is how we approach custom WordPress development, and you can see the results in our work. If your editors are afraid to touch the site, send us a short brief.

Frequently asked questions

What is the difference between a block pattern and a custom block?

A pattern is a saved arrangement of existing blocks that editors insert and then edit. A custom block is new code with its own markup and settings. If core blocks can build the layout, a pattern is usually the better choice.

How do I lock a Gutenberg block so editors can only change the text?

Set templateLock to contentOnly on the InnerBlocks area, or rely on WordPress 7.0, which applies content-only editing to unsynced patterns by default. Custom blocks inside need their content attributes marked with the content role to stay editable.

Should a custom Gutenberg block be static or dynamic?

Make it dynamic with a render.php file when the markup may change later or depends on data outside the post. Static blocks suit simple content that will not change, because their HTML is saved into the post.

Keep reading

All articles

Have a project in mind?

Four questions, two minutes. A fixed-scope proposal within 48 hours.

A fixed-scope proposal within 48 hours.