ACF Post Grid — Dynamic Source with Grid or Carousel Layout
An ACF Flexible Content post-grid layout with two data sources — latest posts via WP_Query or hand-picked posts via a Relationship field — and two display modes: CSS grid or CSS scroll-snap carousel. Switch source and layout from the ACF panel, no code changes.
An ACF Flexible Content post-grid layout with two data sources — latest posts via WP_Query or hand-picked posts via a Relationship field — and two display modes: CSS grid or CSS scroll-snap carousel. Switch source and layout from the ACF panel, no code changes.
Use this when you need a reusable "posts" section on any page — homepage featured articles, a related projects row, a blog highlights strip — where the editor controls what appears and how it's displayed without touching code.
Building It Step by Step
5 steps from zero to a working post grid. Create the ACF fields first, then build the PHP one branch at a time — the code grows by exactly one new piece each step.
Build a post grid from scratch in 5 steps. Start by creating the ACF fields so you know what names to use in PHP, then write one branch at a time.
Step 1: Create the ACF fields. Before writing any PHP, open ACF → Field Groups → Add New and create a field group called Post Grid. Add these 3 fields:
- Button Group — Label:
Data Source, Name:data_source, Choices:latest : Latestandmanual : Manual, Default:latest - Group — Label:
Latest Settings, Name:latest_settings. Conditional Logic: show whenData Source = latest. Inside this group, add two sub-fields:- Number — Label:
Post Count, Name:post_count, Default:3 - Select — Label:
Post Order, Name:post_order, Choices:DESC : Newest firstandASC : Oldest first, Default:DESC
- Number — Label:
- Relationship — Label:
Manual Posts, Name:manual_posts, Return Format:Post Object. Conditional Logic: show whenData Source = manual
Assign the field group to Pages. Save.
<?php
// The ACF fields you just created:
//
// data_source — Button Group — returns 'latest' or 'manual'
// latest_settings — Group — visible only when data_source = latest
// └ post_count — Number — returns an integer (e.g. 3)
// └ post_order — Select — returns 'DESC' or 'ASC'
// manual_posts — Relationship — visible only when data_source = manual
// returns an array of WP_Post objects
//
// Conditional logic is on the group, not on each sub-field.
// The PHP reads the group in one call and gets both values as an array.
Step 2: Read the field value into a variable. Create template-parts/sections/post-grid.php and start with one line. This reads whatever the editor chose in the ACF panel:
<?php
$data_source = get_sub_field( 'data_source' );
// $data_source is now the string 'latest' or 'manual'
Step 3: Write the if/elseif skeleton. Before adding any real logic, write the structure first. This makes it obvious which source goes where — and it already works (it just outputs nothing yet):
<?php
$data_source = get_sub_field( 'data_source' );
if ( $data_source === 'manual' ) {
// manual content will go here
} elseif ( $data_source === 'latest' ) {
// latest content will go here
}
Step 4: Fill in the manual branch. The Relationship field returns an array of WP_Post objects. Loop through them and echo each title. The latest branch still has its placeholder comment:
<?php
$data_source = get_sub_field( 'data_source' );
if ( $data_source === 'manual' ) {
$posts = get_sub_field( 'manual_posts' ) ?: [];
foreach ( $posts as $post ) {
echo '<p>' . esc_html( $post->post_title ) . '</p>';
}
} elseif ( $data_source === 'latest' ) {
// latest content will go here
}
Step 5: Fill in the latest branch. Get the latest_settings group in one call — it returns both values as an array. Then pass them into WP_Query:
<?php
$data_source = get_sub_field( 'data_source' );
if ( $data_source === 'manual' ) {
$posts = get_sub_field( 'manual_posts' ) ?: [];
foreach ( $posts as $post ) {
echo '<p>' . esc_html( $post->post_title ) . '</p>';
}
} elseif ( $data_source === 'latest' ) {
$latest = get_sub_field( 'latest_settings' );
$post_count = $latest['post_count'] ?? 3;
$post_order = $latest['post_order'] ?? 'DESC';
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => $post_count,
'orderby' => 'date',
'order' => $post_order,
'no_found_rows' => true,
] );
foreach ( $query->posts as $post ) {
echo '<p>' . esc_html( $post->post_title ) . '</p>';
}
}
That’s the core logic working. Both branches read ACF fields, fetch posts, and output titles. Section 02 explains why each part is written the way it is. Section 03 shows how to add ordering, a layout switcher, and styled post cards on top of this foundation.
How It Works — Why Each Layer Exists
Five layers, each solving a specific problem from the previous step.
Button Group drives the branch — one field, two completely different data sources
The Button Group stores a string: latest or manual. An if/else checks that string and runs different code for each path. Both paths end with the same $posts array of post objects, so everything after the branch doesn’t care which source was used.
Order mapping — convert a plain string into WP_Query arguments
WP_Query needs orderby and order — it doesn’t understand “newest” or “alphabetical”. The $order_map array translates the editor’s choice into what WP_Query expects. array_merge() combines the base query args with the sort args. The ?? operator provides a safe fallback to “newest” if the key is missing.
Layout switching — one modifier class, zero JavaScript libraries
Grid and carousel use identical HTML. The only difference is one extra CSS class on the wrapper. When the editor picks Carousel, the PHP adds post-grid__grid--carousel. No carousel JS or CSS is included — that’s intentionally left to your theme so this works with any library.
render_post_card() — WP_Post in, safe HTML out
The function takes a WP_Post object — both WP_Query and the Relationship field return these by default. get_the_post_thumbnail() builds the complete img tag with srcset/sizes automatically. The image link uses tabindex="-1" and aria-hidden="true" because the title link below goes to the same URL — this prevents duplicate focus for keyboard/screen reader users.
Production safety — absint, early return, defensive defaults
absint() safely converts the Number field to a positive integer. An empty field (null) becomes 0, and 0 ?: 3 gives us the default of 3. The early return when $posts is empty prevents an empty wrapper div from appearing in a CSS grid/flexbox layout.
Setup & ACF Fields
Two PHP files and one JS snippet. Everything wires together in about 10 minutes.
Create the PHP files
Create the two PHP files:
template-parts/sections/post-grid.php— paste the Step 5 production codeinc/post-card.php— paste the Step 4 helper function
In functions.php, add: require_once get_template_directory() . '/inc/post-card.php';
Register the layout in your Flexible Content field
If you are using a Flexible Content field, open it in ACF, find your FC field, and add a new layout named post-grid. Inside that layout, add the same sub-fields with the same names you created in Section 01. The sub-field names must match exactly what the PHP reads.
If you already created a standalone field group in Section 01, skip this step — your field group is already attached to the page.
Wire up your carousel library
Point your carousel library at .post-grid__grid--carousel. The PHP sets that class — your JavaScript handles the rest. Example with Swiper:
new Swiper('.post-grid__grid--carousel', { slidesPerView: 3, spaceBetween: 24 });
Making It Your Own
Three common extensions and two things that break silently.
Add a section heading field
Add a section heading. Add a Text field called heading to the layout. In the template, before the grid wrapper:
$heading = get_sub_field('heading');
if ($heading) {
echo '<h2 class="post-grid__heading">' . esc_html($heading) . '</h2>';
}
Add category filtering to the latest source
Add category filtering. Add a Taxonomy field called filter_category (return format: Term IDs). In the WP_Query:
$terms = get_sub_field('filter_category');
if ($terms) {
$query_args['tax_query'] = [[
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => $terms,
]];
}
Add a column count control
Add column count. Add a Select field called columns with choices 2, 3, 4. Pass it as a CSS custom property:
$columns = absint(get_sub_field('columns')) ?: 3;
echo '<div class="post-grid__grid" style="--columns:' . $columns . '">';
In CSS: .post-grid__grid { display: grid; grid-template-columns: repeat(var(--columns, 3), 1fr); }
Two things that commonly break:
1. Relationship field return format. render_post_card() expects WP_Post objects. If your Relationship field is set to return Post IDs, the function fails silently. Fix: set Return Format → Post Object in ACF.
2. Cards stretching in carousel mode. If .post-card has width: 100% or flex: 1 from your stylesheet, cards stretch instead of scrolling. Fix: add flex-shrink: 0 to .post-grid__grid--carousel .post-card.
No comments yet. Be the first.