3 min read

Custom WordPress Blocks (Vanilla JS + PHP)

This guide explains the "No-Compiler" architecture for building native WordPress blocks. This method bypasses the need for Node.js/NPM by using the global window.wp object and a manual PHP autoloader.


1. Folder Structure

Every block lives in its own directory within the theme's /blocks folder:

wp-content/themes/wp-scratch/
└── blocks/
    └── call-to-action/
        ├── block.json    # Metadata and PHP link
        ├── index.js      # Editor UI (Vanilla JS)
        └── render.php    # Front-end Template (PHP)

2. The Metadata (block.json)

The JSON must be kept minimal. Do not include the editorScript key here, as we load the script manually in PHP to handle dependencies.

{
  "apiVersion": 3,
  "name": "wp-scratch/call-to-action",
  "title": "Call to Action",
  "category": "text",
  "icon": "megaphone",
  "render": "file:./call-to-action.php"
}

3. The Editor Logic (index.js)

Since there is no compiler to handle import statements or JSX, we use the global wp object. We wrap the code in a recursive init function to ensure the script doesn't fire until window.wp is fully defined by the browser.

(function () {
    const initBlock = () => {
        if (typeof wp !== 'undefined' && wp.blocks && wp.blockEditor) {
            const el = wp.element.createElement;
            const useBlockProps = wp.blockEditor.useBlockProps;
            const InnerBlocks = wp.blockEditor.InnerBlocks;

            wp.blocks.registerBlockType('wp-scratch/call-to-action', {
                edit: function () {
                    const blockProps = useBlockProps({ className: 'custom-admin-wrapper' });
                    // Return the Editor UI using React.createElement (el)
                    return el('div', blockProps, el(InnerBlocks));
                },
                save: function () {
                    // For dynamic blocks, save returns the InnerBlocks content
                    return el(InnerBlocks.Content);
                },
            });
        } else {
            setTimeout(initBlock, 50); // Retry if WP isn't ready
        }
    };
    initBlock();
})();

4. The Front-end Template (render.php)

This file controls the HTML on the website. The variable $content automatically contains all blocks placed inside the InnerBlocks area in the editor.

<?php
$wrapper_attributes = get_block_wrapper_attributes(['class' => 'my-cta-section']);
?>
<section <?php echo $wrapper_attributes; ?>>
    <div class="container mx-auto">
        <?php echo $content; ?>
    </div>
</section>

5. The PHP Autoloader (functions.php)

This loop scans the /blocks folder. It performs two critical tasks:

  1. It registers the block via block.json.
  2. It forces the index.js to load only in the editor with the necessary dependencies (wp-blocks, wp-element, etc.).
add_action( 'init', function() {
    $blocks_dir = get_template_directory() . '/blocks';
    $folders = array_diff( scandir( $blocks_dir ), array( '..', '.' ) );

    foreach ( $folders as $folder ) {
        $path = $blocks_dir . '/' . $folder;
        if ( file_exists( $path . '/block.json' ) ) {
            register_block_type( $path );

            add_action( 'enqueue_block_editor_assets', function() use ( $folder, $path ) {
                wp_enqueue_script(
                    $folder . '-js',
                    get_template_directory_uri() . '/blocks/' . $folder . '/index.js',
                    array( 'wp-blocks', 'wp-element', 'wp-block-editor' ),
                    filemtime( $path . '/index.js' ),
                    true
                );
            });
        }
    }
});

6. Adding Editable Attributes

To make a block truly "custom" (e.g., a background color picker), follow this pattern:

Update block.json Add an attributes object to define what data WordPress should save.

"attributes": {
    "backgroundColor": {
        "type": "string",
        "default": "#ffffff"
    }
}

Update index.js Extract attributes and setAttributes from the edit function

edit: function (props) {
    const { attributes, setAttributes } = props;
    const el = wp.element.createElement;
    const InspectorControls = wp.blockEditor.InspectorControls;
    const PanelBody = wp.components.PanelBody;
    const ColorPicker = wp.components.ColorPicker;

    return [
        // Sidebar UI
        el(InspectorControls, {}, 
            el(PanelBody, { title: 'Settings' },
                el(ColorPicker, {
                    color: attributes.backgroundColor,
                    onChangeComplete: (val) => setAttributes({ backgroundColor: val.hex })
                })
            )
        ),
        // Editor UI
        el('div', { style: { background: attributes.backgroundColor } }, 'Content here...')
    ];
}

Update render.php

Key Takeaways

No JSX: Use wp.element.createElement (aliased as el) instead of HTML tags in JS.

Dependencies: The script must have the dependency array in wp_enqueue_script or window.wp will be undefined.

Dynamic Names: Ensure the namespace (e.g., wp-scratch/) is consistent across the JSON and the JS registration.

Tips

  • Use /scripts/newblock/ps.1 to add new blocks in a starter tempalte.

    • .\newblock.ps1 -Name name-of-block
    • .\newblock.ps1 [-Name] [name-of-block]
      • [-Name] use this to call the command to name the newblock folder/files
      • [name-of-block] add any name here to to name the block folder/files
  • Call blocks within blocks TEMPLATE

    const TEMPLATE = [                
      ['wp-scratch/icon-title', {}], //calls blocks/icon-title
  • When editing block template files, sometimes have to delete them from the editor and re-add to fetch the latest changes