A better, and easier, grid loop

Don’t feel like coding? I built a plugin that does this for you.
See the Genesis Grid Loop plugin.

Note: This technique can be used by all WordPress themes. I’m proposing it as a replacement for the Genesis-specific grid loop, so aspects of this post might be Genesis-specific.

A popular request is to list posts in multiple columns. I do it on my blog, and do it often on my clients’ sites.

Genesis developed a Grid Loop, which you can utilize inside your Genesis themes for this effect, and is how this blog does it. While very useful, it can be difficult to set up. I believe this is because they combined two separate functions: what content to display (your query) and how to display it.

By breaking those two functions we can use another feature of Genesis, the column classes, to build grid loops easier. You can also copy that CSS to any WordPress theme and make it work too.

For this example I’ll be using a Gallery theme I recently built. On the archive pages, it displays posts in three columns. See the screenshot above, or click here) for an example.

Step 1: Multiple columns using post_class

The post_class filter lets us customize the classes applied to each post in the loop. Since I want this to only apply to archive pages, I’m placing it in archive.php.

/**
 * Archive Post Class
 * @since 1.0.0
 *
 * Breaks the posts into three columns
 * @link http://www.billerickson.net/code/grid-loop-using-post-class
 *
 * @param array $classes
 * @return array
 */
function be_archive_post_class( $classes ) {
	global $wp_query;
	if( ! $wp_query->is_main_query() )
		return $classes;
		
	$classes[] = 'one-third';
	if( 0 == $wp_query->current_post % 3 )
		$classes[] = 'first';
	return $classes;
}
add_filter( 'post_class', 'be_archive_post_class' );

The first line adds a class of ‘one-third’ to all posts. Then I grab the current post counter out of $wp_query, and if this is the first post ( 0 == $wp_query->current_post ) or if the remainder of the current post divided by 3 is zero (this tells us the current post is the first in a row), apply a class of “first” as well.

That’s it! You now have your content broken into multiple columns. If you want two columns, use ‘one-half’ and divide the current post by 2. If you want four columns, use ‘one-fourth’ and divide the current post by 4.

Step 2: Customize the Query

When I view the archive page now, it’s in three columns but it’s only displaying 10 posts. The last post sits by itself in its own row. I’m going to modify the main query to show 27 posts per page. You could use any number you want, just make sure it’s a multiple of columns. For more information on customizing the main query, see this post.

/**
 * Archive Query
 *
 * Sets all archives to 27 per page
 * @link http://www.billerickson.net/customize-the-wordpress-query/
 *
 * @param object $query
 */
function be_archive_query( $query ) {
	if( $query->is_main_query() && $query->is_archive() ) {
		$query->set( 'posts_per_page', 27 );
	}
}
add_filter( 'pre_get_posts', 'be_archive_query' );

This code must go in functions.php, since the main query runs before it reaches archive.php (it checks the query to figure out what template to load).

Even easier sitewide

If you’re using this for all listings of posts (home, archive, search…), it is even easier to set up. Put the post_class filter in functions.php so it runs sitewide, and add a conditional to check if it isn’t singular:

/**
 * Archive Post Class
 *
 * Breaks the posts into three columns
 * @link http://www.billerickson.net/code/grid-loop-using-post-class
 *
 * @param array $classes
 * @return array
 */
function be_archive_post_class( $classes ) {

	// Don't run on single posts or pages
	if( is_singular() )
		return $classes;

	$classes[] = 'one-third';
	global $wp_query;
	if( 0 == $wp_query->current_post || 0 == $wp_query->current_post % 3 )
		$classes[] = 'first';
	return $classes;
}
add_filter( 'post_class', 'be_archive_post_class' );

And instead of the function to customize the number of posts, go to Settings > Reading and tweak it there.

Advanced Example

The code snippet below (added to functions.php), modifies the blog’s homepage and archive pages to display 5 features and 6 teasers (in three columns) on the first page. On inner pages, it displays 0 features and 12 teasers (in three columns). It also updates the post image to use image sizes specifically created for features and teasers.

The first function, be_grid_loop_pagination(), is where we control the grid loop. Under the comment that says “Sections of site that should use grid loop”, you can modify that list to specify where you want the grid loop displayed. Right now it is running when is_home() or is_archive() is true. The second part, under the comment that says “Specify pagination”, is where you specify how many features and teasers to show on the homepage and subsequent pages.

The second function, be_grid_loop_query_args(), doesn’t require any customization from you. It uses the pagination information you added to the previous function to tell WordPress how many posts show up on each page.

