| name | wp-block |
| description | Quick scaffold for Gutenberg block development with block.json, PHP registration, and React components. |
Skill: WordPress Block Development
Build custom Gutenberg blocks using the WordPress Block API and @wordpress/scripts.
Before Starting
- Load
/CLAUDE/context/wordpress-dev.md for standards
- Ensure Node.js 20+ and npm installed
- Understand React basics and WordPress data layer
Block Types
Static Block
Renders same content in editor and frontend from saved markup.
Dynamic Block
Renders via PHP callback, content generated server-side.
Interactive Block
Uses @wordpress/interactivity API for frontend interactivity without full React.
Project Setup
Using @wordpress/create-block
npx @wordpress/create-block my-block --namespace gt
npx @wordpress/create-block my-dynamic-block --namespace gt --variant dynamic
npx @wordpress/create-block my-interactive-block --namespace gt --template @wordpress/create-block-interactive-template
Manual Setup (for existing plugins)
npm init -y
npm install @wordpress/scripts --save-dev
{
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"format": "wp-scripts format",
"lint:css": "wp-scripts lint-style",
"lint:js": "wp-scripts lint-js",
"packages-update": "wp-scripts packages-update"
}
}
File Structure
my-block/
├── my-block.php # Plugin file / block registration
├── package.json
├── src/
│ ├── block.json # Block metadata
│ ├── index.js # Block registration
│ ├── edit.js # Editor component
│ ├── save.js # Save component
│ ├── style.scss # Frontend + editor styles
│ ├── editor.scss # Editor-only styles
│ └── view.js # Frontend interactivity (optional)
└── build/ # Compiled output (gitignored)
block.json
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "gt/my-block",
"version": "1.0.0",
"title": "My Block",
"category": "widgets",
"icon": "smiley",
"description": "A custom block that does something useful.",
"keywords": ["custom", "example"],
"textdomain": "my-block",
"attributes": {
"content": {
"type": "string",
"source":
Block Registration (PHP)
Static Block
<?php
defined( 'ABSPATH' ) || exit;
function gt_my_block_init() {
register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'gt_my_block_init' );
Dynamic Block
<?php
function gt_my_block_init() {
register_block_type(
__DIR__ . '/build',
array(
'render_callback' => 'gt_my_block_render',
)
);
}
add_action( 'init', 'gt_my_block_init' );
function gt_my_block_render( $attributes, $content, $block ) {
$wrapper_attributes = get_block_wrapper_attributes(
array(
'class' => 'gt-my-block',
)
);
$output = sprintf(
'<div %1$s><p>%2$s</p></div>',
$wrapper_attributes,
esc_html( $attributes['content'] ?? '' )
);
return $output;
}
Using render.php Template
<?php
defined( 'ABSPATH' ) || exit;
$content_text = $attributes['content'] ?? '';
$show_icon = $attributes['showIcon'] ?? true;
?>
<div <?php echo get_block_wrapper_attributes( array( 'class' => 'gt-my-block' ) ); ?>>
<?php if ( $show_icon ) : ?>
<span class="gt-my-block__icon" aria-hidden="true">★</span>
<?php endif; ?>
<p class="gt-my-block__content"><?php echo esc_html( $content_text ); ?></p>
</div>
Block JavaScript
index.js
import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockType( metadata.name, {
edit: Edit,
save,
} );
edit.js (Simple)
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
RichText,
BlockControls,
AlignmentToolbar,
InspectorControls,
} from '@wordpress/block-editor';
import {
PanelBody,
ToggleControl,
RangeControl,
} from '@wordpress/components';
import './editor.scss';
export default function Edit( { attributes, setAttributes } ) {
const { content, alignment, showIcon, iconSize } = attributes;
const blockProps = useBlockProps( {
className: `has-text-align-${ alignment }`,
} );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ alignment }
onChange={ ( newAlignment ) =>
setAttributes( { alignment: newAlignment } )
}
/>
</BlockControls>
<InspectorControls>
< = ( '', '' ) }>
setAttributes( { showIcon: value } )
}
/>
{ showIcon && (
setAttributes( { iconSize: value } )
}
min={ 16 }
max={ 64 }
/>
) }
{ showIcon && (
★
) }
setAttributes( { content: value } )
}
placeholder={ __( 'Enter text…', 'my-block' ) }
/>
);
}
save.js
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const { content, alignment, showIcon, iconSize } = attributes;
const blockProps = useBlockProps.save( {
className: `has-text-align-${ alignment }`,
} );
return (
<div { ...blockProps }>
{ showIcon && (
<span
className="gt-my-block__icon"
style={ { fontSize: iconSize } }
aria-hidden="true"
>
★
</span>
) }
<RichText.Content
tagName="p"
className="gt-my-block__content"
value={ content }
/>
</div>
);
}
Advanced Patterns
Using InnerBlocks
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
const ALLOWED_BLOCKS = [ 'core/paragraph', 'core/heading', 'core/image' ];
const TEMPLATE = [
[ 'core/heading', { placeholder: 'Enter heading...' } ],
[ 'core/paragraph', { placeholder: 'Enter content...' } ],
];
export default function Edit() {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<InnerBlocks
allowedBlocks={ ALLOWED_BLOCKS }
template={ TEMPLATE }
templateLock={ false }
/>
</div>
);
}
export function save() {
const blockProps = useBlockProps.save();
return (
<div { ...blockProps }>
);
}
Using useSelect for Data
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
export default function Edit( { attributes } ) {
const { postId } = attributes;
const post = useSelect(
( select ) => {
if ( ! postId ) return null;
return select( coreStore ).getEntityRecord(
'postType',
'post',
postId
);
},
[ postId ]
);
const isLoading = useSelect(
( select ) => {
if ( ! postId ) return false;
return select( coreStore ).isResolving( 'getEntityRecord', [
'postType',
'post',
postId,
] );
},
[ postId ]
);
if ( isLoading ) {
return <p>Loading...</p>;
}
return (
< { () }>
{ post ? post.title.rendered : 'No post selected' }
);
}
Post/Term Selector
import { useState } from '@wordpress/element';
import { ComboboxControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
function PostSelector( { value, onChange } ) {
const [ search, setSearch ] = useState( '' );
const posts = useSelect(
( select ) => {
return select( coreStore ).getEntityRecords( 'postType', 'post', {
per_page: 10,
search,
_fields: 'id,title',
} );
},
[ search ]
);
const options = ( posts || [] ).map( ( post ) => ( {
value: post.id,
label: post.title.rendered,
} ) );
return (
<ComboboxControl
label="Select Post"
value={ }
= }
= }
= }
/>
);
}
Interactivity API
view.js (Frontend Interactivity)
import { store, getContext } from '@wordpress/interactivity';
store( 'gt/my-block', {
state: {
get isOpen() {
const context = getContext();
return context.isOpen;
},
},
actions: {
toggle() {
const context = getContext();
context.isOpen = ! context.isOpen;
},
open() {
const context = getContext();
context.isOpen = true;
},
close() {
const context = getContext();
context.isOpen = false;
},
},
callbacks: {
onToggle() {
const context = getContext();
console.log( 'Toggled:', context.isOpen );
},
},
} );
render.php with Interactivity
<?php
$unique_id = wp_unique_id( 'gt-accordion-' );
?>
<div
<?php echo get_block_wrapper_attributes(); ?>
data-wp-interactive="gt/my-block"
<?php echo wp_interactivity_data_wp_context( array( 'isOpen' => false ) ); ?>
>
<button
data-wp-on--click="actions.toggle"
data-wp-bind--aria-expanded="state.isOpen"
aria-controls="<?php echo esc_attr( $unique_id ); ?>"
>
<?php esc_html_e( 'Toggle Content', 'my-block' ); ?>
</button>
<div
id="<?php echo esc_attr( $unique_id ); ?>"
data-wp-bind--hidden="!state.isOpen"
data-wp-watch="callbacks.onToggle"
>
<?php echo wp_kses_post( $content ); ?>
</div>
</div>
Styles
style.scss (Frontend + Editor)
.wp-block-gt-my-block {
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 4px;
&__icon {
display: inline-block;
margin-right: 0.5rem;
color: var(--wp--preset--color--accent, #0073aa);
}
&__content {
margin: 0;
}
&.has-text-align-center {
text-align: center;
}
&.has-text-align-right {
text-align: right;
}
&.has-background {
padding: 2rem;
}
}
editor.scss (Editor Only)
.wp-block-gt-my-block {
outline: 2px dashed transparent;
transition: outline-color 0.2s;
&:focus-within {
outline-color: var(--wp-admin-theme-color, #007cba);
}
.components-placeholder {
margin: 0;
}
}
Block Variations
import { registerBlockVariation } from '@wordpress/blocks';
registerBlockVariation( 'core/group', {
name: 'gt-card',
title: 'Card',
description: 'A card container with shadow and padding.',
attributes: {
className: 'is-style-gt-card',
style: {
spacing: {
padding: {
top: 'var:preset|spacing|40',
right: 'var:preset|spacing|40',
bottom: 'var:preset|spacing|40',
left: 'var:preset|spacing|40',
},
},
border: {
radius: '8px',
},
},
backgroundColor: 'base',
},
isActive: ( blockAttributes ) =>
blockAttributes.className?.includes( 'is-style-gt-card' ),
scope: [ 'inserter', 'transform' ],
icon: 'id-alt',
} );
Block Transforms
import { createBlock } from '@wordpress/blocks';
const transforms = {
from: [
{
type: 'block',
blocks: [ 'core/paragraph' ],
transform: ( { content } ) => {
return createBlock( 'gt/my-block', {
content,
} );
},
},
{
type: 'shortcode',
tag: 'my_shortcode',
transform: ( { named: { content } } ) => {
return createBlock( 'gt/my-block', {
content: content || '',
} );
},
},
],
to: [
{
type: 'block',
blocks: [ 'core/paragraph' ],
transform: ( { content } ) => {
return createBlock( 'core/paragraph', {
content,
} );
},
},
],
};
registerBlockType( metadata.name, {
edit: Edit,
save,
transforms,
} );
Deprecations
const deprecated = [
{
attributes: {
content: {
type: 'string',
source: 'html',
selector: '.my-block-content',
},
},
save( { attributes } ) {
return (
<div className="my-old-block">
<p className="my-block-content">{ attributes.content }</p>
</div>
);
},
migrate( attributes ) {
return {
...attributes,
};
},
},
];
registerBlockType( metadata.name, {
edit: Edit,
save,
deprecated,
} );
Testing Blocks
Jest Tests
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Edit from '../edit';
jest.mock( '@wordpress/block-editor', () => ( {
useBlockProps: () => ( { className: 'test-block' } ),
RichText: ( { value, onChange, placeholder } ) => (
<input
value={ value }
onChange={ ( e ) => onChange( e.target.value ) }
placeholder={ placeholder }
/>
),
InspectorControls: ( { children } ) => <div>{ children }</div>,
BlockControls: ( { children } ) => <div>{ children }</div>,
} ) );
describe( 'Edit component', () => {
const defaultAttributes = {
content: ,
: ,
: ,
: ,
};
( , {
setAttributes = jest.();
(
);
( screen.( ) ).();
} );
( , () => {
setAttributes = jest.();
user = userEvent.();
(
);
user.( screen.( ), );
( setAttributes ).();
} );
} );
Build Commands
npm start
npm run build
npm run lint:js
npm run lint:css
npm run format
npm run packages-update
npm run plugin-zip
Quality Checklist