solid-drift 0.2.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Austin Nguyen
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,93 @@
1
+ # solid-drift
2
+
3
+ Signal-native animation for SolidJS. Animate **values, not elements**: springs and tweens follow your signals, and retargeting mid-flight is seamless by design — no restarts, no jumps.
4
+
5
+ Built with AI assistance.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install solid-drift
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```tsx
16
+ import { createSignal } from "solid-js";
17
+ import { createSpring, createTween, drift } from "solid-drift";
18
+
19
+ function Panel() {
20
+ const [open, setOpen] = createSignal(false);
21
+
22
+ // Springs follow the signal with physics; tweens use duration + easing.
23
+ const y = createSpring(() => (open() ? 0 : 24), { stiffness: 170, damping: 26 });
24
+ const opacity = createTween(() => (open() ? 1 : 0), { duration: 250 });
25
+
26
+ return (
27
+ <>
28
+ <button onClick={() => setOpen(!open())}>toggle</button>
29
+ <div use:drift={{ y, opacity }}>slides and fades</div>
30
+ </>
31
+ );
32
+ }
33
+ ```
34
+
35
+ ## API
36
+
37
+ ### `createSpring(source, options?)`
38
+
39
+ Returns a signal that follows `source` with spring physics. Changing the source mid-flight bends the spring toward the new target, keeping its velocity.
40
+
41
+ | Option | Default | Description |
42
+ | ----------- | ------- | ------------------------------------ |
43
+ | `stiffness` | `170` | Spring stiffness |
44
+ | `damping` | `26` | Damping coefficient |
45
+ | `mass` | `1` | Mass |
46
+ | `precision` | `0.01` | Rest threshold for value and velocity |
47
+ | `onRest` | — | Called once the spring settles |
48
+
49
+ ### `createTween(source, options?)`
50
+
51
+ Returns a signal that tweens toward `source` over a fixed duration. Interrupting retargets from the current value.
52
+
53
+ | Option | Default | Description |
54
+ | ------------ | ---------------- | ------------------------------ |
55
+ | `duration` | `300` | Duration in milliseconds |
56
+ | `delay` | `0` | Delay before starting (ms) |
57
+ | `easing` | `"easeOutCubic"` | Easing function or name |
58
+ | `onComplete` | — | Called when the tween finishes |
59
+
60
+ ### `animate(from, to, options?)`
61
+
62
+ Imperative one-shot animation. Returns `{ stop, finished }`.
63
+
64
+ ```ts
65
+ const ctl = animate(0, 1, {
66
+ duration: 500,
67
+ easing: "easeOutExpo",
68
+ onUpdate: (v) => (el.style.opacity = String(v)),
69
+ });
70
+ await ctl.finished; // or ctl.stop()
71
+ ```
72
+
73
+ ### `drift` directive
74
+
75
+ Binds animated values directly to an element's style. Each prop accepts a plain number or any signal.
76
+
77
+ ```tsx
78
+ <div use:drift={{ x, y, opacity, scale, scaleX, scaleY, rotate }} />
79
+ ```
80
+
81
+ `x`/`y` map to `translate3d` px, `rotate` to degrees.
82
+
83
+ ### Easings
84
+
85
+ Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack` — plus `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
86
+
87
+ ## How it works
88
+
89
+ One shared `requestAnimationFrame` loop drives every animation in the app, so hundreds of springs cost a single rAF tick per frame. Springs integrate with semi-implicit Euler; tweens sample an easing curve. Everything is SSR-safe (animations simply don't run on the server).
90
+
91
+ ## License
92
+
93
+ MIT © Austin Nguyen
@@ -0,0 +1,32 @@
1
+ import { type Easing, type EasingName } from "./easing.js";
2
+ export interface AnimateOptions {
3
+ /** Duration in milliseconds. Default 300. */
4
+ duration?: number;
5
+ /** Delay before starting, in milliseconds. Default 0. */
6
+ delay?: number;
7
+ /** Easing function or name. Default "easeOutCubic". */
8
+ easing?: Easing | EasingName;
9
+ /** Called every frame with the current value. */
10
+ onUpdate?: (value: number) => void;
11
+ /** Called when the animation completes (not when stopped). */
12
+ onComplete?: () => void;
13
+ }
14
+ export interface AnimationControls {
15
+ /** Stop the animation immediately. */
16
+ stop: () => void;
17
+ /** Resolves when the animation completes or is stopped. */
18
+ finished: Promise<void>;
19
+ }
20
+ /**
21
+ * Imperative one-shot animation from `from` to `to`.
22
+ *
23
+ * ```ts
24
+ * const ctl = animate(0, 100, {
25
+ * duration: 500,
26
+ * easing: "easeOutExpo",
27
+ * onUpdate: (v) => el.style.opacity = String(v / 100),
28
+ * });
29
+ * await ctl.finished;
30
+ * ```
31
+ */
32
+ export declare function animate(from: number, to: number, options?: AnimateOptions): AnimationControls;
@@ -0,0 +1,58 @@
1
+ import { now, schedule } from "./engine.js";
2
+ import { resolveEasing } from "./easing.js";
3
+ import { prefersReducedMotion } from "./reduced-motion.js";
4
+ /**
5
+ * Imperative one-shot animation from `from` to `to`.
6
+ *
7
+ * ```ts
8
+ * const ctl = animate(0, 100, {
9
+ * duration: 500,
10
+ * easing: "easeOutExpo",
11
+ * onUpdate: (v) => el.style.opacity = String(v / 100),
12
+ * });
13
+ * await ctl.finished;
14
+ * ```
15
+ */
16
+ export function animate(from, to, options = {}) {
17
+ const { duration = 300, delay = 0, onUpdate, onComplete } = options;
18
+ const easing = resolveEasing(options.easing ?? "easeOutCubic");
19
+ let resolveFinished;
20
+ const finished = new Promise((resolve) => {
21
+ resolveFinished = resolve;
22
+ });
23
+ let done = false;
24
+ const finish = (completed) => {
25
+ if (done)
26
+ return;
27
+ done = true;
28
+ if (completed)
29
+ onComplete?.();
30
+ resolveFinished();
31
+ };
32
+ // Accessibility: skip the animation entirely, deliver the end value.
33
+ if (prefersReducedMotion()) {
34
+ onUpdate?.(to);
35
+ finish(true);
36
+ return { stop: () => finish(false), finished };
37
+ }
38
+ const startAt = now() + delay;
39
+ const span = Math.max(duration, 0.001);
40
+ const cancel = schedule((t) => {
41
+ if (t < startAt)
42
+ return true;
43
+ const p = Math.min((t - startAt) / span, 1);
44
+ onUpdate?.(from + (to - from) * easing(p));
45
+ if (p >= 1) {
46
+ finish(true);
47
+ return false;
48
+ }
49
+ return true;
50
+ });
51
+ return {
52
+ stop: () => {
53
+ cancel();
54
+ finish(false);
55
+ },
56
+ finished,
57
+ };
58
+ }
@@ -0,0 +1,44 @@
1
+ import { type Accessor } from "solid-js";
2
+ type MaybeAccessor<T> = T | Accessor<T>;
3
+ export interface DriftProps {
4
+ /** Horizontal offset in px. */
5
+ x?: MaybeAccessor<number>;
6
+ /** Vertical offset in px. */
7
+ y?: MaybeAccessor<number>;
8
+ /** Opacity 0–1. */
9
+ opacity?: MaybeAccessor<number>;
10
+ /** Uniform scale. */
11
+ scale?: MaybeAccessor<number>;
12
+ /** Horizontal scale. */
13
+ scaleX?: MaybeAccessor<number>;
14
+ /** Vertical scale. */
15
+ scaleY?: MaybeAccessor<number>;
16
+ /** Rotation in degrees. */
17
+ rotate?: MaybeAccessor<number>;
18
+ }
19
+ declare module "solid-js" {
20
+ namespace JSX {
21
+ interface Directives {
22
+ drift: DriftProps | Accessor<DriftProps>;
23
+ }
24
+ }
25
+ }
26
+ /**
27
+ * Directive that binds animated values straight to an element's style.
28
+ * Each prop can be a plain number or any signal — combine with
29
+ * `createSpring` / `createTween` for buttery motion:
30
+ *
31
+ * ```tsx
32
+ * import { drift } from "solid-drift";
33
+ *
34
+ * const [open, setOpen] = createSignal(false);
35
+ * const y = createSpring(() => (open() ? 0 : 24));
36
+ * const opacity = createTween(() => (open() ? 1 : 0), { duration: 250 });
37
+ *
38
+ * <div use:drift={{ y, opacity }}>slides and fades</div>
39
+ * ```
40
+ *
41
+ * Note: importing this module registers the `drift` directive type globally.
42
+ */
43
+ export declare function drift(el: HTMLElement, props: Accessor<DriftProps>): void;
44
+ export {};
@@ -0,0 +1,42 @@
1
+ import { createEffect } from "solid-js";
2
+ function resolve(value) {
3
+ return typeof value === "function" ? value() : value;
4
+ }
5
+ /**
6
+ * Directive that binds animated values straight to an element's style.
7
+ * Each prop can be a plain number or any signal — combine with
8
+ * `createSpring` / `createTween` for buttery motion:
9
+ *
10
+ * ```tsx
11
+ * import { drift } from "solid-drift";
12
+ *
13
+ * const [open, setOpen] = createSignal(false);
14
+ * const y = createSpring(() => (open() ? 0 : 24));
15
+ * const opacity = createTween(() => (open() ? 1 : 0), { duration: 250 });
16
+ *
17
+ * <div use:drift={{ y, opacity }}>slides and fades</div>
18
+ * ```
19
+ *
20
+ * Note: importing this module registers the `drift` directive type globally.
21
+ */
22
+ export function drift(el, props) {
23
+ createEffect(() => {
24
+ const p = props();
25
+ const parts = [];
26
+ const x = p.x !== undefined ? resolve(p.x) : 0;
27
+ const y = p.y !== undefined ? resolve(p.y) : 0;
28
+ if (x !== 0 || y !== 0)
29
+ parts.push(`translate3d(${x}px, ${y}px, 0)`);
30
+ const rotate = p.rotate !== undefined ? resolve(p.rotate) : 0;
31
+ if (rotate !== 0)
32
+ parts.push(`rotate(${rotate}deg)`);
33
+ const sx = p.scaleX !== undefined ? resolve(p.scaleX) : p.scale !== undefined ? resolve(p.scale) : 1;
34
+ const sy = p.scaleY !== undefined ? resolve(p.scaleY) : p.scale !== undefined ? resolve(p.scale) : 1;
35
+ if (sx !== 1 || sy !== 1)
36
+ parts.push(`scale(${sx}, ${sy})`);
37
+ el.style.transform = parts.join(" ");
38
+ if (p.opacity !== undefined) {
39
+ el.style.opacity = String(resolve(p.opacity));
40
+ }
41
+ });
42
+ }
@@ -0,0 +1,35 @@
1
+ /** Easing function: maps progress [0, 1] to eased progress [0, 1]. */
2
+ export type Easing = (t: number) => number;
3
+ export declare const linear: Easing;
4
+ export declare const easeInQuad: Easing;
5
+ export declare const easeOutQuad: Easing;
6
+ export declare const easeInOutQuad: Easing;
7
+ export declare const easeInCubic: Easing;
8
+ export declare const easeOutCubic: Easing;
9
+ export declare const easeInOutCubic: Easing;
10
+ export declare const easeInQuart: Easing;
11
+ export declare const easeOutQuart: Easing;
12
+ export declare const easeInOutQuart: Easing;
13
+ export declare const easeOutExpo: Easing;
14
+ export declare const easeOutBack: Easing;
15
+ /**
16
+ * Cubic bezier easing, same parameters as CSS `cubic-bezier(x1, y1, x2, y2)`.
17
+ */
18
+ export declare function cubicBezier(x1: number, y1: number, x2: number, y2: number): Easing;
19
+ export declare const easings: {
20
+ readonly linear: Easing;
21
+ readonly easeInQuad: Easing;
22
+ readonly easeOutQuad: Easing;
23
+ readonly easeInOutQuad: Easing;
24
+ readonly easeInCubic: Easing;
25
+ readonly easeOutCubic: Easing;
26
+ readonly easeInOutCubic: Easing;
27
+ readonly easeInQuart: Easing;
28
+ readonly easeOutQuart: Easing;
29
+ readonly easeInOutQuart: Easing;
30
+ readonly easeOutExpo: Easing;
31
+ readonly easeOutBack: Easing;
32
+ };
33
+ export type EasingName = keyof typeof easings;
34
+ /** Accept an easing function or its name; throws on unknown names. */
35
+ export declare function resolveEasing(easing: Easing | EasingName | undefined): Easing;
package/dist/easing.js ADDED
@@ -0,0 +1,81 @@
1
+ export const linear = (t) => t;
2
+ export const easeInQuad = (t) => t * t;
3
+ export const easeOutQuad = (t) => 1 - (1 - t) * (1 - t);
4
+ export const easeInOutQuad = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
5
+ export const easeInCubic = (t) => t * t * t;
6
+ export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
7
+ export const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
8
+ export const easeInQuart = (t) => t * t * t * t;
9
+ export const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4);
10
+ export const easeInOutQuart = (t) => t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;
11
+ export const easeOutExpo = (t) => t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
12
+ export const easeOutBack = (t) => {
13
+ const c1 = 1.70158;
14
+ const c3 = c1 + 1;
15
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
16
+ };
17
+ /**
18
+ * Cubic bezier easing, same parameters as CSS `cubic-bezier(x1, y1, x2, y2)`.
19
+ */
20
+ export function cubicBezier(x1, y1, x2, y2) {
21
+ // Newton-Raphson + bisection fallback, adapted from the CSS spec approach.
22
+ const cx = 3 * x1;
23
+ const bx = 3 * (x2 - x1) - cx;
24
+ const ax = 1 - cx - bx;
25
+ const cy = 3 * y1;
26
+ const by = 3 * (y2 - y1) - cy;
27
+ const ay = 1 - cy - by;
28
+ const sampleX = (t) => ((ax * t + bx) * t + cx) * t;
29
+ const sampleY = (t) => ((ay * t + by) * t + cy) * t;
30
+ const sampleDX = (t) => (3 * ax * t + 2 * bx) * t + cx;
31
+ return (x) => {
32
+ let t = x;
33
+ for (let i = 0; i < 5; i++) {
34
+ const dx = sampleDX(t);
35
+ if (Math.abs(dx) < 1e-6)
36
+ break;
37
+ const err = sampleX(t) - x;
38
+ t -= err / dx;
39
+ }
40
+ // Fallback bisection if Newton diverged.
41
+ let lo = 0;
42
+ let hi = 1;
43
+ t = Math.min(Math.max(t, 0), 1);
44
+ while (hi - lo > 1e-6) {
45
+ const v = sampleX(t);
46
+ if (Math.abs(v - x) < 1e-6)
47
+ break;
48
+ if (v < x)
49
+ lo = t;
50
+ else
51
+ hi = t;
52
+ t = (lo + hi) / 2;
53
+ }
54
+ return sampleY(t);
55
+ };
56
+ }
57
+ export const easings = {
58
+ linear,
59
+ easeInQuad,
60
+ easeOutQuad,
61
+ easeInOutQuad,
62
+ easeInCubic,
63
+ easeOutCubic,
64
+ easeInOutCubic,
65
+ easeInQuart,
66
+ easeOutQuart,
67
+ easeInOutQuart,
68
+ easeOutExpo,
69
+ easeOutBack,
70
+ };
71
+ /** Accept an easing function or its name; throws on unknown names. */
72
+ export function resolveEasing(easing) {
73
+ if (!easing)
74
+ return easeOutCubic;
75
+ if (typeof easing === "function")
76
+ return easing;
77
+ const fn = easings[easing];
78
+ if (!fn)
79
+ throw new Error(`[solid-drift] unknown easing: "${easing}"`);
80
+ return fn;
81
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared animation clock.
3
+ *
4
+ * A single requestAnimationFrame loop drives every active animation in the
5
+ * app, so hundreds of springs and tweens cost exactly one rAF tick per frame.
6
+ * Tasks return `false` when finished and are removed automatically.
7
+ */
8
+ export type AnimationTask = (now: number) => boolean;
9
+ /**
10
+ * Register a task on the shared clock. Returns a cancel function.
11
+ * Safe to call during SSR (no-op without requestAnimationFrame).
12
+ */
13
+ export declare function schedule(task: AnimationTask): () => void;
14
+ /** Monotonic clock in milliseconds. */
15
+ export declare function now(): number;
package/dist/engine.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared animation clock.
3
+ *
4
+ * A single requestAnimationFrame loop drives every active animation in the
5
+ * app, so hundreds of springs and tweens cost exactly one rAF tick per frame.
6
+ * Tasks return `false` when finished and are removed automatically.
7
+ */
8
+ const tasks = new Set();
9
+ let rafId = 0;
10
+ function tick(now) {
11
+ for (const task of tasks) {
12
+ let alive = false;
13
+ try {
14
+ alive = task(now);
15
+ }
16
+ catch {
17
+ alive = false;
18
+ }
19
+ if (!alive)
20
+ tasks.delete(task);
21
+ }
22
+ rafId = tasks.size > 0 ? requestAnimationFrame(tick) : 0;
23
+ }
24
+ /**
25
+ * Register a task on the shared clock. Returns a cancel function.
26
+ * Safe to call during SSR (no-op without requestAnimationFrame).
27
+ */
28
+ export function schedule(task) {
29
+ if (typeof requestAnimationFrame === "undefined")
30
+ return () => { };
31
+ tasks.add(task);
32
+ if (!rafId)
33
+ rafId = requestAnimationFrame(tick);
34
+ return () => {
35
+ tasks.delete(task);
36
+ };
37
+ }
38
+ /** Monotonic clock in milliseconds. */
39
+ export function now() {
40
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
41
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * solid-drift — signal-native animation for SolidJS.
3
+ *
4
+ * Animate values, not elements: springs and tweens follow your signals, and
5
+ * retargeting mid-flight is seamless by design.
6
+ */
7
+ export { createSpring, type SpringOptions } from "./spring.js";
8
+ export { createTween, type TweenOptions } from "./tween.js";
9
+ export { animate, type AnimateOptions, type AnimationControls, } from "./animate.js";
10
+ export { drift, type DriftProps } from "./directive.js";
11
+ export { createScrollProgress, type ScrollTarget } from "./scroll.js";
12
+ export { createInView, type InViewOptions } from "./inview.js";
13
+ export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion.js";
14
+ export { createStagger } from "./stagger.js";
15
+ export { easings, cubicBezier, linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeInQuart, easeOutQuart, easeInOutQuart, easeOutExpo, easeOutBack, resolveEasing, type Easing, type EasingName, } from "./easing.js";
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * solid-drift — signal-native animation for SolidJS.
3
+ *
4
+ * Animate values, not elements: springs and tweens follow your signals, and
5
+ * retargeting mid-flight is seamless by design.
6
+ */
7
+ export { createSpring } from "./spring.js";
8
+ export { createTween } from "./tween.js";
9
+ export { animate, } from "./animate.js";
10
+ export { drift } from "./directive.js";
11
+ export { createScrollProgress } from "./scroll.js";
12
+ export { createInView } from "./inview.js";
13
+ export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion.js";
14
+ export { createStagger } from "./stagger.js";
15
+ export { easings, cubicBezier, linear, easeInQuad, easeOutQuad, easeInOutQuad, easeInCubic, easeOutCubic, easeInOutCubic, easeInQuart, easeOutQuart, easeInOutQuart, easeOutExpo, easeOutBack, resolveEasing, } from "./easing.js";
@@ -0,0 +1,25 @@
1
+ import { type Accessor } from "solid-js";
2
+ export interface InViewOptions {
3
+ /** Visibility ratio that counts as "in view". Default 0.15. */
4
+ threshold?: number;
5
+ /** Stop observing after the first intersection. Default true. */
6
+ once?: boolean;
7
+ }
8
+ /**
9
+ * A boolean signal reporting whether an element is visible in the viewport.
10
+ *
11
+ * Built on IntersectionObserver: cheap, off-main-thread, and exact. With
12
+ * `once: true` (default) the signal latches on first visibility — ideal
13
+ * for entrance animations. Set `once: false` for a live in/out signal.
14
+ *
15
+ * The observer is created when the ref resolves and disconnected on
16
+ * cleanup. SSR-safe: returns a constant `false` accessor on the server.
17
+ *
18
+ * ```tsx
19
+ * let card: HTMLDivElement | undefined;
20
+ * const inView = createInView(() => card);
21
+ * const opacity = createTween(() => (inView() ? 1 : 0), { duration: 400 });
22
+ * <div ref={card} style={{ opacity: opacity() }}>…</div>
23
+ * ```
24
+ */
25
+ export declare function createInView(ref: () => Element | null | undefined, options?: InViewOptions): Accessor<boolean>;
package/dist/inview.js ADDED
@@ -0,0 +1,47 @@
1
+ import { createEffect, createSignal, onCleanup, } from "solid-js";
2
+ /**
3
+ * A boolean signal reporting whether an element is visible in the viewport.
4
+ *
5
+ * Built on IntersectionObserver: cheap, off-main-thread, and exact. With
6
+ * `once: true` (default) the signal latches on first visibility — ideal
7
+ * for entrance animations. Set `once: false` for a live in/out signal.
8
+ *
9
+ * The observer is created when the ref resolves and disconnected on
10
+ * cleanup. SSR-safe: returns a constant `false` accessor on the server.
11
+ *
12
+ * ```tsx
13
+ * let card: HTMLDivElement | undefined;
14
+ * const inView = createInView(() => card);
15
+ * const opacity = createTween(() => (inView() ? 1 : 0), { duration: 400 });
16
+ * <div ref={card} style={{ opacity: opacity() }}>…</div>
17
+ * ```
18
+ */
19
+ export function createInView(ref, options = {}) {
20
+ const { threshold = 0.15, once = true } = options;
21
+ // SSR (or browsers without IntersectionObserver): never "in view".
22
+ if (typeof window === "undefined" ||
23
+ typeof IntersectionObserver === "undefined") {
24
+ return () => false;
25
+ }
26
+ const [inView, setInView] = createSignal(false);
27
+ createEffect(() => {
28
+ const el = ref();
29
+ if (!el)
30
+ return;
31
+ const observer = new IntersectionObserver((entries) => {
32
+ for (const entry of entries) {
33
+ if (entry.isIntersecting) {
34
+ setInView(true);
35
+ if (once)
36
+ observer.disconnect();
37
+ }
38
+ else if (!once) {
39
+ setInView(false);
40
+ }
41
+ }
42
+ }, { threshold });
43
+ observer.observe(el);
44
+ onCleanup(() => observer.disconnect());
45
+ });
46
+ return inView;
47
+ }
@@ -0,0 +1,21 @@
1
+ import { type Accessor } from "solid-js";
2
+ /**
3
+ * Non-reactive check: does the user currently prefer reduced motion?
4
+ *
5
+ * SSR-safe — always `false` on the server. `createSpring`, `createTween`
6
+ * and `animate` sample this whenever they (re)start: when reduced motion
7
+ * is preferred they jump straight to the target value instead of animating.
8
+ */
9
+ export declare function prefersReducedMotion(): boolean;
10
+ /**
11
+ * Reactive signal for the `(prefers-reduced-motion: reduce)` media query.
12
+ *
13
+ * Updates live if the OS preference changes while the app is running.
14
+ * SSR-safe: `false` on the server.
15
+ *
16
+ * ```tsx
17
+ * const reduced = usePrefersReducedMotion();
18
+ * const duration = () => (reduced() ? 0 : 400);
19
+ * ```
20
+ */
21
+ export declare function usePrefersReducedMotion(): Accessor<boolean>;
@@ -0,0 +1,38 @@
1
+ import { createSignal, onCleanup } from "solid-js";
2
+ const QUERY = "(prefers-reduced-motion: reduce)";
3
+ function queryMatches() {
4
+ return (typeof window !== "undefined" &&
5
+ typeof window.matchMedia === "function" &&
6
+ window.matchMedia(QUERY).matches);
7
+ }
8
+ /**
9
+ * Non-reactive check: does the user currently prefer reduced motion?
10
+ *
11
+ * SSR-safe — always `false` on the server. `createSpring`, `createTween`
12
+ * and `animate` sample this whenever they (re)start: when reduced motion
13
+ * is preferred they jump straight to the target value instead of animating.
14
+ */
15
+ export function prefersReducedMotion() {
16
+ return queryMatches();
17
+ }
18
+ /**
19
+ * Reactive signal for the `(prefers-reduced-motion: reduce)` media query.
20
+ *
21
+ * Updates live if the OS preference changes while the app is running.
22
+ * SSR-safe: `false` on the server.
23
+ *
24
+ * ```tsx
25
+ * const reduced = usePrefersReducedMotion();
26
+ * const duration = () => (reduced() ? 0 : 400);
27
+ * ```
28
+ */
29
+ export function usePrefersReducedMotion() {
30
+ const [reduced, setReduced] = createSignal(queryMatches());
31
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
32
+ const mq = window.matchMedia(QUERY);
33
+ const onChange = () => setReduced(mq.matches);
34
+ mq.addEventListener("change", onChange);
35
+ onCleanup(() => mq.removeEventListener("change", onChange));
36
+ }
37
+ return reduced;
38
+ }
@@ -0,0 +1,26 @@
1
+ import { type Accessor } from "solid-js";
2
+ /** What to measure scroll progress against: the whole page or one element. */
3
+ export type ScrollTarget = "page" | (() => Element | null | undefined);
4
+ /**
5
+ * A signal tracking scroll progress as a number from 0 to 1.
6
+ *
7
+ * - `"page"` (default): 0 at the very top of the page, 1 when the bottom
8
+ * of the page reaches the bottom of the viewport.
9
+ * - element accessor: 0 when the element's top edge touches the bottom of
10
+ * the viewport, 1 when its bottom edge touches the top — i.e. the
11
+ * element's full traversal through the viewport.
12
+ *
13
+ * Updates are rAF-throttled: no matter how many scroll/resize events fire,
14
+ * progress is measured at most once per frame. Listeners are passive and
15
+ * removed on cleanup.
16
+ *
17
+ * SSR-safe: returns a constant `0` accessor on the server (never touches
18
+ * `window` or `document`).
19
+ *
20
+ * ```tsx
21
+ * const progress = createScrollProgress(); // page progress
22
+ * const bar = createScrollProgress(() => sectionRef); // element progress
23
+ * <div style={{ transform: `scaleX(${progress()})` }} />
24
+ * ```
25
+ */
26
+ export declare function createScrollProgress(target?: ScrollTarget): Accessor<number>;
package/dist/scroll.js ADDED
@@ -0,0 +1,74 @@
1
+ import { createEffect, createSignal, onCleanup, } from "solid-js";
2
+ function clamp01(v) {
3
+ return v < 0 ? 0 : v > 1 ? 1 : v;
4
+ }
5
+ /**
6
+ * A signal tracking scroll progress as a number from 0 to 1.
7
+ *
8
+ * - `"page"` (default): 0 at the very top of the page, 1 when the bottom
9
+ * of the page reaches the bottom of the viewport.
10
+ * - element accessor: 0 when the element's top edge touches the bottom of
11
+ * the viewport, 1 when its bottom edge touches the top — i.e. the
12
+ * element's full traversal through the viewport.
13
+ *
14
+ * Updates are rAF-throttled: no matter how many scroll/resize events fire,
15
+ * progress is measured at most once per frame. Listeners are passive and
16
+ * removed on cleanup.
17
+ *
18
+ * SSR-safe: returns a constant `0` accessor on the server (never touches
19
+ * `window` or `document`).
20
+ *
21
+ * ```tsx
22
+ * const progress = createScrollProgress(); // page progress
23
+ * const bar = createScrollProgress(() => sectionRef); // element progress
24
+ * <div style={{ transform: `scaleX(${progress()})` }} />
25
+ * ```
26
+ */
27
+ export function createScrollProgress(target = "page") {
28
+ // SSR: no window, no scrolling — report the top of the page.
29
+ if (typeof window === "undefined")
30
+ return () => 0;
31
+ const [progress, setProgress] = createSignal(0);
32
+ let rafId = 0;
33
+ const compute = () => {
34
+ if (target === "page") {
35
+ const max = document.documentElement.scrollHeight - window.innerHeight;
36
+ setProgress(max > 0 ? clamp01(window.scrollY / max) : 0);
37
+ return;
38
+ }
39
+ const el = target();
40
+ if (!el)
41
+ return;
42
+ const rect = el.getBoundingClientRect();
43
+ const viewport = window.innerHeight;
44
+ const travel = viewport + rect.height;
45
+ setProgress(travel > 0 ? clamp01((viewport - rect.top) / travel) : 0);
46
+ };
47
+ const requestCompute = () => {
48
+ if (typeof requestAnimationFrame === "undefined") {
49
+ compute();
50
+ return;
51
+ }
52
+ if (rafId)
53
+ return; // a measurement is already queued for this frame
54
+ rafId = requestAnimationFrame(() => {
55
+ rafId = 0;
56
+ compute();
57
+ });
58
+ };
59
+ // Track the element accessor so late-bound refs start measuring too.
60
+ createEffect(() => {
61
+ if (target !== "page")
62
+ target();
63
+ compute();
64
+ });
65
+ window.addEventListener("scroll", requestCompute, { passive: true });
66
+ window.addEventListener("resize", requestCompute, { passive: true });
67
+ onCleanup(() => {
68
+ window.removeEventListener("scroll", requestCompute);
69
+ window.removeEventListener("resize", requestCompute);
70
+ if (rafId)
71
+ cancelAnimationFrame(rafId);
72
+ });
73
+ return progress;
74
+ }
@@ -0,0 +1,28 @@
1
+ import { type Accessor } from "solid-js";
2
+ export interface SpringOptions {
3
+ /** Spring stiffness. Default 170. */
4
+ stiffness?: number;
5
+ /** Damping coefficient. Default 26. */
6
+ damping?: number;
7
+ /** Mass. Default 1. */
8
+ mass?: number;
9
+ /** Rest threshold for value and velocity. Default 0.01. */
10
+ precision?: number;
11
+ /** Called once the spring settles at its target. */
12
+ onRest?: () => void;
13
+ }
14
+ /**
15
+ * A signal that smoothly follows a source signal with spring physics.
16
+ *
17
+ * Retargeting is seamless: if the source changes mid-flight, the spring
18
+ * keeps its current velocity and bends toward the new target — no jumps,
19
+ * no restarts.
20
+ *
21
+ * ```tsx
22
+ * const [target, setTarget] = createSignal(0);
23
+ * const x = createSpring(target, { stiffness: 170, damping: 26 });
24
+ * <div style={{ transform: `translateX(${x()}px)` }} />
25
+ * setTarget(200); // glides there
26
+ * ```
27
+ */
28
+ export declare function createSpring(source: Accessor<number>, options?: SpringOptions): Accessor<number>;
package/dist/spring.js ADDED
@@ -0,0 +1,68 @@
1
+ import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
2
+ import { now, schedule } from "./engine.js";
3
+ import { prefersReducedMotion } from "./reduced-motion.js";
4
+ /**
5
+ * A signal that smoothly follows a source signal with spring physics.
6
+ *
7
+ * Retargeting is seamless: if the source changes mid-flight, the spring
8
+ * keeps its current velocity and bends toward the new target — no jumps,
9
+ * no restarts.
10
+ *
11
+ * ```tsx
12
+ * const [target, setTarget] = createSignal(0);
13
+ * const x = createSpring(target, { stiffness: 170, damping: 26 });
14
+ * <div style={{ transform: `translateX(${x()}px)` }} />
15
+ * setTarget(200); // glides there
16
+ * ```
17
+ */
18
+ export function createSpring(source, options = {}) {
19
+ const { stiffness = 170, damping = 26, mass = 1, precision = 0.01, onRest, } = options;
20
+ const [value, setValue] = createSignal(untrack(source));
21
+ let current = untrack(source);
22
+ let velocity = 0;
23
+ let cancel = null;
24
+ let lastTime = 0;
25
+ const step = (t) => {
26
+ const dt = Math.min(Math.max((t - lastTime) / 1000, 0), 0.064);
27
+ lastTime = t;
28
+ const target = untrack(source);
29
+ // Semi-implicit Euler: stable for the stiffness ranges used in UI.
30
+ const force = -stiffness * (current - target) - damping * velocity;
31
+ velocity += (force / mass) * dt;
32
+ current += velocity * dt;
33
+ setValue(current);
34
+ const settled = Math.abs(current - target) < precision &&
35
+ Math.abs(velocity) < precision;
36
+ if (settled) {
37
+ current = target;
38
+ velocity = 0;
39
+ setValue(target);
40
+ cancel = null;
41
+ onRest?.();
42
+ return false;
43
+ }
44
+ return true;
45
+ };
46
+ const kick = () => {
47
+ if (cancel)
48
+ return; // already running; step() reads the live target
49
+ lastTime = now();
50
+ cancel = schedule(step);
51
+ };
52
+ createEffect(() => {
53
+ const target = source(); // track
54
+ if (prefersReducedMotion()) {
55
+ // Accessibility: skip the animation, land exactly on the target.
56
+ cancel?.();
57
+ cancel = null;
58
+ current = target;
59
+ velocity = 0;
60
+ setValue(target);
61
+ onRest?.();
62
+ return;
63
+ }
64
+ kick();
65
+ });
66
+ onCleanup(() => cancel?.());
67
+ return value;
68
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Builds a stagger-delay lookup for cascading animations across a list.
3
+ *
4
+ * Given an item index, returns its delay in milliseconds (`index * delayMs`).
5
+ * Pair with `createTween`'s `delay` option (or `animate`) so items entrance
6
+ * one after another instead of all at once. `count` is informational — the
7
+ * number of items being staggered.
8
+ *
9
+ * ```ts
10
+ * const at = createStagger(5, 80); // 5 items, 80ms apart
11
+ * at(0); // 0
12
+ * at(3); // 240
13
+ * ```
14
+ */
15
+ export declare function createStagger(count: number, delayMs: number): (index: number) => number;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Builds a stagger-delay lookup for cascading animations across a list.
3
+ *
4
+ * Given an item index, returns its delay in milliseconds (`index * delayMs`).
5
+ * Pair with `createTween`'s `delay` option (or `animate`) so items entrance
6
+ * one after another instead of all at once. `count` is informational — the
7
+ * number of items being staggered.
8
+ *
9
+ * ```ts
10
+ * const at = createStagger(5, 80); // 5 items, 80ms apart
11
+ * at(0); // 0
12
+ * at(3); // 240
13
+ * ```
14
+ */
15
+ export function createStagger(count, delayMs) {
16
+ void count;
17
+ return (index) => index * delayMs;
18
+ }
@@ -0,0 +1,24 @@
1
+ import { type Accessor } from "solid-js";
2
+ import { type Easing, type EasingName } from "./easing.js";
3
+ export interface TweenOptions {
4
+ /** Duration in milliseconds. Default 300. */
5
+ duration?: number;
6
+ /** Delay before starting, in milliseconds. Default 0. */
7
+ delay?: number;
8
+ /** Easing function or name. Default "easeOutCubic". */
9
+ easing?: Easing | EasingName;
10
+ /** Called when the tween reaches its target. */
11
+ onComplete?: () => void;
12
+ }
13
+ /**
14
+ * A signal that tweens toward a source signal's value over a fixed duration.
15
+ *
16
+ * Interrupting mid-tween retargets from the current value — no snapping.
17
+ *
18
+ * ```tsx
19
+ * const [open, setOpen] = createSignal(false);
20
+ * const opacity = createTween(() => (open() ? 1 : 0), { duration: 250 });
21
+ * <div style={{ opacity: opacity() }} />
22
+ * ```
23
+ */
24
+ export declare function createTween(source: Accessor<number>, options?: TweenOptions): Accessor<number>;
package/dist/tween.js ADDED
@@ -0,0 +1,48 @@
1
+ import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
2
+ import { now, schedule } from "./engine.js";
3
+ import { resolveEasing } from "./easing.js";
4
+ import { prefersReducedMotion } from "./reduced-motion.js";
5
+ /**
6
+ * A signal that tweens toward a source signal's value over a fixed duration.
7
+ *
8
+ * Interrupting mid-tween retargets from the current value — no snapping.
9
+ *
10
+ * ```tsx
11
+ * const [open, setOpen] = createSignal(false);
12
+ * const opacity = createTween(() => (open() ? 1 : 0), { duration: 250 });
13
+ * <div style={{ opacity: opacity() }} />
14
+ * ```
15
+ */
16
+ export function createTween(source, options = {}) {
17
+ const { duration = 300, delay = 0, onComplete } = options;
18
+ const easing = resolveEasing(options.easing ?? "easeOutCubic");
19
+ const [value, setValue] = createSignal(untrack(source));
20
+ let cancel = null;
21
+ createEffect(() => {
22
+ const to = source();
23
+ const from = untrack(value);
24
+ cancel?.();
25
+ cancel = null;
26
+ if (prefersReducedMotion() || duration <= 0 || from === to) {
27
+ // Accessibility: jump straight to the target, no animation.
28
+ setValue(to);
29
+ onComplete?.();
30
+ return;
31
+ }
32
+ const startAt = now() + delay;
33
+ cancel = schedule((t) => {
34
+ if (t < startAt)
35
+ return true;
36
+ const p = Math.min((t - startAt) / duration, 1);
37
+ setValue(from + (to - from) * easing(p));
38
+ if (p >= 1) {
39
+ cancel = null;
40
+ onComplete?.();
41
+ return false;
42
+ }
43
+ return true;
44
+ });
45
+ });
46
+ onCleanup(() => cancel?.());
47
+ return value;
48
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "author": "Austin Nguyen",
3
+ "description": "Signal-native animation library for SolidJS",
4
+ "devDependencies": {
5
+ "solid-js": "^1.9.0",
6
+ "typescript": "^5.6.0",
7
+ "vitest": "^5.0.2"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "default": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "keywords": [
19
+ "solid",
20
+ "solidjs",
21
+ "animation",
22
+ "spring",
23
+ "tween",
24
+ "signals",
25
+ "motion"
26
+ ],
27
+ "license": "MIT",
28
+ "main": "./dist/index.js",
29
+ "name": "solid-drift",
30
+ "peerDependencies": {
31
+ "solid-js": "^1.0.0 || ^2.0.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/austinpnguyen/solid-drift.git"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.build.json",
39
+ "dev": "tsc --watch",
40
+ "test": "vitest run",
41
+ "prepublishOnly": "npm run build",
42
+ "prepare": "npm run build"
43
+ },
44
+ "type": "module",
45
+ "types": "./dist/index.d.ts",
46
+ "version": "0.2.0"
47
+ }