The third function, be_grid_loop_post_classes(), applies relevant classes to each post. It’s adding a class of ‘feature’ to each feature post, and column classes to the teasers. The only thing that you’d need to change is the teasers sections if you want something other than three columns. Here’s what you’d need to change if you wanted two columns.

The fourth function, be_grid_image_sizes(), specifies the two image sizes. Change these to whatever size you’d like. If you don’t want images, you can leave out this function and the last one, be_grid_loop_image(). Also note that you need to have “Include Featured Image” checked in Genesis > Theme Settings > Content Archives.

The fifth function, be_grid_loop_image(), overrides the image size set in Genesis > Theme Settings > Content Archives with the image sizes we created in the previous function. No changes need to be made to this function.

The sixth function, be_fix_posts_nav() does as its name implies. The post navigation (Older/Newer, numerical links to posts pages…) uses $wp_query->max_num_pages to know how many pages there are, and this is based on the current page’s posts_per_page. So if you have less posts on your homepage than inner pages, the post navigation on the homepage will be off (this is noticeable if you’re using numerical links). This code changes the max_num_pages based on the grid args.

/**
 * Grid Loop Pagination
 * Returns false if not grid loop.
 * Returns an array describing pagination if is grid loop
 *
 * @author Bill Erickson
 * @link http://www.billerickson.net/a-better-and-easier-grid-loop/
 *
 * @param object $query
 * @return bool is grid loop (true) or not (false)
 */
function be_grid_loop_pagination( $query = false ) {

	// If no query is specified, grab the main query
	global $wp_query;
	if( !isset( $query ) || empty( $query ) || !is_object( $query ) )
		$query = $wp_query;
		
	// Sections of site that should use grid loop	
	if( ! ( $query->is_home() || $query->is_archive() ) )
		return false;
		
	// Specify pagination
	return array(
		'features_on_front' => 5,
		'teasers_on_front' => 6,
		'features_inside' => 0,
		'teasers_inside' => 12,
	);
}

/**
 * Grid Loop Query Arguments
 *
 * @author Bill Erickson
 * @link http://www.billerickson.net/a-better-and-easier-grid-loop/
 *
 * @param object $query
 * @return null
 */
function be_grid_loop_query_args( $query ) {
	$grid_args = be_grid_loop_pagination( $query );
	if( $query->is_main_query() && !is_admin() && $grid_args ) {

		// First Page
		$page = $query->query_vars['paged'];
		if( ! $page ) {
			$query->set( 'posts_per_page', ( $grid_args['features_on_front'] + $grid_args['teasers_on_front'] ) );
			
		// Other Pages
		} else {
			$query->set( 'posts_per_page', ( $grid_args['features_inside'] + $grid_args['teasers_inside'] ) );
			$query->set( 'offset', ( $grid_args['features_on_front'] + $grid_args['teasers_on_front'] ) + ( $grid_args['features_inside'] + $grid_args['teasers_inside'] ) * ( $page - 2 ) );
			// Offset is posts on first page + posts on internal pages * ( current page - 2 )
		}

	}
}
add_action( 'pre_get_posts', 'be_grid_loop_query_args' );

/**
 * Grid Loop Post Classes
 *
 * @author Bill Erickson
 * @link http://www.billerickson.net/a-better-and-easier-grid-loop/
 *
 * @param array $classes
 * @return array $classes
 */
function be_grid_loop_post_classes( $classes ) {
	global $wp_query;
	
	// Only run on main query
	if( ! $wp_query->is_main_query() )
		return $classes;
	
	// Only run on grid loop
	$grid_args = be_grid_loop_pagination();
	if( ! $grid_args || ! $wp_query->is_main_query() )
		return $classes;
		
	// First Page Classes
	if( ! $wp_query->query_vars['paged'] ) {
	
		// Features
		if( $wp_query->current_post < $grid_args['features_on_front'] ) {
			$classes[] = 'feature';
		
		// Teasers
		} else {
			$classes[] = 'one-third';
			if( 0 == ( $wp_query->current_post - $grid_args['features_on_front'] ) || 0 == ( $wp_query->current_post - $grid_args['features_on_front'] ) % 3 )
				$classes[] = 'first';
		}
		
	// Inner Pages
	} else {

		// Features
		if( $wp_query->current_post < $grid_args['features_inside'] ) {
			$classes[] = 'feature';
		
		// Teasers
		} else {
			$classes[] = 'one-third';
			if( 0 == ( $wp_query->current_post - $grid_args['features_inside'] ) || 0 == ( $wp_query->current_post - $grid_args['features_inside'] ) % 3 )
				$classes[] = 'first';
		}
	
	}
	
	return $classes;
}
add_filter( 'post_class', 'be_grid_loop_post_classes' );

