Spoiler: I’m not a React developer.
But I still need to build custom blocks for clients. As a solo developer working with small businesses, I don’t have the bandwidth (or desire) to learn an entirely new technology stack. With Advanced Custom Fields (ACF) blocks, I can create fully functional custom blocks using the PHP and WordPress templating I already know.
Advanced Custom Fields (ACF®) is available for free through the WordPress plugin repository but you will need the Pro version to create custom ACF blocks.
Custom ACF blocks1 combine the flexibility of custom fields with the ease of building out content in the modern block editor interface. No need to write React components or deal with complex JavaScript build processes. Hooray!
ACF Custom Blocks: In a theme or in a plugin?
Custom ACF blocks can be plugins or built into your theme. If you are creating a plugin, you just need to register the block from your plugin file instead of functions.php.
Use a plugin when:
- The block is site-specific functionality that should persist if you ever switch themes
- You want to reuse the block across multiple sites
- The block is complex enough to warrant its own updates and version control
I prefer to keep it in my theme when:
- The block is built for that specific theme’s design system
- It relies on theme-specific styles or CSS custom properties
- I’m building a custom theme where the blocks are part of the overall design package
- The client is unlikely to change themes
For my client work, I usually build blocks as part of the theme. But if I find myself rebuilding the same block type for multiple projects (like an FAQ accordion or a team member grid), that’s when I convert it to a plugin so I can reuse it. You can always move a block from theme to plugin later.
Setting up your block structure
With block themes, you should use a block.json metadata file2 rather than registering blocks entirely through PHP. This approach lets WordPress automatically handle asset loading and block registration, which means better performance and less manual code.
Your block will live in its own directory within your theme. For this example, I’ll walk through creating a text rotation block that cycles through different phrases with a typing animation effect, something a client might use for a dynamic homepage headline.
For a custom ACF block, you’ll need at minimum two files: a block.json file and a PHP template file.
- block.json – This is where you define the block’s name, title, description, category, icon3, keywords, and any rendering settings.
- render.php – You can name this file whatever you want (commonly something like
block-name.phportemplate.php), but you need to reference it correctly in yourblock.jsonusing therenderproperty. I use the same names for all custom blocks, only the /custom-block-name/ folder changes – I just find it easier to set things up that way. - style.css – While you can enqueue styles globally, best practice for performance is to use the
styleproperty inblock.jsonto load your block’s stylesheet only when the block is actually used on a page. - script.js – You can also add a JavaScript file if your block needs any frontend interactivity.
Here’s the directory structure I’m creating within my theme:
/blocks/
/text-rotate/
- block.json
- render.php
- style.css
- script.jsEach custom block – /text-rotate/ in this example – gets its own folder inside of /blocks/ with all its assets contained together. This keeps your theme organized and makes blocks portable if you need to move them to a different theme or turn them into a plugin later.
Let’s build a custom block
The block.json configuration
The block.json file defines your block’s metadata and tells WordPress where to find your assets:
{
"$schema": "https://schemas.wp.org/wp/6.9/block.json",
"apiVersion": 3,
"name": "acf/text-rotate",
"title": "Text Rotate",
"description": "Rotate words on the homepage",
"style": [ "file:./style.css" ],
"script": [ "file:./script.js" ],
"category": "text",
"icon": "carrot",
"keywords": ["rotate", "text slide"],
"acf": {
"mode": "preview",
"renderTemplate": "render.php"
}
}
You can also extend your ACF blocks by adding Block Supports to your block.json file. This allows editors access to core WordPress block attributes like spacing, alignment, or color.
Note: ACF Blocks V3 is now available but you have to manually enable this. This version makes the preview mode unnecessary, “…in ACF Blocks Version 3, the concept of modes no longer exists. Rather, your block will always be in “preview”, always showing what’s in your PHP render template in the block preview area. The block’s ACF fields show up in the sidebar, and also in a pop-out sidebar that has more space.”
Registering the Block
In your theme’s functions.php, you only need a few lines to register the block:
function mytheme_register_acf_blocks() {
register_block_type( __DIR__ . '/blocks/text-rotate' );
}
add_action( 'init', 'mytheme_register_acf_blocks' );
The __DIR__ constant points to your theme directory, and WordPress will automatically find and process the block.json file. No need to manually enqueue your CSS and JavaScript, WordPress handles that based on the block.json configuration.
Creating the ACF field group
Once your block is registered, create an ACF field group inside of WordPress:
- Add a repeater field. I called mine:
text_slide - Inside the repeater, add a text sub-field. Mine is:
slide_it - Set the location rule to “Block is equal to Text Rotate (sub your block name)”



The render template
Advanced Custom Fields use PHP templates (server-side rendering) rather than JavaScript for block rendering. This simplifies our workflow and may have some performance and SEO benefits over JavaScript.
Your render.php file outputs the block’s HTML. This is standard PHP that loops through your ACF fields:
<?php
/**
* Text Rotate Block Template
*/
// Create id attribute allowing for custom "anchor" value
$id = 'text-rotate-' . $block['id'];
if ( ! empty($block['anchor'] ) ) {
$id = $block['anchor'];
}
// Create class attribute allowing for custom "className" and "align" values
$classes = 'block-text-rotate';
if ( ! empty( $block['className'] ) ) {
$classes .= ' ' . $block['className'];
}
if ( ! empty( $block['align'] ) ) {
$classes .= ' align' . $block['align'];
}
?>
<div id="<?php echo esc_attr( $id ); ?>" class="<?php echo esc_attr( $classes ); ?>">
<?php if ( have_rows( 'text_slide' ) ) : ?>
<h3><span class="headline slide">
<span>Words hit the page like</span>
<span class="words-wrapper">
<?php while ( have_rows( 'text_slide' ) ) : the_row(); ?>
<i class="is-hidden"><?php echo esc_html( get_sub_field( 'slide_it' ) ); ?></i>
<?php endwhile; ?>
</span>.</span></h3>
<?php else : ?>
<p>Add some text to see the rotation effect.</p>
<?php endif; ?>
</div>
The first part of this template handles block settings that users configure in the block editor’s sidebar. The className and align handling ensures your custom block respects the same settings that core WordPress blocks use.
WordPress passes these settings to your template in the $block array, which you escape with esc_attr() before output – standard practice for any dynamic data.
If you (like me) need a starter when working with PHP, the plugin ACF Theme Code is super helpful. There is a free version but it won’t work for repeaters.
Note: For a deeper explanation of WordPress security best practices, see the ACF security principles.
Styling the block
In our render.php template, input words start with the .is-hidden class applied. The CSS and JavaScript will handle revealing and animating them.
The CSS handles the positioning and visibility states for the rotating text:
.words-wrapper {
display: inline-block;
position: relative;
min-width: 150px;
}
.words-wrapper i {
display: inline-block;
position: absolute;
white-space: nowrap;
left: 0;
top: 0;
font-style: normal;
}
.words-wrapper i.is-visible {
position: relative;
opacity: 1;
}
.words-wrapper i.is-hidden {
opacity: 0;
visibility: hidden;
}
.headline.slide span {
display: inline-block;
margin: auto;
}
.headline.slide .words-wrapper {
overflow: hidden;
vertical-align: top;
}
/* Show first word in block editor */
.editor-styles-wrapper .words-wrapper i:first-child {
opacity: 1;
visibility: visible;
}
All the <i> elements are absolutely positioned, stacking on top of each other. Only the visible one uses position: relative, which means only it affects the layout and takes up space.
I also wanted to be able to see the first word added to my repeater in the editor. Adding a rule that targets the .editor-styles-wrapper and the :first-child does this.
Let’s improve this CSS by adding an accessibility consideration for the media query prefers-reduced-motion4:
@media (prefers-reduced-motion: reduce) {
.words-wrapper i:first-child {
opacity: 1 !important;
visibility: visible !important;
position: relative !important;
}
.words-wrapper i:not(:first-child) {
display: none;
}
}But wait – we hate those bossy !important declarations. How about we instead adjust the CSS specificity by adding the class .is-hidden and .is-visible:
@media (prefers-reduced-motion: reduce) {
.words-wrapper i:first-child.is-hidden,
.words-wrapper i:first-child.is-visible {
opacity: 1;
visibility: visible;
position: relative;
}
.words-wrapper i:not(:first-child) {
display: none;
}
}This targets the first <i> regardless of which class JavaScript applies, matching the specificity without needing to force it with that self-important !important.
Adding the typing animation
The JavaScript creates a typing effect where each word types out character by character, pauses briefly, then deletes before moving to the next word:
document.addEventListener('DOMContentLoaded', function() {
const headlines = document.querySelectorAll('.headline.slide');
const typingSpeed = 100;
const pauseTime = 2000;
headlines.forEach(function(headline) {
const words = headline.querySelectorAll('.words-wrapper i');
if (words.length === 0) return;
let currentIndex = 0;
function typeWord(word, text, callback) {
word.textContent = '';
word.classList.add('is-visible');
word.classList.remove('is-hidden');
let charIndex = 0;
const typeInterval = setInterval(function() {
if (charIndex < text.length) {
word.textContent += text.charAt(charIndex);
charIndex++;
} else {
clearInterval(typeInterval);
if (callback) setTimeout(callback, pauseTime);
}
}, typingSpeed);
}
function deleteWord(word, callback) {
const deleteInterval = setInterval(function() {
const currentText = word.textContent;
if (currentText.length > 0) {
word.textContent = currentText.slice(0, -1);
} else {
clearInterval(deleteInterval);
word.classList.remove('is-visible');
word.classList.add('is-hidden');
if (callback) callback();
}
}, typingSpeed / 2);
}
function animateNext() {
const currentWord = words[currentIndex];
const originalText = currentWord.getAttribute('data-text') || currentWord.textContent.trim();
// Store original text so we can retype it on each loop
if (!currentWord.hasAttribute('data-text')) {
currentWord.setAttribute('data-text', originalText);
}
typeWord(currentWord, originalText, function() {
deleteWord(currentWord, function() {
currentIndex = (currentIndex + 1) % words.length;
animateNext();
});
});
}
animateNext();
});
});
The script stores the original text in a data-text attribute so it can be retyped on each loop iteration. The intervals are properly cleared to avoid memory leaks, and the modulo operator5 (%) wraps the index back to zero, ensuring the animation loops back to the first word after reaching the end.
Mistakes were made (learning is doing)
A few things tripped me up when building this ACF block:
- Forgetting the
file:./prefix in block.json will cause WordPress to look for your assets in the wrong location. Always use the relative path prefix. - Not using block.json at all and trying to register everything in PHP. While this might work in classic themes, block themes expect the metadata file for proper asset handling.
- Not clearing intervals in JavaScript animations. Always use
clearInterval()to prevent memory leaks, especially in animations that run continuously. - Using Claude.ai for help: I did have to push back on Claude to reduce complexity. Providing guidance to AI before you start building anything is a good idea: don’t add complexity, check for accessibility, security and performance, avoid
!importantdeclarations, etc.
Troubleshooting common issues
Asset Loading Issues – CSS and JavaScript files loading on every page instead of only when the block is actually used. If you’re manually enqueuing assets in functions.php, they load globally. Use the style and script properties in block.json instead – WordPress will only load those files on pages where the block appears.
Block Preview Not Updating – Sometimes you make changes to your ACF fields or template, but the block preview in the editor doesn’t reflect them. This is usually a caching issue. Save the page, refresh the editor, or temporarily change the block’s mode from “preview” to “edit” in your block.json to force a refresh.
Field Group Not Connected – Your block won’t show any fields if the ACF field group isn’t properly assigned to it. In your field group settings, make sure the location rule is set to “Block” and matches your block’s name exactly (like acf/text-rotate). If you rename your block, you need to update the field group location too.
File Path Problems – Using relative paths incorrectly is a quick way to break things. In block.json, paths to your CSS and JavaScript files need the file: prefix and should be relative to the block.json file itself (like "style": ["file:./style.css"]). However, if you’re using ACF’s renderTemplate property, it’s just the filename.
Missing Escaping – Any time you’re outputting ACF field data or block attributes, escape it. Use esc_html() for text content, esc_attr() for HTML attributes, and esc_url() for URLs. Skipping this creates security vulnerabilities, especially if clients can input the data.
Block Not Appearing in Inserter – If your block doesn’t show up when you try to add it, check your block.json syntax. Also make sure your theme is actually registering the block directory. Remember, you need code in functions.php to tell WordPress where to find your blocks.
Benefits of the custom block approach for client projects
The combination of ACF’s visual field builder with block theme architecture gives you something powerful: blocks that are easy for clients to use but give you complete control over markup and styling.
For solo consultants working with small businesses, this is a big deal. You’re not just building the site, you’re building something clients can manage and maybe even enjoy working with!
- The ACF Pro version is necessary to create ACF Blocks ↩︎
- Advanced Custom Fields, Block Development ↩︎
- The easiest way to add icons to your custom block is with Dashicons ↩︎
- prefer-reduced-motion media query on MDN ↩︎
- What is modulo?, explainer video on YouTube ↩︎
Resources:
- WP Engine Builders, ACF Chat Friday on YouTube
- ACF, Known Issues
- Very Useful: Meta Field Block Plugin by Phi Phan allows you pull in custom fields dynamic data to your blocks and templates! Here’s a good explainer video from WPTuts

