In Part 2 we scaffolded a plugin. In Part 3 we write a real custom block. Our target: a Callout block with a configurable type (info / warning / tip / error), a title, and a body.
Step 1 — Update block.json
block.json is the manifest. WordPress reads it to register the block. Replace the scaffolded version with ours:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-first-block/callout",
"title": "Callout",
"category": "text",
"icon": "megaphone",
"description": "A styled callout box for notes, tips, and warnings.",
"textdomain": "my-first-block",
"attributes": {
"title": {
"type": "string",
"default": "Heads up"
},
"body": {
"type": "string",
"default": "Body text goes here."
},
"calloutType": {
"type": "string",
"default": "info",
"enum": ["info", "warning", "tip", "error"]
}
},
"supports": {
"html": false,
"align": ["wide", "full"]
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"editorStyle": "file:./index.css"
}
The important fields:
name— globally unique, namespaced asplugin/block-name. Required.apiVersion— current is3(added WP 6.3). Use the latest.attributes— the data model. Each attribute has atypeand optionaldefault.supports— opt-in or opt-out of editor features.html: falsehides the “Edit as HTML” option (cleaner for custom blocks).
Step 2 — Register the block in src/index.js
Replace the scaffolded src/index.js with:
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import Save from './save';
import metadata from '../block.json';
registerBlockType(metadata.name, {
edit: Edit,
save: Save,
});
That’s it. The CLI tool wires the rest. block.json is imported so WordPress and the bundler stay in sync on the block name.
Step 3 — Write the Edit component
This is the React component WordPress mounts in the editor. Replace src/edit.js:
import { useBlockProps, RichText } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
export default function Edit({ attributes, setAttributes }) {
const { title, body, calloutType } = attributes;
const blockProps = useBlockProps({
className: `my-callout my-callout-${calloutType}`,
});
return (
<div {...blockProps}>
<RichText
tagName="h4"
className="my-callout-title"
value={title}
onChange={(value) => setAttributes({ title: value })}
placeholder={__('Callout title', 'my-first-block')}
/>
<RichText
tagName="p"
className="my-callout-body"
value={body}
onChange={(value) => setAttributes({ body: value })}
placeholder={__('Callout body text', 'my-first-block')}
/>
</div>
);
}
Three patterns to understand:
useBlockProps()— returns the props WordPress needs on the wrapper element (className, data-attributes, focus handling). Always spread it on your outermost element.RichText— a contenteditable component that supports inline formatting (bold, italic, links). Use it for any text the user should be able to edit with formatting.setAttributes— the way to update block state. Equivalent to React’ssetState, but tied into WordPress’s undo/redo and serialization.
Step 4 — Write the Save component
This is what gets serialized to post_content. Replace src/save.js:
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function Save({ attributes }) {
const { title, body, calloutType } = attributes;
const blockProps = useBlockProps.save({
className: `my-callout my-callout-${calloutType}`,
});
return (
<div {...blockProps}>
<RichText.Content tagName="h4" className="my-callout-title" value={title} />
<RichText.Content tagName="p" className="my-callout-body" value={body} />
</div>
);
}
Note the differences from Edit:
useBlockProps.save()instead ofuseBlockProps()(save-time variant)RichText.Contentinstead ofRichText—.Contentrenders the saved HTML without editing affordances
Step 5 — Add styles
Update src/style.scss (front-end + editor) with the visual styles:
.my-callout {
border-left: 4px solid;
padding: 1rem 1.25rem;
border-radius: 0.5rem;
margin: 1.5rem 0;
&.my-callout-info { border-color: #2271b1; background: #e7f5ff; }
&.my-callout-tip { border-color: #16a34a; background: #ecfdf5; }
&.my-callout-warning { border-color: #d97706; background: #fff7ed; }
&.my-callout-error { border-color: #d63638; background: #fee2e2; }
.my-callout-title { font-weight: 600; margin: 0 0 0.5rem 0; }
.my-callout-body { margin: 0; }
}
Save. Webpack rebuilds. Refresh the editor.
Step 6 — Test it
Testing my callout
Testing my callout
Heads up
Body text goes here.
In wp-admin, add a new post, search the inserter for “Callout”, insert it. You should see the editable title and body. Type into both. Save the post and view it on the front-end — the same styled box renders.
Common errors
| Error | Cause | Fix |
|---|---|---|
| ”Block validation failed” | Edit and Save output different HTML | Make sure they produce identical markup |
| Block doesn’t appear in inserter | Stale browser cache | Hard refresh wp-admin |
| Styles don’t apply | style.scss not compiled | Confirm npm run start is running |
RichText is not defined | Missing import | import { RichText } from '@wordpress/block-editor' |
What’s next
You have a working custom block. Part 4 dives into the attribute system in depth — sources, types, serialization quirks, and how to evolve attributes without breaking saved content.
Get the next one in your inbox. Practical tips, no fluff.
More tutorials
View all- WordPress
How to Prepare Your Plugin or Theme for WordPress 7.1
A WordPress 7.1 compatibility checklist: iframed editor, client-side media hooks, Abilities public flag, jQuery UI 1.14.2, plus features that did not ship (React 19, collaboration).
22 min
- WordPress
What's New in WordPress 7.1 for Developers
WordPress 7.1 field guide for developers: client-side media, Abilities API, always-iframed editor, SVG Icon API, global styles, and what to test before users upgrade.
18 min
- WordPress
Abilities API Changes in WordPress 7.1
WordPress 7.1 upgrades the Abilities API: a unified public flag, wp_get_abilities() filtering, execution lifecycle hooks, JSON Schema for clients, and typed REST input.
25 min