ACF Gallery Field with Lightbox

Loop through an ACF Gallery field, render thumbnails with srcset, and open full-size images in a vanilla JS lightbox — no plugins, no jQuery.

PHP + JS Production-tested ~10 min setup No jQuery Beginner-friendly WP 6.0+ · ACF 6.0+ Updated May 20, 2026
TL;DR

Loop through an ACF Gallery field, render thumbnails with srcset, and open full-size images in a vanilla JS lightbox — no plugins, no jQuery.

Use this when your page or custom post type needs a photo gallery backed by ACF — portfolio shots, property photos, product images, event shots. The editor uploads images in wp-admin, and the template handles everything else: thumbnails, lightbox, captions, and accessibility.

01 — Building it step by step

Building It Step by Step

Don't try to understand a 30-line gallery template all at once. We build it in 5 steps — each one adds a single concept you can test before moving on.

We’ll build this in 5 progressive steps. Each one works on its own — paste it in, upload a few test images in wp-admin, and preview the page before moving on. Step 1 sets up the ACF field. Steps 2–4 build the PHP loop one concept at a time. Step 5 is the complete production file.

Step 1: Create the ACF Gallery field. Before writing any PHP, set up the field in ACF. The Return Format setting is the most important — set it to Image Array or the loop code in the next steps will not work.

  1. Go to Custom Fields → Add New
  2. Set the Title to Page Gallery (any name is fine)
  3. Click Add Field and configure:
    • Field Type: Gallery
    • Field Label: Gallery
    • Field Name: gallery (ACF fills this in automatically)
    • Return Format: Image Array
    • Allowed File Types: jpg, jpeg, png, webp
  4. Scroll to Location → set Post Type is equal to Page (swap for your CPT)
  5. Click Publish

Open any Page in wp-admin — you’ll see a Gallery upload box at the bottom of the editor. The reference file below shows every array key the loop will use.

acf-fields-reference.php
php
<?php
// acf-fields-reference.php — keep this open while writing the loop.
//
// Field Group: Page Gallery
//   Assign to: Post Type is equal to Page  (swap for your CPT)
//
// Field: Gallery
//   Label:         Gallery
//   Name:          gallery
//   Type:          Gallery
//   Return Format: Image Array  ← must be "Array", not ID or URL
//   Allowed Types: jpg, jpeg, png, webp
//
// get_field( 'gallery' ) returns:
//   false                — no images uploaded yet
//   [ $image, $image ]   — one entry per image (indexed array)
//
// Each $image is an array:
//   $image['ID']        — attachment ID (int)
//   $image['url']       — full-size image URL
//   $image['alt']       — alt text set in media library
//   $image['caption']   — caption set in media library
//   $image['sizes']     — [ 'thumbnail' => url, 'medium_large' => url, ... ]

Step 2: The bare loop. get_field( 'gallery' ) returns false when the editor hasn’t added any images — so we check that first before looping. When images exist, it returns an indexed array: one element per image, each element being an array of image data from the media library. At this step we output the full-size image URL directly as the src — this loads the largest file every time. Step 4 will fix that with proper thumbnails.

step-2-bare-loop.php
php
<?php
$images = get_field( 'gallery' ); // ACF: Gallery — Return Format: Array
if ( $images ) {
    ?>
    <div class="gallery">
    <?php
    foreach ( $images as $image ) {
        ?>
        <img
            src="<?php echo esc_url( $image['url'] ); ?>"
            alt="<?php echo esc_attr( $image['alt'] ); ?>"
        >
        <?php
    }
    ?>
    </div>
    <?php
}

Step 3: Wrap each image in a lightbox link. We add an <a> tag with two things: href pointing to $image['url'] (the full-size image — this is what the lightbox opens), and a data-lightbox attribute. That attribute is a marker — the JavaScript in Section 03 Step 2 finds every <a data-lightbox> on the page and attaches the click handler. The thumbnail in the grid is still the full-size image for now — Step 4 fixes that. At this step, clicking a gallery image navigates to the image URL directly (the JS isn’t wired up yet).

