coily 0.14.0 → 0.16.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/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Simulate values using spring physics.
4
4
 
5
- Coily animates numbers and whole numeric shapes with damped spring motion. It computes each frame from the closed-form solution of the spring equation (underdamped, critically damped, or overdamped) rather than numerical integration, so motion doesn't accumulate error and you can retarget a spring mid-flight without losing momentum.
5
+ Coily animates numbers, and objects or arrays of numbers, with spring motion. It computes each frame from an exact formula for the spring's motion, for bouncy and non-bouncy springs alike, instead of stepping a simulation forward. Error does not build up over a long animation, and you can retarget a spring mid-flight without losing its momentum.
6
6
 
7
- The core has zero dependencies and runs anywhere (it falls back from `requestAnimationFrame` to `setTimeout` outside the browser). Vue and Nuxt integrations ship as separate entry points, with `vue` and `@nuxt/kit` as optional peer dependencies.
7
+ The core has zero dependencies and runs anywhere. Outside the browser, it uses `setTimeout` in place of `requestAnimationFrame`. The Vue and Nuxt integrations are separate entry points, with `vue` and `@nuxt/kit` as optional peer dependencies. A third entry point, `coily/css`, turns a spring config into static CSS.
8
8
 
9
9
  ## Install
10
10
 
@@ -26,48 +26,51 @@ spring.onUpdate(() => {
26
26
  ball.style.setProperty('--x', String(spring.value))
27
27
  })
28
28
 
29
- // Animate toward a new target at any time. Position and velocity
30
- // carry over, so interruptions look natural.
29
+ // Animate toward a new target at any time. The value and velocity
30
+ // carry over, so an interruption stays smooth.
31
31
  spring.target = 300
32
32
  ```
33
33
 
34
34
  ## Configuring springs
35
35
 
36
- `defineSpring` accepts several input shapes and derives whatever you leave out:
36
+ `defineSpring` accepts several combinations of options and derives the ones you leave out:
37
37
 
38
38
  ```ts
39
- defineSpring({ tension: 500, damping: 40 }) // direct physical parameters
39
+ defineSpring({ tension: 500, damping: 40 }) // stiffness and friction, set directly
40
40
  defineSpring({ tension: 500, dampingRatio: 0.7 }) // damping derived
41
41
  defineSpring({ tension: 500, bounce: 0.3 }) // bounce = 1 - dampingRatio
42
- defineSpring({ duration: 750, dampingRatio: 1 }) // tuned to settle in ~750ms
42
+ defineSpring({ duration: 750, dampingRatio: 1 }) // tuned to rest at about 750 ms
43
43
  defineSpring({ duration: 750, bounce: 0.5 })
