Create a page builder tool using ACF Pro

I hate Visual Composer. Yeah, it’s an extremely impressive plugin with a ton of power, features, and functionality that make it possible for people who don’t know anything at all about code to build creative, complex page layouts.

And that’s my problem with it: you’re giving all that power to people who shouldn’t be wielding it. For the most part, the biggest issue that comes from it is code bloat which is compounded when it’s a non-developer creating layouts, most likely adding in even more unnecessary fluff to the mix. I find that VC-powered sites are often extremely slow to load and move around, especially on the edit page due to all of the components that have been added and the incredible amount of JavaScript required for everything to work.

So with a recent project, I had to come up with a VC-style page builder that would allow the client to build dynamic pages with whatever layout the given page’s content demanded. My requirements were far narrower and so I didn’t have to concern myself with allowing for inline code on every component and every component had strict design rules in place ensuring that each instance would look similar–the general page layout is what would change.

I needed the following components:

  • Accordion
  • Banner Image
  • Hubspot form
  • Side By Side content (boxes of content that adjust to be 50% or 33.33333% depending on the number)
  • Slider/Carousel
  • Stylized Title
  • Tabbed Content
  • Teaser (a box with an image on the left and paragraph text to the right of the image)
  • WYISWYG content editor
    • Needed to have the ability to include an image with the content and if one was included, the image was always to float in the upper-left corner of the component’s output

Before diving into this, a few things to note:

  1. I always (unless a client demands otherwise) build WordPress themes from scratch using FoundationPress because Foundation is simply the best front end framework available. Sorry Bootstrap lovers, but it uses Sass natively and has been updated regularly over the last four years–something you can’t really say about Bootstrap at all. Version 6.4 added the new Flexbox-based XY Grid which is extremely powerful and flexible.
  2. This requires ACF Pro
  3. The goal here is to create as few fields possible, reusing them when appropriate to minimize repeating ourselves while maximizing functionality.

We know given the required components, we’re going to need the following fields:

  1. Select field to choose which component this is
  2. Plain text field for titles
  3. WYSIWYG content editor
  4. Image field
  5. Textarea (for inputting code. Using the WYSIWYG for this would result in all special characters being encoded and output as text instead of actual working code)
  6. Repeater with the following sub-fields:
    1. Text field for title
    2. Text field for subtitle
    3. WYSIWYG editor
    4. Image field
    5. Text field for CTA text
    6. Page Link field for CTA URL
  7. True/False field for including a divider (horizontal rule) at the end of the current component

Since this is going to be dynamic on a page-by-page basis with any number of components added in any order, this must be a Repeater at the top-level so we can add as many instances as possible. I try to keep my naming conventions as abstract and generic as possible to allow for the greatest amount of reuse. Below are screenshots of the fields I’ve created:

And the sub-repeater expanded to show its fields:

And with these fields, we now have the base in place for creating all of the required components. We’re going to use values of the fields here to include class names in our HTML that will allow us to target properly in both Sass/CSS and jQuery.

The next step is to add the appropriate options to the Content Type select field’s “Choices” box. Once that’s done, we’ll be able to use the values in that field to drive conditional logic on the rest of the fields to have them show when appropriate.

accordion : Accordion
banner_image : Banner Image
code_block : Code Block
side_by_side : Side By Side Content Blocks
slider_carousel : Slider/Carousel
stylized_title : Stylized title
tabbed_content : Tabbed Content
teaser : Teaser (image and content side-by-side)
wysiwyg : WYSIWYG

Using the values of each option from this field, we’re going to setup a block of PHP code for conditionally calling the correct PHP partial file to display the proper fields. I follow Ruby naming conventions when it comes to partials meaning I put an underscore (_) as the first character of the file (same as with Sass partials because originally, Sass required Ruby to compile). This block lives in its own partial to make it easy to drop into any page template file we want with a single line and then any changes made are picked up everywhere this file is called:

// filename: template-parts/_repeater_content_blocks.php

<?php if( have_rows(‘repeater_content_block’) ): ?>
<section class="repeater-content-blocks">

<?php while ( have_rows(‘repeater_content_block’) ) : the_row(); ?>
<?php if(get_sub_field(‘content_type’) === ‘slider_carousel’): ?>
<?php $slider_background_image = get_sub_field(‘image’); ?>
<article class="content-block <?php the_sub_field(‘content_type’); ?><?php echo isset($slider_background_image) ? ‘ has-background-image’ : ‘ no-background-image’; ?>"<?php echo isset($slider_background_image) ? ‘ style="background-image: url(\” . $slider_background_image[‘sizes’][‘fp-xlarge’] . ‘\’)"’ : ”; ?>>
<?php elseif(get_sub_field(‘content_type’) === ‘tabbed_content’): ?>
<article class="content-block grid-x grid-container <?php the_sub_field(‘content_type’); ?>">

<?php else: ?>
<article class="content-block grid-x grid-padding-x <?php the_sub_field(‘content_type’); ?>">
<div class="cell">
<?php endif; ?>
if(get_sub_field(‘content_type’) === ‘accordion’):
get_template_part( ‘template-parts/content-blocks/_content_block_accordion’ );

elseif(get_sub_field(‘content_type’) === ‘banner_image’):
get_template_part( ‘template-parts/content-blocks/_content_block_banner_image’ );

elseif(get_sub_field(‘content_type’) === ‘code_block’):
get_template_part( ‘template-parts/content-blocks/_content_block_code_block’ );

elseif(get_sub_field(‘content_type’) === ‘side_by_side’):
get_template_part( ‘template-parts/content-blocks/_content_side_by_side’ );

elseif(get_sub_field(‘content_type’) === ‘slider_carousel’):
get_template_part( ‘template-parts/content-blocks/_content_block_slider’ );