step-3-lightbox-links.php
php
<?php
$images = get_field( 'gallery' ); // ACF: Gallery — Return Format: Array
if ( $images ) {
    ?>
    <div class="gallery">
    <?php
    foreach ( $images as $image ) {
        ?>
        <a href="<?php echo esc_url( $image['url'] ); ?>" class="gallery-item" data-lightbox>
            <img
                src="<?php echo esc_url( $image['url'] ); ?>"
                alt="<?php echo esc_attr( $image['alt'] ); ?>"
            >
        </a>
        <?php
    }
    ?>
    </div>
    <?php
}

Step 4: Proper thumbnails with srcset. Replace the raw $image['url'] inside the link with wp_get_attachment_image(). This WordPress function generates a complete <img> tag with srcset and sizes attributes — the browser picks the right image size for the visitor’s screen automatically. Pass $image['ID'] (the attachment ID from the gallery array) and the size name 'medium_large'. The href on the <a> tag still points to the full-size $image['url'] — that’s what the lightbox opens when clicked.

step-4-srcset-thumbnails.php
php
<?php
$images = get_field( 'gallery' ); // ACF: Gallery — Return Format: Array
if ( $images ) {
    ?>
    <div class="gallery">
    <?php
    foreach ( $images as $image ) {
        ?>
        <a href="<?php echo esc_url( $image['url'] ); ?>" class="gallery-item" data-lightbox>
            <?php echo wp_get_attachment_image( $image['ID'], 'medium_large' ); ?>
        </a>
        <?php
    }
    ?>
    </div>
    <?php
}

Step 5: Everything together — the production file. Steps 1–4 are teaching code. This is the complete production file: all steps combined, with one addition — an optional caption pulled from $image['caption']. Captions come from the Caption field in the WordPress media library (visible when you click any image in the Media Library). If the editor left that field empty, the <span> is skipped — no blank element in the page source.

template-parts/sections/gallery-lightbox.php
php
<?php
/**
 * Full file: template-parts/sections/gallery-lightbox.php
 * Steps 1–4 combined — copy this into your theme as the production version.
 */
// ── 1. Fetch gallery ─────────────────────────────────────────────────
$images = get_field( 'gallery' ); // ACF: Gallery — Return Format: Array
if ( ! $images ) {
    return;
}
?>
<div class="gallery">
<?php
// ── 2. Loop and render each image ────────────────────────────────────
foreach ( $images as $image ) {
    ?>
    <a href="<?php echo esc_url( $image['url'] ); ?>" class="gallery-item" data-lightbox>
        <?php echo wp_get_attachment_image( $image['ID'], 'medium_large' ); ?>
        <?php if ( $image['caption'] ) { ?>
            <span class="gallery-caption">
                <?php echo esc_html( $image['caption'] ); ?>
            </span>
        <?php } ?>
    </a>
    <?php
}
?>
</div>

Where to call the file

Add this line to the page template or Flexible Content section where you want the gallery:

get_template_part( 'template-parts/sections/gallery-lightbox' );

The gallery only appears when the editor has added at least one image to the Gallery field. The if ( ! $images ) { return; } guard handles the empty case — no output, no empty <div> in the page source.

02 — How it works

How It Works — Why Each Decision Matters

Five concepts, each with a real reason. Below we break down the ACF Gallery return format, the loop and array structure, the data attribute lightbox pattern, srcset thumbnails, and the JS overlay mechanics.

Five concepts, each with a real reason. Below we break down the ACF Gallery return format, the loop and array structure, the data attribute lightbox pattern, srcset thumbnails, and the JS lightbox mechanics.

1

Return Format: Image Array — why this setting controls everything