44
+ defineSpring({ tension: 500, bounce: 0.9, arrival: 'stop' }) // stops dead at the target, no overshoot
44
45
  ```
45
46
 
46
- - **`tension`**: stiffness (> 0)
47
- - **`damping`**: friction (>= 0)
48
- - **`dampingRatio`**: 0 = undamped, < 1 = bouncy, 1 = critically damped, > 1 = overdamped
49
- - **`bounce`**: a friendlier alias for damping ratio, from -1 (overdamped) to 1 (max bounce)
50
- - **`duration`**: target settle time in ms. Assumes an initial displacement of 1. Pass `displacement` matching your animation range for accurate timing
51
- - **`mass`**: defaults to 1
52
- - **`precision`**: decimal places of the resting threshold (default 2). A spring is _resting_ once its remaining motion cannot reach half a unit in the last place (0.005 at the default). Coily never rounds values. Set `precision` to match your domain's resolution (see [PRECISION.md](https://github.com/tkofh/coily/blob/main/PRECISION.md))
47
+ - `tension`: stiffness, greater than 0. Higher tension makes the whole motion faster.
48
+ - `damping`: friction, 0 or greater. Higher damping slows the spring down faster and reduces bounce.
49
+ - `dampingRatio`: friction as a fraction of the amount that just stops the bounce. At 0 the spring never settles. Below 1 it overshoots and bounces. At 1 (critically damped) it settles as fast as it can without overshoot. Above 1 (overdamped) it settles more slowly, still without overshoot.
50
+ - `bounce`: another way to set the damping ratio, as `1 - dampingRatio`. It goes from -1 (overdamped) to 1 (maximum bounce). A config that sets `damping` without `duration` needs it below 1.
51
+ - `duration`: the settle time in ms. The spring rests at this time, within one frame, at any `precision`. A bouncy config can rest up to about one oscillation earlier, or several short ones when `bounce` is near 1. The timing assumes that the motion starts 1 unit from the target. For accurate timing, set `displacement` to the range of your animation.
52
+ - `mass`: the weight of the moving value. Defaults to 1.
53
+ - `precision`: the number of decimal places that decide rest. Defaults to 2. A spring is _resting_ once its remaining motion cannot reach half a unit in the last place, which is 0.005 at the default. Coily never rounds values. A `duration` config is tuned against this threshold, so it still rests at its `duration`. Set `precision` to match the resolution of your values (see [PRECISION.md](https://github.com/tkofh/coily/blob/main/PRECISION.md)).
54
+ - `arrival`: what the motion does when the value reaches the target. `'passthrough'` (the default) swings through and settles. `'stop'` ends the motion exactly on the target the first time the value gets there, however bouncy the config. A number between -1 and 1 multiplies the velocity at every crossing, and negative values rebound. A spring that never crosses its target ignores `arrival`. For example, a spring with a `dampingRatio` of 1 or above that starts from rest closes in from one side and never crosses. Coily solves each crossing exactly and never samples it frame by frame (see [PRECISION.md](https://github.com/tkofh/coily/blob/main/PRECISION.md)).
53
55
 
54
- Without a config, springs are critically damped with a ~500ms settle time.
56
+ Without a config, a spring is critically damped and settles in about 500 ms.
55
57
 
56
58
  ## Spring API
57
59
 
58
- - `spring.target`: set to animate toward a new value
59
- - `spring.value`: current value, writable to displace the spring
60
- - `spring.velocity`: current velocity, writable to fling
61
- - `spring.jumpTo(v)`: snap to a value with no animation
62
- - `spring.config`: assign a new `SpringDefinition`, or `null` to revert to the default
63
- - `spring.isResting`, `spring.timeRemaining`: settle state and estimated ms until rest
64
- - `spring.settled`: a promise that resolves when the spring next comes to rest (immediately if already resting). Retargeting mid-flight extends the wait; disposing resolves it. `await spring.settled` to sequence animations
65
- - `spring.onUpdate(cb)` / `onStart(cb)` / `onStop(cb)` / `onDispose(cb)`: subscribe. Each returns an unsubscribe function. `start` fires when the spring leaves rest, `stop` when it settles. The two always alternate, and retargeting mid-flight fires neither
66
- - `spring.dispose()`: release the spring (calling it twice is a no-op)
60
+ - `spring.target`: set it to animate toward a new value.
61
+ - `spring.value`: the current value. Write it to displace the spring.
62
+ - `spring.velocity`: the current velocity, in units per second. Write it to fling the spring.
63
+ - `spring.acceleration`: the current acceleration, in units per second squared. Read-only.
64
+ - `spring.jumpTo(v)`: snap to a value with no animation.
65
+ - `spring.config`: assign a new `SpringDefinition`, or `null` to revert to the default.
66
+ - `spring.isResting`, `spring.timeRemaining`: whether the spring is resting, and the ms until it rests. Coily solves the time from the motion and does not estimate it (see [PRECISION.md](https://github.com/tkofh/coily/blob/main/PRECISION.md)).
67
+ - `spring.settled`: a promise that resolves when the spring next comes to rest, or at once if it is already resting. A retarget mid-flight extends the wait, and `dispose()` resolves it. Use `await spring.settled` to run animations in sequence.
68
+ - `spring.onUpdate(cb)`, `onStart(cb)`, `onStop(cb)`, `onDispose(cb)`: subscribe to events. Each returns an unsubscribe function. `start` fires when the spring leaves rest, and `stop` fires when it settles. The two always alternate, and a retarget mid-flight fires neither.
69
+ - `spring.dispose()`: release the spring. A second call does nothing.
67
70
 
68
71
  ### Chaining
69
72
 
70
- A spring can follow another spring's live value instead of a fixed number: assign a spring to `target`, or pass one straight to `createSpring` to follow from birth. `mapSpring` derives new followable values from existing ones (offsets, mirrors, clamps, any pure function of the value):
73
+ A spring can follow another spring's live value instead of a fixed number. Assign a spring to `target`, or pass one to `createSpring` to follow it from creation. `mapSpring` derives new values that springs can follow, such as offsets, mirrors, clamps, or any pure function of the value:
71
74
 
72
75
  ```ts
