Modern WordPress development empowers you to build custom blocks using either Advanced Custom Fields (ACF) or native React. For maintainable, scalable projects, itβs crucial to adopt a universal approach to structuring, building, and registering your blocksβregardless of their type.
This article provides a unified workflow for processing both ACF and native (React) blocks, covering file structure, build configuration, PHP registration, and best practices.
Important: the ACF blocks example are made by using ACF and Timber, I’m not mentionning It every time because It’s too long.
Why a Universal Approach?
- Consistency: One structure for all block types simplifies onboarding and maintenance.
- Portability: Blocks can be moved between projects with minimal changes.
- Scalability: Unified build and registration processes make it easy to add new blocks.
Block Configuration: block.json
The block.json file is the heart of every block, defining metadata, assets, and supported features.
Example for Native (React) Block:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "namespace/your-block",
"title": "Your block",
"icon": "block-default",
"description": "A custom react block.",
"category": "widgets",
"keywords": ["block", "example"],
"version": "1.0.0",
"textdomain": "your-textdomain",
"attributes": {},
"supports": {},
"viewScript": "file:./assets/view.js",
"editorScript": "file:./assets/index.js",
"editorStyle": "file:./assets/index.css",
"style": "file:./assets/style-index.css",
"viewStyle": "file:./assets/view.css"
}
Example for ACF Block:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "namespace/your-block",
"title": "Your block",
"icon": "block-default",
"description": "A custom ACF block.",
"category": "widgets",
"keywords": ["block", "example"],
"version": "1.0.0",
"textdomain": "your-textdomain",
"attributes": {},
"supports": {},
"acf": {
"mode": "preview",
"renderTemplate": "./includes/render.php"
},
"viewScript": "file:./assets/view.js",
"viewStyle": "file:./assets/style-view.css",
"editorScript": "file:./assets/index.js",
"editorStyle": "file:./assets/index.css"
}
Build Process: Webpack & Scripts
Use a single build process for all blocks.
Adjust paths as needed for your project.
webpack.config.js
const wordpressConfig = require("@wordpress/scripts/config/webpack.config");
const CopyWebpackPlugin = require("copy-webpack-plugin");
module.exports = {
...wordpressConfig,
plugins: [
...wordpressConfig.plugins,
new CopyWebpackPlugin({
patterns: [
{ from: "**/*.twig", context: process.env.WP_SOURCE_PATH },
{ from: "**/*.php", context: process.env.WP_SOURCE_PATH }
],
}),
],
};
package.json scripts:
{
"scripts": {
"watch:blocks": "wp-scripts start --webpack-src-dir=blocks --output-path=build/blocks",
"build:blocks": "wp-scripts build --webpack-src-dir=blocks --output-path=build/blocks"
}
}
Registering and Enqueueing Blocks
Automatically register all blocks by scanning for block.json files, this ensures all block assets and metadata are registered and enqueued automatically.
<?php
/**
* Registers blocks using metadata from `block.json` files.
*
* This function registers blocks and their assets so they can be enqueued
* through the block editor in the corresponding context.
*
* @see https://developer.wordpress.org/reference/functions/register_block_type/
* @see wp-includes\blocks.php
*
* @return void
*/
function register_blocks(): void
{
// Include all files in the block folder
$dirpath = get_stylesheet_directory() . '/build/blocks/';
$files = glob($dirpath . '**/block.json');
if (!empty($files)) {
foreach ($files as $file) {
if (file_exists($file)) {
// Get all the block metadata
$metadata = wp_json_file_decode($file, ['associative' => true]);
if (!is_array($metadata) || empty($metadata['name'])) {
continue;
}
// Register the block
$metadata['file'] = wp_normalize_path(realpath($file));
register_block_type($metadata['file']);
// Handle translation for the block
$scriptEditorHandle = generate_block_asset_handle($metadata['name'], 'editorScript');
$scriptHandle = generate_block_asset_handle($metadata['name'], 'viewScript');
$handles = [];
if (!empty($scriptEditorHandle)) {
$handles[] = $scriptEditorHandle;
}
if (!empty($scriptHandle)) {
$handles[] = $scriptHandle;
}
// Include acf.php if it exists
$block_dir = dirname($file);
$acf_file = $block_dir . '/includes/acf.php';
if (file_exists($acf_file)) {
include_once $acf_file;
}
}
}
}
}
add_action('init', 'register_blocks');
ACF Block Example
Folder Structure:
π your-block
βββ π assets
β βββ π styles
β β βββ style.scss # block styles
β β βββ editor.scss # Editor-specific styles
β βββ index.js # Editor-specific JavaScript
β βββ view.js # Front-specific JavaScript
βββ π includes
β βββ acf.php # ACF field configuration
β βββ render.php # PHP render callback
βββ π views
β βββ view.twig # Twig template
βββ block.json # Block configuration
ACF Field Registration (includes/acf.php):
<?php
if (!function_exists('acf_add_local_field_group')) {
return;
}
acf_add_local_field_group([
'key' => 'group_your_block',
'title' => 'Your Block Fields',
'fields' => [
[
'key' => 'field_your_field',
'label' => 'Your Field',
'name' => 'your_field',
'type' => 'text',
// ... other field settings
],
// Add more fields as needed
],
'location' => [
[
[
'param' => 'block',
'operator' => '==',
'value' => 'acf/your-block',
],
],
],
]);
Render Callback (includes/render.php):
<?php
/**
* ACF Block Template
*
* Available variables:
* @var array $block The block settings and attributes
* @var string $content The block inner HTML (empty)
* @var boolean $is_preview True during backend preview render
* @var integer $post_id The Post ID of the current context
* @var array $context The context provided to the block
*/
use Timber\Timber;
// Get ACF fields
$field = get_field('field_your_field');
// Render the Twig template
Timber::render('views/view.twig', [
'field' => !empty($field) ? $field : 'placeholder'
]);
Twig Template (views/view.twig):
<section class="your-block">
{{ field }}
</section>
Helper Functions for Block Output
When working with ACF blocks, you may need to convert block attributes and style arrays into valid CSS for inline styles or classes. Here are some useful PHP helpers:
<?php
class BlockHelpers
{
/**
* Converts custom property strings to CSS variable format.
*
* @param string $value The custom property string to convert.
* @return string The converted CSS variable string.
*/
public static function convertCustomProperties(string $value): string
{
$prefix = 'var:';
$prefix_len = mb_strlen($prefix);
$token_in = '|';
$token_out = '--';
if (str_starts_with($value, $prefix)) {
$unwrapped_name = str_replace(
$token_in,
$token_out,
mb_substr($value, $prefix_len)
);
$value = "var(--wp--{$unwrapped_name})";
}
return $value;
}
/**
* Returns inline block style properties as a CSS string.
*
* @param array $block The block data.
* @return string The inline CSS style properties.
*/
public static function getBlockStyleInline(array $block): string
{
$attrs = [];
// Map of preset properties to their CSS variable formatters
// Each formatter is a closure that generates the appropriate CSS variable string
// e.g., fontSize: 'large' -> var(--wp--preset--font-size--large)
$presetMap = [
'fontSize' => fn ($v) => "var(--wp--preset--font-size--{$v})",
'textColor' => fn ($v) => "var(--wp--preset--color--{$v})",
'backgroundColor' => fn ($v) => "var(--wp--preset--color--{$v})",
];
// Process each preset property if it exists in the block
foreach ($presetMap as $key => $formatter) {
if (isset($block[$key])) {
// Convert camelCase property names to kebab-case CSS properties
$cssKey = match ($key) {
'fontSize' => 'font-size',
'textColor' => 'color',
'backgroundColor' => 'background-color',
};
$attrs[$cssKey] = $formatter($block[$key]);
}
}
// Spacing
if (isset($block['style']['spacing'])) {
foreach (['margin', 'padding'] as $prop) {
$spacing = $block['style']['spacing'][$prop] ?? null;
if (is_array($spacing)) {
foreach ($spacing as $dir => $value) {
$attrs["{$prop}-{$dir}"] = self::convertCustomProperties($value);
}
}
}
}
// Colors
if (isset($block['style']['color'])) {
foreach (['text' => 'color', 'background' => 'background-color'] as $key => $cssKey) {
$color = $block['style']['color'][$key] ?? null;
if ($color) {
$attrs[$cssKey] = self::convertCustomProperties($color);
}
}
}
// Convert attributes array to CSS string
// If no attributes exist, return empty string
// Otherwise, map each key-value pair to "key:value" format and join with semicolons
return empty($attrs) ? '' : implode(';', array_map(
fn ($k, $v) => "{$k}:{$v}",
array_keys($attrs),
$attrs
));
}
/**
* Retrieves block classes based on back-office settings.
*
* @param array $block The block data.
* @return string The block classes as a space-separated string.
*/
public static function getBlockClass(array $block): string
{
$classes = [];
if (isset($block['align'])) {
$align = $block['align'];
$classes[] = "align{$align}";
}
if (isset($block['className'])) {
$classes[] = $block['className'];
}
if (!empty($classes)) {
$classes = implode(' ', $classes);
}
return $classes;
}
/**
* Gets theme color value from a color label.
*
* @param string $label The color label to look up.
* @param string $returnKey The key to return from the color data (default: 'slug').
* @return string The color value or empty string if not found.
*/
public static function getThemeColorsFromLabel(string $label, string $returnKey = 'slug'): string
{
$theme_json = \WP_Theme_JSON_Resolver::get_merged_data('theme');
$theme_data = $theme_json->get_raw_data();
$theme_colors = $theme_data['settings']['color']['palette']['theme'] ?? [];
if (empty($theme_colors)) {
return '';
}
// Use array_filter to find the color with matching name
$matching_colors = array_filter($theme_colors, function ($color) use ($label) {
return $color['name'] === $label;
});
// If we found a match, return its color value
if (!empty($matching_colors)) {
$first_match = reset($matching_colors);
return $first_match[$returnKey];
}
// Return empty string if no match found
return '';
}
}
Using Native Block Features
You can use native WordPress block features (like spacing, color, typography, etc.) in your ACF blocks. To do this, process the values from the `$block` variable in your `render.php` file and pass them to your template for inline styles or classes.
Example: Handling spacing and outputting inline styles
<?php
// In render.php
$style_to_apply = BlockHelpers::getBlockStyleInline($block);
$block_context['style_to_apply'] = $style_to_apply;
Then in your Twig template:
{# In your Twig template #}
<section class="your-block" style="{{ style_to_apply }}">
{{ field }}
</section>
The same pattern applies to other block features like colors, typography, and alignment. Just access the values from the `$block` variable and process them as needed.
Styles
Front-end Styles (assets/styles/style.scss)
// Style for the block
.block-acf---your-block {
border: 1px solid #ccc;
min-height: 200px;
}
Editor Styles (assets/styles/editor.scss)
// Style for the gutenberg editor part of the block
@use 'style.scss';
.block-acf---your-block {}
Scripts
Front-end Scripts (assets/view.js)
// This file is for the front-end part of the block
import './styles/style.scss'
Editor Scripts (assets/index.js)
// This file is for the gutenberg editor part of the block
import './styles/editor.scss'
Native (React) Block Example
Folder Structure:
π your-block
βββ π assets
β βββ index.jsx # Main entry point
β βββ view.js # Frontend view script
β βββ π scripts
β β βββ save.jsx # Save component
β β βββ edit.jsx # Edit component
β βββ π styles
β βββ style.scss # Frontend styles
β βββ editor.scss # Editor styles
βββ block.json # Block configuration
Main Entry (assets/index.jsx):
/**
* Let webpack process CSS, SASS or SCSS files referenced in JavaScript files.
* All files containing `style` keyword are bundled together. The code used
* gets applied both to the front of your site and to the editor.
*
* @see https://www.npmjs.com/package/@wordpress/scripts#using-css
*/
import "./styles/style.scss";
/**
* Registers a new block provided a unique name and an object defining its behavior.
*
* @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
*/
import {registerBlockType} from "@wordpress/blocks";
import metadata from "../block.json";
/**
* Internal dependencies
*/
import Edit from "./scripts/edit";
import Save from "./scripts/save";
/**
* Every block starts by registering a new block type definition.
*
* @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
*/
registerBlockType(metadata.name, {
/**
* @see ./edit.js
*/
edit: Edit,
/**
* @see ./save.js
*/
save: Save,
});
Edit Component (assets/scripts/edit.jsx):
import "../styles/editor.scss";
import {useBlockProps} from "@wordpress/block-editor";
import {__} from "@wordpress/i18n";
export default function Edit({attributes, isSelected, setAttributes}) {
// ---------- attributes are the states stored by WordPress
// They are defined in the block.json
// ----------
// ---------- BlockProps are the data that will be inserted into the main html tag of the block (style, data-attr, etc...)
const blockProps = useBlockProps();
// ----------
// ---------- States that aren't stored by WordPress
// They are only useful for the preview
// ----------
// ---------- Other variables
// ----------
return (
<section {...blockProps}>
<h1>{__("this is my custom block", "your-textdomain")}</h1>
</section>
);
}
Save Component (assets/scripts/save.jsx):
import {useBlockProps} from "@wordpress/block-editor";
import {__} from "@wordpress/i18n";
export default function Save({attributes}) {
const blockProps = useBlockProps.save();
return (
<section {...blockProps}>
<h1>{__("this is my custom block", "your-textdomain")}</h1>
</section>
);
}
Styles
Frontend Styles (style.scss)
.wp-block-namespace-your-block {
border: 1px solid #ccc;
min-height: 200px;
}
Editor Styles (editor.scss)
.wp-block-namespace-your-block {
// Editor-specific styles
}
Using Timber Inside a React Block
To integrate Timber with your React block, use a PHP render callback (render.php) to process block attributes and render a Twig template.
1. Folder Structure Update
π your-block
βββ π includes
β βββ render.php # PHP render callback
βββ π views
βββ view.twig # Twig template
2. Block Configuration Update (block.json)
Add the render parameter to your block.json to specify the PHP render callback:
{
"render": "file:./includes/render.php",
}
3. PHP Render Callback (includes/render.php)
<?php
use Timber\Timber;
if (!isset($block) || !isset($attributes)) {
return '';
}
$inner_blocks = $block->inner_blocks;
$inner_blocks_html = '';
if (!empty($inner_blocks)) {
foreach ($inner_blocks as $inner_block) {
$inner_blocks_html .= $inner_block->render();
}
}
Timber::render('views/view.twig', [
'inner_blocks' => $inner_blocks_html
]);
4. Twig Template (views/view.twig)
<section class="block-react---your-block">
{{ inner_blocks|raw }}
</section>
Best Practices
- Consistent Structure: Use the same folder and file naming conventions for all blocks.
- block.json First: Always define block metadata and assets in
block.json. - Unified Build: Use a single build process for all block types.
- Auto-Registration: Register blocks by scanning for
block.jsonfiles. - Separation of Concerns: Keep templates, scripts, and styles modular and organized.
- Accessibility & Internationalization: Use semantic HTML and
@wordpress/i18nfor translations. - Performance: Optimize bundle size and use dynamic imports for heavy dependencies.
References
- WordPress Block Editor Handbook
- block.json
- ACF Blocks Documentation
- @wordpress/scripts
- Timber Documentation
By following this universal approach, youβll ensure your WordPress blocksβwhether ACF or nativeβare robust, maintainable, and ready for any project.
Let me know if you want this tailored for a specific block, or if you need a more concise or expanded version!

Leave a Reply