Custom Gutenberg blocks: create-block, InnerBlocks, context and patterns

Building a block with create-block, from scaffold and registration to InnerBlocks, context and patterns

Leave a comment
Risograph illustration of a hand placing a small block into a wooden box already holding smaller blocks

A custom Gutenberg block is a plugin with a build step. This covers scaffolding one with @wordpress/create-block, what each generated file is for, how the plugin registers it, sidebar controls, nesting with InnerBlocks, and the four APIs that save us from writing a second block: styles, variations, context and patterns.

Why JSX

JSX is JavaScript with markup written inline. React DOM escapes every value embedded in JSX before rendering it, so user input dropped into <h1>{ title }</h1> cannot inject a tag. That is one whole class of XSS closed by the tooling rather than by memory, until someone reaches for dangerouslySetInnerHTML.

@wordpress/scripts runs webpack and Babel over it, turning the JSX into JavaScript a browser accepts.

Scaffolding a block

create-block writes the plugin as well as the block, so run it in the plugins directory:

shell · bash
npx @wordpress/create-block@latest testblock

It needs Node.js 22.13 or newer. To add a block to a plugin that already exists, run it inside that plugin with --no-plugin, which writes only the block files. --variant dynamic scaffolds a block rendered in PHP instead.

The block’s files land in src/testblock/, not straight in src, which is the first change from the 2022 notes:

  • block.json -> name, attributes, icon and the rest of the metadata
  • edit.js -> how the block behaves in the editor
  • save.js -> the markup saved into post content, which the front end shows. A dynamic block has render.php instead
  • index.js -> imports edit and save and registers the block
  • editor.scss and style.scss -> editor-only and shared styles
  • view.js -> front-end script

None of that is production code yet. It is source:

shell · bash
npm run build

That fills build/, which holds the files WordPress actually loads, plus a blocks-manifest.php.

Registering it from PHP

The second change. The 2022 notes called register_block_type() once per build directory. The current scaffold registers every block in build/ in one call:

testblock.phpphp
function create_block_testblock_block_init() {
	wp_register_block_types_from_metadata_collection( __DIR__ . '/build', __DIR__ . '/build/blocks-manifest.php' );
}
add_action( 'init', 'create_block_testblock_block_init' );

That function arrived in WordPress 6.8, so the scaffold sets “Requires at least” to 6.8. The manifest is generated by wp-scripts build --blocks-manifest, which saves WordPress reading and parsing each block.json from disk separately. register_block_type( __DIR__ . '/build/testblock' ) still works for one block.

When a block refuses to appear, ask the registry: WP_Block_Type_Registry::get_instance()->is_registered( 'create-block/testblock' ), or get_all_registered() for the full list.

The scaffold also sets apiVersion 3 in block.json, which declares that the block works inside an iframe. WordPress 7.1 always loads the post editor in an iframe, whatever the apiVersion, so that is no longer optional.

Controls in the sidebar

InspectorControls puts fields in the editor sidebar, and @wordpress/components supplies the fields:

src/testblock/edit.jsjavascript
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';

export default function Edit( { attributes, setAttributes } ) {
	return (
		<>
			<InspectorControls>
				<PanelBody title={ __( 'Test settings', 'movie-library' ) }>
					<TextControl
						label={ __( 'Setting 1', 'movie-library' ) }
						value={ attributes.setting }
						onChange={ ( setting ) => setAttributes( { setting } ) }
					/>
				</PanelBody>
			</InspectorControls>
			<p { ...useBlockProps() }>{ __( 'Hello from the editor', 'movie-library' ) }</p>
		</>
	);
}

onChange writes through setAttributes, and the attribute has to exist in block.json before it will hold a value:

src/testblock/block.jsonjson
"attributes": {
	"setting": { "type": "string", "default": "" }
}

Every label goes through __(), which only works if the text domain is set up correctly.

Nesting with InnerBlocks

InnerBlocks is what lets a block contain blocks, the way group and columns do. useInnerBlocksProps is the hook form of it and gives us control over the wrapper markup:

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