73
76
  const leader = system.createSpring(0)
@@ -77,9 +80,9 @@ const trailing = system.createSpring(mapSpring(leader, (v) => v + 20))
77
80
  const mirrored = system.createSpring(mapSpring(leader, (v) => -v))
78
81
  ```
79
82
 
80
- A follower's config is its own: following changes what a spring chases, never how it moves. To match a leader's feel, pass its config at creation — `system.createSpring(leader, leader.config)` — or share a `defineSpring` constant. Assigning a number to `target` unfollows.
83
+ A follower keeps its own config. Following changes what a spring chases, never how it moves. To match a leader's feel, pass the leader's config at creation, as in `system.createSpring(leader, leader.config)`, or share one `defineSpring` constant. Assign a number to `target` to stop following.
81
84
 
82
- `mapSpring` also combines several springs: pass a shape (a plain object or array with springs at the leaves, nested arbitrarily) and a function of their values:
85
+ `mapSpring` also combines several springs. Pass a shape, which is a plain object or array with springs at the leaves, nested to any depth, and a function of their values:
83
86
 
84
87
  ```ts
85
88
  const x = system.createSpring(0)
@@ -88,7 +91,7 @@ const y = system.createSpring(0)
88
91
  const distance = system.createSpring(mapSpring({ x, y }, ({ x, y }) => Math.hypot(x, y)))
89
92
  ```
90
93
 
91
- Composite springs (below) are sources of their whole value shape, so one map can derive a scalar from every channel at once:
94
+ A composite spring (see below) is a source of its whole value shape, so one map can derive a single number from every channel at once:
92
95
 
93
96
  ```ts
94
97
  const point = system.createSpring({ x: 3, y: 4 })
@@ -96,34 +99,40 @@ const point = system.createSpring({ x: 3, y: 4 })
96
99
  const magnitude = system.createSpring(mapSpring(point, ({ x, y }) => Math.hypot(x, y)))
97
100
  ```
98
101
 
99
- A mapped value is a `SpringSource`, the contract `target` accepts and every `Spring` implements. A `CompositeSpring` is a source _of its shape_: `mapSpring` reads it, alone or at the leaves of a shape, but only scalar sources can be followed directly.
102
+ A mapped value is a `SpringSource`. `target` accepts this type, and every `Spring` implements it. A `CompositeSpring` is a source _of its shape_. `mapSpring` can read it, alone or at the leaves of a shape, but a single-number spring can follow only a single-number source.
100
103
 
101
- Follow graphs may contain cycles: retargets never re-emit, so nothing loops within a frame. Mutual followers converge to a shared value. A cycle whose maps expand (`(v) => v + 10` in both directions) chases forever and never rests, keeping the system awake until you break the cycle.
104
+ Chains advance in dependency order. Each frame, a spring moves after the springs it follows, whatever order you created or connected them in. A follower therefore chases its leader's value for the current frame, not the previous one. Update events fire once per spring per frame, after every spring has moved, in the same leader-first order. A callback that reads another spring sees that spring's final value for the frame. A spring that a callback retargets starts moving on the next frame.
105
+
106
+ Following is accurate within a frame, too. A follower moves against the path its leader traveled during the frame, not one value sampled at the frame boundary. Sometimes one frame carries more motion than the system's `couplingTolerance` allows, which is 0.1 value units by default. The system then splits that frame into internal sub-steps. Dropped frames and targets that jump get these sub-steps automatically, and update events still fire once per frame. As a result, a chain keeps the same shape at 30fps as at 120fps. See [PRECISION.md](https://github.com/tkofh/coily/blob/main/PRECISION.md) for the model.
107
+
108
+ Follow graphs can contain cycles. A retarget emits no `update`, so nothing loops within a frame. Mutual followers converge to a shared value, and that value is the same at any frame rate. A cycle has no order that puts every member after its leaders. Its members advance in creation order, and the edge that closes the loop chases one frame behind. When the maps in a cycle push the values apart, such as `(v) => v + 10` in both directions, the springs chase forever and never rest. The system stays awake until you break the cycle.
102
109
 
103
110
  ### Composites
104
111
 
105
- `createSpring` also takes any numeric shape: a plain object or array whose leaves are all numbers, nested arbitrarily. Each leaf becomes an independent channel behind one composite API, a `CompositeSpring`:
112
+ `createSpring` also takes a numeric shape: a plain object or array, nested to any depth, whose leaves are all numbers. Each leaf becomes an independent channel behind one composite API, a `CompositeSpring`:
106
113
 
107
114
  ```ts
108
115
  const spring = system.createSpring({ position: { x: 0, y: 0 }, opacity: 1 })
109
116
 
110
- spring.target = { position: { x: 100 } } // a partial target leaves other channels alone
111
- spring.value // { position: { x, y }, opacity } — a stable, read-only mirror
117
+ spring.target = { position: { x: 100 } } // a partial target leaves the other channels alone
118
+ spring.value // { position: { x, y }, opacity }, a read-only object that every read refreshes
112
119
  ```
113
120
 
114
- The shape is fixed at creation, and unknown channels throw with their path (`position.z`). `value`, `velocity`, and `jumpTo` take the same partial shapes. Composites coalesce their events: `update` fires at most once per frame with every channel in its final per-frame state, and `stop` always lands after that frame's final `update`. `settled` and reduced motion compose channel-wise.
121
+ The shape is fixed at creation. An unknown channel throws an error that names its path (`position.z`). `value`, `velocity`, and `jumpTo` take the same partial shapes. A composite coalesces its events. `update` fires at most once per frame, with every channel in its final state for that frame, and `stop` always comes after that frame's final `update`. `settled` and reduced motion apply channel by channel.
115
122
 
116
- The `Shape` type validates shapes at compile time too (interfaces like your own `Vector2` work without index signatures) and rejects non-numeric, optional, or `undefined`-typed channels where they're declared.
123
+ The `Shape` type, exported from `coily`, also checks shapes at compile time. Interfaces such as your own `Vector2` work without index signatures. It rejects channels that are non-numeric, optional, or typed `undefined` where you declare them.
117
124
 
118
- Configs apply per channel. Pass a single `SpringDefinition` for every channel, or a shape mirroring the value with configs (or `null` to revert) at any level. A config at a subtree covers every channel below it:
125
+ Configs apply per channel. Pass one `SpringDefinition` for every channel, or a shape that mirrors the value. The shape can hold a config, or `null` to revert to the default, at any level. A config at a subtree covers every channel below it:
119
126
 
120
127
  ```ts
128
+ const stiff = defineSpring({ tension: 800, dampingRatio: 1 })
129
+
121
130
  spring.config = { position: stiff, opacity: defineSpring({ duration: 300, dampingRatio: 1 }) }
