Skip to main content
Login Join
Snippet Β· PHP

Product Marquee Block for WooCommerce

Shared by Kishore Sahoo (Looking for Job) Β· June 29, 2026

22 views
Back to Snippets

A scrolling product marquee Gutenberg block for WooCommerce. Shows best-selling, featured, latest, or manually picked products.

<?php
/**
 * Plugin Name: Product Marquee Block
 * Description: A scrolling product marquee Gutenberg block for WooCommerce. Shows best-selling, featured, latest, or manually picked products.
 * Version: 1.1.0
 * Author: Kishores
 * Author URI: https://profiles.wordpress.org/kishores/
 * Requires Plugins: woocommerce
 *
 * Drop this file into /wp-content/plugins/product-marquee-block/
 * and activate it from Plugins β†’ Installed Plugins.
 */

if ( ! defined( 'ABSPATH' ) ) exit;

/* ──────────────────────────────────────────────
   1. REGISTER THE BLOCK
   ────────────────────────────────────────────── */
add_action( 'init', function () {

    if ( ! function_exists( 'register_block_type' ) ) return;

    register_block_type( 'pmb/product-marquee', [
        'title'           => 'Product Marquee',
        'description'     => 'A scrolling product marquee. Choose best-selling, featured, latest, or hand-picked products.',
        'category'        => 'woocommerce',
        'icon'            => 'slides',
        'supports'        => [ 'align' => [ 'full', 'wide' ] ],
        'attributes'      => [
            'source'       => [ 'type' => 'string',  'default' => 'best_sellers' ], // best_sellers | featured | latest | manual
            'productIds'   => [ 'type' => 'string',  'default' => '' ],             // comma-separated post IDs (manual mode)
            'limit'        => [ 'type' => 'integer', 'default' => 10 ],
            'speed'        => [ 'type' => 'integer', 'default' => 50 ],
            'showPrice'    => [ 'type' => 'boolean', 'default' => true ],
            'pauseOnHover' => [ 'type' => 'boolean', 'default' => true ],
            'borderTop'    => [ 'type' => 'boolean', 'default' => true ],
            'borderBottom' => [ 'type' => 'boolean', 'default' => true ],
        ],
        'editor_script'   => 'pmb-editor',
        'render_callback' => 'pmb_render_block',
    ] );
} );

/* ──────────────────────────────────────────────
   2. REGISTER EDITOR SCRIPT (block controls UI)
   ────────────────────────────────────────────── */
