WooCommerce shows the product gallery as one big image with small thumbnails. Sometimes you want the gallery images as a row of slides on the product page instead, for example for lifestyle photos below the summary. This snippet adds a custom WooCommerce product gallery slider built from the images in the product’s Product gallery box.
It uses CSS scroll snapping and about twenty lines of plain JavaScript. There is no jQuery, no carousel library and nothing loaded from a CDN, and the CSS and JS are only printed on pages that actually show a slider.
The snippet
<?php
/**
* WooCommerce product gallery slider: the product's gallery images in a
* scroll-snap slider with previous/next buttons. No jQuery, no CDN.
*
* Shown below the product summary, or anywhere with
* [dpc_product_gallery_slider] or [dpc_product_gallery_slider id="123"].
*/
add_action( 'woocommerce_after_single_product_summary', 'dpc_product_gallery_slider_output', 5 );
add_shortcode( 'dpc_product_gallery_slider', 'dpc_product_gallery_slider' );
function dpc_product_gallery_slider_output() {
echo dpc_product_gallery_slider(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped below.
}
function dpc_product_gallery_slider( $atts = array() ): string {
if ( ! function_exists( 'wc_get_product' ) ) {
return '';
}
$atts = shortcode_atts(
array(
'id' => 0,
'size' => 'woocommerce_single',
),
$atts,
'dpc_product_gallery_slider'
);
$product = absint( $atts['id'] ) ? wc_get_product( absint( $atts['id'] ) ) : ( $GLOBALS['product'] ?? null );
if ( ! $product instanceof WC_Product ) {
$product = wc_get_product( get_the_ID() );
}
if ( ! $product instanceof WC_Product ) {
return '';
}
$slides = '';
foreach ( $product->get_gallery_image_ids() as $image_id ) {
$image = wp_get_attachment_image( (int) $image_id, sanitize_key( $atts['size'] ) );
if ( '' === $image ) {
continue;
}
$slides .= sprintf(
'<li class="dpc-pgs__slide"><a href="%1$s">%2$s</a></li>',
esc_url( (string) wp_get_attachment_image_url( (int) $image_id, 'full' ) ),
$image
);
}
if ( '' === $slides ) {
return '';
}
dpc_product_gallery_slider_assets();
return sprintf(
'<section class="dpc-pgs" aria-label="%1$s"><ul class="dpc-pgs__track" tabindex="0">%2$s</ul><button type="button" class="dpc-pgs__btn dpc-pgs__btn--prev" aria-label="%3$s" disabled>‹</button><button type="button" class="dpc-pgs__btn dpc-pgs__btn--next" aria-label="%4$s">›</button></section>',
esc_attr__( 'Product gallery', 'dpc' ),
$slides,
esc_attr__( 'Previous image', 'dpc' ),
esc_attr__( 'Next image', 'dpc' )
);
}
/** CSS and JS once per page, and only on pages that show a slider. */
function dpc_product_gallery_slider_assets(): void {
if ( wp_script_is( 'dpc-pgs', 'enqueued' ) ) {
return;
}
$css = '.dpc-pgs{position:relative;clear:both;margin:2em 0}'
. '.dpc-pgs__track{display:flex;gap:10px;margin:0;padding:0;list-style:none;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:thin}'
. '.dpc-pgs__slide{flex:0 0 100%;margin:0;scroll-snap-align:start}'
. '@media (min-width:600px){.dpc-pgs__slide{flex-basis:calc((100% - 10px)/2)}}'
. '@media (min-width:1000px){.dpc-pgs__slide{flex-basis:calc((100% - 20px)/3)}}'
. '.dpc-pgs__slide img{display:block;width:100%;height:auto}'
. '.dpc-pgs__btn{position:absolute;top:50%;width:40px;height:40px;padding:0;border:0;border-radius:50%;background:rgba(0,0,0,.6);color:#fff;font-size:28px;line-height:1;cursor:pointer;transform:translateY(-50%)}'
. '.dpc-pgs__btn--prev{left:8px}.dpc-pgs__btn--next{right:8px}'
. '.dpc-pgs__btn:disabled{opacity:.3;cursor:default}'
. '@media (prefers-reduced-motion:reduce){.dpc-pgs__track{scroll-behavior:auto}}';
$js = <<<'JS'
document.querySelectorAll('.dpc-pgs').forEach(function (slider) {
var track = slider.querySelector('.dpc-pgs__track');
var prev = slider.querySelector('.dpc-pgs__btn--prev');
var next = slider.querySelector('.dpc-pgs__btn--next');
function update() {
prev.disabled = track.scrollLeft <= 1;
next.disabled = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
}
function step(direction) {
var slide = track.querySelector('.dpc-pgs__slide');
var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
track.scrollBy({ left: direction * (slide.getBoundingClientRect().width + gap) });
}
prev.addEventListener('click', function () { step(-1); });
next.addEventListener('click', function () { step(1); });
track.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
update();
});
JS;
// Handles without a file: WordPress prints only the inline code.
wp_register_style( 'dpc-pgs', false, array(), null );
wp_add_inline_style( 'dpc-pgs', $css );
wp_enqueue_style( 'dpc-pgs' );
wp_register_script( 'dpc-pgs', false, array(), null, array( 'in_footer' => true ) );
wp_add_inline_script( 'dpc-pgs', $js );
wp_enqueue_script( 'dpc-pgs' );
}
Add it with Scripts Organizer
In Scripts Organizer go to Code Blocks → Add New and give the block a name. Set Trigger location to Everywhere, tick PHP under Script location, paste the code into the PHP editor and publish. Leave Action/Hook empty.
Everything lives in that one PHP block on purpose. Scripts Organizer prints Header and Footer code only with the Conditions trigger, so a separate CSS or JS part would need its own conditions. Here the slider adds its small inline style and script itself, once per page and only where it renders. WooCommerce functions are only called inside the callbacks, so the block can load before WooCommerce does.
The same code also works unchanged in a small plugin or an mu-plugin.
How it works
woocommerce_after_single_product_summaryat priority 5 places the slider below the image and summary, above the tabs. The[dpc_product_gallery_slider]shortcode renders the same markup anywhere else, with an optionalidfor another product.- The images come from
get_gallery_image_ids(), the Product gallery box. The main product image is not repeated. Each slide links to the full-size image;wp_get_attachment_image()addssrcset,altand lazy loading. - The track is a flex row with
scroll-snap-type: x mandatory: one slide on phones, two from 600px, three from 1000px. Visitors can swipe, scroll with a trackpad or focus the track and use the arrow keys. - The buttons scroll by one slide width and are disabled at either end. Smooth scrolling is switched off for visitors who prefer reduced motion. There is no autoplay.
- The old version loaded Owl Carousel and its CSS from a CDN on every page of the site and enqueued a theme file,
custom-product-gallery-slider.js, that you had to create first. Owl Carousel needs jQuery and is no longer maintained.
Customise it
- Slides per view: change the
flex-basisvalues in$css. For four on wide screens usecalc((100% - 30px)/4). - Image size:
[dpc_product_gallery_slider size="medium_large"], or change thesizedefault inshortcode_atts(). - Only the shortcode: remove the first
add_action()line, then place the shortcode in a Shortcode block or your page builder’s product template. - Replace the default gallery thumbnails: add
remove_action( 'woocommerce_product_thumbnails', 'woocommerce_show_product_thumbnails', 20 );inside aninitcallback.
Gotchas
- Products with no gallery images show nothing, and their pages load no slider CSS or JS.
- Block themes build the product page from the Single Product template. If the slider does not appear there, use the shortcode in a Shortcode block in that template.
- The full-size links open the image itself. If your theme or a lightbox plugin hooks into links to images, it will pick them up.
In the Scripts Organizer library Import it with one click from Scripts Organizer.