122
131
  ```
123
132
 
124
- Configs are `SpringDefinition` instances, built with `defineSpring`. Config positions don't accept bare option objects: a plain object is always a per-channel shape, so any non-config leaf it reaches throws with its path.
133
+ A config is a `SpringDefinition` instance, built with `defineSpring`. A config position does not accept a bare options object. Coily always reads a plain object as a per-channel shape, so any leaf in it that is not a config throws an error that names its path.
125
134
 
126
- Composite springs chain channel-wise. Assign another composite spring of the exact same shape to follow it whole, or name channels individually: each channel of a partial target takes a number or a scalar `SpringSource`. While following, a partial target detaches only the channels it names:
135
+ A composite spring follows channel by channel. To follow another composite spring as a whole, assign it to `target`. The two must have exactly the same shape. You can also name channels one at a time, and each channel of a partial target takes a number or a single-number `SpringSource`. While the composite follows, a partial target detaches only the channels it names:
127
136
 
128
137
  ```ts
129
138
  follower.target = leader
@@ -135,28 +144,74 @@ follower.target = {
135
144
  }
136
145
  ```
137
146
 
147
+ ### Velocity, acceleration, and target
148
+
149
+ `velocityOf`, `accelerationOf`, and `targetOf` each turn one part of a moving value into a source. `velocityOf` reads its velocity in units per second, `accelerationOf` reads its acceleration in units per second squared, and `targetOf` reads where it is heading. A spring can follow the result, and `mapSpring` can shape it:
150
+
151
+ ```ts
152
+ const x = system.createSpring(0)
153
+
154
+ // A blur that grows with speed: a spring follows a map of x's velocity.
155
+ const blur = system.createSpring(mapSpring(velocityOf(x), (v) => Math.abs(v) / 100))
156
+
157
+ // The distance x has left to travel, measured against where it is heading.
158
+ const remaining = system.createSpring(
159
+ mapSpring({ value: x, target: targetOf(x) }, ({ value, target }) => target - value),
160
+ )
161
+ ```
162
+
163
+ All three accept a `Spring`, a `CompositeSpring`, a ramp, or a Vue ref that wraps one. A composite gives a result of the same shape. A `mapSpring` result has no velocity or target of its own, so all three reject it. The result is a view, not a spring, and needs no disposal.
164
+
165
+ ### Ramps
166
+
167
+ A ramp is a value that moves toward its target at a fixed speed, in a straight line, and stops there. It is a source like a spring, so a spring can follow it:
168
+
169
+ ```ts
170
+ // An endless spin at 270 deg/s. The spring trails it like an object with inertia.
171
+ const sweep = system.createRamp(0, { rate: 270, target: Infinity })
172
+ const ring = system.createSpring(sweep)
173
+
174
+ // A heading that turns at no more than 400 deg/s: retarget the ramp, not the spring.
175
+ const slew = system.createRamp(0, { rate: 400 })
176
+ const core = system.createSpring(slew)
177
+ slew.target = 180
178
+
179
+ // A clock in ms, mapped into a 600 ms pulse.
180
+ const clock = system.createRamp(0, { rate: 1000, target: 600 })
181
+ const pulse = system.createSpring(mapSpring(clock, (ms) => Math.sin((Math.PI * ms) / 600)))
182
+ ```
183
+
184
+ With the default `arrival`, following a ramp is exact. The spring moves against the ramp's steady motion. With any other `arrival`, the spring holds the target still within each of a frame's internal sub-steps. By contrast, a target that you write once per frame reaches the spring as a staircase, about half a frame behind.
185
+
186
+ A ramp has the parts of the spring API that apply to it. These are `value`, `target`, `rate`, a read-only `velocity`, `isResting`, `timeRemaining` in ms, `settled`, `jumpTo`, `dispose`, and the same four events. You can write `value`, and the ramp carries on from where you put it. A `target` of `Infinity` or `-Infinity` makes the ramp endless. `rate` is in units per second. `velocityOf`, `accelerationOf`, and `targetOf` accept a ramp.
187
+
188
+ A ramp's value is a function of time. A ramp costs nothing per frame unless something listens to it frame by frame. An update listener does, and every follower is one. On a bounded ramp, a stop listener or a pending `settled` does too. A ramp that nothing watches keeps moving on the system's clock and reports its position when you read it. The clock counts frames, `advance` calls, and the time a started system sleeps. It stops while the system is stopped. A sleeping system also skips the time the page is hidden. An awake system gets no frames in a hidden tab, so the hidden spell counts as one frame when the page returns, clamped like any other lag.
189
+
138
190
  ## Reduced motion
139
191
 
140
- Coily respects `prefers-reduced-motion` by default. When it's active, springs snap to their targets instead of animating: retargets and value writes apply instantly, and springs ignore velocity impulses. Events stay coherent (one `update` per change, no `start`/`stop`, and `settled` resolves immediately), so code written against the animated path keeps working.
192
+ Coily follows `prefers-reduced-motion` by default. When it is on, springs snap to their targets instead of animating. Retargets and value writes apply at once, and springs ignore velocity writes. Events stay consistent: one `update` per change, no `start` or `stop`, and `settled` resolves at once. Code written for the animated path keeps working.
141
193
 
142
- Control it with the `reducedMotion` system option: `'always'`, `'never'`, or the default `'user'`, which follows the OS setting — a live change finishes in-flight animations instantly. Read `system.reducedMotion` to gate decorative effects (particles, flourishes) in your own code.
194
+ Ramps follow the same rule. A ramp jumps to a bounded target, and an endless ramp holds still until reduced motion turns off. A value that moves nothing on screen, such as a cross-fade, a color, or a clock that drives logic, takes `purpose: 'nonmotion'` and keeps animating. `createSpring`, `createRamp`, `useSpring`, and `useRamp` take `purpose` in their options, and `<SpringValue>` takes it as a prop.
195
+
196
+ Control it with the `reducedMotion` system option: `'always'`, `'never'`, or the default `'user'`, which follows the OS setting. When the OS setting turns on, animations in flight finish at once. Read `system.reducedMotion` to turn off decorative effects in your own code, such as particles.
143
197
 
144
198
  ## Vue
145
199
 
146
- Call `useSpringSystem()` once near the root of your app. It returns the spring system provided by this component or an ancestor. If none exists, it creates one, provides it to descendants, and starts/stops it with the component lifecycle. It's idempotent, and options apply only when a system is created:
200
+ Call `useSpringSystem()` once near the root of your app. It returns the spring system that this component or an ancestor provides. If none exists, it creates one, provides it to descendants, starts it on mount, and disposes it on unmount. A second call in any descendant returns the same system. Options apply only when the call creates a system:
147
201
 
148
202
  ```ts