/**
 * Grid Image Sizes 
 *
 */
function be_grid_image_sizes() {
	add_image_size( 'be_grid', 175, 120, true );
	add_image_size( 'be_feature', 570, 333, true );
}
add_action( 'genesis_setup', 'be_grid_image_sizes', 20 );

/**
 * Grid Loop Featured Image
 *
 * @param string image size
 * @return string
 */
function be_grid_loop_image( $image_size ) {
	global $wp_query;
	$grid_args = be_grid_loop_pagination();
	if( ! $grid_args )
		return $image_size;
		
	// Feature
	if( ( ! $wp_query->query_vars['paged'] && $wp_query->current_post < $grid_args['features_on_front'] ) || ( $wp_query->query_vars['paged'] && $wp_query->current_post < $grid_args['features_inside'] ) )
		$image_size = 'be_feature';
		
	if( ( ! $wp_query->query_vars['paged'] && $wp_query->current_post > ( $grid_args['features_on_front'] - 1 ) ) || ( $wp_query->query_vars['paged'] && $wp_query->current_post > ( $grid_args['features_inside'] - 1 ) ) )
		$image_size = 'be_grid';
		
	return $image_size;
}
add_filter( 'genesis_pre_get_option_image_size', 'be_grid_loop_image' );

/**
 * Fix Posts Nav
 *
 * The posts navigation uses the current posts-per-page to 
 * calculate how many pages there are. If your homepage
 * displays a different number than inner pages, there
 * will be more pages listed on the homepage. This fixes it.
 *
 */
function be_fix_posts_nav() {
	
	if( get_query_var( 'paged' ) )
		return;
		
	global $wp_query;
	$grid_args = be_grid_loop_pagination();
	if( ! $grid_args )
		return;

	$max = ceil ( ( $wp_query->found_posts - $grid_args['features_on_front'] - $grid_args['teasers_on_front'] ) / ( $grid_args['features_inside'] + $grid_args['teasers_inside'] ) ) + 1;
	$wp_query->max_num_pages = $max;
	
}
add_filter( 'genesis_after_endwhile', 'be_fix_posts_nav', 5 );

Bill Erickson

Bill Erickson is the co-founder and lead developer at CultivateWP, a WordPress agency focusing on high performance sites for web publishers.

About Me
Ready to upgrade your website?

I build custom WordPress websites that look great and are easy to manage.

Let's Talk

Reader Interactions

Comments are closed. Continue the conversation with me on Twitter: @billerickson

