Skip to content

Instantly share code, notes, and snippets.

@vapvarun
Created April 10, 2026 08:29
Show Gist options
  • Select an option

  • Save vapvarun/dfd667a3326bb91932afea0e86cb1ace to your computer and use it in GitHub Desktop.

Select an option

Save vapvarun/dfd667a3326bb91932afea0e86cb1ace to your computer and use it in GitHub Desktop.
Building Custom Gutenberg Blocks with React: Step-by-Step Tutorial (wppioneer.com)
#!/bin/bash
# Step 1: Create the plugin directory inside WordPress
cd wp-content/plugins
mkdir highlight-card-block
cd highlight-card-block
# Step 2: Initialise npm project
npm init -y
# Step 3: Install @wordpress/scripts as a dev dependency
npm install --save-dev @wordpress/scripts
# Step 4: Create the src directory
mkdir src
{
"name": "highlight-card-block",
"version": "1.0.0",
"description": "Custom Gutenberg block — Highlight Card",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"lint:js": "wp-scripts lint-js",
"lint:style": "wp-scripts lint-style",
"test": "wp-scripts test-unit-js"
},
"devDependencies": {
"@wordpress/scripts": "^30.0.0"
}
}
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-plugin/highlight-card",
"version": "1.0.0",
"title": "Highlight Card",
"category": "design",
"icon": "star-filled",
"description": "A customisable card block with a heading, body text, background color, and optional link.",
"keywords": ["card", "highlight", "callout"],
"textdomain": "highlight-card-block",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"attributes": {
"heading": {
"type": "string",
"source": "html",
"selector": "h3.highlight-card__heading",
"default": ""
},
"body": {
"type": "string",
"source": "html",
"selector": "p.highlight-card__body",
"default": ""
},
"backgroundColor": {
"type": "string",
"default": "#ffffff"
},
"textColor": {
"type": "string",
"default": "#1a1a1a"
},
"linkUrl": {
"type": "string",
"default": ""
}
},
"supports": {
"html": false,
"align": ["wide", "full"],
"spacing": {
"padding": true,
"margin": true
}
}
}
<?php
/**
* Plugin Name: Highlight Card Block
* Description: A custom Gutenberg block built with @wordpress/scripts and React.
* Requires at least: 6.4
* Requires PHP: 8.0
* Version: 1.0.0
* Author: WP Pioneer
* License: GPL-2.0-or-later
* Text Domain: highlight-card-block
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Register the block using block.json.
* Passing the build/ directory path lets WordPress auto-enqueue
* editorScript, editorStyle, and style as declared in block.json.
*/
function highlight_card_block_init() {
register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'highlight_card_block_init' );
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
RichText,
InspectorControls,
PanelColorSettings,
} from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
const { heading, body, backgroundColor, textColor, linkUrl } = attributes;
// useBlockProps MUST be called and spread on the outermost element.
// It injects selection, focus, and toolbar positioning data.
const blockProps = useBlockProps( {
style: {
backgroundColor,
color: textColor,
},
className: 'highlight-card',
} );
return (
<>
{/* InspectorControls renders into the right-hand sidebar panel */}
<InspectorControls>
<PanelColorSettings
title={ __( 'Color Settings', 'highlight-card-block' ) }
initialOpen={ true }
colorSettings={ [
{
value: backgroundColor,
onChange: ( color ) =>
setAttributes( { backgroundColor: color } ),
label: __( 'Background Color', 'highlight-card-block' ),
},
{
value: textColor,
onChange: ( color ) =>
setAttributes( { textColor: color } ),
label: __( 'Text Color', 'highlight-card-block' ),
},
] }
/>
<PanelBody
title={ __( 'Link Settings', 'highlight-card-block' ) }
initialOpen={ true }
>
<TextControl
label={ __( 'Link URL', 'highlight-card-block' ) }
value={ linkUrl }
onChange={ ( value ) =>
setAttributes( { linkUrl: value } )
}
placeholder="https://example.com"
type="url"
/>
</PanelBody>
</InspectorControls>
{/* Block content renders on the editing canvas */}
<div { ...blockProps }>
<RichText
tagName="h3"
className="highlight-card__heading"
value={ heading }
onChange={ ( value ) => setAttributes( { heading: value } ) }
placeholder={ __( 'Card heading…', 'highlight-card-block' ) }
allowedFormats={ [ 'core/bold', 'core/italic' ] }
/>
<RichText
tagName="p"
className="highlight-card__body"
value={ body }
onChange={ ( value ) => setAttributes( { body: value } ) }
placeholder={ __( 'Card body text…', 'highlight-card-block' ) }
/>
</div>
</>
);
}
/**
* Extended InspectorControls example showing both PanelColorSettings
* and a TextControl for the link URL in a separate PanelBody.
*
* Drop this into the Edit function — it replaces the InspectorControls
* section from the basic edit example.
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls, PanelColorSettings } from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
export function HighlightCardInspector( { attributes, setAttributes } ) {
const { backgroundColor, textColor, linkUrl } = attributes;
return (
<InspectorControls>
{/* Color panel — uses WordPress's built-in colour picker UI */}
<PanelColorSettings
title={ __( 'Color Settings', 'highlight-card-block' ) }
initialOpen={ true }
colorSettings={ [
{
value: backgroundColor,
onChange: ( color ) =>
setAttributes( { backgroundColor: color } ),
label: __( 'Background', 'highlight-card-block' ),
},
{
value: textColor,
onChange: ( color ) =>
setAttributes( { textColor: color } ),
label: __( 'Text', 'highlight-card-block' ),
},
] }
/>
{/* Link panel — separate PanelBody for clear visual grouping */}
<PanelBody
title={ __( 'Link Settings', 'highlight-card-block' ) }
initialOpen={ true }
>
<TextControl
__nextHasNoMarginBottom
label={ __( 'Card Link URL', 'highlight-card-block' ) }
help={ __(
'Wrap the entire card in this URL. Leave blank for no link.',
'highlight-card-block'
) }
value={ linkUrl }
onChange={ ( value ) =>
setAttributes( { linkUrl: value } )
}
placeholder="https://"
type="url"
/>
</PanelBody>
</InspectorControls>
);
}
import { useBlockProps, RichText } from '@wordpress/block-editor';
/**
* Save component — serialises the block to static HTML.
*
* Rules:
* 1. Must be a pure function of attributes → never use React state or effects.
* 2. Must return exactly the same HTML for the same attributes on every call.
* 3. Use RichText.Content (not RichText) to render stored rich text safely.
* 4. useBlockProps.save() is the save-side counterpart to useBlockProps().
*/
export default function Save( { attributes } ) {
const { heading, body, backgroundColor, textColor, linkUrl } = attributes;
const blockProps = useBlockProps.save( {
style: {
backgroundColor,
color: textColor,
},
className: 'highlight-card',
} );
const cardContent = (
<div { ...blockProps }>
<RichText.Content
tagName="h3"
className="highlight-card__heading"
value={ heading }
/>
<RichText.Content
tagName="p"
className="highlight-card__body"
value={ body }
/>
</div>
);
// Conditionally wrap in an anchor if linkUrl is set.
// This conditional is based on an attribute, so it is deterministic.
if ( linkUrl ) {
return (
<a
href={ linkUrl }
className="highlight-card__link"
rel="noopener noreferrer"
>
{ cardContent }
</a>
);
}
return cardContent;
}
{
"attributes": {
"heading": {
"type": "string",
"source": "html",
"selector": "h3.highlight-card__heading",
"default": ""
},
"body": {
"type": "string",
"source": "html",
"selector": "p.highlight-card__body",
"default": ""
},
"backgroundColor": {
"type": "string",
"default": "#ffffff"
},
"textColor": {
"type": "string",
"default": "#1a1a1a"
},
"linkUrl": {
"type": "string",
"default": ""
},
"alignment": {
"type": "string",
"enum": ["left", "center", "right"],
"default": "left"
},
"mediaId": {
"type": "integer",
"default": 0
},
"mediaUrl": {
"type": "string",
"source": "attribute",
"selector": "img.highlight-card__image",
"attribute": "src",
"default": ""
}
}
}
#!/bin/bash
# From inside your plugin directory:
# Development mode — watch for changes and rebuild on every save
npm start
# --- OR ---
# Production build — minified assets ready for deployment
npm run build
# Verify the build output
ls -la build/
# Expected output:
# index.js — editor JavaScript bundle
# index.css — editor-only styles
# style-index.css — front-end + editor shared styles
# index.asset.php — dependency manifest (used by wp_enqueue_script)
# block.json — copied from src/
# Activate via WP-CLI (optional)
wp plugin activate highlight-card-block
// style.scss — loads on both editor and front end
// @wordpress/scripts compiles this to build/style-index.css automatically
.highlight-card {
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
&__heading {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 0.75rem;
line-height: 1.3;
}
&__body {
font-size: 1rem;
line-height: 1.7;
margin: 0;
}
&__link {
display: block;
text-decoration: none;
color: inherit;
&:hover .highlight-card {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14);
}
}
}
// Alignment support (added via block supports.align)
.wp-block-my-plugin-highlight-card.alignwide {
max-width: var(--wp--style--global--wide-size, 1200px);
}
.wp-block-my-plugin-highlight-card.alignfull {
max-width: none;
border-radius: 0;
}
import { useBlockProps, RichText } from '@wordpress/block-editor';
/**
* Block deprecations array.
*
* Add an entry here BEFORE shipping any change to the save function.
* WordPress walks the array newest-first, trying each save function
* against the stored markup until one matches, then runs migrate().
*/
const deprecated = [
{
// v1.0.0 — save function before linkUrl was added
attributes: {
heading: {
type: 'string',
source: 'html',
selector: 'h3.highlight-card__heading',
default: '',
},
body: {
type: 'string',
source: 'html',
selector: 'p.highlight-card__body',
default: '',
},
backgroundColor: {
type: 'string',
default: '#ffffff',
},
textColor: {
type: 'string',
default: '#1a1a1a',
},
},
// The save function exactly as it was in v1.0.0 (no link wrapping)
save( { attributes } ) {
const { heading, body, backgroundColor, textColor } = attributes;
return (
<div
{ ...useBlockProps.save( {
style: { backgroundColor, color: textColor },
className: 'highlight-card',
} ) }
>
<RichText.Content
tagName="h3"
className="highlight-card__heading"
value={ heading }
/>
<RichText.Content
tagName="p"
className="highlight-card__body"
value={ body }
/>
</div>
);
},
// Migrate old attributes to the new schema (add linkUrl default)
migrate( attributes ) {
return {
...attributes,
linkUrl: '',
};
},
},
];
export default deprecated;
import { __ } from '@wordpress/i18n';
/**
* Block variations — multiple presets from a single block registration.
*
* Register via: registerBlockVariation( 'my-plugin/highlight-card', variation )
* Or export as the `variations` property in the block registration object.
*/
const variations = [
{
name: 'warning',
title: __( 'Warning Card', 'highlight-card-block' ),
description: __( 'Amber warning card for important notices.', 'highlight-card-block' ),
icon: 'warning',
attributes: {
backgroundColor: '#fff3cd',
textColor: '#664d03',
heading: __( 'Important Notice', 'highlight-card-block' ),
},
isDefault: false,
scope: [ 'inserter' ],
},
{
name: 'success',
title: __( 'Success Card', 'highlight-card-block' ),
description: __( 'Green success card for positive messages.', 'highlight-card-block' ),
icon: 'yes-alt',
attributes: {
backgroundColor: '#d1e7dd',
textColor: '#0a3622',
heading: __( 'Success', 'highlight-card-block' ),
},
isDefault: false,
scope: [ 'inserter' ],
},
{
name: 'info',
title: __( 'Info Card', 'highlight-card-block' ),
description: __( 'Blue info card for supplementary content.', 'highlight-card-block' ),
icon: 'info',
attributes: {
backgroundColor: '#cfe2ff',
textColor: '#052c65',
heading: __( 'Did You Know?', 'highlight-card-block' ),
},
isDefault: false,
scope: [ 'inserter' ],
},
];
export default variations;
import { createBlock } from '@wordpress/blocks';
/**
* Block transforms — convert to/from other blocks.
*
* Attach this as the `transforms` property in registerBlockType().
*/
const transforms = {
from: [
{
// Convert a core/paragraph into a Highlight Card
type: 'block',
blocks: [ 'core/paragraph' ],
transform( { content, align } ) {
return createBlock( 'my-plugin/highlight-card', {
body: content,
alignment: align || 'left',
} );
},
},
{
// Convert a core/heading into a Highlight Card
type: 'block',
blocks: [ 'core/heading' ],
transform( { content } ) {
return createBlock( 'my-plugin/highlight-card', {
heading: content,
} );
},
},
],
to: [
{
// Convert the Highlight Card back to a core/paragraph
type: 'block',
blocks: [ 'core/paragraph' ],
transform( { body, heading } ) {
// Combine heading and body into a single paragraph
const combined = [ heading, body ].filter( Boolean ).join( ' ' );
return createBlock( 'core/paragraph', {
content: combined,
} );
},
},
],
};
export default transforms;
highlight-card-block/
├── highlight-card-block.php # Plugin entry — register_block_type( __DIR__ . '/build' )
├── package.json # npm scripts: build, start, lint, test
├── .gitignore # node_modules/, build/ (optional — see note)
├── src/
│ ├── block.json # Block manifest — name, attributes, asset paths
│ ├── index.js # registerBlockType() — ties edit, save, deprecated, transforms
│ ├── edit.js # React edit component (editor canvas)
│ ├── save.js # Pure save function (serialised HTML)
│ ├── inspector.js # InspectorControls sidebar panels
│ ├── deprecated.js # Previous save function versions
│ ├── variations.js # Block variation presets
│ ├── transforms.js # From/to block conversions
│ ├── style.scss # Front-end + editor shared styles
│ └── editor.scss # Editor-only styles (optional)
├── build/ # Generated by @wordpress/scripts — commit this
│ ├── index.js # Compiled editor bundle
│ ├── index.css # Compiled editor styles
│ ├── style-index.css # Compiled shared styles
│ ├── index.asset.php # Dependency manifest
│ └── block.json # Copied from src/
└── node_modules/ # .gitignore this — never commit
# Note on build/ directory:
# Unlike most projects, the build/ directory SHOULD be committed for
# WordPress plugins. This ensures the plugin works on servers without
# Node.js installed, and makes deployment a simple file copy.
/**
* Unit tests for the Highlight Card block.
*
* Run with: npm test
* @wordpress/scripts ships Jest configured for block testing.
*/
import { serialize, registerBlockType, unregisterBlockType } from '@wordpress/blocks';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import blockJson from '../block.json';
import Edit from '../edit';
import save from '../save';
// Register and unregister around each test to keep the registry clean
beforeEach( () => {
registerBlockType( blockJson.name, {
...blockJson,
edit: Edit,
save,
} );
} );
afterEach( () => {
unregisterBlockType( blockJson.name );
} );
describe( 'Highlight Card — save output', () => {
it( 'serialises heading and body attributes correctly', () => {
const attributes = {
heading: 'Test Heading',
body: 'Test body content.',
backgroundColor: '#fff3cd',
textColor: '#664d03',
linkUrl: '',
};
const html = serialize( [
{
name: blockJson.name,
isValid: true,
attributes,
innerBlocks: [],
},
] );
expect( html ).toContain( 'Test Heading' );
expect( html ).toContain( 'Test body content.' );
expect( html ).toContain( 'highlight-card__heading' );
expect( html ).toContain( 'highlight-card__body' );
} );
it( 'wraps content in an anchor when linkUrl is set', () => {
const attributes = {
heading: 'Linked Card',
body: 'Body text.',
backgroundColor: '#ffffff',
textColor: '#1a1a1a',
linkUrl: 'https://wppioneer.com',
};
const html = serialize( [
{
name: blockJson.name,
isValid: true,
attributes,
innerBlocks: [],
},
] );
expect( html ).toContain( 'href="https://wppioneer.com"' );
expect( html ).toContain( 'highlight-card__link' );
} );
it( 'matches the save output snapshot', () => {
const attributes = {
heading: 'Snapshot Heading',
body: 'Snapshot body.',
backgroundColor: '#cfe2ff',
textColor: '#052c65',
linkUrl: '',
};
const html = serialize( [
{
name: blockJson.name,
isValid: true,
attributes,
innerBlocks: [],
},
] );
expect( html ).toMatchSnapshot();
} );
} );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment