This article is part of a series on PHP-only Gutenberg block registration. For an introduction to the feature and its background, read PHP-Only Block Registration in WordPress.
A scrolling marquee is one of those blocks that looks simple on the surface but has a few interesting implementation details underneath. The block itself is registered entirely in PHP — one file, no build step. The only JavaScript involved is the frontend animation script, and even that is loaded lazily: it is registered at init time but only enqueued when the block is actually present on the page.
The animation uses requestAnimationFrame rather than CSS animations, giving you precise per-frame control over speed without fighting @keyframes timing.
What the block does
- A TextControl for the scrolling text
- A SelectControl for speed (Slow / Medium / Fast)
- A SelectControl for direction (Left / Right)
- Native text and background colour pickers via
supports.color - Font size, weight, and style via
supports.typography - Padding via
supports.spacing - Wide and full alignment support
- Frontend script loaded only on pages containing the block
One block = one row. Stack two blocks vertically for a dual-row layout — each with its own text, colour, and speed.
The mini-plugin
This plugin has three files:
marquee-block/
├── marquee-block.php
├── marquee.js
└── style.css
marquee-block/marquee-block.php
<?php
/**
* Plugin Name: Marquee Block
* Description: A PHP-only Marquee Gutenberg block with lazy-loaded animation script.
* Version: 1.0.0
* Requires at least: 6.7
* Requires Plugins: gutenberg
*/
defined( 'ABSPATH' ) || exit;
define( 'MARQUEE_BLOCK_VERSION', '1.0.0' );
// ---------------------------------------------------------------------------
// Script registration (lazy — enqueued only from the render callback)
// ---------------------------------------------------------------------------
add_action( 'init', function () {
wp_register_script(
'marquee-block-script',
plugin_dir_url( __FILE__ ) . 'marquee.js',
array(),
MARQUEE_BLOCK_VERSION,
true // load in footer
);
} );
// ---------------------------------------------------------------------------
// Block registration
// ---------------------------------------------------------------------------
add_action( 'init', 'marquee_block_register' );
function marquee_block_register(): void {
register_block_type(
'my-plugin/marquee',
array(
'title' => 'Marquee',
'description' => 'A single infinitely scrolling text row. Stack multiple blocks for multi-row layouts.',
'category' => 'text',
'icon' => 'leftright',
'attributes' => array(
'text' => array(
'type' => 'string',
'default' => 'Your Text —',
'label' => 'Text',
),
// Enum values are sentence-cased. autoRegister uses the raw
// enum values as both the stored value and the display label
// in the SelectControl. "Slow" is nicer than "slow".
'speed' => array(
'type' => 'string',
'enum' => array( 'Slow', 'Medium', 'Fast' ),
'default' => 'Medium',
'label' => 'Speed',
),
'direction' => array(
'type' => 'string',
'enum' => array( 'Left', 'Right' ),
'default' => 'Left',
'label' => 'Direction',
),
),
'supports' => array(
'autoRegister' => true,
'align' => array( 'wide', 'full' ),
'color' => array(
'text' => true, // text colour for the scrolling content
'background' => true, // background colour for the track
),
'spacing' => array( 'padding' => true ),
'typography' => array(
'fontSize' => true,
'fontWeight' => true,
'fontStyle' => true,
),
),
'render_callback' => 'marquee_block_render',
)
);
}
// ---------------------------------------------------------------------------
// Render callback
// ---------------------------------------------------------------------------
function marquee_block_render( array $attributes, string $content, WP_Block $block ): string {
// Lazy-enqueue the animation script. WordPress deduplicates by handle,
// so this is a no-op if the block appears multiple times on the same page.
wp_enqueue_script( 'marquee-block-script' );
$speed_map = array(
'Slow' => 0.5,
'Medium' => 1.0,
'Fast' => 2.0,
);
$step = $speed_map[ $attributes['speed'] ] ?? 1.0;
$direction = ( 'Right' === $attributes['direction'] ) ? 'right' : 'left';
$wrapper_attrs = get_block_wrapper_attributes(
array( 'class' => 'marquee-block' )
);
return sprintf(
'<div %s><div class="marquee-block__row" data-step="%s" data-repeat="16" data-direction="%s">%s</div></div>',
$wrapper_attrs,
esc_attr( $step ),
esc_attr( $direction ),
esc_html( $attributes['text'] )
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
add_action( 'init', 'marquee_block_register_styles' );
function marquee_block_register_styles(): void {
wp_enqueue_block_style(
'my-plugin/marquee',
array(
'handle' => 'marquee-block-style',
'src' => plugin_dir_url( __FILE__ ) . 'style.css',
'path' => plugin_dir_path( __FILE__ ) . 'style.css',
'ver' => MARQUEE_BLOCK_VERSION,
)
);
}
add_action( 'enqueue_block_editor_assets', 'marquee_block_editor_styles' );
function marquee_block_editor_styles(): void {
wp_enqueue_style(
'marquee-block-style',
plugin_dir_url( __FILE__ ) . 'style.css',
array(),
MARQUEE_BLOCK_VERSION
);
}
marquee-block/marquee.js
/**
* Marquee block — frontend animation.
*
* Reads data-step, data-repeat, and data-direction from each .marquee-block__row
* element. The script is only loaded on pages where the block is present.
*/
( function () {
'use strict';
/**
* @param {HTMLElement} el The row element.
* @param {number} repeatCount How many times to duplicate the text.
* @param {number} step Pixels to advance per animation frame.
* @param {string} direction 'left' (default) or 'right'.
*/
function startMarquee( el, repeatCount, step, direction ) {
var content = el.innerHTML;
var position = 0;
// Measure the SINGLE copy's width BEFORE duplication.
// If you measure after duplication, the right-scroll start position
// will be pushed 16× too far off-screen.
el.style.position = 'absolute';
var singleWidth = el.clientWidth + 1;
el.style.position = '';
el.innerHTML = Array( repeatCount ).fill( content ).join( '' );
function animate() {
position = position < singleWidth ? position + step : 1;
// Left: 0 → -singleWidth (content enters from right)
// Right: -singleWidth → 0 (content enters from left)
el.style.marginLeft = ( direction === 'right' ? position - singleWidth : -position ) + 'px';
requestAnimationFrame( animate );
}
animate();
}
function init() {
document.querySelectorAll( '.marquee-block__row' ).forEach( function ( el ) {
var repeat = parseInt( el.dataset.repeat, 10 ) || 12;
var step = parseFloat( el.dataset.step ) || 1;
var direction = el.dataset.direction || 'left';
startMarquee( el, repeat, step, direction );
} );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
}() );
marquee-block/style.css
.marquee-block {
overflow: hidden;
width: 100%;
}
/* overflow: hidden and white-space: nowrap are set in CSS — not inline by JS.
The animation script only updates margin-left. */
.marquee-block__row {
overflow: hidden;
white-space: nowrap;
}
Things to know
The lazy script pattern
wp_register_script() at init registers the script handle with WordPress but does not load it. wp_enqueue_script() inside the render callback schedules it for output — but only when the block is actually rendered. On pages without a marquee block, the script is never loaded.
WordPress deduplicates by handle: if three marquee blocks appear on the same page, wp_enqueue_script( 'marquee-block-script' ) is called three times but the script loads exactly once. All three rows are picked up by document.querySelectorAll( '.marquee-block__row' ) in the init() function.
Measuring before duplicating (the right-scroll fix)
The animation loop needs to know the width of one “cycle” of text so it knows when to reset the position and loop seamlessly. The natural instinct is to measure el.clientWidth after filling the element with repeated copies. But at that point clientWidth is the total width of all copies — for 16 repetitions, that’s 16× larger than you need.
The fix: measure the single-copy width first, then duplicate. To get an accurate measurement with clientWidth, the element needs to be in the normal flow rather than clipped by its parent’s overflow: hidden. The script briefly sets position: absolute on the row (removing it from the overflow-constrained flow), reads the width, then removes position: absolute again before duplicating.
For left scrolling this doesn’t matter much — the loop resets naturally. For right scrolling, starting from position - singleWidth places the content correctly at the right edge, and it travels left toward zero (appearing to slide in from the right). Using position - totalWidth would start the content 16× off-screen.
Typography controls and CSS
The Typography panel applies font size, weight, and style as inline styles on the wrapper div, and they cascade to child elements. This only works if your CSS doesn’t hardcode those same properties on .marquee-block__row. Remove any font-size, font-weight, or line-height from that class and let inheritance do the work.
Direction as sentence-case enum
autoRegister displays enum values exactly as defined in the PHP array. Setting them as 'Left' and 'Right' (sentence case) means the SelectControl in the editor shows clean, professional labels without any additional processing. The render callback compares against the stored value ('Right') and maps it to the lowercase data-direction attribute used by JavaScript.
Further reading
- PHP-Only Block Registration in WordPress
- Make WordPress Core dev note
- Block Editor Handbook — Registration of a block