Add a new Gutenberg block to an existing plugin or theme.
Register a new block in an existing plugin or theme following WordPress block development conventions.
Ask the user: "What will this block do?" — a brief description of what it displays or enables in the editor.
Based on the description, suggest 2–3 name options (kebab-case), for example:
Based on your description, here are some suggestions:
# Block name 1 featured-card2 promo-card3 highlight-cardWhich would you like to use, or do you have your own preference?
Once the name is confirmed, apply the find-plugin skill to determine whether:
Only proceed to the steps below if Core and existing plugins do not cover the need, or if the user has explicitly agreed to build a custom block after reviewing the recommendation.
| Input | Required | Notes |
|---|---|---|
| Target package | Yes | Which theme or plugin should own this block? Scan packages/ and list options. |
| Block vendor prefix | Yes | The namespace prefix used in block.json name, e.g. acme → acme/hero-banner. Check sibling block.json files in the plugin to confirm. |
| Block category | No | One of text, media, design, widgets, theme, embed. Default: design. |
| Is it dynamic (server-rendered)? | No | If yes, save() returns null and a PHP render callback is provided. Defaults to yes for server-rendered projects. |
| Attributes | No | Ask what data the block needs to store (text, image URL, boolean toggle, etc.). |
| Needs frontend JS? | No | Does the block need a view.js for frontend interactivity? Defaults to no. |
Look for an existing block registration pattern in the target package. Typical location:
src/blocks/<block-name>/ (or src/<block-name>/ depending on plugin structure)
block.json
block.php (dynamic: registration + render via namespaced functions)
edit.js
index.js
save.js (omit if dynamic)
style.scss (front-end styles)
editor.scss (editor-only styles)
view.js (only if frontend interactivity is needed)
Read the plugin's main PHP file to confirm the PHP namespace and how blocks are registered/loaded.
block.json{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "<vendor>/<block-name>",
"version": "1.0.0",
"title": "<Block Title>",
"category": "<category>",
"description": "",
"supports": {
"html": false,
"align": ["wide", "full"]
},
"attributes": {},
"textdomain": "<plugin-slug>",
"editorScript": "file:index.js",
"editorStyle": "file:index.css",
"style": "file:style-index.css"
}
"render" — the render callback is registered in block.php."viewScript": "file:view.js" (omit otherwise).block.php (dynamic blocks only)<?php
/**
* Block: <Block Title>
*
* @package <PluginNamespace>
*/
declare( strict_types=1 );
namespace <PluginNamespace>\Blocks\<BlockNamePascal>;
/**
* Register block.
*/
function register_block(): void {
register_block_type_from_metadata(
__DIR__,
[
'render_callback' => __NAMESPACE__ . '\\render_block',
]
);
}
add_action( 'init', __NAMESPACE__ . '\\register_block' );
/**
* Render block.
*
* @param array<string, mixed> $attributes Block attributes.
* @param string $content Block inner content.
* @return string
*/
function render_block( array $attributes, string $content ): string {
$wrapper_attributes = get_block_wrapper_attributes();
ob_start();
?>
<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<?php // Block output here ?>
</div>
<?php
return ob_get_clean() ?: '';
}
namespace to the plugin's existing namespace (e.g. ProjectBase\BlockLibrary\Blocks\HeroBanner).get_block_wrapper_attributes() for the wrapper element.ob_start()/ob_get_clean() for template rendering.esc_html(), esc_attr(), esc_url(), wp_kses_post().index.js/**
* WordPress dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import metadata from './block.json';
import Edit from './edit';
import save from './save'; // omit for dynamic blocks
registerBlockType( metadata.name, {
edit: Edit,
save, // omit for dynamic blocks
} );
edit.js/**
* WordPress dependencies
*/
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
/**
* @param {Object} props Block props.
* @return {JSX.Element} Block editor UI.
*/
export default function Edit( { attributes, setAttributes } ) {
const { title } = attributes;
const blockProps = useBlockProps();
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings', '<text-domain>' ) }>
{ /* Add controls here */ }
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
<RichText
tagName="h2"
value={ title }
onChange={ ( value ) => setAttributes( { title: value } ) }
placeholder={ __( 'Add a title…', '<text-domain>' ) }
/>
</div>
</>
);
}
save.js (static blocks only)/**
* WordPress dependencies
*/
import { useBlockProps, RichText } from '@wordpress/block-editor';
/**
* @param {Object} props Block props.
* @return {JSX.Element} Saved block markup.
*/
export default function save( { attributes } ) {
const { title } = attributes;
return (
<div { ...useBlockProps.save() }>
<RichText.Content tagName="h2" value={ title } />
</div>
);
}
view.js (only if frontend interactivity is needed)// Frontend behaviour for <Block Title> block
document.querySelectorAll( '.wp-block-<vendor>-<block-name>' ).forEach( ( block ) => {
// Initialise block behaviour
} );
Open the plugin's main PHP file and confirm it loads blocks via glob:
foreach ( glob( __DIR__ . '/build/*/block.php' ) as $block ) {
require_once $block;
}
If the glob pattern is missing, add it. If the plugin uses a different registration pattern (e.g. a BlockRegistrar class), follow that pattern instead.
cd packages/plugins/<plugin-slug>
npm run build # or from root: npm run build
npm run lint:js
npm run lint:css
composer lint
Confirm the block appears in build/<block-name>/ after the build.
import statements grouped: WordPress core → internal. The @wordpress/dependency-group ESLint rule is enforcedget_block_wrapper_attributes() in PHP render — this enables block spacing, align, and custom className support@wordpress/* packages or native DOM APIsWhen done, the following files exist under src/<block-name>/:
block.json — block metadatablock.php — PHP server-side render + registration (dynamic blocks)index.js — block registration entry pointedit.js — block editor componentsave.js — static save output (static blocks only)view.js — (optional) frontend scriptAfter npm run build, the block appears in build/<block-name>/ and is loadable in the WordPress editor. Lint passes with no errors.