const ALLOWED_BLOCKS = [ 'core/image', 'core/paragraph' ];
const TEMPLATE = [ [ 'core/image' ], [ 'core/paragraph', { placeholder: 'Synopsis' } ] ];

export function Edit() {
	const innerBlocksProps = useInnerBlocksProps( useBlockProps(), {
		allowedBlocks: ALLOWED_BLOCKS,
		template: TEMPLATE,
	} );
	return <div { ...innerBlocksProps } />;
}

export function save() {
	return (
		<div { ...useBlockProps.save() }>
			<InnerBlocks.Content />
		</div>
	);
}

The options that do the work:

  • allowedBlocks -> what the inserter offers inside our block. Since 6.5 it can also go in block.json
  • orientation -> horizontal or vertical. It does not lay anything out. It sets which way the mover arrows point and how drag and drop behaves
  • template -> the default content a fresh block starts with
  • templateLock -> four values, not three. all blocks every operation. insert allows moving but not adding or removing. contentOnly blocks everything, hides blocks with no content from the list view, and is the one value a child cannot override. false opts out of a lock a parent imposed

parent and ancestor

Both go in block.json and both restrict where a block can be used:

json
"parent": [ "core/columns" ],
"ancestor": [ "core/columns" ]

parent requires that block to be the direct parent. ancestor, since 6.0, only requires it somewhere above in the tree. Reach for ancestor unless the block really must sit one level down, because parent is the one that makes a block vanish from the inserter.

Block styles

To restyle a core block without touching core, register a style:

javascript
wp.blocks.registerBlockStyle( 'core/quote', {
	name: 'fancy-quote',
	label: 'Fancy Quote',
} );

Selecting it adds the class is-style-fancy-quote and nothing else. The class is the whole feature. We still write the CSS.

Block variations

When two blocks differ only by attributes, they are one block with variations. The embed block covers every provider this way:

javascript
variations: [
	{
		name: 'test1',
		isDefault: true,
		title: __( 'Test 1', 'movie-library' ),
		attributes: { providerNameSlug: 'wordpress' },
	},
	{
		name: 'test2',
		title: __( 'Test 2', 'movie-library' ),
		attributes: { providerNameSlug: 'google' },
		keywords: [ __( 'search', 'movie-library' ) ],
	},
],

The 2022 notes counted three inserter entries here. It is two: isDefault makes Test 1 replace the plain block in the inserter. One block in the markup, and only the attribute value differs. Variations can also live in block.json, and since 6.7 that field can point at a PHP file that builds the list on the server.

Block context

Context is how a parent hands data down without either block knowing much about the other. Parent block.json:

json
"attributes": { "movie": { "type": "number", "default": 0 } },
"providesContext": { "movie-library/movie": "movie" }

Child block.json:

json
"usesContext": [ "movie-library/movie" ]

In the child’s edit it arrives as the context prop, context[ 'movie-library/movie' ], and in render.php as $block->context['movie-library/movie']. Keep the plugin namespace in the key, so it cannot collide with context WordPress provides itself.

Block patterns

A pattern is prewritten block markup offered in the inserter. Registered in PHP on init, no build step:

php
register_block_pattern(
	'movie-library/test-pattern',
	array(
		'title'       => __( 'Test pattern', 'movie-library' ),
		'description' => __( 'Two paragraphs', 'movie-library' ),
		'categories'  => array( 'text' ),
		'content'     => '<!-- wp:paragraph --><p>First</p><!-- /wp:paragraph -->',
	)
);

content is literal block markup, comment delimiters included. Since 6.5, filePath can point at a file instead, loaded only when needed. A theme does not need the call at all: PHP files with a pattern header in its patterns/ directory register themselves.

What this does not cover

Dynamic blocks in depth, block supports, and deprecations, which is the API we need the moment a saved block’s markup changes after people have used it.

Leave a comment

Seen this one too, or think I got something wrong? Your email is not published, and a first comment waits for me to approve it.