@quirelabs/fluid
v0.1.0
Published
Motion from simulations, not keyframes. A math-driven motion engine for interface components.
Downloads
20
Maintainers
Readme
@quirelabs/fluid
Motion from simulations, not keyframes.
A spring solved in closed form is exact at any timestep, so a component that is interrupted mid-motion carries its velocity into whatever happens next. Nothing snaps, nothing replays from the start, and nothing has to be tuned to a duration.
npm install @quirelabs/fluidNo dependencies. Framework-agnostic. For React, see @quirelabs/fluid-react.
The idea
Physics writes numbers into CSS custom properties. CSS decides what those numbers mean.
import { PressSim, sharedTicker } from '@quirelabs/fluid';
const press = new PressSim();
sharedTicker.add(press, (vars) => {
for (const [name, value] of Object.entries(vars)) {
button.style.setProperty(name, String(value));
}
});
button.addEventListener('pointerdown', () => press.press());
button.addEventListener('pointerup', () => press.release());.button {
scale: calc(1 - var(--fluid-press, 0) * 0.04);
}The simulation never emits a class name or decides anything about appearance. It produces a number between 0 and 1, and the stylesheet chooses whether that drives a scale, a shadow, a colour or nothing at all.
Why closed form
solve(config, dt) returns the four coefficients of the linear map
(x₀, v₀) → (x, v) for a damped harmonic oscillator. There is no integration
error to accumulate, the result is identical at 60Hz and 144Hz, and a dropped
frame does not change where the animation ends up.
import { Spring, springFromFeel } from '@quirelabs/fluid';
// Stiffness and damping, or the perceptual pair.
const spring = new Spring(0, springFromFeel({ response: 0.3, bounce: 0.4 }));
spring.target = 1;
spring.step(1 / 60);
spring.value; // 0.34…response is roughly how long it takes to get there; bounce is how much it
overshoots. Both are easier to reason about than stiffness and damping, and both
map onto them exactly.
What is in the box
| Export | What it does |
| --- | --- |
| Spring, SpringVector, solve | Closed-form damped oscillator |
| PressSim | Press, release and cancel as three different springs |
| MagnetSim | Pointer attraction with a smootherstep falloff |
| FocusSim, RippleSim | Focus rings and pooled click ripples |
| ToggleSim, RangeSim | Drag with momentum and rubber-banded bounds |
| TrailSim | A follower spring, for droplets and stretch |
| ScalarSim, ShakeSim, SizeSim | Named values, decaying oscillation, measured size |
| sharedTicker | One rAF for the page |
| noise, fbm, metaballField, WaveField1D | Fields, for the less ordinary cases |
The ticker sleeps
One requestAnimationFrame drives every simulation on the page, and it stops
entirely when they are all at rest, when the element scrolls out of view, or
when the tab is hidden. A page of idle components costs nothing.
Rest is decided by amplitude and velocity thresholds rather than by a timer, so a spring that has visually arrived stops being computed.
Reduced motion
prefers-reduced-motion is honoured in the engine, not in the stylesheet.
Simulations settle immediately to their target instead of animating, which means
every value still ends up correct and no component needs a second code path.
Licence
MIT