elseif(get_sub_field(‘content_type’) === ‘stylized_title’):
get_template_part( ‘template-parts/content-blocks/_content_block_stylized_title’ );

elseif(get_sub_field(‘content_type’) === ‘tabbed_content’):
get_template_part( ‘template-parts/content-blocks/_content_block_tabbed_content’ );

elseif(get_sub_field(‘content_type’) === ‘teaser’):
get_template_part( ‘template-parts/content-blocks/_content_block_teaser’ );

elseif(get_sub_field(‘content_type’) === ‘wysiwyg’):
get_template_part( ‘template-parts/content-blocks/_content_block_wysiwyg’ );
<?php if(get_sub_field(‘content_type’) !== ‘slider_carousel’ && get_sub_field(‘content_type’) !== ‘tabbed_content’): ?>
<?php endif; ?>

<?php if(get_sub_field(‘include_divider’)): ?>
<hr />
<?php endif; ?>
<?php endwhile; ?>

<?php endif;

A few things to note: as mentioned previously, I’m using FoundationPress so there are Foundation classes in the HTML structure. In the case of this client, sliders often have a single background image on the container element with the slides containing text that changes as opposed to having separate images in each slide. There is a ternary statement checking for the existence of the image field for a slider and, if it’s there, applies the image along with the .has-background-image class. No image results in the .no-background-image class. Sliders are also full-width elements.

All of those things can obviously change for your specific needs.

You’ll also notice that in lines 9 and 12, I’m outputting the value of the Content Type selector on the article.content-block element so that I can apply the necessary JS and CSS to each element while also being able to apply global styles to all .content-block elements (i.e. margin-bottom: 4rem;).

Now to set conditional logic on the fields we’ve created for this Content Builder ACF module, here’s a picture of the settings for the Title text field:

I also included the following text in the field instructions box that will display whenever this field does informing users of how the field will be output depending on which component type was chosen:

For “Stylized Title” this title field will be output as an H2 on the page and will feature a truncated orange bottom border. For “Accordion” and “Tabbed Content” this title will not be displayed but will be used as the unique ID for those elements to ensure there are no conflicts with multiple versions of that content type on a single page. For the WYSIWYG options, this field will be an H3 above the content block.

Now with that completed, here are the conditional settings for the remaining fields in order of how they should be stacked in the Repeater: Content Blocks component:


  • Content Type is equal to WYSIWYG


  • Content Type is equal to Banner Image
  • Content Type is equal to Slider/Carousel
  • Content Type is equal to WYSIWYG

Code Block

  • Content Type is equal to Code Block

Repeater: Grouped Content Panel

  • Content Type is equal to Accordion
  • Content Type is equal to Side By Side Content
  • Content Type is equal to Slider/Carousel
  • Content Type is equal to Tabbed Content
  • Content Type is equal to Teaser


  • Content Type is equal to Banner Image
  • Content Type is equal to Slider/Carousel
  • Content Type is equal to WYSIWYG

The final step is to choose where these fields appear and set that logic on the top-level Repeater element making sure to then include this template part in the appropriate PHP files where they should be.

Now all that’s left is outputting the fields and styling to match your client’s needs. I’m assuming you know how to output ACF fields and so will only show code for one of my partials as an example. For this, it’ll be the Accordion element from Foundation 6.4:

// template-parts/content-blocks/_content_block_accordion.php

$title = get_sub_field(‘stylized_title’);
$title = preg_replace(‘/[^a-zA-Z0-9\’]/’, ‘_’, $title);
$title = str_replace(‘ ‘, ‘_’, $title);
$panel_content_count = 0;
# $panel_title_count = 0;

<?php if( have_rows(‘repeater_grouped_content_panel’) ): ?>
<ul class="accordion" data-accordion data-deep-link="true" data-update-history="true" data-deep-link-smudge="true" data-deep-link-smudge="500" id="<?php echo $title; ?>">
<?php while ( have_rows(‘repeater_grouped_content_panel’) ) : the_row(); ?>
$panel_title = get_sub_field(‘group_title’);
$panel_title = preg_replace(‘/[^a-zA-Z0-9\’]/’, ‘_’, $panel_title);
$panel_title = str_replace(‘ ‘, ‘_’, $panel_title);
<li class="accordion-item<?php echo $panel_content_count < 1 ? ‘ is-active’ : ” ?>" data-accordion-item>
<a href="#<?php echo $title . ‘_’ . $panel_title;; ?>" class="accordion-title"><?php the_sub_field(‘group_title’); ?></a>
<div class="accordion-content" data-tab-content id="<?php echo $title . ‘_’ . $panel_title; ?>">
<?php the_sub_field(‘group_content’); ?>

<?php if(get_sub_field(‘group_cta_link’)): ?>
<p><a href="<?php the_sub_field(‘group_cta_link’); ?>">+
<?php if(get_sub_field(‘group_cta_text’)): ?>
<?php the_sub_field(‘group_cta_text’); ?>
<?php else: ?>
Learn More
<?php endif; ?>
<?php endif; ?>

<?php if(get_sub_field(‘group_image’)): ?>
<?php $panel_image = get_sub_field(‘group_image’); ?>

<div class="panel-image">
<img src="<?php echo $panel_image[‘sizes’][‘stir-accordion-banner’]; ?>" alt="<?php the_sub_field(‘group_title’); ?>" />
<?php endif; ?>
<?php $panel_content_count++; ?>
<?php endwhile; ?>
<?php endif;

There’s some code at the top there that I use for the Accordion and Tabbed Content elements to set unique IDs for each as required so that there can be multiple instances of each on a single page without any interference between them.

Contact Me

Have a question for me? Get in touch with me here and I'll respond as quickly as I can!
  • This field is for validation purposes and should be left unchanged.