wake-marquee 0.1.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 +81 -0
- package/LICENSE +21 -0
- package/README.md +326 -0
- package/dist/wake-marquee.auto.js +1 -0
- package/dist/wake-marquee.css +1 -0
- package/dist/wake-marquee.js +1 -0
- package/integrations/astro/WakeMarquee.astro +80 -0
- package/package.json +66 -0
- package/src/auto.js +24 -0
- package/src/marquee.js +716 -0
- package/src/motion.js +131 -0
- package/src/wake-marquee.css +115 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
While the version is below `1.0.0` the API may still change in a minor release.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- The row no longer jumps as it starts. Three separate causes, all of them
|
|
14
|
+
the same shape: work spread across frames that the browser paints one by
|
|
15
|
+
one.
|
|
16
|
+
- Measuring, cloning and the first transform were left to three different
|
|
17
|
+
observer callbacks. They now happen together, in the task that starts the
|
|
18
|
+
row, for any row already on screen.
|
|
19
|
+
- The track's overhang was applied at construction while the transform that
|
|
20
|
+
cancels it was written on first sight. Between the two, the row sat a full
|
|
21
|
+
wake amplitude off to one side. Both now happen in the same frame.
|
|
22
|
+
- The first animated frame landed on the loop's zero point rather than on
|
|
23
|
+
the position the static row already occupied. It is now aligned to it,
|
|
24
|
+
which matters most for a row reached by anchor link or a restored scroll
|
|
25
|
+
position, where the two are furthest apart.
|
|
26
|
+
- A lane that resizes under a running row, from a late web font or an image
|
|
27
|
+
with no `width` and `height`, no longer shifts the row by the difference.
|
|
28
|
+
The offset is restated in the new period, holding the phase.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `wake-marquee/astro` renders `--wake-gap` and `--wake-fade` as inline custom
|
|
33
|
+
properties, so the static row is laid out correctly before the script runs.
|
|
34
|
+
The `data-wake-*` attributes still carry the same values for the script.
|
|
35
|
+
|
|
36
|
+
## [0.1.0] - 2026-09-01
|
|
37
|
+
|
|
38
|
+
First release.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- `createMarquee(element, options)` turns an element and its children into an
|
|
43
|
+
endless marquee and hands back a handle with `play`, `pause`, `refresh` and
|
|
44
|
+
`destroy`. `destroy` restores the original DOM exactly, which is what makes
|
|
45
|
+
the React integration a ref and an effect rather than an adapter package.
|
|
46
|
+
- The wake: the layer holding the content is built `wake` percent wider than
|
|
47
|
+
its container on each side, and the scroll spends exactly that reserve as
|
|
48
|
+
the element crosses the viewport. The displacement is bounded by the
|
|
49
|
+
overhang, so the ends of the row can never come into view.
|
|
50
|
+
- The turn: scrolling up reverses travel, eased from `+1` to `-1` rather than
|
|
51
|
+
switched. The easing is framerate independent, so the same reversal takes
|
|
52
|
+
the same time at 60 and at 120 Hz.
|
|
53
|
+
- `initMarquees()` and `wake-marquee/auto` start every `[data-wake-marquee]`
|
|
54
|
+
on the page from its own `data-wake-*` attributes, so a page with no build
|
|
55
|
+
step needs one script tag and no JavaScript of its own.
|
|
56
|
+
- One shared `requestAnimationFrame` loop for every instance, reading all
|
|
57
|
+
geometry before writing any transform, so a frame costs one layout rather
|
|
58
|
+
than one per row. The loop stops when nothing is on screen.
|
|
59
|
+
- Rows are measured and cloned on first sight, not at construction, so a page
|
|
60
|
+
of ten marquees does no work for the nine nobody has scrolled to.
|
|
61
|
+
- `wake-marquee/css`: the loop geometry in an `@layer wake-marquee` cascade
|
|
62
|
+
layer, 407 B gzipped. It owns nothing about how items look.
|
|
63
|
+
- `wake-marquee/astro`: Astro component rendering the same attributes.
|
|
64
|
+
- Accessibility: clones are `inert` and `aria-hidden`, with a `tabindex="-1"`
|
|
65
|
+
fallback where `inert` is missing, so the content is announced once rather
|
|
66
|
+
than once per lane. Lazy images inside clones are switched to eager, because
|
|
67
|
+
a clone starts off screen and would otherwise arrive as a hole in the row.
|
|
68
|
+
- `prefers-reduced-motion: reduce` is honoured live: the row never starts, and
|
|
69
|
+
turning the preference on mid-session stops and resets it.
|
|
70
|
+
|
|
71
|
+
### Notes
|
|
72
|
+
|
|
73
|
+
- The spacing between items is a `margin-inline-end` on the item rather than
|
|
74
|
+
`gap` on the row. The lane's width is the loop period, so the space after
|
|
75
|
+
the last item has to belong to the lane; with `gap` there is none between
|
|
76
|
+
one lane and the next and the seam shows on every pass.
|
|
77
|
+
- Right-to-left writing modes are not supported. Flex rows follow the writing
|
|
78
|
+
direction while the transforms are physical, so the two disagree.
|
|
79
|
+
|
|
80
|
+
[Unreleased]: https://github.com/robin-gogolok/wake-marquee/compare/v0.1.0...HEAD
|
|
81
|
+
[0.1.0]: https://github.com/robin-gogolok/wake-marquee/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robin Gogolok
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# wake-marquee
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
**[Live demo →](https://robin-gogolok.github.io/wake-marquee/)**
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
3.0 kB gzipped JS
|
|
9
|
+
0.4 kB gzipped CSS
|
|
10
|
+
0 runtime dependencies
|
|
11
|
+
1 rAF loop, however many rows are on the page
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
A row of logos that plays on a loop is wallpaper. The reader learns in two seconds that it will never do anything, and stops looking. This makes the row part of the page instead: it has momentum, it turns around when they do, and it is visibly being dragged through the scroll.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Why this exists
|
|
19
|
+
|
|
20
|
+
A CSS marquee is four lines and costs nothing, and for a lot of pages that is the right answer. It also has nothing to say to the reader. Nothing about it changes whether they are moving, still, or heading back up.
|
|
21
|
+
|
|
22
|
+
The scroll-linked half of this cannot be done in CSS today. `animation-timeline: scroll()` can drive an animation from the scroll position, but not an animation that also runs on its own, in a direction the scroll only influences. That needs a frame loop.
|
|
23
|
+
|
|
24
|
+
So this is a frame loop, kept as small as one can honestly be:
|
|
25
|
+
|
|
26
|
+
- **One `requestAnimationFrame` for the whole page.** Ten rows share it. It reads every element's geometry before it writes any transform, so a frame costs one layout rather than one per row.
|
|
27
|
+
- **It stops when nothing is on screen.** An idle rAF loop still keeps the compositor awake, and that shows up on a battery.
|
|
28
|
+
- **Nothing is measured or cloned until a row is actually seen.** A page with a row in the footer does no work for it until the reader gets there.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm i wake-marquee
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Use
|
|
37
|
+
|
|
38
|
+
Import the stylesheet once, anywhere in your app:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import 'wake-marquee/css'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Write the container and its items. The direct children become the items; the library builds everything else.
|
|
45
|
+
|
|
46
|
+
```html
|
|
47
|
+
<div data-wake-marquee data-wake-speed="55" data-wake="10" data-wake-fade="6rem">
|
|
48
|
+
<img src="/logos/a.svg" alt="Nordlicht">
|
|
49
|
+
<img src="/logos/b.svg" alt="Kvist">
|
|
50
|
+
<img src="/logos/c.svg" alt="Halden & Co">
|
|
51
|
+
</div>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then either start every row on the page in one line:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import 'wake-marquee/auto'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
or take a handle and drive it yourself:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { createMarquee } from 'wake-marquee'
|
|
64
|
+
|
|
65
|
+
const row = createMarquee(document.querySelector('#logos'), {
|
|
66
|
+
direction: 'left',
|
|
67
|
+
speed: 55,
|
|
68
|
+
wake: 10,
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Astro
|
|
73
|
+
|
|
74
|
+
```astro
|
|
75
|
+
---
|
|
76
|
+
import WakeMarquee from 'wake-marquee/astro'
|
|
77
|
+
---
|
|
78
|
+
<WakeMarquee speed={55} wake={10} fade="6rem" aria-label="Brands we stock">
|
|
79
|
+
{brands.map((b) => <img src={b.logo} alt={b.name} />)}
|
|
80
|
+
</WakeMarquee>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### React
|
|
84
|
+
|
|
85
|
+
The library takes an element, so a ref and an effect are the whole integration. No adapter package.
|
|
86
|
+
|
|
87
|
+
```jsx
|
|
88
|
+
import { useEffect, useRef } from 'react'
|
|
89
|
+
import { createMarquee } from 'wake-marquee'
|
|
90
|
+
import 'wake-marquee/css'
|
|
91
|
+
|
|
92
|
+
function Marquee({ children, ...options }) {
|
|
93
|
+
const ref = useRef(null)
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const row = createMarquee(ref.current, options)
|
|
97
|
+
return () => row.destroy()
|
|
98
|
+
// Options are read once at construction; change the key to rebuild.
|
|
99
|
+
}, [])
|
|
100
|
+
|
|
101
|
+
return <div ref={ref} data-wake-marquee>{children}</div>
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`destroy()` puts the DOM back exactly as it found it, which is what makes this safe under React 18's double-invoked effects in development.
|
|
106
|
+
|
|
107
|
+
## What the wake is
|
|
108
|
+
|
|
109
|
+
The row does two things at once.
|
|
110
|
+
|
|
111
|
+
The **loop** is the ordinary part: lanes of content translate sideways by up to one lane width, then snap back. The snap is invisible because it lands exactly one repeat of the content later.
|
|
112
|
+
|
|
113
|
+
The **wake** is the part worth having. The layer holding those lanes is built wider than its container by `wake` percent on each side, and the scroll spends precisely that reserve as the element crosses the viewport:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
element enters centre element leaves
|
|
117
|
+
│ │ │
|
|
118
|
+
track ├────┼──────────────────────────┼──────────────────────────┼────┤
|
|
119
|
+
└ overhang overhang ┘
|
|
120
|
+
→ → → · ← ← ←
|
|
121
|
+
held back against its travel zero pulled the other way
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Because the displacement is bounded by exactly the overhang the track was given, the ends of the row can never come into view, at any scroll position or scroll speed. That bound is a unit test, not a hope.
|
|
125
|
+
|
|
126
|
+
Set `wake: 0` and you have a plain loop. Around `8` is a row that feels attached to the page; past `20` it reads as an effect in its own right.
|
|
127
|
+
|
|
128
|
+
## The turn
|
|
129
|
+
|
|
130
|
+
When the reader scrolls up, the row turns around. Not by flipping a sign, which looks like a video played backwards, but by easing the direction factor from `+1` to `-1` over `ease` seconds. The row decelerates, stops, and picks up the other way, so it reads as something with mass.
|
|
131
|
+
|
|
132
|
+
The easing is framerate independent, so the same turn takes the same time on a 60 Hz laptop and a 120 Hz phone. (`current += (target - current) * 0.1` per frame, the usual lerp, would be twice as fast on the phone.)
|
|
133
|
+
|
|
134
|
+
Set `reverse: false` to keep a fixed heading and keep the wake.
|
|
135
|
+
|
|
136
|
+
## Starting without a flash
|
|
137
|
+
|
|
138
|
+
A row has two lives: the static one the server sends, and the running one the
|
|
139
|
+
script builds. The handover between them is where marquees usually give
|
|
140
|
+
themselves away, jumping once or twice before they settle.
|
|
141
|
+
|
|
142
|
+
The library does its part. Measuring, cloning and the first transform all
|
|
143
|
+
happen in one task, so no half-built state is ever painted, and that first
|
|
144
|
+
frame is positioned to land on the same pixel the static row occupied rather
|
|
145
|
+
than wherever the loop's zero point falls. A row reached by anchor link, or a
|
|
146
|
+
reload at a restored scroll position, starts as cleanly as one scrolled to.
|
|
147
|
+
|
|
148
|
+
Two things are left to you, because both change the layout of the static row
|
|
149
|
+
before any script has run:
|
|
150
|
+
|
|
151
|
+
**Declare the spacing in CSS, not only in the attribute.** `data-wake-gap` is
|
|
152
|
+
read by the script, which means the static row is laid out with the default
|
|
153
|
+
`2rem` until then and the items shift when it runs.
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<div data-wake-marquee data-wake-gap="4.5rem" style="--wake-gap:4.5rem">
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Same for `data-wake-fade` and `--wake-fade`. The library leaves a property
|
|
160
|
+
that is already set alone, so there is no conflict. `wake-marquee/astro` does
|
|
161
|
+
this for you.
|
|
162
|
+
|
|
163
|
+
**Give images `width` and `height`.** An unsized image changes the lane width
|
|
164
|
+
when it decodes, and the lane width is the loop period. The library restates
|
|
165
|
+
the offset in the new period so the row does not leap, but the items around it
|
|
166
|
+
still move: only the attributes prevent that.
|
|
167
|
+
|
|
168
|
+
## API
|
|
169
|
+
|
|
170
|
+
### `createMarquee(element, options?)`
|
|
171
|
+
|
|
172
|
+
Turns an element and its children into a marquee. Returns a handle.
|
|
173
|
+
|
|
174
|
+
| Option | Default | |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `direction` | `'left'` | `'left'` or `'right'`, while scrolling down |
|
|
177
|
+
| `speed` | `60` | Travel speed in pixels per second |
|
|
178
|
+
| `wake` | `8` | Scroll displacement, as a percentage of the container width. `0` turns it off |
|
|
179
|
+
| `reverse` | `true` | Whether scrolling up reverses travel |
|
|
180
|
+
| `ease` | `5` | How sharply a reversal settles, per second. Around `1` is a long, heavy turn |
|
|
181
|
+
| `gap` | `2rem` | Space between two items, any CSS length |
|
|
182
|
+
| `fade` | `false` | Soft edges left and right, any CSS length |
|
|
183
|
+
| `pauseOnHover` | `false` | Hold still while a real pointer rests on the row |
|
|
184
|
+
| `scroller` | `window` | What to read the scroll direction from |
|
|
185
|
+
| `respectMotionPreference` | `true` | Stay still under `prefers-reduced-motion: reduce` |
|
|
186
|
+
|
|
187
|
+
The handle:
|
|
188
|
+
|
|
189
|
+
| Member | |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `element` | The element you passed in |
|
|
192
|
+
| `options` | The normalised options |
|
|
193
|
+
| `play()` | Resume. Does nothing under a reduced-motion preference |
|
|
194
|
+
| `pause()` | Hold the row where it is. The wake stops with it |
|
|
195
|
+
| `refresh()` | Re-measure and top up the clones. Call it after changing the items yourself |
|
|
196
|
+
| `destroy()` | Undo everything: clones, wrappers, observers, listeners, attributes |
|
|
197
|
+
|
|
198
|
+
### `initMarquees({ root?, defaults? })`
|
|
199
|
+
|
|
200
|
+
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.
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
import { initMarquees } from 'wake-marquee'
|
|
204
|
+
|
|
205
|
+
initMarquees() // the whole document
|
|
206
|
+
initMarquees({ root: panel, defaults: { wake: 4 } }) // scoped, with a house default
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`defaults` sits under each element's own attributes, so the markup always wins.
|
|
210
|
+
|
|
211
|
+
### Attributes
|
|
212
|
+
|
|
213
|
+
Every option except `scroller` and `respectMotionPreference` can be declared in markup, which is what makes `wake-marquee/auto` a complete integration on its own.
|
|
214
|
+
|
|
215
|
+
| Attribute | Option |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `data-wake-marquee` | Marks the container. Also the stylesheet's hook |
|
|
218
|
+
| `data-wake-direction="right"` | `direction` |
|
|
219
|
+
| `data-wake-speed="55"` | `speed` |
|
|
220
|
+
| `data-wake="10"` | `wake` |
|
|
221
|
+
| `data-wake-ease="2.5"` | `ease` |
|
|
222
|
+
| `data-wake-gap="4rem"` | `gap` |
|
|
223
|
+
| `data-wake-fade="6rem"` | `fade` |
|
|
224
|
+
| `data-wake-reverse="false"` | `reverse` |
|
|
225
|
+
| `data-wake-pause-on-hover` | `pauseOnHover`, presence is the value |
|
|
226
|
+
|
|
227
|
+
The library writes two attributes back, for you to hang CSS off:
|
|
228
|
+
|
|
229
|
+
| | |
|
|
230
|
+
|---|---|
|
|
231
|
+
| `data-wake-active` | Present while the row is on screen and running |
|
|
232
|
+
| `data-wake-travel` | `forward` or `reversed`, following the reader |
|
|
233
|
+
|
|
234
|
+
```css
|
|
235
|
+
[data-wake-travel="reversed"] .item { color: var(--accent); }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Without a bundler
|
|
239
|
+
|
|
240
|
+
```html
|
|
241
|
+
<link rel="stylesheet" href="https://unpkg.com/wake-marquee/dist/wake-marquee.css">
|
|
242
|
+
<script src="https://unpkg.com/wake-marquee/dist/wake-marquee.auto.js" defer></script>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Starts every `[data-wake-marquee]` on the page and leaves the API on `window.WakeMarquee`.
|
|
246
|
+
|
|
247
|
+
## Styling
|
|
248
|
+
|
|
249
|
+
The library owns the loop geometry and nothing else. Items look however you style them.
|
|
250
|
+
|
|
251
|
+
| Custom property | Default | |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `--wake-gap` | `2rem` | Space after each item |
|
|
254
|
+
| `--wake-fade` | — | Width of the soft edge, when `fade` is on |
|
|
255
|
+
|
|
256
|
+
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
|
+
|
|
258
|
+
All rules live in an `@layer wake-marquee` cascade layer, so your own unlayered CSS wins without `!important`.
|
|
259
|
+
|
|
260
|
+
### Tailwind CSS v4
|
|
261
|
+
|
|
262
|
+
Declare the layer order before the imports, or the library will outrank every utility:
|
|
263
|
+
|
|
264
|
+
```css
|
|
265
|
+
@layer theme, base, components, wake-marquee, utilities;
|
|
266
|
+
|
|
267
|
+
@import "tailwindcss";
|
|
268
|
+
@import "wake-marquee/css";
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
A layer registered late wins, and an `@import` after `tailwindcss` registers `wake-marquee` after `utilities`. Declared up front, it sits where it belongs: your utilities still win.
|
|
272
|
+
|
|
273
|
+
## Accessibility
|
|
274
|
+
|
|
275
|
+
- **The content is announced once.** Clones are `inert` and `aria-hidden`, so a screen reader hears eight logos, not the forty that fill the track. Where `inert` is missing, focusable elements inside clones get `tabindex="-1"` instead, so they never become invisible tab stops.
|
|
276
|
+
- **Reduced motion is honoured live.** Under `prefers-reduced-motion: reduce` the row does not start, and if the reader turns the preference on while the page is open, it stops and resets. What is left is a static, clipped row: the same thing a reader with JavaScript off sees.
|
|
277
|
+
- **Only `transform` is animated**, on a layer that is promoted while the row is on screen and dropped when it leaves.
|
|
278
|
+
- Give the container an `aria-label` if the row means something (`aria-label="Brands we stock"`), or leave it off if it is decoration.
|
|
279
|
+
|
|
280
|
+
## Browser support
|
|
281
|
+
|
|
282
|
+
| | |
|
|
283
|
+
|---|---|
|
|
284
|
+
| Chrome / Edge | 99+ |
|
|
285
|
+
| Safari / iOS | 15.4+ |
|
|
286
|
+
| Firefox | 97+ |
|
|
287
|
+
|
|
288
|
+
The floor is cascade layers, which is what `@layer wake-marquee` needs. Everything else the library uses, `IntersectionObserver`, `ResizeObserver` and `matchMedia` change events, shipped well before that.
|
|
289
|
+
|
|
290
|
+
One exception: `inert` reached Firefox in 112. Below that, clones fall back to a `tabindex="-1"` sweep, which keeps them out of the tab order but not out of the accessibility tree.
|
|
291
|
+
|
|
292
|
+
Without JavaScript, or before it arrives, the row is a static line of items clipped at the container edge. That is the intended resting state, not a broken one, and it needs no script.
|
|
293
|
+
|
|
294
|
+
## Known limitations
|
|
295
|
+
|
|
296
|
+
### It needs JavaScript
|
|
297
|
+
|
|
298
|
+
There is no build-time or CSS-only version of this, and there cannot be one while the row has to both run on its own and be steered by the scroll. If a completely static row is unacceptable as the no-JS state, this is the wrong library.
|
|
299
|
+
|
|
300
|
+
### Items are cloned
|
|
301
|
+
|
|
302
|
+
Filling the track means copying the row until it covers the container, typically two to four times. That is cheap for images and text and expensive for anything with its own runtime: iframes, videos, canvases and framework components with state get copied along with everything else. Rows of that kind want a different approach.
|
|
303
|
+
|
|
304
|
+
### Horizontal, and left-to-right
|
|
305
|
+
|
|
306
|
+
Vertical marquees are not supported. Neither is a right-to-left writing mode: the flex rows follow the writing direction while the transforms are physical, so the two disagree and the loop runs the wrong way. An `rtl` page can still use this inside an `ltr` container.
|
|
307
|
+
|
|
308
|
+
### One measurement, one period
|
|
309
|
+
|
|
310
|
+
The lane is measured once per resize, and its width is the loop period. Content that changes its own width while the row is running, a counter ticking from 9 to 10, say, drifts the seam until the next `refresh()`. Call it yourself after changing the items.
|
|
311
|
+
|
|
312
|
+
## How it compares
|
|
313
|
+
|
|
314
|
+
| | Size | Scroll-linked | Needs JS |
|
|
315
|
+
|---|---|---|---|
|
|
316
|
+
| **wake-marquee** | 3.4 kB | yes, direction and displacement | yes |
|
|
317
|
+
| CSS `@keyframes` marquee | 0 | no | no |
|
|
318
|
+
| [Marquee3k](https://github.com/ezekielaquino/Marquee3000) | ~2 kB | no | yes |
|
|
319
|
+
| [react-fast-marquee](https://github.com/justin-chu/react-fast-marquee) | ~4 kB | no | yes, React only |
|
|
320
|
+
| GSAP + ScrollTrigger | ~50 kB | yes, with a full timeline API | yes |
|
|
321
|
+
|
|
322
|
+
If a plain loop is all you need, write the four lines of CSS. If you need a timeline, keyframes and scrubbing across a whole page, use GSAP. If you want a row that answers to the reader and nothing else, use this.
|
|
323
|
+
|
|
324
|
+
## Licence
|
|
325
|
+
|
|
326
|
+
MIT © Robin Gogolok
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var WakeMarquee=(()=>{var l=Object.defineProperty;var D=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var B=Object.prototype.hasOwnProperty;var b=i=>{throw TypeError(i)};var Q=(i,e)=>{for(var t in e)l(i,t,{get:e[t],enumerable:!0})},_=(i,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of H(e))!B.call(i,n)&&n!==t&&l(i,n,{get:()=>e[n],enumerable:!(s=D(e,n))||s.enumerable});return i};var G=i=>_(l({},"__esModule",{value:!0}),i);var U=(i,e,t)=>e.has(i)||b("Cannot "+t);var y=(i,e,t)=>e.has(i)?b("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,t);var o=(i,e,t)=>(U(i,e,"access private method"),t);var ie={};Q(ie,{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,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 A(i,e,t){let s=t+e;return s>0?j((t-i)/s,0,1):0}function C(i,e,t){return-t*e*(1-2*i)}function S(i,e=.1){return!Number.isFinite(i)||i<=0?0:Math.min(i/1e3,e)}var K=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),L="data-wake-marquee",V="wake-lane",O="wake-track",Y=1,J="200px 0px";function X(i){return i.firstElementChild?.classList.contains(O)===!0}var u=new Set,p=new Map,m=0,c=0;function q(i){return i===window?window.scrollY:i.scrollTop}function Z(i){let e=p.get(i);if(!e){p.set(i,{last:q(i),sign:1});return}let t=q(i);Math.abs(t-e.last)>.5&&(e.sign=t>e.last?1:-1,e.last=t)}function P(i){return p.get(i)?.sign??1}function F(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function W(i={}){let e={...K};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(!Number.isFinite(e.speed)||e.speed<0)throw new RangeError(`wake-marquee: speed must be a non-negative number, received ${e.speed}`);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 ee(i){let e=i.dataset,t={};return e.wakeDirection&&(t.direction=e.wakeDirection),e.wakeSpeed&&(t.speed=Number(e.wakeSpeed)),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,R,N,g,I,v,T,$,k,w=class{constructor(e,t){y(this,r);this.element=e,this.options=W(t),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=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,R).call(this),o(this,r,I).call(this),u.add(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.period=e,this.measured=!0,o(this,r,N).call(this);let t=x(this.track.getBoundingClientRect().width,e,Y);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=A(t.top,t.height,e);this.amplitude=this.options.wake*t.width/100,this.wakePx=C(s,this.amplitude,this.dirSign)}write(e,t){if(!o(this,r,k).call(this))return;this.aligned||o(this,r,$).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=E(this.offset,this.dirFactor*this.options.speed,e,this.period);let a=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let z of this.lanes)z.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&&F()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,u.delete(this),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,R=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=V;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(L,""),this.options.gap&&n("--wake-gap",this.options.gap),this.options.fade&&(n("--wake-fade",this.options.fade),s("data-wake-fade",""))},N=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,P(this.options.scroller)))},I=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:J,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}F()?(this.paused=!0,o(this,r,T).call(this)):(this.paused=!1,d())},T=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},$=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 te(i){let e=c===0?0:S(i-c);c=i;let t=window.innerHeight;for(let s of u)s.options.reverse&&Z(s.options.scroller),s.read(t);for(let s of u)s.write(e,s.options.reverse?P(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(te)}function f({root:i=document,defaults:e={}}={}){let t=[];for(let s of i.querySelectorAll(`[${L}]`))X(s)||t.push(new w(s,{...e,...ee(s)}));for(let s of t)s.prime();return t}typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>f(),{once:!0}):f());return G(ie);})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/*! wake-marquee | MIT | https://github.com/robin-gogolok/wake-marquee */@layer wake-marquee{[data-wake-marquee]{display:flex;position:relative;overflow:hidden;min-width:0}@supports (overflow: clip){[data-wake-marquee]{overflow-x:clip;overflow-y:visible}}[data-wake-marquee]>:not(.wake-track),.wake-lane>*{flex:none;margin-inline-end:var(--wake-gap, 2rem)}.wake-track{display:flex;flex:none;flex-wrap:nowrap;width:100%}.wake-lane{display:flex;flex:none;flex-wrap:nowrap;align-items:center}[data-wake-active]>.wake-track{will-change:transform}[data-wake-fade]{-webkit-mask-image:linear-gradient(to right,transparent,#000 var(--wake-fade, 0px),#000 calc(100% - var(--wake-fade, 0px)),transparent);mask-image:linear-gradient(to right,transparent,#000 var(--wake-fade, 0px),#000 calc(100% - var(--wake-fade, 0px)),transparent)}@media(prefers-reduced-motion:reduce){[data-wake-active]>.wake-track{will-change:auto}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var v=i=>{throw TypeError(i)};var z=(i,e,t)=>e.has(i)||v("Cannot "+t);var k=(i,e,t)=>e.has(i)?v("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(i):e.set(i,t);var n=(i,e,t)=>(z(i,e,"access private method"),t);function D(i,e,t){return i<e?e:i>t?t:i}function b(i,e,t=1){return!(e>0)||!Number.isFinite(i)?1:Math.ceil(i/e)+1+t}function y(i,e,t,s){return s>0?((i+e*t)%s+s)%s:0}function x(i,e,t,s){return i+(e-i)*(1-Math.exp(-t*s))}function E(i,e,t){let s=t+e;return s>0?D((t-i)/s,0,1):0}function A(i,e,t){return-t*e*(1-2*i)}function M(i,e=.1){return!Number.isFinite(i)||i<=0?0:Math.min(i/1e3,e)}var H=Object.freeze({direction:"left",speed:60,wake:8,reverse:!0,ease:5,gap:null,fade:!1,pauseOnHover:!1,scroller:null,respectMotionPreference:!0}),q="data-wake-marquee",B="wake-lane",F="wake-track",Q=1,_="200px 0px";function P(i){return i.firstElementChild?.classList.contains(F)===!0}var u=new Set,l=new Map,p=0,c=0;function C(i){return i===window?window.scrollY:i.scrollTop}function G(i){let e=l.get(i);if(!e){l.set(i,{last:C(i),sign:1});return}let t=C(i);Math.abs(t-e.last)>.5&&(e.sign=t>e.last?1:-1,e.last=t)}function R(i){return l.get(i)?.sign??1}function S(){return typeof matchMedia=="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches}function U(i={}){let e={...H};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(!Number.isFinite(e.speed)||e.speed<0)throw new RangeError(`wake-marquee: speed must be a non-negative number, received ${e.speed}`);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 j(i){let e=i.dataset,t={};return e.wakeDirection&&(t.direction=e.wakeDirection),e.wakeSpeed&&(t.speed=Number(e.wakeSpeed)),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,L,O,w,N,m,I,T,g,f=class{constructor(e,t){k(this,r);this.element=e,this.options=U(t),this.dirSign=this.options.direction==="right"?1:-1,this.dirFactor=this.dirSign,this.offset=0,this.period=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,L).call(this),n(this,r,N).call(this),u.add(this),n(this,r,m).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),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.period=e,this.measured=!0,n(this,r,O).call(this);let t=b(this.track.getBoundingClientRect().width,e,Q);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(a=>a.setAttribute("tabindex","-1")),s.querySelectorAll('img[loading="lazy"]').forEach(a=>a.setAttribute("loading","eager")),this.track.appendChild(s),this.lanes.push(s)}for(;this.lanes.length>t;)this.lanes.pop()?.remove();d()}read(e){if(!n(this,r,g).call(this))return;let t=this.element.getBoundingClientRect(),s=E(t.top,t.height,e);this.amplitude=this.options.wake*t.width/100,this.wakePx=A(s,this.amplitude,this.dirSign)}write(e,t){if(!n(this,r,g).call(this))return;this.aligned||n(this,r,T).call(this);let s=this.hovered?0:this.options.reverse?this.dirSign*t:this.dirSign;this.dirFactor=x(this.dirFactor,s,e,this.options.ease),this.offset=y(this.offset,this.dirFactor*this.options.speed,e,this.period);let o=`translate3d(${(this.offset-this.period).toFixed(2)}px, 0, 0)`;for(let $ of this.lanes)$.style.transform=o;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&&S()?this:(this.paused=!1,d(),this)}pause(){return this.paused=!0,this}destroy(){if(!this.destroyed){this.destroyed=!0,u.delete(this),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,L=function(){let e=this.element,t=e.ownerDocument;for(this.track=t.createElement("div"),this.track.className=F,this.lane=t.createElement("div"),this.lane.className=B;e.firstChild;)this.lane.appendChild(e.firstChild);this.track.appendChild(this.lane),e.appendChild(this.track),this.lanes=[this.lane],this.undo=[];let s=(o,h)=>{e.hasAttribute(o)||(e.setAttribute(o,h),this.undo.push(()=>e.removeAttribute(o)))},a=(o,h)=>{e.style.getPropertyValue(o)||(e.style.setProperty(o,h),this.undo.push(()=>e.style.removeProperty(o)))};s(q,""),this.options.gap&&a("--wake-gap",this.options.gap),this.options.fade&&(a("--wake-fade",this.options.fade),s("data-wake-fade",""))},O=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,R(this.options.scroller)))},N=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&&n(this,r,w).call(this);d()},{rootMargin:_,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,m).call(this),this.motionQuery.addEventListener("change",this.onMotionChange))},m=function(){if(!this.options.respectMotionPreference){d();return}S()?(this.paused=!0,n(this,r,I).call(this)):(this.paused=!1,d())},I=function(){this.offset=0,this.wakePx=0,this.aligned=!1;for(let e of this.lanes)e.style.transform="";this.track.style.transform=""},T=function(){this.aligned=!0;let e=this.amplitude-this.wakePx;this.offset=(e%this.period+this.period)%this.period},g=function(){return!this.destroyed&&!this.paused&&this.visible&&this.period>0};function K(i){let e=c===0?0:M(i-c);c=i;let t=window.innerHeight;for(let s of u)s.options.reverse&&G(s.options.scroller),s.read(t);for(let s of u)s.write(e,s.options.reverse?R(s.options.scroller):1);p=0,d()}function d(){if(p!==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}p=requestAnimationFrame(K)}function Z(i,e={}){if(!i||i.nodeType!==1)throw new TypeError("wake-marquee: createMarquee expects an element");if(P(i))throw new Error("wake-marquee: this element is already a marquee, call destroy() first");return new f(i,e).prime()}function W({root:i=document,defaults:e={}}={}){let t=[];for(let s of i.querySelectorAll(`[${q}]`))P(s)||t.push(new f(s,{...e,...j(s)}));for(let s of t)s.prime();return t}export{f as Marquee,Z as createMarquee,H as defaults,W as initMarquees,U as normalizeOptions,j as readOptions};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Astro wrapper around wake-marquee.
|
|
4
|
+
*
|
|
5
|
+
* Import the stylesheet once in your layout:
|
|
6
|
+
* import 'wake-marquee/css'
|
|
7
|
+
*
|
|
8
|
+
* Options are rendered as `data-wake-*` attributes and read back by the
|
|
9
|
+
* script, so the server sends a plain clipped row and the client enhances it.
|
|
10
|
+
* The row is on screen and correctly laid out before any JavaScript arrives.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Travel direction while scrolling down. Scrolling up reverses it. */
|
|
14
|
+
direction?: 'left' | 'right';
|
|
15
|
+
/** Travel speed in pixels per second. */
|
|
16
|
+
speed?: number;
|
|
17
|
+
/** Scroll-driven displacement, in percent of the container width. 0 turns it off. */
|
|
18
|
+
wake?: number;
|
|
19
|
+
/** Whether scrolling up reverses travel. */
|
|
20
|
+
reverse?: boolean;
|
|
21
|
+
/** How sharply a reversal settles, per second. */
|
|
22
|
+
ease?: number;
|
|
23
|
+
/** Space between two items, any CSS length. */
|
|
24
|
+
gap?: string;
|
|
25
|
+
/** Soft edges left and right, any CSS length. */
|
|
26
|
+
fade?: string;
|
|
27
|
+
/** Hold still while a real pointer rests on the row. */
|
|
28
|
+
pauseOnHover?: boolean;
|
|
29
|
+
class?: string;
|
|
30
|
+
'aria-label'?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Spacing and fade width are rendered as custom properties here rather than
|
|
35
|
+
* left to the script. They change the layout of the static row, so a value
|
|
36
|
+
* that only arrives with the JavaScript makes the items visibly shift the
|
|
37
|
+
* moment it runs. The data-* attributes carry the same values for the script
|
|
38
|
+
* to read back; it leaves a property that is already set alone.
|
|
39
|
+
*/
|
|
40
|
+
const {
|
|
41
|
+
direction,
|
|
42
|
+
speed,
|
|
43
|
+
wake,
|
|
44
|
+
reverse,
|
|
45
|
+
ease,
|
|
46
|
+
gap,
|
|
47
|
+
fade,
|
|
48
|
+
pauseOnHover = false,
|
|
49
|
+
class: className,
|
|
50
|
+
'aria-label': ariaLabel,
|
|
51
|
+
} = Astro.props;
|
|
52
|
+
|
|
53
|
+
const style = [gap && `--wake-gap:${gap}`, fade && `--wake-fade:${fade}`]
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join(';');
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
<div
|
|
59
|
+
class={className}
|
|
60
|
+
style={style || undefined}
|
|
61
|
+
data-wake-marquee
|
|
62
|
+
data-wake-direction={direction}
|
|
63
|
+
data-wake-speed={speed?.toString()}
|
|
64
|
+
data-wake={wake?.toString()}
|
|
65
|
+
data-wake-ease={ease?.toString()}
|
|
66
|
+
data-wake-gap={gap}
|
|
67
|
+
data-wake-fade={fade}
|
|
68
|
+
data-wake-reverse={reverse === undefined ? undefined : String(reverse)}
|
|
69
|
+
data-wake-pause-on-hover={pauseOnHover ? '' : undefined}
|
|
70
|
+
aria-label={ariaLabel}
|
|
71
|
+
>
|
|
72
|
+
<slot />
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script>
|
|
76
|
+
// Astro hoists and deduplicates this, so a page with ten marquees still
|
|
77
|
+
// ships one copy of the library and runs one initialisation pass.
|
|
78
|
+
import { initMarquees } from 'wake-marquee';
|
|
79
|
+
initMarquees();
|
|
80
|
+
</script>
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wake-marquee",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Robin Gogolok (https://robin-gogolok.de)",
|
|
8
|
+
"homepage": "https://robin-gogolok.github.io/wake-marquee/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/robin-gogolok/wake-marquee.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/robin-gogolok/wake-marquee/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"marquee",
|
|
18
|
+
"ticker",
|
|
19
|
+
"logo-ticker",
|
|
20
|
+
"infinite-scroll",
|
|
21
|
+
"scroll-animation",
|
|
22
|
+
"parallax",
|
|
23
|
+
"scroll-linked",
|
|
24
|
+
"astro",
|
|
25
|
+
"vanilla-js",
|
|
26
|
+
"zero-dependency"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./src/marquee.js",
|
|
30
|
+
"./css": "./dist/wake-marquee.css",
|
|
31
|
+
"./css/source": "./src/wake-marquee.css",
|
|
32
|
+
"./auto": "./src/auto.js",
|
|
33
|
+
"./motion": "./src/motion.js",
|
|
34
|
+
"./astro": "./integrations/astro/WakeMarquee.astro",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"main": "./src/marquee.js",
|
|
38
|
+
"files": [
|
|
39
|
+
"src",
|
|
40
|
+
"dist",
|
|
41
|
+
"integrations",
|
|
42
|
+
"README.md",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"sideEffects": [
|
|
47
|
+
"./src/auto.js",
|
|
48
|
+
"**/*.css"
|
|
49
|
+
],
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=18"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "node scripts/build.js && node demo/build.js",
|
|
55
|
+
"test": "npm run test:unit && npm run test:browser",
|
|
56
|
+
"test:unit": "node --test test/*.test.js",
|
|
57
|
+
"pretest:browser": "npm run build",
|
|
58
|
+
"test:browser": "playwright test",
|
|
59
|
+
"serve": "node scripts/serve.js",
|
|
60
|
+
"prepublishOnly": "npm run build && npm run test:unit"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@playwright/test": "^1.50.0",
|
|
64
|
+
"esbuild": "^0.25.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/auto.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-effect entry point: start every `[data-wake-marquee]` on the page.
|
|
3
|
+
*
|
|
4
|
+
* import 'wake-marquee/auto'
|
|
5
|
+
*
|
|
6
|
+
* Each element is configured by its own `data-wake-*` attributes, so this is
|
|
7
|
+
* the whole integration for a page that has no build step to speak of. Reach
|
|
8
|
+
* for `createMarquee` instead when you want a handle to pause, refresh or
|
|
9
|
+
* tear down.
|
|
10
|
+
*
|
|
11
|
+
* @module wake-marquee/auto
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { initMarquees } from './marquee.js';
|
|
15
|
+
|
|
16
|
+
if (typeof document !== 'undefined') {
|
|
17
|
+
if (document.readyState === 'loading') {
|
|
18
|
+
document.addEventListener('DOMContentLoaded', () => initMarquees(), { once: true });
|
|
19
|
+
} else {
|
|
20
|
+
initMarquees();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { initMarquees };
|