The ACF Gallery field stores an ordered list of attachment IDs in post meta. When you call get_field( 'gallery' ) with Return Format: Image Array, ACF fetches each attachment from the database and returns a PHP array — one element per image. The Return Format setting changes what type of data is inside each element: Image Array gives you a full associative array (ID, url, alt, caption, sizes, and more); Image ID returns a plain integer; Image URL returns a string. This snippet uses Image Array because the loop needs $image['ID'] for wp_get_attachment_image() and $image['url'] for the lightbox href — you can’t get both from just an ID or just a URL without extra queries. The if ( $images ) check handles two cases: the field is empty (get_field() returns false) or the field hasn’t been created yet (also returns false).

2

The $image array — url, ID, alt, caption, and closing PHP before HTML

Inside the foreach, the $image variable is an associative array for one image. The keys you’ll use most often: $image['url'] is the full-size URL — pass it to the lightbox href so clicking the thumbnail opens the full-resolution version. $image['alt'] is the alt text set in the media library — always use it on the <img> tag. $image['caption'] is the caption from the media library caption field — empty string if not set, so a truthy check (if ( $image['caption'] )) skips the <span> entirely. $image['ID'] is the WordPress attachment post ID — pass it to wp_get_attachment_image() to get a responsive thumbnail. Closing PHP before HTML (?> before <a>, <?php after </a>) keeps the markup readable and avoids escaped-string bugs.

3

data-lightbox — a selector hook the JS listens for

The data-lightbox attribute on the <a> tag is a pure HTML data attribute — it has no built-in browser behaviour. It acts as a CSS/JS selector hook. The JavaScript uses document.querySelectorAll( 'a[data-lightbox]' ) to find every lightbox link on the page and attaches a click event listener. When the listener fires, it reads this.href (the full-size URL from the PHP template) and sets it as the src of the overlay <img> element. This means the JS works on any <a data-lightbox> link on any page — you don’t need to know how many galleries are on the page or configure anything per-gallery. Add another gallery partial on the same page and the lightbox picks it up automatically.

4

wp_get_attachment_image() — srcset, sizes, and why it beats $image[url]

wp_get_attachment_image( $id, $size ) returns a complete <img> string with src, srcset, sizes, width, height, alt, and class attributes — all properly escaped. The srcset attribute lists every registered image size for that attachment (thumbnail, medium, medium_large, large, and any custom sizes), and the browser picks the most appropriate one for the visitor’s viewport and pixel density. Without wp_get_attachment_image(), you’d need to manually build the srcset string from $image['sizes'] — which is tedious and error-prone. The 'medium_large' size is 768px wide by default, which fills a 3-column grid nicely on desktop without loading a 3000px source image as the thumbnail.

5

The JS lightbox — createElement, class toggle, overflow lock, ESC close

The JavaScript creates the lightbox overlay once on page load using document.createElement() — one <div> containing one <img>, both appended to document.body. Showing and hiding uses a CSS class toggle: the overlay starts with display: none and gets display: flex when the is-open class is added. Three event listeners handle interaction: the forEach click listener on each [data-lightbox] link sets img.src and adds the class; the overlay’s click listener calls close() to remove the class; the keydown listener on document closes on Escape. Setting document.body.style.overflow = 'hidden' when the lightbox opens prevents the page from scrolling behind the overlay — cleared back to an empty string on close.

03 — Setup & integration

Setup & Integration

Three files to create, one enqueue function, one template call. Here's the folder layout and every step in order.

Three files to create, one function to add to functions.php, and one line to call the template. Here’s the folder layout and every step in order.

wp-content/themes/your-theme/
│   ├── template-parts/
│   │   └── sections/
│   │       └── gallery-lightbox.php
│   └── assets/
│   │   ├── css/
│   │   │   └── gallery-lightbox.css
│   │   └── js/
            └── gallery-lightbox.js
1

Create the ACF Gallery field group

