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();
}