@enigmax/primitives

中文文档
0.28.0发布于 2026/09/29Apache-2.0TypeScriptES Module零依赖构建来源已验证近 30 天下载 2086

Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.

原始使用文档
文档目录6 节
  1. Install
  2. Use it
  3. Options
  4. Why the obvious implementation is wrong
  5. Styling
  6. Tests

@enigmax/primitives

Headless interaction primitives: the behaviour, the timing and the accessibility, with no styles of their own.

The marquee takes a speed in pixels per second, never a duration. A lap of a looping row is as long as its content, so a duration makes the speed content / duration and the row runs faster every time an item is added to it. Two testimonial rows split from an odd-length array, same --duration: 20s, measured 53.2 px/s and 66.5 px/s and were meant to look like a pair. A provider rail on a fixed 50s duration measured 45, 67 and 87 px/s at 10, 15 and 20 items, because nobody edits the duration when they add a provider. Take a speed and derive the duration: the same rail then measured 67.0 px/s at all three counts.

Install

npm install @enigmax/primitives
# or, to copy the source in and own it:
enigma add marquee --copy

Use it

React:

import { useMarquee } from "@enigmax/primitives/react";

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

    return (
        <div ref={laneRef} style={{ cursor: dragging ? "grabbing" : "grab" }}>
            <div ref={trackRef} style={{ display: "flex" }}>
                {Array.from({ length: copies }, (_, copy) => (
                    <div key={copy} aria-hidden={copy > 0} style={{ display: "flex", gap: 32 }}>
                        {logos.map(logo => <a key={logo.id} href={logo.href}>{logo.name}</a>)}
                    </div>
                ))}
            </div>
        </div>
    );
}

Vanilla, Astro, or anything else that hands you two elements:

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

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

The core clones the track's first child to fill the lane. In React the hook reports a copies count instead, so React keeps ownership of the DOM.

Options

OptionDefaultMeaning
speed60Pixels per second. Not a duration.
reversefalseScroll towards the start.
verticalfalseScroll on the Y axis.
draggabletrueGrab and throw the row.
hoverScale1Speed multiplier while a mouse rests on the row. 0 pauses.
decay0.12Fraction of the remaining velocity gap left after one second. 0.35 is draggy.
manageStylestrueApply the styles the behaviour needs. Never theme styles.

Why the obvious implementation is wrong

Three of these cost a production bug each. They are the reason this package exists rather than a snippet.

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. This package binds 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 a "slow down on hover" leaves the row at 15% speed for good after the first tap. Every hover path here is gated to pointerType === "mouse" - and that gate alone is not enough, which cost a CI failure: 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. A touch therefore also suppresses hover for a second, and pointerleave clears the state whatever the pointer type, because a hover that cannot be undone is the bug.

The rest, in short: the lap is measured from the step between two copies rather than computed from item widths; 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 them 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; and reduced motion drops the autoplay while keeping the drag, because a drag is an answer to the pointer and not motion of the page's own accord.

Styling

The primitive ships no visual styles. It sets only what the behaviour requires (overflow, touch-action, user-select, will-change, transform) and exposes state as data attributes for a theme to hook:

[data-enigma-marquee]                        { cursor: grab; }
[data-enigma-marquee][data-dragging]         { cursor: grabbing; }
[data-enigma-marquee][data-hovering]         { --logo-opacity: 1; }
[data-enigma-marquee][data-reduced-motion="true"] { /* autoplay is off */ }
[data-enigma-marquee-track]                  { display: flex; }
[data-enigma-marquee-copy]                   { display: flex; gap: 2rem; }

Tests

The suite drives a real browser and samples new DOMMatrixReadOnly(getComputedStyle(track).transform).m41 on every animation frame, rather than asking the engine what it believes.

npm run test:install   # once, fetches Chromium
npm test
CheckPass condition
Same speed at 2, 9, 11, 20 itemsmeasured px/s equal within 1%
Drag follows the pointera 200px swipe moves the row 200.0px
Releasedecays monotonically onto cruise, no sign flip
Four viewport resizesno frame moves more than one frame of cruising
Drag ending on a link0 navigations
Plain click and tap on a link1 navigation each
Tap on touchrow does not stick at the hover speed
Vertical swipe on a phonethe page still scrolls
prefers-reduced-motion0.00px idle drift, drag still works
Held still, then releaseno fling

Two ways to fool yourself, both of which produced a false alarm while this was being written: do not measure a resize as before/after, because setViewportSize takes several hundred milliseconds during which the row legitimately keeps moving - sample every frame and look for a single anomalous one. And discard the first few samples, because the first rAF delta after installing the sampler is near zero and produces a meaningless px/s spike.

版本与发布记录

@enigmax/primitives 共发布 33 个版本,首次发布于 2026/08/14;过去 12 个月发布了 33 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.28.0latest2026/09/291 个月前
0.27.02026/09/072 个月前
0.26.02026/09/072 个月前
0.25.02026/09/062 个月前
0.24.02026/09/052 个月前
0.23.02026/09/022 个月前

关于 @enigmax/primitives 的常见问题

@enigmax/primitives 是什么?

Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.

如何安装 @enigmax/primitives?

在项目目录运行 npm install @enigmax/primitives,或使用 pnpm add / yarn add / bun add。

@enigmax/primitives 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@enigmax/primitives 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@enigmax/primitives 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@enigmax/primitives 可以商用吗?

@enigmax/primitives 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@enigmax/primitives 的下载量有多少?

根据 npm 官方下载统计,@enigmax/primitives 在 2026/10/14 – 2026/11/12 这 30 天内被下载 2,086 次,日均约 70 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@enigmax/primitives 还在维护吗?

最新版本 0.28.0 发布于 2026/09/29(1 个月前);至今共发布 33 个版本,其中过去 12 个月发布了 33 个;首次发布于 2026/08/14。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。