Create the ACF field group. In wp-admin go to Custom Fields → Add New. Set the Title. Click Add Field → type: Gallery, label: Gallery, name: gallery. Set Return Format to Image Array. Under Location, set Post Type is equal to your chosen CPT or Page. Click Publish. Open any post of that type — you’ll see the Gallery upload box. Upload at least 2 test images before testing the loop.

2

Create the CSS and JS asset files

Create assets/css/gallery-lightbox.css and assets/js/gallery-lightbox.js inside your theme folder. Copy the code below into each file. The CSS sets up the 3-column grid and the fixed overlay. The JS builds the overlay on page load and listens for clicks on every [data-lightbox] link.

assets/css/gallery-lightbox.css
css
.gallery {
    display: grid;
    grid-template-columns: repeat( 3, 1fr );
    gap: 12px;
}
.gallery-item img {
    width: 100%;
    height: 200px;
    object-fit: cover;
    display: block;
}
#gallery-lightbox {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba( 0, 0, 0, 0.92 );
    z-index: 9999;
    align-items: center;
    justify-content: center;
    cursor: pointer;
}
#gallery-lightbox.is-open { display: flex; }
#gallery-lightbox-img {
    max-width: 90%;
    max-height: 90vh;
    object-fit: contain;
}
assets/js/gallery-lightbox.js
javascript
( function() {
    var img     = document.createElement( 'img' );
    var overlay = document.createElement( 'div' );
    img.id      = 'gallery-lightbox-img';
    img.alt     = '';
    overlay.id  = 'gallery-lightbox';
    overlay.appendChild( img );
    document.body.appendChild( overlay );

    function close() {
        overlay.classList.remove( 'is-open' );
        document.body.style.overflow = '';
    }

    document.querySelectorAll( 'a[data-lightbox]' ).forEach( function( link ) {
        link.addEventListener( 'click', function( e ) {
            e.preventDefault();
            var thumb = this.querySelector( 'img' );
            img.src   = this.href;
            img.alt   = thumb ? thumb.getAttribute( 'alt' ) : '';
            overlay.classList.add( 'is-open' );
            document.body.style.overflow = 'hidden';
        } );
    } );

    overlay.addEventListener( 'click', close );
    document.addEventListener( 'keydown', function( e ) {
        if ( e.key === 'Escape' ) { close(); }
    } );
} )();
3

Enqueue assets from functions.php

Enqueue from functions.php. Add the function below to your theme’s functions.php. The CSS loads in the <head>. The JS loads just before </body> — the true final argument means “load in the footer”. The version string '1.0.0' busts the browser cache; change it when you update the CSS or JS.

functions.php
php
<?php
function theme_enqueue_gallery_lightbox() {
    wp_enqueue_style(
        'gallery-lightbox',
        get_template_directory_uri() . '/assets/css/gallery-lightbox.css',
        array(),
        '1.0.0'
    );
    wp_enqueue_script(
        'gallery-lightbox',
        get_template_directory_uri() . '/assets/js/gallery-lightbox.js',
        array(),
        '1.0.0',
        true // load in footer, after DOM is ready
    );
}
add_action( 'wp_enqueue_scripts', 'theme_enqueue_gallery_lightbox' );
4

Create the template file and call it from your page

Create the template file and call it. Copy the Step 5 code (from Section 01) into template-parts/sections/gallery-lightbox.php. Then add this line wherever you want the gallery to appear in a page template or Flexible Content layout:

get_template_part( 'template-parts/sections/gallery-lightbox' );

Preview the page in the browser. Click any thumbnail — the overlay should open with the full-size image. Press Escape or click the overlay to close. If the overlay doesn’t appear, open the browser console and check for a 404 on the JS file path.

Return Format must be “Image Array” — not ID, not URL.

