wake-marquee 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -22
- package/README.md +92 -7
- package/dist/wake-marquee.auto.js +1 -1
- package/dist/wake-marquee.js +1 -1
- package/integrations/astro/WakeMarquee.astro +15 -3
- package/package.json +1 -1
- package/src/marquee.js +123 -6
- package/src/motion.js +26 -0
- package/src/wake-marquee.css +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,30 +8,51 @@ While the version is below `1.0.0` the API may still change in a minor release.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
11
|
+
## [0.2.0] - 2026-09-01
|
|
12
|
+
|
|
13
|
+
Three things adopting `0.1.0` in a real project turned up.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `getMarquee(element)` returns the marquee running on an element, or `null`.
|
|
18
|
+
`initMarquees()` hands back only the instances that call created, so the
|
|
19
|
+
declarative integrations were a one-way door: `wake-marquee/astro` starts the
|
|
20
|
+
rows in a hoisted script with nowhere to return a handle to, and calling
|
|
21
|
+
`initMarquees()` again from application code returns an empty array because
|
|
22
|
+
every row is already running. There was no way to reach `pause()`, `refresh()`
|
|
23
|
+
or `destroy()` at all without giving up the integration and hand-rolling the
|
|
24
|
+
markup.
|
|
25
|
+
- `speed` accepts a percentage of the container width per second, `'8%'` as
|
|
26
|
+
well as `60`. Pixels a second is a physical unit on a page whose elements are
|
|
27
|
+
not physically constant: a logo row is 40px tall with a 72px gap on a desktop
|
|
28
|
+
and 16px with a 32px gap on a phone, so one value is calm on the first and
|
|
29
|
+
hectic on the second, with three times as many items going past. The
|
|
30
|
+
percentage is resolved against the width the wake is already measuring every
|
|
31
|
+
frame, so it follows a resize and costs nothing.
|
|
32
|
+
- A console warning when the item spacing has collapsed to zero while
|
|
33
|
+
`--wake-gap` asks for more. An unlayered global reset outranks every cascade
|
|
34
|
+
layer whatever the specificity says, so `* { margin: 0 }` quietly beats the
|
|
35
|
+
library's `margin-inline-end` and the items sit flush. Nothing else gives it
|
|
36
|
+
away: the row still loops and the value still reads correctly in the devtools,
|
|
37
|
+
which makes it look like a mistake in the consuming project. Said once per
|
|
38
|
+
page, and never when `--wake-gap` is genuinely `0`.
|
|
29
39
|
|
|
30
40
|
### Changed
|
|
31
41
|
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
- The README's layer-order guidance was only under "Tailwind CSS v4", which is
|
|
43
|
+
not where somebody with a hand-written reset goes looking. Styling now has a
|
|
44
|
+
"Cascade layers" section covering both, and it records why the item spacing
|
|
45
|
+
stays inside the layer: it is the rule you are most likely to want to
|
|
46
|
+
override, and unlayered library CSS would beat your own utilities.
|
|
47
|
+
- The demo's size figure is weighed at build time rather than written down.
|
|
48
|
+
|
|
49
|
+
### Notes
|
|
50
|
+
|
|
51
|
+
- A duration form of `speed`, `'9.6s'` for one container width, was considered
|
|
52
|
+
and dropped. It reverses the option on itself next to the number form, where
|
|
53
|
+
`'12s'` would be slower than `'6s'` while `120` is faster than `60`, and it
|
|
54
|
+
invites the reading "per lap", which would be the lane width and would mean
|
|
55
|
+
adding one logo slowed the row down.
|
|
35
56
|
|
|
36
57
|
## [0.1.0] - 2026-09-01
|
|
37
58
|
|
|
@@ -58,6 +79,14 @@ First release.
|
|
|
58
79
|
than one per row. The loop stops when nothing is on screen.
|
|
59
80
|
- Rows are measured and cloned on first sight, not at construction, so a page
|
|
60
81
|
of ten marquees does no work for the nine nobody has scrolled to.
|
|
82
|
+
- The handover from the static row to the running one happens inside a single
|
|
83
|
+
task, so no half-built state is ever painted, and the first frame is aligned
|
|
84
|
+
to the position the static row already occupied rather than to the loop's
|
|
85
|
+
own zero point. A row reached by anchor link or a restored scroll position
|
|
86
|
+
starts as cleanly as one scrolled to.
|
|
87
|
+
- A lane resized under a running row, by a late web font or an image with no
|
|
88
|
+
`width` and `height`, does not shift it: the offset is restated in the new
|
|
89
|
+
period, holding the phase.
|
|
61
90
|
- `wake-marquee/css`: the loop geometry in an `@layer wake-marquee` cascade
|
|
62
91
|
layer, 407 B gzipped. It owns nothing about how items look.
|
|
63
92
|
- `wake-marquee/astro`: Astro component rendering the same attributes.
|
|
@@ -70,6 +99,10 @@ First release.
|
|
|
70
99
|
|
|
71
100
|
### Notes
|
|
72
101
|
|
|
102
|
+
- Spacing and fade width change the layout of the static row, so declaring
|
|
103
|
+
them only in `data-wake-gap` or `data-wake-fade` leaves the items shifting
|
|
104
|
+
when the script runs. Set the matching custom property too; the Astro
|
|
105
|
+
component does it for you. Documented in the README.
|
|
73
106
|
- The spacing between items is a `margin-inline-end` on the item rather than
|
|
74
107
|
`gap` on the row. The lane's width is the loop period, so the space after
|
|
75
108
|
the last item has to belong to the lane; with `gap` there is none between
|
|
@@ -77,5 +110,6 @@ First release.
|
|
|
77
110
|
- Right-to-left writing modes are not supported. Flex rows follow the writing
|
|
78
111
|
direction while the transforms are physical, so the two disagree.
|
|
79
112
|
|
|
80
|
-
[Unreleased]: https://github.com/robin-gogolok/wake-marquee/compare/v0.
|
|
113
|
+
[Unreleased]: https://github.com/robin-gogolok/wake-marquee/compare/v0.2.0...HEAD
|
|
114
|
+
[0.2.0]: https://github.com/robin-gogolok/wake-marquee/compare/v0.1.0...v0.2.0
|
|
81
115
|
[0.1.0]: https://github.com/robin-gogolok/wake-marquee/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ An endless marquee that answers to the scroll. It reverses when the reader scrol
|
|
|
5
5
|
**[Live demo →](https://robin-gogolok.github.io/wake-marquee/)**
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
3.
|
|
8
|
+
3.5 kB gzipped JS
|
|
9
9
|
0.4 kB gzipped CSS
|
|
10
10
|
0 runtime dependencies
|
|
11
11
|
1 rAF loop, however many rows are on the page
|
|
@@ -75,11 +75,20 @@ const row = createMarquee(document.querySelector('#logos'), {
|
|
|
75
75
|
---
|
|
76
76
|
import WakeMarquee from 'wake-marquee/astro'
|
|
77
77
|
---
|
|
78
|
-
<WakeMarquee speed=
|
|
78
|
+
<WakeMarquee speed="8%" wake={10} fade="6rem" aria-label="Brands we stock">
|
|
79
79
|
{brands.map((b) => <img src={b.logo} alt={b.name} />)}
|
|
80
80
|
</WakeMarquee>
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
The component's script starts the row and keeps no handle, because a hoisted
|
|
84
|
+
Astro script has nowhere to hand one to. Ask for it by element instead:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import { getMarquee } from 'wake-marquee'
|
|
88
|
+
|
|
89
|
+
getMarquee(document.querySelector('#logos'))?.pause()
|
|
90
|
+
```
|
|
91
|
+
|
|
83
92
|
### React
|
|
84
93
|
|
|
85
94
|
The library takes an element, so a ref and an effect are the whole integration. No adapter package.
|
|
@@ -133,6 +142,36 @@ The easing is framerate independent, so the same turn takes the same time on a 6
|
|
|
133
142
|
|
|
134
143
|
Set `reverse: false` to keep a fixed heading and keep the wake.
|
|
135
144
|
|
|
145
|
+
## Speed that survives a phone
|
|
146
|
+
|
|
147
|
+
`speed: 150` is 150 pixels a second, and pixels a second is a physical unit on
|
|
148
|
+
a page whose elements are not physically constant. The same row of logos is
|
|
149
|
+
40px tall with a 72px gap on a desktop and 16px with a 32px gap on a phone, so
|
|
150
|
+
that one number crosses a 1440px container in nine and a half seconds and a
|
|
151
|
+
390px one in under three, with three times as many items going past. Calm on
|
|
152
|
+
the desktop, hectic on the phone, and no breakpoint anywhere in sight because
|
|
153
|
+
the mismatch is in the unit rather than in the value.
|
|
154
|
+
|
|
155
|
+
So `speed` also takes a percentage, meaning that fraction of the container
|
|
156
|
+
width per second:
|
|
157
|
+
|
|
158
|
+
```html
|
|
159
|
+
<div data-wake-marquee data-wake-speed="8%">
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The width is the one measured every frame for the wake, so this costs nothing
|
|
163
|
+
and follows a resize, a rotation or a container that changes under the row.
|
|
164
|
+
|
|
165
|
+
A rate rather than a duration on purpose. "One container width per 9.6
|
|
166
|
+
seconds" reads well on its own and reverses the option on itself the moment it
|
|
167
|
+
sits beside the number form: `'12s'` would be slower than `'6s'` while `120` is
|
|
168
|
+
faster than `60`. It also invites the reading "per lap", which would be the
|
|
169
|
+
lane width, so adding one logo would slow the row down.
|
|
170
|
+
|
|
171
|
+
There is no floor. A percentage on a very narrow viewport is a very slow row,
|
|
172
|
+
and `8%` of 390px is 31 px/s, which may be slower than you want it. Pick the
|
|
173
|
+
percentage for the width you care about most and check the other end.
|
|
174
|
+
|
|
136
175
|
## Starting without a flash
|
|
137
176
|
|
|
138
177
|
A row has two lives: the static one the server sends, and the running one the
|
|
@@ -174,7 +213,7 @@ Turns an element and its children into a marquee. Returns a handle.
|
|
|
174
213
|
| Option | Default | |
|
|
175
214
|
|---|---|---|
|
|
176
215
|
| `direction` | `'left'` | `'left'` or `'right'`, while scrolling down |
|
|
177
|
-
| `speed` | `60` |
|
|
216
|
+
| `speed` | `60` | Pixels per second, or a percentage of the container width per second (`'8%'`) |
|
|
178
217
|
| `wake` | `8` | Scroll displacement, as a percentage of the container width. `0` turns it off |
|
|
179
218
|
| `reverse` | `true` | Whether scrolling up reverses travel |
|
|
180
219
|
| `ease` | `5` | How sharply a reversal settles, per second. Around `1` is a long, heavy turn |
|
|
@@ -195,6 +234,24 @@ The handle:
|
|
|
195
234
|
| `refresh()` | Re-measure and top up the clones. Call it after changing the items yourself |
|
|
196
235
|
| `destroy()` | Undo everything: clones, wrappers, observers, listeners, attributes |
|
|
197
236
|
|
|
237
|
+
### `getMarquee(element)`
|
|
238
|
+
|
|
239
|
+
The marquee running on an element, or `null`.
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
import { getMarquee } from 'wake-marquee'
|
|
243
|
+
|
|
244
|
+
const row = getMarquee(document.querySelector('#logos'))
|
|
245
|
+
row?.pause()
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`initMarquees()` returns only the instances that call created, which is the
|
|
249
|
+
right answer for something safe to call repeatedly and the wrong one for
|
|
250
|
+
anybody who needs a handle later: asking a second time over the same content
|
|
251
|
+
hands back an empty array, because every row is already running. This is the
|
|
252
|
+
way back in, and it is what makes the declarative path as capable as the
|
|
253
|
+
imperative one.
|
|
254
|
+
|
|
198
255
|
### `initMarquees({ root?, defaults? })`
|
|
199
256
|
|
|
200
257
|
Finds every `[data-wake-marquee]` under `root` and starts it, reading each element's own attributes for its options. Skips rows that are already running, so it is safe to call again after new content arrives. Returns only the handles it created.
|
|
@@ -216,7 +273,7 @@ Every option except `scroller` and `respectMotionPreference` can be declared in
|
|
|
216
273
|
|---|---|
|
|
217
274
|
| `data-wake-marquee` | Marks the container. Also the stylesheet's hook |
|
|
218
275
|
| `data-wake-direction="right"` | `direction` |
|
|
219
|
-
| `data-wake-speed="55"` | `speed` |
|
|
276
|
+
| `data-wake-speed="55"` or `"8%"` | `speed` |
|
|
220
277
|
| `data-wake="10"` | `wake` |
|
|
221
278
|
| `data-wake-ease="2.5"` | `ease` |
|
|
222
279
|
| `data-wake-gap="4rem"` | `gap` |
|
|
@@ -255,11 +312,39 @@ The library owns the loop geometry and nothing else. Items look however you styl
|
|
|
255
312
|
|
|
256
313
|
The spacing is a `margin-inline-end` on each item rather than `gap` on the row, and that is load-bearing rather than a style choice: **the lane's width is the loop's repeat distance**, so the space after the last item has to be part of the lane. With `gap`, there is no space between the last item of one lane and the first of the next, and the seam is visible on every pass.
|
|
257
314
|
|
|
258
|
-
|
|
315
|
+
### Cascade layers
|
|
316
|
+
|
|
317
|
+
All rules live in an `@layer wake-marquee` cascade layer, so your own unlayered CSS wins without `!important`. That is worth knowing in both directions, because the rule behind it is absolute: **an unlayered declaration beats a layered one whatever the specificity says.**
|
|
318
|
+
|
|
319
|
+
The one rule of the library's that this reaches is the item spacing. An ordinary global reset
|
|
320
|
+
|
|
321
|
+
```css
|
|
322
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
beats `margin-inline-end: var(--wake-gap)` at 0,0,0 against 0,1,0, and the gap collapses to zero. Nothing else gives it away: the row still loops, `--wake-gap` still resolves correctly in the devtools, and the items simply sit flush against one another. It reads as a mistake in your own stylesheet, so the library says so in the console when it sees it.
|
|
326
|
+
|
|
327
|
+
Put the reset in a layer, and declare the order before the library's import:
|
|
328
|
+
|
|
329
|
+
```css
|
|
330
|
+
@layer reset, wake-marquee;
|
|
331
|
+
|
|
332
|
+
@import "wake-marquee/css";
|
|
333
|
+
|
|
334
|
+
@layer reset {
|
|
335
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The order declaration comes first and the imports come next, because `@import`
|
|
340
|
+
has to precede every rule that is not a layer statement. Where the reset is a
|
|
341
|
+
file of its own, `@import "reset.css" layer(reset);` does the same job.
|
|
342
|
+
|
|
343
|
+
The spacing stays in the layer rather than being lifted out of it, because it is also the rule you are most likely to want to override: a `me-10` utility or a class of your own on the items should win, and unlayered library CSS would beat both. The library measures whatever spacing ends up applied, so any of that is fine. What it cannot do is guess that you meant a gap and got none.
|
|
259
344
|
|
|
260
345
|
### Tailwind CSS v4
|
|
261
346
|
|
|
262
|
-
Declare the layer order before the imports, or the library will outrank every utility:
|
|
347
|
+
The same rule, the other way round. Declare the layer order before the imports, or the library will outrank every utility:
|
|
263
348
|
|
|
264
349
|
```css
|
|
265
350
|
@layer theme, base, components, wake-marquee, utilities;
|
|
@@ -313,7 +398,7 @@ The lane is measured once per resize, and its width is the loop period. Content
|
|
|
313
398
|
|
|
314
399
|
| | Size | Scroll-linked | Needs JS |
|
|
315
400
|
|---|---|---|---|
|
|
316
|
-
| **wake-marquee** | 3.
|
|
401
|
+
| **wake-marquee** | 3.9 kB | yes, direction and displacement | yes |
|
|
317
402
|
| CSS `@keyframes` marquee | 0 | no | no |
|
|
318
403
|
| [Marquee3k](https://github.com/ezekielaquino/Marquee3000) | ~2 kB | no | yes |
|
|
319
404
|
| [react-fast-marquee](https://github.com/justin-chu/react-fast-marquee) | ~4 kB | no | yes, React only |
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var WakeMarquee=(()=>{var l=Object.defineProperty;var
|
|
1
|
+
var WakeMarquee=(()=>{var l=Object.defineProperty;var _=Object.getOwnPropertyDescriptor;var G=Object.getOwnPropertyNames;var U=Object.prototype.hasOwnProperty;var y=i=>{throw TypeError(i)};var j=(i,e)=>{for(var t in e)l(i,t,{get:e[t],enumerable:!0})},V=(i,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of G(e))!U.call(i,n)&&n!==t&&l(i,n,{get:()=>e[n],enumerable:!(s=_(e,n))||s.enumerable});return i};var K=i=>V(l({},"__esModule",{value:!0}),i);var Y=(i,e,t)=>e.has(i)||y("Cannot "+t);var b=(i,e,t)=>e.has(i)?y("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,t);var o=(i,e,t)=>(Y(i,e,"access private method"),t);var he={};j(he,{initMarquees:()=>f});function J(i,e,t){return i<e?e:i>t?t:i}function x(i,e,t=1){return!(e>0)||!Number.isFinite(i)?1:Math.ceil(i/e)+1+t}function E(i,e){if(typeof i=="number")return i;let t=parseFloat(i);return Number.isFinite(t)&&e>0?t*e/100:0}function C(i,e,t,s){return s>0?((i+e*t)%s+s)%s:0}function M(i,e,t,s){return i+(e-i)*(1-Math.exp(-t*s))}function S(i,e,t){let s=t+e;return s>0?J((t-i)/s,0,1):0}function A(i,e,t){return-t*e*(1-2*i)}function q(i,e=.1){return!Number.isFinite(i)||i<=0?0:Math.min(i/1e3,e)}var W=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),R="data-wake-marquee",X="wake-lane",O="wake-track",Z=1,ee="200px 0px",te=/^\d*\.?\d+%$/;function ie(i){return typeof i=="string"?te.test(i):Number.isFinite(i)&&i>=0}function se(i){return i.firstElementChild?.classList.contains(O)===!0}var u=new Set,F=new WeakMap,p=new Map,m=0,c=0,P=!1;function L(i){return i===window?window.scrollY:i.scrollTop}function re(i){let e=p.get(i);if(!e){p.set(i,{last:L(i),sign:1});return}let t=L(i);Math.abs(t-e.last)>.5&&(e.sign=t>e.last?1:-1,e.last=t)}function $(i){return p.get(i)?.sign??1}function N(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function ne(i={}){let e={...W};for(let[t,s]of Object.entries(i))s!==void 0&&(e[t]=s);if(e.direction!=="left"&&e.direction!=="right")throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${e.direction}"`);if(typeof e.speed=="string"&&(e.speed=e.speed.trim()),!ie(e.speed)){let t=typeof e.speed=="string"?`"${e.speed}"`:e.speed;throw new RangeError(`wake-marquee: speed must be a non-negative number of pixels per second, or a percentage of the container width per second such as "8%", received ${t}`)}if(!Number.isFinite(e.wake)||e.wake<0)throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${e.wake}`);if(!Number.isFinite(e.ease)||e.ease<=0)throw new RangeError(`wake-marquee: ease must be a positive number, received ${e.ease}`);return e.scroller=e.scroller??(typeof window>"u"?null:window),e}function oe(i){let e=i.dataset,t={};if(e.wakeDirection&&(t.direction=e.wakeDirection),e.wakeSpeed){let s=e.wakeSpeed.trim();t.speed=Number.isFinite(Number(s))?Number(s):s}return e.wake&&(t.wake=Number(e.wake)),e.wakeEase&&(t.ease=Number(e.wakeEase)),e.wakeGap&&(t.gap=e.wakeGap),e.wakeFade&&(t.fade=e.wakeFade),e.wakePauseOnHover!==void 0&&(t.pauseOnHover=!0),e.wakeReverse!==void 0&&(t.reverse=e.wakeReverse!=="false"),t}var r,I,T,g,z,v,D,H,B,k,w=class{constructor(e,t){b(this,r);this.element=e,this.options=ne(t),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=0,this.speedPx=0,this.wakePx=0,this.amplitude=0,this.aligned=!1,this.visible=!1,this.measured=!1,this.paused=!1,this.hovered=!1,this.destroyed=!1,this.status="",o(this,r,I).call(this),o(this,r,z).call(this),u.add(this),F.set(e,this),o(this,r,v).call(this)}prime(){if(this.destroyed||this.measured)return this;let e=window.innerHeight,t=this.element.getBoundingClientRect(),s=200;return t.bottom<-s||t.top>e+s?this:(this.visible=!0,this.element.toggleAttribute("data-wake-active",!0),o(this,r,g).call(this),this)}refresh(){if(this.destroyed)return;let e=this.lane.getBoundingClientRect().width;if(!(e>0))return;this.period>0&&e!==this.period&&(this.offset=this.offset/this.period*e),this.measured||o(this,r,H).call(this),this.period=e,this.measured=!0,o(this,r,T).call(this);let t=x(this.track.getBoundingClientRect().width,e,Z);for(;this.lanes.length<t;){let s=this.lane.cloneNode(!0);s.inert=!0,s.setAttribute("aria-hidden","true"),"inert"in s||s.querySelectorAll("a, button, input, select, textarea, [tabindex]").forEach(n=>n.setAttribute("tabindex","-1")),s.querySelectorAll('img[loading="lazy"]').forEach(n=>n.setAttribute("loading","eager")),this.track.appendChild(s),this.lanes.push(s)}for(;this.lanes.length>t;)this.lanes.pop()?.remove();d()}read(e){if(!o(this,r,k).call(this))return;let t=this.element.getBoundingClientRect(),s=S(t.top,t.height,e);this.amplitude=this.options.wake*t.width/100,this.wakePx=A(s,this.amplitude,this.dirSign),this.speedPx=E(this.options.speed,t.width)}write(e,t){if(!o(this,r,k).call(this))return;this.aligned||o(this,r,B).call(this);let s=this.hovered?0:this.options.reverse?this.dirSign*t:this.dirSign;this.dirFactor=M(this.dirFactor,s,e,this.options.ease),this.offset=C(this.offset,this.dirFactor*this.speedPx,e,this.period);let a=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let Q of this.lanes)Q.style.transform=a;this.track.style.transform=`translate3d(${this.wakePx.toFixed(2)}px, 0, 0)`;let h=t===1?"forward":"reversed";h!==this.status&&(this.status=h,this.element.setAttribute("data-wake-travel",h))}play(){return this.destroyed?this:this.options.respectMotionPreference&&N()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,u.delete(this),F.delete(this.element),this.intersection?.disconnect(),this.resize?.disconnect(),this.onEnter&&this.element.removeEventListener("pointerenter",this.onEnter),this.onLeave&&this.element.removeEventListener("pointerleave",this.onLeave),this.motionQuery&&this.onMotionChange&&this.motionQuery.removeEventListener("change",this.onMotionChange);for(let e of this.lanes.slice(1))e.remove();for(;this.lane.firstChild;)this.element.appendChild(this.lane.firstChild);this.track.remove(),this.element.removeAttribute("data-wake-travel"),this.element.removeAttribute("data-wake-active");for(let e of this.undo)e();this.undo=[],this.lanes=[]}}};r=new WeakSet,I=function(){let e=this.element,t=e.ownerDocument;for(this.track=t.createElement("div"),this.track.className=O,this.lane=t.createElement("div"),this.lane.className=X;e.firstChild;)this.lane.appendChild(e.firstChild);this.track.appendChild(this.lane),e.appendChild(this.track),this.lanes=[this.lane],this.undo=[];let s=(a,h)=>{e.hasAttribute(a)||(e.setAttribute(a,h),this.undo.push(()=>e.removeAttribute(a)))},n=(a,h)=>{e.style.getPropertyValue(a)||(e.style.setProperty(a,h),this.undo.push(()=>e.style.removeProperty(a)))};s(R,""),this.options.gap&&n("--wake-gap",this.options.gap),this.options.fade&&(n("--wake-fade",this.options.fade),s("data-wake-fade",""))},T=function(){this.track.style.marginInlineStart=`-${this.options.wake}%`,this.track.style.width=`${100+this.options.wake*2}%`},g=function(){this.refresh(),this.period>0&&!this.paused&&(this.read(window.innerHeight),this.write(0,$(this.options.scroller)))},z=function(){let e=this.element;this.intersection=new IntersectionObserver(t=>{for(let s of t)this.visible=s.isIntersecting,e.toggleAttribute("data-wake-active",s.isIntersecting),s.isIntersecting&&!this.measured&&o(this,r,g).call(this);d()},{rootMargin:ee,threshold:0}),this.intersection.observe(e),this.resize=new ResizeObserver(()=>{this.measured&&this.refresh()}),this.resize.observe(this.lane),this.resize.observe(e),this.options.pauseOnHover&&matchMedia("(hover: hover)").matches&&(this.onEnter=()=>{this.hovered=!0},this.onLeave=()=>{this.hovered=!1,d()},e.addEventListener("pointerenter",this.onEnter),e.addEventListener("pointerleave",this.onLeave)),this.options.respectMotionPreference&&typeof matchMedia=="function"&&(this.motionQuery=matchMedia("(prefers-reduced-motion: reduce)"),this.onMotionChange=()=>o(this,r,v).call(this),this.motionQuery.addEventListener("change",this.onMotionChange))},v=function(){if(!this.options.respectMotionPreference){d();return}N()?(this.paused=!0,o(this,r,D).call(this)):(this.paused=!1,d())},D=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},H=function(){if(P)return;let e=this.lane.firstElementChild;if(!e)return;let t=getComputedStyle(e),s=t.getPropertyValue("--wake-gap").trim();!((s===""?32:parseFloat(s))>0)||parseFloat(t.marginInlineEnd)!==0||(P=!0,console.warn("wake-marquee: the items have no spacing. An unlayered CSS reset outranks @layer wake-marquee whatever the specificity says. Put the reset in a layer and name the order first: @layer reset, wake-marquee;"))},B=function(){this.aligned=!0;let e=this.amplitude-this.wakePx;this.offset=(e%this.period+this.period)%this.period},k=function(){return!this.destroyed&&!this.paused&&this.visible&&this.period>0};function ae(i){let e=c===0?0:q(i-c);c=i;let t=window.innerHeight;for(let s of u)s.options.reverse&&re(s.options.scroller),s.read(t);for(let s of u)s.write(e,s.options.reverse?$(s.options.scroller):1);m=0,d()}function d(){if(m!==0)return;let i=!1;for(let e of u)if(!e.destroyed&&!e.paused&&e.visible){i=!0;break}if(!i){c=0;return}m=requestAnimationFrame(ae)}function f({root:i=document,defaults:e={}}={}){let t=[];for(let s of i.querySelectorAll(`[${R}]`))se(s)||t.push(new w(s,{...e,...oe(s)}));for(let s of t)s.prime();return t}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>f(),{once:!0}):f());return K(he);})();
|
package/dist/wake-marquee.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var
|
|
1
|
+
var k=t=>{throw TypeError(t)};var Q=(t,e,i)=>e.has(t)||k("Cannot "+i);var y=(t,e,i)=>e.has(t)?k("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(t):e.set(t,i);var n=(t,e,i)=>(Q(t,e,"access private method"),i);function _(t,e,i){return t<e?e:t>i?i:t}function b(t,e,i=1){return!(e>0)||!Number.isFinite(t)?1:Math.ceil(t/e)+1+i}function x(t,e){if(typeof t=="number")return t;let i=parseFloat(t);return Number.isFinite(i)&&e>0?i*e/100:0}function E(t,e,i,s){return s>0?((t+e*i)%s+s)%s:0}function C(t,e,i,s){return t+(e-t)*(1-Math.exp(-i*s))}function S(t,e,i){let s=i+e;return s>0?_((i-t)/s,0,1):0}function M(t,e,i){return-i*e*(1-2*t)}function A(t,e=.1){return!Number.isFinite(t)||t<=0?0:Math.min(t/1e3,e)}var G=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),N="data-wake-marquee",U="wake-lane",R="wake-track",j=1,V="200px 0px",K=/^\d*\.?\d+%$/;function Y(t){return typeof t=="string"?K.test(t):Number.isFinite(t)&&t>=0}function L(t){return t.firstElementChild?.classList.contains(R)===!0}var c=new Set,l=new WeakMap,p=new Map,m=0,u=0,F=!1;function P(t){return t===window?window.scrollY:t.scrollTop}function J(t){let e=p.get(t);if(!e){p.set(t,{last:P(t),sign:1});return}let i=P(t);Math.abs(i-e.last)>.5&&(e.sign=i>e.last?1:-1,e.last=i)}function O(t){return p.get(t)?.sign??1}function q(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function W(t={}){let e={...G};for(let[i,s]of Object.entries(t))s!==void 0&&(e[i]=s);if(e.direction!=="left"&&e.direction!=="right")throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${e.direction}"`);if(typeof e.speed=="string"&&(e.speed=e.speed.trim()),!Y(e.speed)){let i=typeof e.speed=="string"?`"${e.speed}"`:e.speed;throw new RangeError(`wake-marquee: speed must be a non-negative number of pixels per second, or a percentage of the container width per second such as "8%", received ${i}`)}if(!Number.isFinite(e.wake)||e.wake<0)throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${e.wake}`);if(!Number.isFinite(e.ease)||e.ease<=0)throw new RangeError(`wake-marquee: ease must be a positive number, received ${e.ease}`);return e.scroller=e.scroller??(typeof window>"u"?null:window),e}function X(t){let e=t.dataset,i={};if(e.wakeDirection&&(i.direction=e.wakeDirection),e.wakeSpeed){let s=e.wakeSpeed.trim();i.speed=Number.isFinite(Number(s))?Number(s):s}return e.wake&&(i.wake=Number(e.wake)),e.wakeEase&&(i.ease=Number(e.wakeEase)),e.wakeGap&&(i.gap=e.wakeGap),e.wakeFade&&(i.fade=e.wakeFade),e.wakePauseOnHover!==void 0&&(i.pauseOnHover=!0),e.wakeReverse!==void 0&&(i.reverse=e.wakeReverse!=="false"),i}var r,$,I,w,T,g,z,D,H,v,f=class{constructor(e,i){y(this,r);this.element=e,this.options=W(i),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=0,this.speedPx=0,this.wakePx=0,this.amplitude=0,this.aligned=!1,this.visible=!1,this.measured=!1,this.paused=!1,this.hovered=!1,this.destroyed=!1,this.status="",n(this,r,$).call(this),n(this,r,T).call(this),c.add(this),l.set(e,this),n(this,r,g).call(this)}prime(){if(this.destroyed||this.measured)return this;let e=window.innerHeight,i=this.element.getBoundingClientRect(),s=200;return i.bottom<-s||i.top>e+s?this:(this.visible=!0,this.element.toggleAttribute("data-wake-active",!0),n(this,r,w).call(this),this)}refresh(){if(this.destroyed)return;let e=this.lane.getBoundingClientRect().width;if(!(e>0))return;this.period>0&&e!==this.period&&(this.offset=this.offset/this.period*e),this.measured||n(this,r,D).call(this),this.period=e,this.measured=!0,n(this,r,I).call(this);let i=b(this.track.getBoundingClientRect().width,e,j);for(;this.lanes.length<i;){let s=this.lane.cloneNode(!0);s.inert=!0,s.setAttribute("aria-hidden","true"),"inert"in s||s.querySelectorAll("a, button, input, select, textarea, [tabindex]").forEach(o=>o.setAttribute("tabindex","-1")),s.querySelectorAll('img[loading="lazy"]').forEach(o=>o.setAttribute("loading","eager")),this.track.appendChild(s),this.lanes.push(s)}for(;this.lanes.length>i;)this.lanes.pop()?.remove();d()}read(e){if(!n(this,r,v).call(this))return;let i=this.element.getBoundingClientRect(),s=S(i.top,i.height,e);this.amplitude=this.options.wake*i.width/100,this.wakePx=M(s,this.amplitude,this.dirSign),this.speedPx=x(this.options.speed,i.width)}write(e,i){if(!n(this,r,v).call(this))return;this.aligned||n(this,r,H).call(this);let s=this.hovered?0:this.options.reverse?this.dirSign*i:this.dirSign;this.dirFactor=C(this.dirFactor,s,e,this.options.ease),this.offset=E(this.offset,this.dirFactor*this.speedPx,e,this.period);let a=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let B of this.lanes)B.style.transform=a;this.track.style.transform=`translate3d(${this.wakePx.toFixed(2)}px, 0, 0)`;let h=i===1?"forward":"reversed";h!==this.status&&(this.status=h,this.element.setAttribute("data-wake-travel",h))}play(){return this.destroyed?this:this.options.respectMotionPreference&&q()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,c.delete(this),l.delete(this.element),this.intersection?.disconnect(),this.resize?.disconnect(),this.onEnter&&this.element.removeEventListener("pointerenter",this.onEnter),this.onLeave&&this.element.removeEventListener("pointerleave",this.onLeave),this.motionQuery&&this.onMotionChange&&this.motionQuery.removeEventListener("change",this.onMotionChange);for(let e of this.lanes.slice(1))e.remove();for(;this.lane.firstChild;)this.element.appendChild(this.lane.firstChild);this.track.remove(),this.element.removeAttribute("data-wake-travel"),this.element.removeAttribute("data-wake-active");for(let e of this.undo)e();this.undo=[],this.lanes=[]}}};r=new WeakSet,$=function(){let e=this.element,i=e.ownerDocument;for(this.track=i.createElement("div"),this.track.className=R,this.lane=i.createElement("div"),this.lane.className=U;e.firstChild;)this.lane.appendChild(e.firstChild);this.track.appendChild(this.lane),e.appendChild(this.track),this.lanes=[this.lane],this.undo=[];let s=(a,h)=>{e.hasAttribute(a)||(e.setAttribute(a,h),this.undo.push(()=>e.removeAttribute(a)))},o=(a,h)=>{e.style.getPropertyValue(a)||(e.style.setProperty(a,h),this.undo.push(()=>e.style.removeProperty(a)))};s(N,""),this.options.gap&&o("--wake-gap",this.options.gap),this.options.fade&&(o("--wake-fade",this.options.fade),s("data-wake-fade",""))},I=function(){this.track.style.marginInlineStart=`-${this.options.wake}%`,this.track.style.width=`${100+this.options.wake*2}%`},w=function(){this.refresh(),this.period>0&&!this.paused&&(this.read(window.innerHeight),this.write(0,O(this.options.scroller)))},T=function(){let e=this.element;this.intersection=new IntersectionObserver(i=>{for(let s of i)this.visible=s.isIntersecting,e.toggleAttribute("data-wake-active",s.isIntersecting),s.isIntersecting&&!this.measured&&n(this,r,w).call(this);d()},{rootMargin:V,threshold:0}),this.intersection.observe(e),this.resize=new ResizeObserver(()=>{this.measured&&this.refresh()}),this.resize.observe(this.lane),this.resize.observe(e),this.options.pauseOnHover&&matchMedia("(hover: hover)").matches&&(this.onEnter=()=>{this.hovered=!0},this.onLeave=()=>{this.hovered=!1,d()},e.addEventListener("pointerenter",this.onEnter),e.addEventListener("pointerleave",this.onLeave)),this.options.respectMotionPreference&&typeof matchMedia=="function"&&(this.motionQuery=matchMedia("(prefers-reduced-motion: reduce)"),this.onMotionChange=()=>n(this,r,g).call(this),this.motionQuery.addEventListener("change",this.onMotionChange))},g=function(){if(!this.options.respectMotionPreference){d();return}q()?(this.paused=!0,n(this,r,z).call(this)):(this.paused=!1,d())},z=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},D=function(){if(F)return;let e=this.lane.firstElementChild;if(!e)return;let i=getComputedStyle(e),s=i.getPropertyValue("--wake-gap").trim();!((s===""?32:parseFloat(s))>0)||parseFloat(i.marginInlineEnd)!==0||(F=!0,console.warn("wake-marquee: the items have no spacing. An unlayered CSS reset outranks @layer wake-marquee whatever the specificity says. Put the reset in a layer and name the order first: @layer reset, wake-marquee;"))},H=function(){this.aligned=!0;let e=this.amplitude-this.wakePx;this.offset=(e%this.period+this.period)%this.period},v=function(){return!this.destroyed&&!this.paused&&this.visible&&this.period>0};function Z(t){let e=u===0?0:A(t-u);u=t;let i=window.innerHeight;for(let s of c)s.options.reverse&&J(s.options.scroller),s.read(i);for(let s of c)s.write(e,s.options.reverse?O(s.options.scroller):1);m=0,d()}function d(){if(m!==0)return;let t=!1;for(let e of c)if(!e.destroyed&&!e.paused&&e.visible){t=!0;break}if(!t){u=0;return}m=requestAnimationFrame(Z)}function re(t,e={}){if(!t||t.nodeType!==1)throw new TypeError("wake-marquee: createMarquee expects an element");if(L(t))throw new Error("wake-marquee: this element is already a marquee, call destroy() first");return new f(t,e).prime()}function ne({root:t=document,defaults:e={}}={}){let i=[];for(let s of t.querySelectorAll(`[${N}]`))L(s)||i.push(new f(s,{...e,...X(s)}));for(let s of i)s.prime();return i}function oe(t){return t&&l.get(t)||null}export{f as Marquee,re as createMarquee,G as defaults,oe as getMarquee,ne as initMarquees,W as normalizeOptions,X as readOptions};
|
|
@@ -8,12 +8,22 @@
|
|
|
8
8
|
* Options are rendered as `data-wake-*` attributes and read back by the
|
|
9
9
|
* script, so the server sends a plain clipped row and the client enhances it.
|
|
10
10
|
* The row is on screen and correctly laid out before any JavaScript arrives.
|
|
11
|
+
*
|
|
12
|
+
* The script below starts the row and keeps no handle, because a hoisted Astro
|
|
13
|
+
* script has nowhere to hand one to. Ask for it by element instead:
|
|
14
|
+
*
|
|
15
|
+
* import { getMarquee } from 'wake-marquee'
|
|
16
|
+
* getMarquee(document.querySelector('#logos'))?.pause()
|
|
11
17
|
*/
|
|
12
18
|
interface Props {
|
|
13
19
|
/** Travel direction while scrolling down. Scrolling up reverses it. */
|
|
14
20
|
direction?: 'left' | 'right';
|
|
15
|
-
/**
|
|
16
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Travel speed in pixels per second, or a percentage of the container width
|
|
23
|
+
* per second (`"8%"`). The percentage keeps the pace the same on a phone
|
|
24
|
+
* and on a desktop; a fixed pixel speed does not.
|
|
25
|
+
*/
|
|
26
|
+
speed?: number | `${number}%`;
|
|
17
27
|
/** Scroll-driven displacement, in percent of the container width. 0 turns it off. */
|
|
18
28
|
wake?: number;
|
|
19
29
|
/** Whether scrolling up reverses travel. */
|
|
@@ -74,7 +84,9 @@ const style = [gap && `--wake-gap:${gap}`, fade && `--wake-fade:${fade}`]
|
|
|
74
84
|
|
|
75
85
|
<script>
|
|
76
86
|
// Astro hoists and deduplicates this, so a page with ten marquees still
|
|
77
|
-
// ships one copy of the library and runs one initialisation pass.
|
|
87
|
+
// ships one copy of the library and runs one initialisation pass. The
|
|
88
|
+
// handles it returns are dropped on purpose: there is no way to pass them
|
|
89
|
+
// back to the page from here, and getMarquee(element) finds them again.
|
|
78
90
|
import { initMarquees } from 'wake-marquee';
|
|
79
91
|
initMarquees();
|
|
80
92
|
</script>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wake-marquee",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "An endless marquee that answers to the scroll: it reverses when the reader scrolls back, and drags against its own direction of travel as it crosses the viewport.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/marquee.js
CHANGED
|
@@ -23,13 +23,24 @@
|
|
|
23
23
|
* @module wake-marquee
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
advance,
|
|
28
|
+
clamp,
|
|
29
|
+
easeDirection,
|
|
30
|
+
frameDelta,
|
|
31
|
+
laneCount,
|
|
32
|
+
resolveSpeed,
|
|
33
|
+
viewProgress,
|
|
34
|
+
wakeOffset,
|
|
35
|
+
} from './motion.js';
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
38
|
* @typedef {object} MarqueeOptions
|
|
30
39
|
* @property {'left' | 'right'} [direction='left'] Travel direction while the
|
|
31
40
|
* reader scrolls down. Scrolling up reverses it unless `reverse` is off.
|
|
32
|
-
* @property {number} [speed=60] Travel speed in pixels per second
|
|
41
|
+
* @property {number | string} [speed=60] Travel speed in pixels per second,
|
|
42
|
+
* or a percentage of the container width per second, e.g. `'8%'`. The
|
|
43
|
+
* percentage is the one that reads the same on a phone and on a desktop.
|
|
33
44
|
* @property {number} [wake=8] Scroll-driven displacement, as a percentage of
|
|
34
45
|
* the container width. `0` turns the wake off and leaves a plain loop.
|
|
35
46
|
* @property {boolean} [reverse=true] Whether scrolling up reverses travel.
|
|
@@ -73,6 +84,25 @@ const LANE_BUFFER = 1;
|
|
|
73
84
|
/** How far outside the viewport an instance starts running, in px. */
|
|
74
85
|
const ROOT_MARGIN = '200px 0px';
|
|
75
86
|
|
|
87
|
+
/** The relative form of `speed`: `'8%'` of the container width, per second. */
|
|
88
|
+
const PERCENT = /^\d*\.?\d+%$/;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Is this a speed the loop can run at: px per second, or a percentage of the
|
|
92
|
+
* container width per second?
|
|
93
|
+
*
|
|
94
|
+
* Deliberately no time form. `'9.6s'` for one container width reads well until
|
|
95
|
+
* it sits next to the number form, where it reverses the direction of the
|
|
96
|
+
* option: `'12s'` would be slower than `'6s'` while `120` is faster than `60`.
|
|
97
|
+
*
|
|
98
|
+
* @param {unknown} value
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
function isSpeed(value) {
|
|
102
|
+
if (typeof value === 'string') return PERCENT.test(value);
|
|
103
|
+
return Number.isFinite(value) && /** @type {number} */ (value) >= 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
76
106
|
/**
|
|
77
107
|
* Has this element already been turned into a marquee?
|
|
78
108
|
*
|
|
@@ -91,12 +121,23 @@ function isInitialised(el) {
|
|
|
91
121
|
/** @type {Set<Marquee>} Every live instance, driven by the one shared loop. */
|
|
92
122
|
const registry = new Set();
|
|
93
123
|
|
|
124
|
+
/**
|
|
125
|
+
* @type {WeakMap<Element, Marquee>} The same instances, indexed by element, so
|
|
126
|
+
* a handle can be found again by whoever ends up needing one. Weak because the
|
|
127
|
+
* key is the caller's element: a row removed from the document should not be
|
|
128
|
+
* held alive by this.
|
|
129
|
+
*/
|
|
130
|
+
const byElement = new WeakMap();
|
|
131
|
+
|
|
94
132
|
/** @type {Map<Window | HTMLElement, {last: number, sign: number}>} */
|
|
95
133
|
const scrollers = new Map();
|
|
96
134
|
|
|
97
135
|
let rafId = 0;
|
|
98
136
|
let lastFrame = 0;
|
|
99
137
|
|
|
138
|
+
/** Warned about collapsed spacing once? A global reset breaks every row. */
|
|
139
|
+
let warnedSpacing = false;
|
|
140
|
+
|
|
100
141
|
/**
|
|
101
142
|
* Read the scroll offset of either the window or an overflow container.
|
|
102
143
|
* @param {Window | HTMLElement} scroller
|
|
@@ -167,8 +208,13 @@ export function normalizeOptions(options = {}) {
|
|
|
167
208
|
if (config.direction !== 'left' && config.direction !== 'right') {
|
|
168
209
|
throw new RangeError(`wake-marquee: direction must be "left" or "right", received "${config.direction}"`);
|
|
169
210
|
}
|
|
170
|
-
if (
|
|
171
|
-
|
|
211
|
+
if (typeof config.speed === 'string') config.speed = config.speed.trim();
|
|
212
|
+
if (!isSpeed(config.speed)) {
|
|
213
|
+
const received = typeof config.speed === 'string' ? `"${config.speed}"` : config.speed;
|
|
214
|
+
throw new RangeError(
|
|
215
|
+
'wake-marquee: speed must be a non-negative number of pixels per second, or a percentage ' +
|
|
216
|
+
`of the container width per second such as "8%", received ${received}`,
|
|
217
|
+
);
|
|
172
218
|
}
|
|
173
219
|
if (!Number.isFinite(config.wake) || config.wake < 0) {
|
|
174
220
|
throw new RangeError(`wake-marquee: wake must be a non-negative number, received ${config.wake}`);
|
|
@@ -197,7 +243,13 @@ export function readOptions(el) {
|
|
|
197
243
|
const options = {};
|
|
198
244
|
|
|
199
245
|
if (d.wakeDirection) options.direction = /** @type {'left' | 'right'} */ (d.wakeDirection);
|
|
200
|
-
|
|
246
|
+
// A percentage passes through as the string it is. Anything else that is
|
|
247
|
+
// not a number passes through too, so normalisation can name it in the
|
|
248
|
+
// error rather than reporting the NaN that Number() would have made of it.
|
|
249
|
+
if (d.wakeSpeed) {
|
|
250
|
+
const speed = d.wakeSpeed.trim();
|
|
251
|
+
options.speed = Number.isFinite(Number(speed)) ? Number(speed) : speed;
|
|
252
|
+
}
|
|
201
253
|
if (d.wake) options.wake = Number(d.wake);
|
|
202
254
|
if (d.wakeEase) options.ease = Number(d.wakeEase);
|
|
203
255
|
if (d.wakeGap) options.gap = d.wakeGap;
|
|
@@ -231,6 +283,8 @@ class Marquee {
|
|
|
231
283
|
this.offset = 0;
|
|
232
284
|
/** Width of one lane in px: the loop's repeat distance. */
|
|
233
285
|
this.period = 0;
|
|
286
|
+
/** Last resolved travel speed in px per second. */
|
|
287
|
+
this.speedPx = 0;
|
|
234
288
|
/** Last written wake displacement in px, kept so a paused frame holds. */
|
|
235
289
|
this.wakePx = 0;
|
|
236
290
|
/** Last measured wake amplitude in px. */
|
|
@@ -250,6 +304,7 @@ class Marquee {
|
|
|
250
304
|
this.#observe();
|
|
251
305
|
|
|
252
306
|
registry.add(this);
|
|
307
|
+
byElement.set(root, this);
|
|
253
308
|
this.#applyMotionPreference();
|
|
254
309
|
}
|
|
255
310
|
|
|
@@ -480,6 +535,7 @@ class Marquee {
|
|
|
480
535
|
this.offset = (this.offset / this.period) * period;
|
|
481
536
|
}
|
|
482
537
|
|
|
538
|
+
if (!this.measured) this.#checkSpacing();
|
|
483
539
|
this.period = period;
|
|
484
540
|
this.measured = true;
|
|
485
541
|
this.#setOverhang();
|
|
@@ -515,6 +571,40 @@ class Marquee {
|
|
|
515
571
|
schedule();
|
|
516
572
|
}
|
|
517
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Say something when a global CSS reset has eaten the spacing between items.
|
|
576
|
+
*
|
|
577
|
+
* `margin-inline-end` on the item lives in `@layer wake-marquee`, and every
|
|
578
|
+
* unlayered declaration beats every layered one whatever the specificity
|
|
579
|
+
* says, so an ordinary `* { margin: 0 }` reset wins over it. Nothing breaks
|
|
580
|
+
* loudly: the row still loops, `--wake-gap` still resolves correctly in the
|
|
581
|
+
* devtools, and the items simply sit flush against one another. A failure
|
|
582
|
+
* that quiet, in the interaction between two stylesheets, is worth a line in
|
|
583
|
+
* the console; it otherwise reads as a mistake in the consuming project.
|
|
584
|
+
*
|
|
585
|
+
* Runs once per instance, and never again on any of them once it has fired.
|
|
586
|
+
*/
|
|
587
|
+
#checkSpacing() {
|
|
588
|
+
if (warnedSpacing) return;
|
|
589
|
+
const item = this.lane.firstElementChild;
|
|
590
|
+
if (!item) return;
|
|
591
|
+
|
|
592
|
+
const style = getComputedStyle(item);
|
|
593
|
+
// Unset resolves to the stylesheet's own 2rem, which is not zero either.
|
|
594
|
+
const declared = style.getPropertyValue('--wake-gap').trim();
|
|
595
|
+
const asked = declared === '' ? 32 : parseFloat(declared);
|
|
596
|
+
// A calc() or a var() chain parses as NaN. Unknowable, so say nothing:
|
|
597
|
+
// a false warning about someone else's CSS is worse than none.
|
|
598
|
+
if (!(asked > 0) || parseFloat(style.marginInlineEnd) !== 0) return;
|
|
599
|
+
|
|
600
|
+
warnedSpacing = true;
|
|
601
|
+
console.warn(
|
|
602
|
+
'wake-marquee: the items have no spacing. An unlayered CSS reset outranks ' +
|
|
603
|
+
'@layer wake-marquee whatever the specificity says. Put the reset in a layer ' +
|
|
604
|
+
'and name the order first: @layer reset, wake-marquee;',
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
518
608
|
/** Read geometry. Never writes, so it cannot force a layout mid-frame. */
|
|
519
609
|
read(viewport) {
|
|
520
610
|
if (!this.#running()) return;
|
|
@@ -524,6 +614,11 @@ class Marquee {
|
|
|
524
614
|
// overhang the track was given, and that is measured in these same px.
|
|
525
615
|
this.amplitude = (this.options.wake * rect.width) / 100;
|
|
526
616
|
this.wakePx = wakeOffset(progress, this.amplitude, this.dirSign);
|
|
617
|
+
// Resolved here rather than in write(), because a percentage speed is a
|
|
618
|
+
// fraction of this same width and this is where the width is honest. It
|
|
619
|
+
// is re-read every frame, so an option changed at runtime takes effect on
|
|
620
|
+
// the next one, and a container resized under a running row is followed.
|
|
621
|
+
this.speedPx = resolveSpeed(this.options.speed, rect.width);
|
|
527
622
|
}
|
|
528
623
|
|
|
529
624
|
/**
|
|
@@ -554,7 +649,7 @@ class Marquee {
|
|
|
554
649
|
const target = this.hovered ? 0 : this.options.reverse ? this.dirSign * scrollDir : this.dirSign;
|
|
555
650
|
this.dirFactor = easeDirection(this.dirFactor, target, dt, this.options.ease);
|
|
556
651
|
|
|
557
|
-
this.offset = advance(this.offset, this.dirFactor * this.
|
|
652
|
+
this.offset = advance(this.offset, this.dirFactor * this.speedPx, dt, this.period);
|
|
558
653
|
|
|
559
654
|
// One period of lead, so travelling left never exposes the origin.
|
|
560
655
|
const x = this.offset - this.period;
|
|
@@ -598,6 +693,7 @@ class Marquee {
|
|
|
598
693
|
if (this.destroyed) return;
|
|
599
694
|
this.destroyed = true;
|
|
600
695
|
registry.delete(this);
|
|
696
|
+
byElement.delete(this.element);
|
|
601
697
|
|
|
602
698
|
this.intersection?.disconnect();
|
|
603
699
|
this.resize?.disconnect();
|
|
@@ -713,4 +809,25 @@ export function initMarquees({ root = document, defaults = {} } = {}) {
|
|
|
713
809
|
return created;
|
|
714
810
|
}
|
|
715
811
|
|
|
812
|
+
/**
|
|
813
|
+
* The marquee running on an element, or `null` if there is none.
|
|
814
|
+
*
|
|
815
|
+
* `initMarquees()` returns only the instances that call created, which is the
|
|
816
|
+
* right answer for something safe to call repeatedly and the wrong one for
|
|
817
|
+
* anybody who needs a handle later: the second call over the same content
|
|
818
|
+
* returns an empty array, because every row is already running. Without a way
|
|
819
|
+
* back in, a declarative integration is a one-way door: `wake-marquee/astro`
|
|
820
|
+
* starts the rows for you and there is nothing to pause, refresh or destroy.
|
|
821
|
+
*
|
|
822
|
+
* @param {Element | null | undefined} element
|
|
823
|
+
* @returns {Marquee | null}
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* const row = getMarquee(document.querySelector('#logos'))
|
|
827
|
+
* row?.pause()
|
|
828
|
+
*/
|
|
829
|
+
export function getMarquee(element) {
|
|
830
|
+
return (element && byElement.get(element)) || null;
|
|
831
|
+
}
|
|
832
|
+
|
|
716
833
|
export { DEFAULTS as defaults, Marquee };
|
package/src/motion.js
CHANGED
|
@@ -37,6 +37,32 @@ export function laneCount(trackWidth, period, buffer = 1) {
|
|
|
37
37
|
return Math.ceil(trackWidth / period) + 1 + buffer;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the `speed` option to pixels per second.
|
|
42
|
+
*
|
|
43
|
+
* A number is already px/s. A percentage is that fraction of the container
|
|
44
|
+
* width per second, and it exists because px/s is a physical unit on a page
|
|
45
|
+
* whose elements are not physically constant: a logo row 40px tall with a
|
|
46
|
+
* 72px gap on a desktop is 16px tall with a 32px gap on a phone, so a fixed
|
|
47
|
+
* 150 px/s crosses a 1440px container in ten seconds and a 390px one in under
|
|
48
|
+
* three, with three times as many items passing per second. One value, calm on
|
|
49
|
+
* the desktop and hectic on the phone. A percentage scales with everything
|
|
50
|
+
* else on the page and reads as the same pace on both.
|
|
51
|
+
*
|
|
52
|
+
* A rate rather than a duration on purpose. `speed` already means "larger is
|
|
53
|
+
* faster" in its number form, and seconds-per-width would reverse that inside
|
|
54
|
+
* the same option: `'12s'` slower than `'6s'` while `120` is faster than `60`.
|
|
55
|
+
*
|
|
56
|
+
* @param {number | string} speed px per second, or `'8%'` of the container.
|
|
57
|
+
* @param {number} width Container width in px.
|
|
58
|
+
* @returns {number} px per second.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveSpeed(speed, width) {
|
|
61
|
+
if (typeof speed === 'number') return speed;
|
|
62
|
+
const percent = parseFloat(speed);
|
|
63
|
+
return Number.isFinite(percent) && width > 0 ? (percent * width) / 100 : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Move the loop on by one frame and wrap it back into `[0, period)`.
|
|
42
68
|
*
|
package/src/wake-marquee.css
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Everything lives in one cascade layer, so your own unlayered CSS wins
|
|
5
5
|
* without reaching for !important. Declare the order yourself if you use
|
|
6
6
|
* layers too: @layer theme, wake-marquee, utilities;
|
|
7
|
+
*
|
|
8
|
+
* The same rule cuts the other way, and it is the one thing to watch for: an
|
|
9
|
+
* unlayered global reset beats every layer whatever the specificity says, so
|
|
10
|
+
* `* { margin: 0 }` collapses the item spacing below to zero and the items sit
|
|
11
|
+
* flush. Put the reset in a layer of its own and name the order first:
|
|
12
|
+
* @layer reset, wake-marquee;
|
|
7
13
|
*/
|
|
8
14
|
@layer wake-marquee {
|
|
9
15
|
/**
|