Marquee

A looping row that can be grabbed and thrown - logo walls, testimonial strips, pill rails. Takes a speed in pixels per second, never a duration.

A row that loops forever and that a visitor can grab and throw. Logo walls, testimonial strips, pill rails, “trusted by” bands.

default
Claude CodeCodexopencodeKimi CodeMCPGuardrailsRecallDashboard

Grab it and throw it. Release without moving and it eases back to cruise - same integrator, so nothing arrives as a cut.

A logo wall

What the row is most often used for. The primitive is the same; only the content changes.

brand colours
Claude CodeCodexopencodeKimi CodeNorthwindVellumAcme GridPlinth

Four real marks and four obvious placeholders, every one in the same box. Drag it - the logos stay links if you make them links.

beta Brand assets arrive in seven different colours and a wall of them is noisy, so a monochrome treatment is common. The technique is not a filter chain - that cannot land on an exact colour - it is to use the asset as a mask and paint the colour behind it. That works for any transparent asset: SVG, PNG or WebP alike.

tinted
Claude CodeCodexopencodeKimi CodeNorthwindVellumAcme GridPlinth

One colour, set once. Change --logo-color and every mark follows.

This lives in your CSS, not in @enigmax/primitives - the primitive ships no styles, and a colour is a look. It is documented here because every logo wall needs it. It is also beta and deliberately opt-in: a brand’s own colours are the correct rendering, and flattening them is a design decision, not a default. Check contrast when you do it - a mark tinted to your background disappears.

Customize

Grab it and throw it. Speed is in pixels per second, so adding items lengthens the lap instead of speeding the row up - which is the whole contract.

Installation

Adds the package and prints the import to use. Append --copy to write the source into your project instead.

enigma add marquee

Installs with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.

Variants

hover: pause
Claude CodeCodexopencodeKimi CodeMCPGuardrailsRecallDashboard

Rest a MOUSE on it and it stops; a tap on touch does not, which is the whole point of the pointerType gate below.

reverse, faster
PrismaPostgreSQLZodAstroReactVitePlaywrightBun

Two rows running opposite ways at the same measured speed, whatever their item counts.

The speed contract

It takes a speed in pixels per second. Never a duration.

A lap of a looping row is as long as its content. If the API takes a duration, the speed becomes content / duration, so the row runs faster every time an item is added to it. That is not theoretical:

  • Two testimonial rows on one page, same --duration: 20s, split from an odd-length array into 4 cards and 5. Measured 53.2 px/s and 66.5 px/s. They were meant to look like a pair.
  • A provider rail fed from a list that grows with the product: at 10, 15 and 20 items a fixed 50s duration gives 45, 67 and 87 px/s. Nobody edits the duration when they add a provider, so the row silently accelerates over releases.

Take a speed and derive the duration. After the fix the same rail measured 67.0 px/s at all three counts.

React

import { useMarquee } from "@enigmax/primitives/react";
import styles from "./LogoWall.module.css";

export function LogoWall({ logos }) {
    const { laneRef, trackRef, copies, dragging, period } = useMarquee({
        speed: 80,
        hover: 0.15
    });

    return (
        <div
            ref={laneRef}
            className={styles.lane}
            style={{ cursor: dragging ? "grabbing" : "grab" }}
        >
            <div ref={trackRef} className={styles.track}>
                {/* `copies` is how many times to repeat the content. It starts at 2 on
                    the server and grows once the lane and the lap have been measured. */}
                {Array.from({ length: copies }, (_, copy) => (
                    <div key={copy} className={styles.copy} aria-hidden={copy > 0}>
                        {logos.map((logo) => (
                            <a key={logo.id} href={logo.href} draggable={false}>
                                <img src={logo.src} alt={logo.name} draggable={false} />
                            </a>
                        ))}
                    </div>
                ))}
            </div>
        </div>
    );
}
/* LogoWall.module.css */
.lane  { position: relative; }
.track { display: flex; }
.copy  { display: flex; align-items: center; gap: 44px; padding-right: 44px; }
.copy img { height: 26px; width: auto; }

Only copy 0 is read by a screen reader; the repeats carry aria-hidden so the same names are not announced four times.

