A row that loops forever and that a visitor can grab and throw. Logo walls, testimonial strips, pill rails, “trusted by” bands.
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.
Four real marks and four obvious placeholders, every one in the same box. Drag it - the logos stay links if you make them links.
One colour for every logo
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.
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 marqueeInstalls with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.
1 Install the dependencies
2 Use it
Import from @enigmax/primitives/react.
This is exactly what enigma add marquee --copy writes into your project - read from the
installed package at build time, so it cannot drift from what you actually get.
import type { ReactNode, Ref } from "react";
import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
/**
* A styled marquee, yours to edit.
*
* The behaviour comes from the primitive, which ships no styles at all; every class
* below is a suggestion you are meant to change.
*
* TAILWIND v4 WARNING: never put a `translate-*` utility on the track. v4 writes those
* to the CSS `translate` property, which COMPOSES with `transform` rather than replacing
* it, so the class and the engine's transform add up and the row drifts. Anything you
* need to offset goes on the lane or on an inner element, never on the moved one.
*/
export interface MarqueeProps<T> {
items: T[];
/** Pixels per second. Never a duration - the row would speed up as items are added. */
speed?: number;
/**
* What a mouse resting on the row does to its speed:
* "off" ignores hover, "pause" stops, a number multiplies the cruise speed
* (0.15 crawls, 2 doubles), { speed } sets an absolute px/s.
*/
hover?: MarqueeHover;
reverse?: boolean;
/** Fade both ends so items enter and leave instead of being cut. */
fade?: boolean;
className?: string;
children: (item: T, index: number) => ReactNode;
}
export function Marquee<T>({
items,
speed = 70,
hover = "off",
reverse = false,
fade = true,
className = "",
children
}: MarqueeProps<T>) {
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
return (
<div
ref={laneRef as Ref<HTMLDivElement>}
className={[
"relative w-full",
dragging ? "cursor-grabbing" : "cursor-grab",
fade ? "[mask-image:linear-gradient(90deg,transparent,#000_6%,#000_94%,transparent)]" : "",
className
].filter(Boolean).join(" ")}
>
{/* No translate-* utility here. See the note at the top of this file. */}
<div ref={trackRef as Ref<HTMLDivElement>} className="flex">
{Array.from({ length: copies }, (_, copy) => (
<div key={copy} aria-hidden={copy > 0} className="flex shrink-0 items-center gap-10 pr-10">
{items.map((item, index) => (
<div key={index} className="shrink-0">{children(item, index)}</div>
))}
</div>
))}
</div>
</div>
);
}import "./styles.css";
import type { ReactNode, Ref } from "react";
import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
/**
* A styled marquee, yours to edit.
*
* The behaviour comes from the primitive, which ships no styles at all; marquee.css
* beside this file is a starting point you are meant to change.
*/
export interface MarqueeProps<T> {
items: T[];
/** Pixels per second. Never a duration - the row would speed up as items are added. */
speed?: number;
/**
* What a mouse resting on the row does to its speed:
* "off" ignores hover, "pause" stops, a number multiplies the cruise speed
* (0.15 crawls, 2 doubles), { speed } sets an absolute px/s.
*/
hover?: MarqueeHover;
reverse?: boolean;
/** Fade both ends so items enter and leave instead of being cut. */
fade?: boolean;
className?: string;
children: (item: T, index: number) => ReactNode;
}
export function Marquee<T>({
items,
speed = 70,
hover = "off",
reverse = false,
fade = true,
className = "",
children
}: MarqueeProps<T>) {
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
return (
<div
ref={laneRef as Ref<HTMLDivElement>}
className={["enigma-marquee", fade ? "is-faded" : "", className].filter(Boolean).join(" ")}
data-grabbing={dragging ? "" : undefined}
>
<div ref={trackRef as Ref<HTMLDivElement>} className="enigma-marquee__track">
{Array.from({ length: copies }, (_, copy) => (
<div key={copy} aria-hidden={copy > 0} className="enigma-marquee__copy">
{items.map((item, index) => (
<div key={index} className="enigma-marquee__item">{children(item, index)}</div>
))}
</div>
))}
</div>
</div>
);
}/* A starting point for the marquee, yours to edit. The primitive ships no styles;
it sets only what the behaviour needs (overflow, touch-action, user-select,
will-change, transform) and publishes its state as data-* attributes. */
.enigma-marquee { position: relative; width: 100%; cursor: grab; }
.enigma-marquee[data-grabbing] { cursor: grabbing; }
/* Fade both ends so items enter and leave instead of being cut. */
.enigma-marquee.is-faded {
-webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
/* Never set `transform` or `translate` on the track: the engine owns it, and in
Tailwind v4 a translate-* utility composes with it rather than replacing it. */
.enigma-marquee__track { display: flex; }
.enigma-marquee__copy { display: flex; flex-shrink: 0; align-items: center; gap: 2.5rem; padding-right: 2.5rem; }
.enigma-marquee__item { flex-shrink: 0; }
/* State the engine publishes, for you to style. */
.enigma-marquee[data-hovering] .enigma-marquee__item { opacity: 1; }
.enigma-marquee[data-reduced-motion="true"] { /* autoplay is off; the drag still works */ }Used like this:
<Marquee items={logos} speed={70} hover={0.15}>
{(logo) => (
<a href={logo.href}>
<img src={logo.src} alt={logo.name} width={22} height={22} draggable={false} />
</a>
)}
</Marquee>Variants
Rest a MOUSE on it and it stops; a tap on touch does not, which is the whole point of the pointerType gate below.
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.
setPointerCapture silently breaks every link in the row
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 |