@croxton/buttery 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Tobias Reich
4
+ Copyright (c) 2026 Mark Croxton
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,291 @@
1
- # Temporary Holding Version
1
+ # Buttery.js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A lightweight, performance-focused scroll animation library. Based on [basicScroll](https://github.com/electerious/basicScroll) by Tobias Reich, customised by @croxton.
4
+
5
+ When the browser supports it, the library uses native **scroll-driven animations** (`ScrollTimeline` + Web Animations API). Otherwise it falls back to an event-driven `requestAnimationFrame` loop. Either way, you use the same API.
6
+
7
+ ## Contents
8
+
9
+ - [What's different from the original](#whats-different-from-the-original)
10
+ - [Requirements](#requirements)
11
+ - [Setup](#setup)
12
+ - [API](#api)
13
+ - [Module](#module)
14
+ - [Instance](#instance)
15
+ - [Data](#data)
16
+ - [Props](#props)
17
+ - [Rendering modes](#rendering-modes)
18
+ - [Custom properties and `@property`](#custom-properties-and-property)
19
+ - [iOS Safari](#ios-safari)
20
+ - [Tips](#tips)
21
+
22
+ ## What's different from the original
23
+
24
+ | Feature | Original | Customised |
25
+ |---|---|---|
26
+ | Rendering | rAF loop that runs all the time | Native `ScrollTimeline` when supported, otherwise a rAF update that only runs on scroll |
27
+ | Lifecycle | Starts automatically on import | Explicit `mount()` / `unmount()` (works with page transitions, e.g. htmx) |
28
+ | Relative anchors | `top`, `middle`, `bottom` | Also percentages, e.g. `50%-40%` |
29
+ | Prop keyframes | `from` → `to` | Optional `via` midpoint value |
30
+ | Resize handling | Recalculates on every `resize` | Ignores height-only resizes (iOS toolbar); recalculating on width changes is opt-in |
31
+ | Overscroll | Animations follow the rubber-band bounce | Scroll position is clamped to the scrollable range |
32
+ | Viewport height | `window.innerHeight` | Stable `documentElement.clientHeight` |
33
+ | Style writes | Every frame | Only when a value changes; all reads happen before writes |
34
+ | Zero-length ranges | Division by zero | Handled |
35
+ | Back/forward cache | Not handled | Refreshes on `pageshow` |
36
+
37
+ ## Requirements
38
+
39
+ Use it from npm with a bundler, or drop in a `<script>` tag. The npm build lists `parse-unit` and `eases` as dependencies; the script-tag build bundles them.
40
+
41
+ Native scroll timelines need `ScrollTimeline`, `Animation`, `KeyframeEffect` and `CSS.registerProperty` (Chrome/Edge 115+, Safari 26+, including all browsers on iOS 26+). Other browsers, such as current Firefox, use the JavaScript fallback automatically.
42
+
43
+ ## Setup
44
+
45
+ ### Install
46
+
47
+ With npm:
48
+
49
+ ```bash
50
+ npm install @croxton/buttery
51
+ ```
52
+
53
+ ```javascript
54
+ import * as buttery from '@croxton/buttery'
55
+ ```
56
+
57
+ Or with a script tag, which exposes a `buttery` global:
58
+
59
+ ```html
60
+ <script src="https://unpkg.com/@croxton/buttery/dist/buttery.min.js"></script>
61
+ <script>
62
+ const buttery = window.buttery
63
+ </script>
64
+ ```
65
+
66
+ ### Usage
67
+
68
+ ```javascript
69
+ // Start listening for scroll (safe to call more than once)
70
+ buttery.mount()
71
+
72
+ const instance = buttery.create({
73
+ elem: document.querySelector('.element'),
74
+ from: 'top-bottom',
75
+ to: 'bottom-top',
76
+ direct: true,
77
+ props: {
78
+ '--ty': {
79
+ from: '0px',
80
+ to: '-100px'
81
+ }
82
+ }
83
+ })
84
+
85
+ instance.start()
86
+ ```
87
+
88
+ Use the CSS variables in your stylesheet:
89
+
90
+ ```css
91
+ .element {
92
+ transform: translateY(var(--ty));
93
+ }
94
+ ```
95
+
96
+ When the page is replaced (for example, on an htmx navigation), clean up:
97
+
98
+ ```javascript
99
+ buttery.unmount()
100
+ ```
101
+
102
+ ## API
103
+
104
+ ### Module
105
+
106
+ #### `mount(options?)`
107
+
108
+ Adds the scroll, resize, orientation and `pageshow` listeners, plus a `ResizeObserver` that watches the document height. Call it before or after creating instances. Extra calls are ignored until `unmount()` is called.
109
+
110
+ | Option | Type | Default | Description |
111
+ |---|---|---|---|
112
+ | `recalculateOnResize` | `Boolean` | `false` | Call `calculate()` on every instance with `track: true` when the viewport **width** changes |
113
+ | `timeline` | `Boolean` | `true` | Allow native scroll timelines. `false` forces the JavaScript fallback for every instance |
114
+
115
+ ```javascript
116
+ buttery.mount({ recalculateOnResize: true })
117
+ ```
118
+
119
+ #### `unmount()`
120
+
121
+ Removes all listeners and cancels pending frames. Destroys instances whose `elem` is no longer in the DOM. If no instances are left, the internal instance list is reset.
122
+
123
+ #### `create(data)`
124
+
125
+ Creates and returns an instance. Its `from`/`to` positions are calculated straight away. Nothing animates until `instance.start()` is called.
126
+
127
+ #### `supportsTimeline()`
128
+
129
+ Returns `true` if the browser supports native scroll-driven animations.
130
+
131
+ ```javascript
132
+ if (buttery.supportsTimeline()) {
133
+ // e.g. remove CSS transitions that were only there to smooth the JS fallback
134
+ }
135
+ ```
136
+
137
+ ### Instance
138
+
139
+ | Method | Description |
140
+ |---|---|
141
+ | `start()` | Starts animating and applies the current state straight away |
142
+ | `stop()` | Stops animating. A native animation is paused at its current state |
143
+ | `calculate()` | Recalculates `from`/`to` positions and keyframes. Call it after the layout changes |
144
+ | `update()` | Applies the current state and runs the callbacks. Returns the calculated props |
145
+ | `destroy()` | Cancels any native animation and removes the instance |
146
+ | `isActive()` | Returns `true` if the instance is started |
147
+ | `getData()` | Returns the parsed data |
148
+ | `usesTimeline()` | Returns `true` if the instance runs on a native scroll timeline |
149
+ | `syncTimeline()` | Rebuilds the native keyframes for the current scroll range. Called automatically when the page height changes |
150
+
151
+ To recalculate after a layout change:
152
+
153
+ ```javascript
154
+ instance.calculate()
155
+ instance.update()
156
+ ```
157
+
158
+ ## Data
159
+
160
+ | Property | Type | Default | Description |
161
+ |---|---|---|---|
162
+ | `elem` | `Node` | — | Element used to work out relative `from`/`to` values. Required when `direct: true` |
163
+ | `from` | `String\|Number` | — | Where the animation starts. Absolute (`'0px'`) or relative (`'top-bottom'`) |
164
+ | `to` | `String\|Number` | — | Where the animation ends |
165
+ | `direct` | `Boolean\|Node` | `false` | Where the props are applied: `false` = `document.documentElement`, `true` = `elem`, a `Node` = that element |
166
+ | `track` | `Boolean` | `true` | Include in automatic recalculation when `mount({ recalculateOnResize: true })` is used |
167
+ | `props` | `Object` | `{}` | Properties to animate. See [Props](#props) |
168
+ | `inside` | `Function` | — | `(instance, percentage, props) => {}`. Runs while the scroll position is between `from` and `to` |
169
+ | `outside` | `Function` | — | `(instance, percentage, props) => {}`. Runs while it's outside that range |
170
+ | `timeline` | `'auto'\|Boolean` | `'auto'` | Rendering mode. See [Rendering modes](#rendering-modes) |
171
+
172
+ ### Relative values
173
+
174
+ A relative value has the form `{element anchor}-{viewport anchor}`. It means: the point where that position on the element meets that position in the viewport.
175
+
176
+ Each anchor can be `top`, `middle`, `bottom` or a **percentage**:
177
+
178
+ | Value | Meaning |
179
+ |---|---|
180
+ | `top-bottom` | The element's top meets the viewport's bottom |
181
+ | `middle-middle` | The element's middle meets the viewport's middle |
182
+ | `bottom-top` | The element's bottom meets the viewport's top |
183
+ | `50%-40%` | 50% down the element meets 40% down the viewport |
184
+
185
+ Relative values become absolute pixel positions when `calculate()` runs. If the layout changes later, call `calculate()` again.
186
+
187
+ ## Props
188
+
189
+ ```javascript
190
+ props: {
191
+ '--opacity': {
192
+ from: 0,
193
+ via: 1, // optional
194
+ to: 0,
195
+ timing: 'sineInOut'
196
+ }
197
+ }
198
+ ```
199
+
200
+ | Property | Type | Default | Description |
201
+ |---|---|---|---|
202
+ | `from` | `String\|Number` | — | Value at the start |
203
+ | `via` | `String\|Number` | — | Optional value halfway through (after easing). The animation goes `from` → `via` → `to` |
204
+ | `to` | `String\|Number` | — | Value at the end |
205
+ | `timing` | `String\|Function` | `'linear'` | Any [eases](https://www.npmjs.com/package/eases) name, or a function `(t) => t` |
206
+
207
+ Units come from `from` or `to`, so `from: 0, to: '100px'` animates in `px`.
208
+
209
+ Keys can be CSS custom properties (`--ty`) or normal CSS properties (`opacity`, `transform`).
210
+
211
+ ## Rendering modes
212
+
213
+ Each instance runs in one of two modes:
214
+
215
+ ### Native timeline
216
+
217
+ The instance's range and props are turned into keyframes on a Web Animation linked to a document `ScrollTimeline`. The browser updates the values in step with scrolling, with no JavaScript per frame. Easing and `via` are sampled into keyframes, so the result matches the JavaScript calculation.
218
+
219
+ ### JavaScript fallback
220
+
221
+ A passive `scroll` listener schedules at most one `requestAnimationFrame` update per frame. Values are calculated for every active instance first, then only the changed values are written.
222
+
223
+ ### Choosing a mode
224
+
225
+ | `data.timeline` | Behaviour |
226
+ |---|---|
227
+ | `'auto'` (default) | Uses the timeline if the browser supports it **and** the instance has no `inside`/`outside` callbacks |
228
+ | `true` | Uses the timeline whenever supported. Callbacks only run when `update()` is called |
229
+ | `false` | Always uses the JavaScript fallback |
230
+
231
+ An instance also falls back to JavaScript automatically if a custom property can't be registered, or if creating the animation throws an error.
232
+
233
+ > **Callbacks need JavaScript on every frame.** Instances that use `inside`/`outside` to drive per-frame work (for example, drawing SVG lines) stay on the JavaScript fallback in `'auto'` mode.
234
+
235
+ ## Custom properties and `@property`
236
+
237
+ A native animation can only interpolate a custom property smoothly if the property is **registered**. Unregistered properties jump between values instead.
238
+
239
+ If a property isn't registered yet, the library registers it with `CSS.registerProperty()`. The syntax is based on the unit:
240
+
241
+ | Unit | Syntax | Initial value |
242
+ |---|---|---|
243
+ | none | `<number>` | `0` |
244
+ | `deg`, `grad`, `rad`, `turn` | `<angle>` | `0deg` |
245
+ | `s`, `ms` | `<time>` | `0s` |
246
+ | anything else | `<length-percentage>` | `0px` |
247
+
248
+ Registration affects the whole site: a registered property always has a value, so `var(--x, fallback)` fallbacks stop working. **It's better to register the properties yourself in CSS**, with sensible initial values. The library will then use your registrations:
249
+
250
+ ```css
251
+ @property --tx { syntax: "<length-percentage>"; inherits: true; initial-value: 0px; }
252
+ @property --ty { syntax: "<length-percentage>"; inherits: true; initial-value: 0px; }
253
+ @property --scale { syntax: "<number>"; inherits: true; initial-value: 1; }
254
+ @property --opacity { syntax: "<number>"; inherits: true; initial-value: 1; }
255
+ @property --blur { syntax: "<length>"; inherits: true; initial-value: 0px; }
256
+ ```
257
+
258
+ ## iOS Safari
259
+
260
+ These features are there to reduce scroll jitter on iOS:
261
+
262
+ - **Native timelines** remove the delay between Safari's scrolling and JavaScript updates, which are also held near 60 fps on ProMotion devices.
263
+ - **Overscroll clamping:** animations stay still during the rubber-band bounce at the top and bottom of the page.
264
+ - **Stable viewport height:** relative positions use `documentElement.clientHeight`, which doesn't change when the toolbar collapses.
265
+ - **Height-only resizes are ignored** for recalculation. Only width and orientation changes count as real resizes.
266
+
267
+ ## Building
268
+
269
+ ```bash
270
+ npm install
271
+ npm run build
272
+ ```
273
+
274
+ Vite builds `lib/buttery.js` twice (see `vite.config.js`):
275
+
276
+ | File | Use | Dependencies |
277
+ |---|---|---|
278
+ | `dist/buttery.js` | `import` in bundlers | left as imports |
279
+ | `dist/buttery.cjs` | `require()` | left as requires |
280
+ | `dist/buttery.min.js` | `<script>` tag / CDN, `window.buttery` | bundled, minified |
281
+
282
+ `test/index.html` is a small page for checking the script-tag build (`npx vite`, then open `/test/`).
283
+
284
+ ## Tips
285
+
286
+ - **Don't put CSS `transition`s on scroll-driven properties.** In timeline mode they add delay to every frame. In fallback mode they cause wobble on iOS.
287
+ - **Prefer `transform` and `opacity`.** Animate them directly as prop keys where you can, e.g. `props: { opacity: { from: 0, to: 1 } }`. The GPU can run these, while custom properties are updated on the main thread.
288
+ - **Animate HTML elements, not SVG children.** WebKit doesn't give SVG `<path>` elements their own GPU layer, so animating them repaints the whole SVG.
289
+ - **Avoid animating `filter: blur()`** and avoid putting scroll-driven elements inside `mask: url(#…)` on mobile. Both force software repaints.
290
+ - **Call `calculate()` after layout changes** that move elements, such as content loading in or temporary `position: sticky` changes. Changes to page height alone are picked up automatically.
291
+ - **Respect reduced motion.** Check `prefers-reduced-motion` before calling `create()`.