add_action( 'enqueue_block_editor_assets', function () {

    $inline_js = <<<'JS'
(function(blocks, element, blockEditor, components) {
    var el                = element.createElement;
    var useBlockProps     = blockEditor.useBlockProps;
    var InspectorControls = blockEditor.InspectorControls;
    var PanelBody         = components.PanelBody;
    var SelectControl     = components.SelectControl;
    var RangeControl      = components.RangeControl;
    var ToggleControl     = components.ToggleControl;
    var TextareaControl   = components.TextareaControl;
    var Notice            = components.Notice;

    blocks.registerBlockType('pmb/product-marquee', {
        edit: function(props) {
            var attr    = props.attributes;
            var setAttr = props.setAttributes;
            var isManual = attr.source === 'manual';

            var sourceLabel = {
                best_sellers: 'Best Sellers',
                featured:     'Featured',
                latest:       'Latest',
                manual:       'Manual',
            }[attr.source] || attr.source;

            var previewNote = isManual
                ? 'Manual IDs: ' + (attr.productIds || '(none yet)')
                : 'Source: ' + sourceLabel + '  Β·  ' + attr.limit + ' products';

            return [
                el(InspectorControls, { key: 'controls' },

                    el(PanelBody, { title: 'Product Source', initialOpen: true },
                        el(SelectControl, {
                            label: 'Source',
                            value: attr.source,
                            options: [
                                { label: 'Best Sellers',      value: 'best_sellers' },
                                { label: 'Featured Products', value: 'featured' },
                                { label: 'Latest Products',   value: 'latest' },
                                { label: 'Manual (by ID)',    value: 'manual' },
                            ],
                            onChange: function(v) { setAttr({ source: v }); }
                        }),

                        isManual
                            ? el(TextareaControl, {
                                label: 'Product IDs',
                                help: 'Enter WooCommerce product IDs separated by commas. Find IDs under Products β†’ hover a product β†’ "ID: xxx" in the URL.',
                                value: attr.productIds,
                                rows: 3,
                                onChange: function(v) { setAttr({ productIds: v }); }
                              })
                            : el(RangeControl, {
                                label: 'Number of Products',
                                value: attr.limit,
                                min: 4, max: 24,
                                onChange: function(v) { setAttr({ limit: v }); }
                              }),

                        !isManual && el('p', {
                            style: { fontSize: '12px', color: '#757575', margin: '4px 0 0' }
                        }, 'Falls back to latest products if no results are found for the chosen source.')
                    ),

                    el(PanelBody, { title: 'Marquee Settings', initialOpen: false },
                        el(RangeControl, {
                            label: 'Scroll Speed (seconds)',
                            value: attr.speed,
                            min: 10, max: 120,
                            onChange: function(v) { setAttr({ speed: v }); }
                        }),
                        el(ToggleControl, {
                            label: 'Show Price',
                            checked: attr.showPrice,
                            onChange: function(v) { setAttr({ showPrice: v }); }
                        }),
                        el(ToggleControl, {
                            label: 'Pause on Hover',
                            checked: attr.pauseOnHover,
                            onChange: function(v) { setAttr({ pauseOnHover: v }); }
                        }),
                        el(ToggleControl, {
                            label: 'Border Top',
                            checked: attr.borderTop,
                            onChange: function(v) { setAttr({ borderTop: v }); }
                        }),
                        el(ToggleControl, {
                            label: 'Border Bottom',
                            checked: attr.borderBottom,
                            onChange: function(v) { setAttr({ borderBottom: v }); }
                        })
                    )
                ),

                el('div', useBlockProps({ className: 'pmb-editor-preview' }),
                    el('div', {
                        style: {
                            padding: '14px 18px',
                            background: '#f9f9f9',
                            textAlign: 'center',
                            border: '1px dashed #ccc',
                            borderRadius: '4px',
                            display: 'flex',
                            flexDirection: 'column',
                            gap: '4px',
                        }
                    },
                        el('strong', { style: { fontSize: '14px' } }, 'πŸ› Product Marquee'),
                        el('span', { style: { fontSize: '12px', color: '#666' } }, previewNote),
                        el('span', { style: { fontSize: '12px', color: '#999' } }, attr.speed + 's scroll Β· ' + (attr.pauseOnHover ? 'pauses on hover' : 'continuous'))
                    )
                )
            ];
        },
        save: function() { return null; } // server-side render
    });

}(window.wp.blocks, window.wp.element, window.wp.blockEditor, window.wp.components));
JS;

    wp_register_script( 'pmb-editor', '', [ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components' ], null, true );
    wp_add_inline_script( 'pmb-editor', $inline_js );
} );

/* ──────────────────────────────────────────────
   3. SERVER-SIDE RENDER CALLBACK
   ────────────────────────────────────────────── */
function pmb_render_block( array $attr ): string {

    if ( ! class_exists( 'WooCommerce' ) ) {
        return '<p style="color:red">WooCommerce is required for the Product Marquee block.</p>';
    }

    $source        = $attr['source']       ?? 'best_sellers';
    $product_ids   = $attr['productIds']   ?? '';
    $limit         = (int) ( $attr['limit']        ?? 10 );
    $speed         = (int) ( $attr['speed']        ?? 50 );
    $show_price    = (bool) ( $attr['showPrice']   ?? true );
    $pause_hover   = (bool) ( $attr['pauseOnHover'] ?? true );
    $border_top    = (bool) ( $attr['borderTop']   ?? true );
    $border_bottom = (bool) ( $attr['borderBottom'] ?? true );

    /* ── Build query based on source ── */
    $base_args = [
        'post_type'      => 'product',
        'post_status'    => 'publish',
        'posts_per_page' => $limit,
        'meta_query'     => [ [ 'key' => '_stock_status', 'value' => 'instock' ] ],
    ];

    $query_args = $base_args;

    if ( $source === 'manual' && ! empty( $product_ids ) ) {

        // Parse and sanitise IDs
        $ids = array_filter( array_map( 'intval', preg_split( '/[s,]+/', $product_ids ) ) );

        if ( ! empty( $ids ) ) {
            $query_args['post__in']      = $ids;
            $query_args['orderby']       = 'post__in'; // preserve entered order
            $query_args['posts_per_page']= count( $ids );
            unset( $query_args['meta_query'] );        // show even out-of-stock for manual picks
        }

    } elseif ( $source === 'best_sellers' ) {
        $query_args['meta_key'] = 'total_sales';
        $query_args['orderby']  = 'meta_value_num';
        $query_args['order']    = 'DESC';

    } elseif ( $source === 'featured' ) {
        $query_args['tax_query'] = [ [
            'taxonomy' => 'product_visibility',
            'field'    => 'name',
            'terms'    => 'featured',
        ] ];

    } else {
        // 'latest' β€” just newest first (default WP order)
        $query_args['orderby'] = 'date';
        $query_args['order']   = 'DESC';
    }

    $products = new WP_Query( $query_args );

    /* ── Fallback: latest products ── */
    $used_fallback = false;
    if ( ! $products->have_posts() && $source !== 'latest' && $source !== 'manual' ) {
        $fallback_args = $base_args;
        $fallback_args['orderby'] = 'date';
        $fallback_args['order']   = 'DESC';
        $products      = new WP_Query( $fallback_args );
        $used_fallback = true;
    }

    if ( ! $products->have_posts() ) {
        return '<p style="padding:1rem;color:#666">No products found for this marquee.</p>';
    }

    /* ── Build items HTML ── */
    $items_html = '';
    while ( $products->have_posts() ) {
        $products->the_post();
        $product = wc_get_product( get_the_ID() );
        if ( ! $product ) continue;

        $title   = esc_html( get_the_title() );
        $url     = esc_url( get_permalink() );
        $img_id  = $product->get_image_id();
        $img_url = $img_id
            ? esc_url( wp_get_attachment_image_url( $img_id, 'woocommerce_thumbnail' ) )
            : esc_url( wc_placeholder_img_src( 'woocommerce_thumbnail' ) );

        $price_html = $show_price
            ? '<span class="pmb-item__price">' . wp_kses_post( $product->get_price_html() ) . '</span>'
            : '';

        $items_html .= <<<HTML
<a href="{$url}" class="pmb-item">
  <div class="pmb-item__img">
    <img loading="lazy" src="{$img_url}" alt="{$title}">
  </div>
  <div class="pmb-item__meta">
    <span class="pmb-item__title">{$title}</span>
    {$price_html}
  </div>
</a>
HTML;
    }
    wp_reset_postdata();

    /* ── Wrapper ── */
    $wrapper_classes = 'pmb-wrapper';
    if ( $border_top    ) $wrapper_classes .= ' pmb-border-top';
    if ( $border_bottom ) $wrapper_classes .= ' pmb-border-bottom';

    $pause_attr    = $pause_hover   ? 'data-pause-hover="1"' : '';
    $fallback_attr = $used_fallback ? 'data-fallback="1"'    : '';
    $uid           = 'pmb-' . wp_unique_id();

    ob_start(); ?>
<div class="<?php echo esc_attr( $wrapper_classes ); ?>"
     id="<?php echo esc_attr( $uid ); ?>"
     data-speed="<?php echo esc_attr( $speed ); ?>"
     <?php echo $pause_attr; ?>
     <?php echo $fallback_attr; ?>>
  <div class="pmb-track">
    <div class="pmb-content pmb-original"><?php echo $items_html; ?></div>
  </div>
</div>

<style>
/* ── Product Marquee Block ─────────────────── */
.pmb-wrapper {
  width: 100%;
  overflow: hidden;
  position: relative;
  background: transparent;
  padding: .625rem 0;
}
.pmb-border-top    { border-top:    1px solid currentColor; }
.pmb-border-bottom { border-bottom: 1px solid currentColor; }

.pmb-track {
  display: flex;
  white-space: nowrap;
  will-change: transform;
}

.pmb-content {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
}

.pmb-item {
  display: inline-flex;
  align-items: center;
  gap: .75rem;
  margin: 0 2.5rem;
  text-decoration: none;
  color: inherit;
  transition: opacity .2s;
}
.pmb-item:hover { opacity: .72; }

.pmb-item__img img {
  display: block;
  height: 70px;
  width: auto;
  object-fit: contain;
}

.pmb-item__meta {
  display: flex;
  flex-direction: column;
  gap: .15rem;
}

.pmb-item__title {
  font-size: .875rem;
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  white-space: nowrap;
  line-height: 1.2;
}

.pmb-item__price {
  font-size: .8rem;
  opacity: .65;
  white-space: nowrap;
}
.pmb-item__price del { opacity: .5; margin-right: .25rem; }

@media (max-width: 768px) {
  .pmb-item { margin: 0 1.25rem; gap: .5rem; }
  .pmb-item__img img { height: 52px; }
  .pmb-item__title { font-size: .75rem; }
}

@keyframes <?php echo esc_attr( $uid ); ?>_scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(var(--pmb-width)); }
}
</style>

<script>
(function() {
  var wrapper = document.getElementById('<?php echo esc_js( $uid ); ?>');
  if (!wrapper) return;

  var track    = wrapper.querySelector('.pmb-track');
  var original = wrapper.querySelector('.pmb-original');
  var speed    = parseInt(wrapper.dataset.speed) || 50;
  var pauseHov = wrapper.dataset.pauseHover === '1';

  function init() {
    track.querySelectorAll('.pmb-clone').forEach(function(el){ el.remove(); });

    requestAnimationFrame(function() {
      var cw    = original.scrollWidth;
      var ww    = wrapper.offsetWidth;
      var total = cw;

      while (total < ww * 2.5) {
        var clone = original.cloneNode(true);
        clone.classList.remove('pmb-original');
        clone.classList.add('pmb-clone');
        track.appendChild(clone);
        total += clone.scrollWidth;
      }

      wrapper.style.setProperty('--pmb-width', '-' + cw + 'px');

      track.style.animation = 'none';
      void track.offsetWidth; // force reflow
      track.style.animation = '<?php echo esc_js( $uid ); ?>_scroll ' + speed + 's linear infinite';
    });
  }

  if (pauseHov) {
    wrapper.addEventListener('mouseenter', function() { track.style.animationPlayState = 'paused'; });
    wrapper.addEventListener('mouseleave', function() { track.style.animationPlayState = 'running'; });
  }

  init();
  window.addEventListener('resize', init);
  window.addEventListener('orientationchange', init);
})();
</script>
<?php
    return ob_get_clean();
}
Know a different way to do this? Add your approach as a variation so folks can compare them side by side.
Submit a variation

0 comments