149
203
  import { useSpringSystem } from 'coily/vue'
150
204
 
151
- const system = useSpringSystem() // e.g. read system.reducedMotion to gate decorative effects
205
+ const system = useSpringSystem() // for example, read system.reducedMotion
152
206
  ```
153
207
 
154
- (`provideSpringSystem(system, app?)` remains for plugging in a system you created yourself — mostly useful in tests. You manage `start()`/`stop()` for it.)
208
+ `provideSpringSystem(system, app?)` supplies a system that you created yourself, which is mostly useful in tests. You call `start()`, `stop()`, and `dispose()` on that system yourself.
155
209
 
156
210
  Then animate anywhere below:
157
211
 
158
212
  ```vue
159
213
  <script setup>
214
+ import { ref } from 'vue'
160
215
  import { useSpring } from 'coily/vue'
161
216
 
162
217
  const target = ref(0)
@@ -168,15 +223,22 @@ const x = useSpring(target, { duration: 500, bounce: 0.3 })
168
223
  </template>
169
224
  ```
170
225
 
171
- `useSpring` returns a `SpringRef`: a writable ref of the animated value with `velocity`, `isResting`, and `timeRemaining` refs plus `jumpTo()` attached. The target can be a ref, a getter, or another `SpringRef` (which chains the springs). Options are also reactive: swap configs and the spring reconfigures in place.
226
+ `useSpring` returns a `SpringRef`: a writable ref of the animated value, with `velocity`, `isResting`, and `timeRemaining` refs, `settled`, and `jumpTo()` attached. The target can be a ref, a getter, or another `SpringRef`, which chains the two springs. The config is reactive too. When it changes, the spring reconfigures in place.
227
+
228
+ A `SpringRef` is also a `SpringSource`, and `useSpring` follows any source. Chain through `mapSpring`, as in `useSpring(mapSpring({ x, y }, ({ x, y }) => Math.hypot(x, y)))`. Or pass a getter that returns a source to switch leaders reactively, as in `useSpring(() => (split.value ? left : right))`. Following bypasses Vue reactivity, because a follower subscribes to the backing spring directly. An effect or getter therefore never gains a dependency on a leader's animation.
172
229
 
173
- A `SpringRef` is also a `SpringSource`, and `useSpring` follows any source: chain through `mapSpring` (`useSpring(mapSpring({ x, y }, ({ x, y }) => Math.hypot(x, y)))`), or pass a getter of a source to switch leaders reactively (`useSpring(() => (split.value ? left : right))`). Following bypasses Vue reactivity — followers subscribe to the backing spring directly — so an effect or getter never gains a dependency on a leader's animation.
230
+ Numeric shapes work the same way. Pass a record or array, as a plain value, a ref, or a getter, and you get a `CompositeSpringRef`. Reads return the deep-readonly composite value, and writes take partial shapes. The config can be a reactive per-channel config shape. Pass another `CompositeSpringRef` to follow it channel by channel. A deeply reactive target retargets when a nested property changes. For several _independent_ single-number springs, map over the targets, since composables are safe to call in a loop: `targets.map((t) => useSpring(t))`.
174
231
 
175
- Numeric shapes work the same way: pass a record or array (plain, ref, or getter) and get a `CompositeSpringRef`. Reads are the deep-readonly composite value, writes take partial shapes, options accept reactive per-channel config shapes, and passing another `CompositeSpringRef` follows it channel-wise. Deeply reactive targets retarget on nested mutation. For several _independent_ scalar springs, map over the targets (composables are loop-safe): `targets.map((t) => useSpring(t))`.
232
+ `useRamp(target, rate, { from, purpose })` returns a `RampRef`: a writable ref of a ramp's value, with `velocity`, `isResting`, and `timeRemaining` refs, `settled`, and `jumpTo()`. `target` and `rate` take numbers, refs, or getters. `from` defaults to the initial target, and you must pass it when the target starts infinite. The ramp stays in the frame loop while something reads the ref, and `useSpring(rampRef)` follows the ramp directly:
233
+
234
+ ```ts
235
+ const angle = useRamp(Infinity, 270, { from: 0 })
236
+ const ring = useSpring(angle, { duration: 300, dampingRatio: 1 })
237
+ ```
176
238
 
177
- There's also a renderless `<SpringValue :target="n">` component exposing `{ value, velocity, isResting, timeRemaining, jumpTo }` through its default slot.
239
+ A renderless `<SpringValue :target="n">` component exposes `{ value, velocity, isResting, timeRemaining, jumpTo }` through its default slot.
178
240
 
179
- `useSpringPool()` covers imperative work: a dynamic set of springs created and disposed at arbitrary times (particles, per-item effects). It returns `createSpring` bound to the provided system. The pool disposes every spring created through it when the component's scope is torn down, so leaked motions are structurally impossible. Disposing a spring manually before that is fine.
241
+ `useSpringPool()` is for imperative work: a changing set of springs that you create and dispose at any time, such as particles or per-item effects. It returns `createSpring` bound to the provided system. When the component's scope ends, the pool disposes every spring created through it, so no pooled spring outlives its component. You can still dispose a spring yourself before that.
180
242
 
181
243
  ## Nuxt
182
244
 
@@ -184,17 +246,37 @@ There's also a renderless `<SpringValue :target="n">` component exposing `{ valu
184
246
  export default defineNuxtConfig({
185
247
  modules: ['coily/nuxt'],
186
248
  coily: {
187
- debug: false, // debug logs active motion counts
249
+ debug: false, // log active motion counts to the console
188
250
  reducedMotion: 'user', // 'user' | 'always' | 'never'
189
251
  },
190
252
  })
191
253
  ```
192
254
 
193
- The module provides a spring system for the whole app (started on the client), auto-imports `useSpring`, `useSpringSystem`, `useSpringPool`, and `defineSpring`, and registers the `SpringValue` component.
255
+ The module provides one spring system for the whole app and starts it on the client. It auto-imports `useSpring`, `useRamp`, `useSpringSystem`, `useSpringPool`, `defineSpring`, `mapSpring`, `velocityOf`, `accelerationOf`, and `targetOf`, and registers the `SpringValue` component. The `coily` key also accepts the system options `fps`, `lagThreshold`, `adjustedLag`, and `couplingTolerance`.
256
+
257
+ ## CSS
258
+
259
+ `coily/css` turns a spring config into static CSS: a `linear()` easing and a duration in ms to pair it with. It needs no DOM and no ticker, so it can run at build time:
260
+
261
+ ```ts
262
+ import { defineSpring } from 'coily'
263
+ import { springToWaapi } from 'coily/css'
264
+
265
+ const config = defineSpring({ duration: 500, bounce: 0.3, displacement: 300 })
266
+ const { keyframes, options } = springToWaapi(config, {
267
+ property: 'translate',
268
+ from: 0,
269
+ to: 300,
270
+ unit: 'px',
271
+ })
272
+ element.animate(keyframes, options)
273
+ ```
274
+
275
+ `springToLinear` returns the easing and its duration alone. `springToCss` returns a `@keyframes` rule and an `animation` value, and `springToTransition` returns a `transition` value. Give a spec a `velocity` to start the animation or transition at a moving value's speed, such as a drag at release. `springFromState` and `springStateAt` let you continue an interrupted animation with its momentum. A config with a `dampingRatio` of 0 and the default `arrival` never rests. `springToLinear`, `springToWaapi`, and `springToCss` turn it into an endless alternating loop, and `springToTransition` throws for it.
194
276
 
195
277
  ## Timing
196
278
 
197
- The built-in ticker advances springs once per displayed frame, so motion is as smooth as the screen it runs on: 60Hz, 120Hz, or adaptive sync. The loop sleeps while every spring is at rest (an idle system schedules no frames) and wakes on the next write. Set `fps` (option/property) to cap the rate: capped ticks land on whole display frames and still receive the true elapsed time. The ticker clamps large frame gaps (e.g. returning from a backgrounded tab) via `lagThreshold` (default 500ms) and `adjustedLag` (default 33ms), so springs don't teleport. For manual stepping, skip `system.start()` and call `system.advance(dtMs)` yourself.
279
+ The built-in ticker advances springs once per displayed frame, so motion is as smooth as the screen allows: 60Hz, 120Hz, or adaptive sync. The loop sleeps while every spring is resting, so an idle system schedules no frames. It wakes on the next write. Set `fps`, as an option or a property, to cap the rate. Capped ticks land on whole display frames and still receive the true elapsed time. The ticker clamps large gaps between frames, such as when a backgrounded tab comes back, so springs do not teleport. A gap longer than `lagThreshold` (default 500 ms) counts as `adjustedLag` (default 33 ms). Set `lagThreshold` to 0 to turn lag clamping off. For manual stepping, skip `system.start()` and call `system.advance(dtMs)` yourself, with a `dtMs` of 0 or more. Each system listens for page visibility and, by default, the reduced-motion setting. Call `system.dispose()` to remove those listeners when you no longer need the system.
198
280
 
199
281
  ## License
200
282