When ACF’s Return Format is set to Image Array, get_field( 'gallery' ) returns an array of image data arrays — each with 'ID', 'url', 'alt', 'caption', and 'sizes' keys. If you change Return Format to Image ID, each $image in the loop is a plain integer — $image['url'] returns null, the <a href> is empty, and wp_get_attachment_image() gets the wrong argument. The page shows broken images with no PHP error. Open the ACF field settings and confirm Return Format is Image Array before debugging anything else.

04 — Making it your own

Making It Your Own

Captions, responsive grid, lazy loading, and custom thumbnail sizes — here's how to extend the gallery without breaking the lightbox.

1

Show image captions below thumbnails

Show image captions below thumbnails. The production file (Step 5) already includes this, but if you built from Step 4, add the block below inside the <a> tag, after wp_get_attachment_image(). Captions come from the Caption field in the media library — click any image in Media → Library to see and edit it. The check if ( $image['caption'] ) means images with no caption don’t render a blank <span>.

step-5-caption.php
php
<?php if ( $image['caption'] ) { ?>
    <span class="gallery-caption">
        <?php echo esc_html( $image['caption'] ); ?>
    </span>
<?php } ?>
2

Make the grid automatically responsive — no media queries

Make the grid automatically responsive — no media queries. The default CSS uses repeat( 3, 1fr ) which always shows 3 columns. Swap it for repeat( auto-fill, minmax( 200px, 1fr ) ) and the grid adjusts on its own: 3 columns on desktop, 2 on tablet, 1 on phone, with no breakpoints needed. Change 200px to set the minimum column width — smaller value → more columns on wide screens.

gallery-lightbox.css
css
/* Auto-responsive: fills columns at 200px min, no media queries needed */
.gallery {
    display: grid;
    grid-template-columns: repeat( auto-fill, minmax( 200px, 1fr ) );
    gap: 12px;
}
3

Add loading="lazy" for faster page load

Defer off-screen images with loading="lazy". wp_get_attachment_image() accepts a fourth argument — an array of extra attributes to add to the <img> tag. Pass 'loading' => 'lazy' to tell the browser to skip loading images until they’re near the viewport. On a gallery with 20+ images, this cuts the initial page load to just the first row. Use it on all gallery images — the ones already visible load at the same time since the browser starts fetching immediately on intersection.

step-4-lazy.php
php
<?php
echo wp_get_attachment_image( $image['ID'], 'medium_large', false, array(
    'loading' => 'lazy',
) );
4

Register a custom thumbnail size for tighter control

Register a custom thumbnail size for tighter control. 'medium_large' is 768px wide — good for a 3-column desktop grid, but it may be larger than needed for a 4-column grid or smaller than ideal for a 2-column layout. Register a custom size in functions.php that matches your grid exactly. After adding the size, run Tools → Regenerate Thumbnails (or the WP-CLI command wp media regenerate --yes on the live server) to generate the new size for existing images.

functions.php
php
<?php
// In functions.php — register once, use anywhere.
add_action( 'after_setup_theme', function() {
    add_image_size( 'gallery-thumb', 600, 400, true ); // crop to exact size
} );

// Then in the gallery loop, replace 'medium_large' with your custom size:
echo wp_get_attachment_image( $image['ID'], 'gallery-thumb' );

3 things that will break the gallery silently:

1. Return Format is not “Image Array”. Already covered in Section 03, but worth repeating — this is the most common ACF gallery bug. If $image['url'] is empty in the browser source, open the ACF field settings and check Return Format first.

2. The JS file loads before the DOM is ready. The JS uses querySelectorAll on page load to wire up click handlers on all [data-lightbox] links. If the script loads in the <head> (before the gallery HTML exists in the DOM), querySelectorAll finds nothing and the lightbox never opens. Fix: make sure wp_enqueue_script() has true as the last argument so it loads in the footer, after the gallery HTML.

3. Thumbnail size not generated for existing images. If you register a new image size in add_image_size() and use it in wp_get_attachment_image(), images uploaded before the size was registered won’t have that size on disk. WordPress falls back to the next available size, which may look stretched. Run wp media regenerate --yes on the server after adding any new image size.

