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/src/motion.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic behind a wake-marquee, with no DOM in sight.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a pure function of numbers. That is not tidiness for its
|
|
5
|
+
* own sake: the loop in `marquee.js` runs sixty times a second across every
|
|
6
|
+
* instance on the page, and the only way to know its maths is right is to be
|
|
7
|
+
* able to run it without a browser. Every function below has a unit test.
|
|
8
|
+
*
|
|
9
|
+
* @module wake-marquee/motion
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {number} value
|
|
14
|
+
* @param {number} min
|
|
15
|
+
* @param {number} max
|
|
16
|
+
* @returns {number}
|
|
17
|
+
*/
|
|
18
|
+
export function clamp(value, min, max) {
|
|
19
|
+
return value < min ? min : value > max ? max : value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many lanes it takes to cover the track without a gap, worst case.
|
|
24
|
+
*
|
|
25
|
+
* The shared transform on the lanes always sits in `[-period, 0)`, so lane 0
|
|
26
|
+
* covers everything left of the origin and the remaining lanes have to span
|
|
27
|
+
* the full track width. That is `ceil(trackWidth / period) + 1`, plus one
|
|
28
|
+
* spare so a sub-pixel rounding error at the right edge cannot open a seam.
|
|
29
|
+
*
|
|
30
|
+
* @param {number} trackWidth Width of the moving layer in px.
|
|
31
|
+
* @param {number} period Width of one lane in px, the loop's repeat distance.
|
|
32
|
+
* @param {number} [buffer=1] Extra lanes held back against rounding.
|
|
33
|
+
* @returns {number} Lane count, at least 1.
|
|
34
|
+
*/
|
|
35
|
+
export function laneCount(trackWidth, period, buffer = 1) {
|
|
36
|
+
if (!(period > 0) || !Number.isFinite(trackWidth)) return 1;
|
|
37
|
+
return Math.ceil(trackWidth / period) + 1 + buffer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Move the loop on by one frame and wrap it back into `[0, period)`.
|
|
42
|
+
*
|
|
43
|
+
* The wrap is a double modulo rather than a plain one, because JavaScript's
|
|
44
|
+
* `%` keeps the sign of the dividend: `-5 % 100` is `-5`, not `95`. A single
|
|
45
|
+
* modulo would therefore hand back a negative offset the moment the marquee
|
|
46
|
+
* runs backwards, and the lanes would jump a full period sideways.
|
|
47
|
+
*
|
|
48
|
+
* @param {number} offset Current offset in px.
|
|
49
|
+
* @param {number} velocity Signed speed in px per second.
|
|
50
|
+
* @param {number} dt Frame time in seconds.
|
|
51
|
+
* @param {number} period Loop repeat distance in px.
|
|
52
|
+
* @returns {number} Offset in `[0, period)`.
|
|
53
|
+
*/
|
|
54
|
+
export function advance(offset, velocity, dt, period) {
|
|
55
|
+
if (!(period > 0)) return 0;
|
|
56
|
+
const next = offset + velocity * dt;
|
|
57
|
+
return ((next % period) + period) % period;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ease the direction factor towards its target, framerate independently.
|
|
62
|
+
*
|
|
63
|
+
* A plain `current += (target - current) * 0.1` per frame is the usual lerp,
|
|
64
|
+
* and it is wrong here: it converges at whatever rate the display happens to
|
|
65
|
+
* refresh at, so the same reversal is twice as fast on a 120 Hz screen. The
|
|
66
|
+
* exponential form asks how much of the remaining distance should be closed
|
|
67
|
+
* over `dt` seconds, which is the same answer at any framerate.
|
|
68
|
+
*
|
|
69
|
+
* @param {number} current Direction factor in `[-1, 1]`.
|
|
70
|
+
* @param {number} target Where it is heading, usually -1, 0 or 1.
|
|
71
|
+
* @param {number} dt Frame time in seconds.
|
|
72
|
+
* @param {number} ease Convergence rate per second; higher snaps harder.
|
|
73
|
+
* @returns {number}
|
|
74
|
+
*/
|
|
75
|
+
export function easeDirection(current, target, dt, ease) {
|
|
76
|
+
return current + (target - current) * (1 - Math.exp(-dt * ease));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How far through its own passage across the viewport an element is.
|
|
81
|
+
*
|
|
82
|
+
* 0 when its top edge is about to enter from below, 1 when its bottom edge
|
|
83
|
+
* has just left at the top. The denominator is `viewport + height` because
|
|
84
|
+
* that is the full distance the element travels while any part of it is on
|
|
85
|
+
* screen, which keeps a tall block and a thin one on the same scale.
|
|
86
|
+
*
|
|
87
|
+
* @param {number} top Element top relative to the viewport, in px.
|
|
88
|
+
* @param {number} height Element height in px.
|
|
89
|
+
* @param {number} viewport Viewport height in px.
|
|
90
|
+
* @returns {number} Progress in `[0, 1]`.
|
|
91
|
+
*/
|
|
92
|
+
export function viewProgress(top, height, viewport) {
|
|
93
|
+
const travel = viewport + height;
|
|
94
|
+
if (!(travel > 0)) return 0;
|
|
95
|
+
return clamp((viewport - top) / travel, 0, 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The wake: how far the moving layer is pushed against its own direction of
|
|
100
|
+
* travel at a given point in the element's passage across the viewport.
|
|
101
|
+
*
|
|
102
|
+
* `1 - 2 * progress` runs from +1 to -1, so the layer is displaced one full
|
|
103
|
+
* amplitude one way as the element enters and the other way as it leaves,
|
|
104
|
+
* passing through zero at the centre. The result is bounded by `amplitude`,
|
|
105
|
+
* which is exactly the overhang the track is given on each side. That bound
|
|
106
|
+
* is the whole point: overshoot it and the audience sees the end of the row.
|
|
107
|
+
*
|
|
108
|
+
* @param {number} progress Passage progress in `[0, 1]`.
|
|
109
|
+
* @param {number} amplitude Maximum displacement in px.
|
|
110
|
+
* @param {number} dirSign Base travel direction, +1 right or -1 left.
|
|
111
|
+
* @returns {number} Displacement in px, within `[-amplitude, amplitude]`.
|
|
112
|
+
*/
|
|
113
|
+
export function wakeOffset(progress, amplitude, dirSign) {
|
|
114
|
+
return -dirSign * amplitude * (1 - 2 * progress);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Clamp a frame delta to something a physics step can survive.
|
|
119
|
+
*
|
|
120
|
+
* A backgrounded tab, a long task or a breakpoint in the devtools all hand
|
|
121
|
+
* the next frame a delta measured in seconds. Integrating that would teleport
|
|
122
|
+
* the loop. Capping it means a stall costs a little drift, never a jump.
|
|
123
|
+
*
|
|
124
|
+
* @param {number} ms Milliseconds since the previous frame.
|
|
125
|
+
* @param {number} [max=0.1] Cap in seconds.
|
|
126
|
+
* @returns {number} Seconds, never negative, never above `max`.
|
|
127
|
+
*/
|
|
128
|
+
export function frameDelta(ms, max = 0.1) {
|
|
129
|
+
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
|
130
|
+
return Math.min(ms / 1000, max);
|
|
131
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*! wake-marquee | MIT | https://github.com/robin-gogolok/wake-marquee */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything lives in one cascade layer, so your own unlayered CSS wins
|
|
5
|
+
* without reaching for !important. Declare the order yourself if you use
|
|
6
|
+
* layers too: @layer theme, wake-marquee, utilities;
|
|
7
|
+
*/
|
|
8
|
+
@layer wake-marquee {
|
|
9
|
+
/**
|
|
10
|
+
* The container clips, and its box is the geometry the wake is measured
|
|
11
|
+
* against. It is a flex row before the script ever runs, which is what
|
|
12
|
+
* makes the unenhanced page look deliberate: one static row of items,
|
|
13
|
+
* cut off at the edge, rather than a stack.
|
|
14
|
+
*/
|
|
15
|
+
[data-wake-marquee] {
|
|
16
|
+
display: flex;
|
|
17
|
+
position: relative;
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
/* A flex or grid child refuses to shrink below its content without this,
|
|
20
|
+
and a marquee is all content. Left out, the row pushes the page wider
|
|
21
|
+
instead of clipping. */
|
|
22
|
+
min-width: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `clip` rather than `hidden` where it is available. `hidden` makes the
|
|
27
|
+
* element a scroll container, so a stray focus inside it can scroll the row
|
|
28
|
+
* sideways and strand it mid-loop; `clip` cannot be scrolled at all. It also
|
|
29
|
+
* lets shadows and hover lifts overflow vertically, which `hidden` would cut.
|
|
30
|
+
*/
|
|
31
|
+
@supports (overflow: clip) {
|
|
32
|
+
[data-wake-marquee] {
|
|
33
|
+
overflow-x: clip;
|
|
34
|
+
overflow-y: visible;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Items, before the script runs and after. The spacing sits inside the item
|
|
40
|
+
* as a margin rather than as `gap` on the row, and that is load-bearing: the
|
|
41
|
+
* lane's width is the loop's repeat distance, so the space after the last
|
|
42
|
+
* item has to be part of the lane. With `gap` there is no space between the
|
|
43
|
+
* last item and the first item of the next lane, and the seam is visible on
|
|
44
|
+
* every pass.
|
|
45
|
+
*
|
|
46
|
+
* A margin, not padding, because an item with a background or a border would
|
|
47
|
+
* otherwise render the spacing as part of itself.
|
|
48
|
+
*/
|
|
49
|
+
[data-wake-marquee] > :not(.wake-track),
|
|
50
|
+
.wake-lane > * {
|
|
51
|
+
flex: none;
|
|
52
|
+
margin-inline-end: var(--wake-gap, 2rem);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The layer the scroll displaces. Its overhang and width are set by the
|
|
57
|
+
* script from the `wake` option: it is wider than the container by exactly
|
|
58
|
+
* the distance the wake can move it, so the row never runs out of content
|
|
59
|
+
* at either edge no matter where the scroll is.
|
|
60
|
+
*/
|
|
61
|
+
.wake-track {
|
|
62
|
+
display: flex;
|
|
63
|
+
flex: none;
|
|
64
|
+
flex-wrap: nowrap;
|
|
65
|
+
width: 100%;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One repeat of the content. Neither shrinking nor wrapping is allowed:
|
|
70
|
+
* both would change the width, and the width *is* the loop period.
|
|
71
|
+
*/
|
|
72
|
+
.wake-lane {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex: none;
|
|
75
|
+
flex-wrap: nowrap;
|
|
76
|
+
align-items: center;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Promoted only while the row is actually on screen. `will-change` held
|
|
81
|
+
* permanently is a standing request for a compositor layer, and a page with
|
|
82
|
+
* eight marquees pays for eight of them whether or not any are in view.
|
|
83
|
+
*/
|
|
84
|
+
[data-wake-active] > .wake-track {
|
|
85
|
+
will-change: transform;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
[data-wake-fade] {
|
|
89
|
+
-webkit-mask-image: linear-gradient(
|
|
90
|
+
to right,
|
|
91
|
+
transparent,
|
|
92
|
+
#000 var(--wake-fade, 0px),
|
|
93
|
+
#000 calc(100% - var(--wake-fade, 0px)),
|
|
94
|
+
transparent
|
|
95
|
+
);
|
|
96
|
+
mask-image: linear-gradient(
|
|
97
|
+
to right,
|
|
98
|
+
transparent,
|
|
99
|
+
#000 var(--wake-fade, 0px),
|
|
100
|
+
#000 calc(100% - var(--wake-fade, 0px)),
|
|
101
|
+
transparent
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The script stops itself under a reduced-motion preference and resets the
|
|
107
|
+
* transforms, so the row simply stands still. This only drops the layer
|
|
108
|
+
* promotion that would then be paid for and never used.
|
|
109
|
+
*/
|
|
110
|
+
@media (prefers-reduced-motion: reduce) {
|
|
111
|
+
[data-wake-active] > .wake-track {
|
|
112
|
+
will-change: auto;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|