Comments

  1. Gene Whitehead says

    Good gosh. My apologies, Bill, it was Photon in Jetpack that suddenly decided to act up. Nasty plugin >.<

  2. stacey says

    Hi – thanks for the tutorial! I almost have it figured out, but I can’t seem to get the counter to work (to add “first” class to every third post). I’ve created a custom post loop, it seems to be bringing in my “one-third” but the “first” is not injecting where it needs to. Any help would be greatly appreciated!

    Here is the code I have so far: https://gist.github.com/anonymous/25f49e54b5a4c5dc0777ac481655bf19

    • Bill Erickson says

      Try this: https://gist.github.com/billerickson/b7d40aedf056b4542a28753b5e9db3c7

      The be_archive_post_class() function is looking at the main query on the page, $wp_query, which is the page itself (Residential Portfolio). You need to be looking at your custom query, $loop.

      Instead of filtering the post classes, use the $extra_classes (first) parameter on either post_class() (which echoes) and get_post_class() (which returns an array).

      • stacey says

        YES – it works perfectly! Thank you so much.
        I’m wrapping my head around it now. Thanks for the explanation!

  3. Temitayo says

    Hello Bill, thanks for such a nice work here. I have tried reading the comments here all through the night, but here is something quite tasking on my end, I would like to make a post an advert among the teasers, say 2 features and 8 teasers, let teaser 4 be an ad, and teaser 8 be an ad, also on the following page same concept still applies.
    Also, will this concept still work with the infinite scroll
    Thanks in anticipation.

    • Bill Erickson says

      In the above code we’re using the $wp_query->current_post as our loop counter. You’ll need to change this to your own custom loop counter. So start by declaring a global variable (Ex: global $be_loop_counter), then after outputting each post add to it (ex: $be_loop_counter++;).

      For your ads, check the current loop counter, and insert the ad if you’re at the right spot.

      Here’s an example with all the code: https://gist.github.com/billerickson/394cb3328f2020e56fcf55678a261d3d

      Yes, this concept will still work with infinite scroll.

      • Temitayo Boboye says

        Thanks so much for your prompt reply, will try it out and notify your of the development.

    • Bill Erickson says

      If you check “Taxonomies” it will use the grid on any taxonomy term archive pages – anything where is_tax() returns true.

      If you want to be more specific about where it applies you can use the genesis_grid_loop_section filter.

  4. ml says

    Hi Bill,

    I encounter a problem that seems like it is a simple fix but I just can’t figure it out. Before I implemented the Advanced Example, everything works perfect. But when I implemented the Advanced Example such that I have only 1 featured post and 3 teasers below, and modify the code accordingly, 2 problems came up:
    1. Featured post is not full width.
    2. Instead of 3 posts in a row, teaser posts is displayed in 2+1 manner (2 above, one below) though each teaser post width seems correct (the 1/3 width).

    I tried to use the plugin instead of doing the manual code just to be sure nothing I did wrong, but it displayed exactly the same.
    Any pointers or help is greatly appreciated!!!

    • Bill Erickson says

      I’d have to see a link to the site. For #1, make sure your CSS is making the featured post 100% wide. For #2, make sure the .first class is being added to the proper posts (the first one in each row).

  5. jason says

    Upgrading to WP 4.7 seems to have broken the Better Grid Loop on my site. I was wondering if anyone else is having this problem. I’ve done the basic troubleshooting already but not a heavy coder so not certain if it’s the grid loop itself or something else in my theme functions causing the problem. When I inspect source, I don’t see any reference to the Grid Loop at all but if I revert WP it’s back and working properly.

    • Bill Erickson says

      I’m running 4.7 and the latest version of Genesis on my local testing environment and can’t find any issues with the Genesis Grid plugin (which uses the same code as the snippets in this post).

        • Christoffer says

          Hi Jason,

          I had the exact same issue as you. It seemed as if the Grid Loop plugin simply wasn’t executed upon loading the blog page.

          Now I am not quite sure what the issue was, but after creating a new blog page the grid loop was working perfectly.

  6. Daphne says

    Hi Bill,
    I’m wondering if this can also made to work with Twentyseventeen theme…
    I thought I’ve read at the beginning that this will work with all WP themes, but I can’t even install the plugin. Any suggestions?
    Thanks!
    Greets,
    Daphne.

    • Bill Erickson says

      This plugin only works with the Genesis theme because it depends upon Genesis’ column classes. The general idea, though, could work with any theme. First you’d add column classes to your theme. Then you’d use the ‘post_class’ filter to add those classes to posts, as illustrated in this post.

  7. Niels says

    Hi Bill, great code, works like a charm and implemented in a few projects now. Now busy with a new implementation and was wandering if the numbers here:
    ‘features_on_front’ => 2,
    ‘teasers_on_front’ => 4,
    ‘features_inside’ => 0,
    ‘teasers_inside’ => 12,
    can be made dynamic in combination with ACF Pro. I made a menu called archive options where my client can set these values without touching the php code. In the code I retrieve the values, made vars, put them in the above code and on the website it throws an error: Fatal error: Maximum function nesting level of ‘500’ reached, aborting!

    Can what I want to achieve be accomplished with acf pro? Any Ideas how?

    Best regards

    • Bill Erickson says

      Yes, you can use ACF post meta or site options to control those. It sounds like there was an issue with your code though.

  8. ml says

    Hi Bill,

    I am using your Genesis Grid for my home.php and archive-cpt.php. Genesis Content Archives is set to display “Entry Excerpts” . I am trying to replace the ‘…’ with a ‘read more’ button. I used

    add_filter( ‘excerpt_more’, ‘new_excerpt_more’ );
    function new_excerpt_more( $more ) {
    return ‘ … Read More‘;

    It did add a ‘read more’ after the auto excerpt from content, until I needed to add character limit to the CUSTOM excerpt using your code :
    function be_grid_content() {

    // First, we make sure we’re in the grid loop.
    if( ! apply_filters( ‘is_genesis_grid_loop’, false ) )
    return;

    // Change length if teaser
    if( in_array( ‘teaser’, get_post_class() ) )
    $length = 25;
    else
    $length = 25;

    echo ” . wp_trim_words( get_the_excerpt(), $length ) . ”;
    // Remove default content so we don’t get both
    remove_action( ‘genesis_entry_content’, ‘genesis_do_post_content’ );

    }

    add_action( ‘genesis_entry_content’, ‘be_grid_content’, 9 );

    Then, I just couldn’t get the ‘read more’ button back, despite trying a few other ways. Any pointers or help is greatly appreciated!!!