Performance — wp_get_attachment_image() batch-fetches metadata.

WordPress pre-fetches all attachment metadata for the images in a gallery loop in a single SQL query before the foreach starts. Calling wp_get_attachment_image( $image['ID'], 'medium_large' ) inside a loop of 15 images costs 1 database query total — not 15. This is WordPress’s object cache at work: the first call per request primes the cache for all attachment IDs in the set.

What costs extra queries: calling wp_get_attachment_url() or get_post_meta() inside the same loop for a field that isn’t part of the pre-fetched set. Stick with the data from get_field( 'gallery' ) — the $image array already contains URL, alt, caption, and sizes, so you rarely need additional database calls inside the loop.

0 comments

No comments yet. Be the first.

Leave a Reply

What Is ACF gallery field PHP?

ACF gallery field PHP gives you full control over how WordPress gallery images are displayed — with lightbox support, responsive thumbnails via srcset, optional captions, and no gallery plugin required. This ACF gallery field PHP snippet walks through five progressive steps: set up the ACF Gallery field in the admin, output a bare image loop, wrap thumbnails in lightbox links, add responsive srcset thumbnails using wp_get_attachment_image(), and combine everything into a production template file with caption support. The official field documentation is at advancedcustomfields.com/resources/gallery.

How ACF gallery field PHP Returns Images

The ACF gallery field PHP loop depends on the Return Format setting — and this is the most critical configuration choice. The ACF Gallery field stores an ordered list of attachment IDs in post meta. When Return Format is set to Image Array, get_field() returns a PHP array — one associative array per image, containing the attachment ID, full-size URL, alt text, caption, and all registered thumbnail sizes. If Return Format is set to Image ID or Image URL instead of Image Array, the loop variables hold a plain integer or string, $image['url'] and $image['ID'] return null, and the page shows broken images with no PHP error. Always set Return Format to Image Array for ACF gallery field PHP.

ACF gallery field PHP with Lightbox

The ACF gallery field PHP lightbox is built with 28 lines of vanilla JavaScript — no jQuery, no external library. On page load the script creates a fixed overlay div containing a single img element and appends both to document.body. It then uses querySelectorAll to find every link with a data-lightbox attribute and attaches a click listener. When a thumbnail is clicked, the listener sets the overlay img src to the full-size URL from the link href, adds an is-open class that switches the overlay from display:none to display:flex, and sets body overflow to hidden. Clicking the overlay or pressing Escape closes it. Because the JS searches for data-lightbox attributes rather than a specific container ID, it works across multiple galleries on the same page.

ACF gallery field PHP with Responsive Thumbnails

The ACF gallery field PHP template uses wp_get_attachment_image() to render thumbnails rather than outputting the raw full-size URL. This WordPress function generates a complete img tag with srcset and sizes attributes — the browser selects the right image size for the visitor’s screen and pixel density automatically. The medium_large size (768px wide by default) keeps thumbnail file sizes small while filling a three-column grid cleanly on desktop. The full wp_get_attachment_image() reference is on the WordPress Developer Handbook. WordPress pre-fetches all attachment metadata in a single SQL query before the foreach loop, so calling wp_get_attachment_image() on 15 images costs one database query total, not fifteen.

Best Practices and Extensions for ACF gallery field PHP

The ACF gallery field PHP snippet covers four practical extensions: showing captions below each thumbnail, replacing the fixed three-column grid with an auto-fill layout, adding the loading="lazy" attribute to defer off-screen images, and registering a custom image size with add_image_size() for exact thumbnail dimensions. All PHP follows WordPress coding standards with esc_url() and esc_html() on every output variable. Tested on WordPress 6.x, ACF Pro 6.x, PHP 8.3. See this pattern in action across client projects — and browse more patterns in the full WordPress code snippets collection.

Chat on WhatsApp