If the content changes after mount - a fetch resolves, a filter narrows the list - call measure() so the lap is read again:

const { measure } = useMarquee({ speed: 80 });
useEffect(() => { measure(); }, [logos, measure]);

The hook reports how many copies to render rather than cloning DOM itself, because cloning behind React’s back is undone on the next render. It starts at 2 so the server-rendered HTML stays small, then grows to cover the lane.

Vanilla and Astro

import { createMarquee } from "@enigmax/primitives";

const marquee = createMarquee(
    document.querySelector("[data-lane]"),
    document.querySelector("[data-track]"),
    { speed: 80, hover: 0.15 }
);

Here the engine clones the track’s first child to fill the lane. Call marquee.destroy() when the page tears down.

Options

Option Default Meaning
speed 60 Pixels per second. Not a duration.
reverse false Scroll towards the start.
vertical false Scroll on the Y axis.
draggable true Grab and throw the row.
hover "off" What a mouse resting on the row does. "off" ignores it, "pause" stops, a number multiplies the cruise speed, { speed } sets an absolute px/s.
decay 0.12 Fraction of the remaining velocity gap left after one second. 0.35 is noticeably draggy.
manageStyles true Apply the styles the behaviour needs. Never theme styles.

The instance exposes offset, period, copyCount, dragging, reducedMotion, and the methods update(), measure(), pause(), resume(), destroy().

Why the obvious implementation is wrong

Each of these was shipped the naive way first and found broken in production.

A pointer capture retargets the compatibility mouse events too, so the click of a plain press arrives on the lane instead of on the card that was pressed, and the card’s link never opens. A nine-logo carousel quietly stops being nine links and nothing in the source looks wrong.

The fix is to bind pointermove / pointerup / pointercancel to window, which is all the capture was ever for. A mousedown already captures the mouse at the OS level, so a release outside the browser window still arrives.

touch-action decides whether a phone can scroll your page

Without it the browser owns the horizontal swipe and the drag handlers never see it. With none, a visitor can no longer scroll the page by starting the gesture on the row - which on a phone is most of the width of the screen. The lane gets pan-y, or pan-x when vertical.

A hover effect that ignores pointerType sticks forever on touch

A tap fires pointerenter too, and on touch nothing ever fires the pointerleave that undoes it, so “slow down on hover” leaves the row at 15% speed for good after the first tap.

Gating on pointerType === "mouse" is necessary and not sufficient: Chromium follows a touch with compatibility pointer events that claim pointerType: "mouse". It emits them on Linux and not on Windows, so the naive gate passes on one machine and fails on the other - which is exactly how it reached CI here. A touch therefore also suppresses hover for a second, and pointerleave clears the state whatever the pointer type.

The rest, in short

The lap is measured from the step between two copies rather than computed from item widths, because itemCount * (itemWidth + gap) is right only until a class changes or a font loads. The transform is driven by one requestAnimationFrame loop rather than CSS keyframes, which restart on every re-author. One integrator carries cruise, hover and momentum, so none of the three can arrive as a cut. A drag past ~6px cancels the click in the capture phase. A right click never starts a drag. A pointer held still for ~90ms before release throws nothing. Reduced motion drops the autoplay and keeps the drag, because a drag is an answer to the pointer and not motion of the page’s own accord.

How it is verified

The suite drives real Chromium and samples new DOMMatrixReadOnly(getComputedStyle(track).transform).m41 on every animation frame - never the engine’s own state, so a test cannot pass on a number the engine merely believes.

Check Pass condition
Same speed at 2, 9, 11, 20 items measured px/s equal within 1%
Drag follows the pointer a 200px swipe moves the row 200.0px
Release decays monotonically onto cruise, no sign flip
Four viewport resizes no frame moves more than one frame of cruising
Drag ending on a link 0 navigations
Plain click and tap on a link 1 navigation each
Tap on touch the row does not stick at the hover speed
Compatibility mouse enter after a touch hover does not engage
Vertical swipe on a phone the page still scrolls
prefers-reduced-motion 0.00px idle drift, drag still works
Held still, then release no fling