diff --git a/packages/spring/LICENSE b/packages/spring/LICENSE
new file mode 100644
index 000000000..38b41d975
--- /dev/null
+++ b/packages/spring/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/spring/README.md b/packages/spring/README.md
new file mode 100644
index 000000000..065d2a428
--- /dev/null
+++ b/packages/spring/README.md
@@ -0,0 +1,63 @@
+
+
+
+
+# @solid-primitives/spring
+
+[![turborepo](https://img.shields.io/badge/built%20with-turborepo-cc00ff.svg?style=for-the-badge&logo=turborepo)](https://turborepo.org/)
+[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/spring?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/spring)
+[![version](https://img.shields.io/npm/v/@solid-primitives/spring?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/spring)
+[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process)
+
+A small SolidJS hook to interpolate signal changes with spring physics. Inspired by & directly forked from [`svelte-motion/spring`](https://svelte.dev/docs/svelte-motion#spring) as such, has a very familiar API design.
+
+With this primitive, you can easily animate values that can be interpolated like `number`, `date`, and collections (arrays or nested objects) of those datatypes.
+
+- `createSpring` - Provides a getter and setter for the spring primitive.
+- `createDerivedSpring` - Provides only a getter for the spring primitive deriving from an accessor parameter. Similar to the [@solid-primitives/tween](https://github.com/solidjs-community/solid-primitives/tree/main/packages/tween) API.
+
+The following physics options are available:
+
+- `stiffness` (number, default `0.15`) — a value between 0 and 1 where higher means a 'tighter' spring
+- `damping` (number, default `0.8`) — a value between 0 and 1 where lower means a 'springier' spring
+- `precision` (number, default `0.01`) — determines the threshold at which the spring is considered to have 'settled', where lower means more precise
+
+## Installation
+
+```bash
+npm install @solid-primitives/spring
+# or
+yarn add @solid-primitives/spring
+# or
+pnpm add @solid-primitives/spring
+```
+
+## How to use it
+
+```ts
+// Basic Example
+const [progress, setProgress] = createSpring(0);
+
+// Example with options (less sudden movement)
+const [radialProgress, setRadialProgress] = createSpring(0, { stiffness: 0.05 });
+
+// Example with collections (e.g. Object or Array).
+const [xy, setXY] = createSpring(
+ { x: 50, y: 50 },
+ { stiffness: 0.08, damping: 0.2, precision: 0.01 },
+);
+
+// Example deriving from an existing signal.
+const [myNumber, myNumber] = createSignal(20);
+const springedValue = createDerivedSpring(myNumber, { stiffness: 0.03 });
+```
+
+## Demo
+
+- **[Playground](https://primitives.solidjs.community/playground/spring)** - [source code](https://github.com/solidjs-community/solid-primitives/blob/main/packages/spring/dev/index.tsx)
+
+- **[CodeSandbox - Basic Example](https://codesandbox.io/p/devbox/ecstatic-borg-k2wqfr)**
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md)
diff --git a/packages/spring/dev/index.tsx b/packages/spring/dev/index.tsx
new file mode 100644
index 000000000..d59259571
--- /dev/null
+++ b/packages/spring/dev/index.tsx
@@ -0,0 +1,121 @@
+import { createSpring } from "../src/index.js"
+
+export default function App() {
+ const [progress, setProgress] = createSpring(0);
+ const [radialProgress, setRadialProgress] = createSpring(0, {
+ stiffness: 0.1, damping: 0.3
+ });
+ const [xy, setXY] = createSpring({ x: 50, y: 50 }, { stiffness: 0.1, damping: 0.3 });
+ const [date, setDate] = createSpring(new Date());
+
+ function toggleProgress() {
+ if (progress() === 0) setProgress(1);
+ else setProgress(0);
+ }
+ function toggleRadialProgress() {
+ if (radialProgress() === 0) setRadialProgress(1);
+ else setRadialProgress(0);
+ }
+ let d = false
+ function toggleXY() {
+ if (d = !d) setXY({ x: 200, y: 200 });
+ else setXY({ x: 50, y: 50 });
+ }
+ function toggleDate() {
+ if (date().getDate() === new Date("2024-12-01").getDate()) setDate(new Date("2024-04-14"));
+ else setDate(new Date("2024-12-01"));
+ }
+
+ return <>
+
+
+
+
+
+
+
+
+
+ {/* Progress */}
+
+
+
+
{(progress() * 100).toFixed(0)}%
+
+
+ {/* Radial progress */}
+
+
+ {(radialProgress() * 100).toFixed(0)}%
+
+
+ {/* XY */}
+
+ {xy().x.toFixed(0)}x{xy().y.toFixed(0)}
+
+
+ {/* Date */}
+
{date()+""}
+
+ >
+};
diff --git a/packages/spring/package.json b/packages/spring/package.json
new file mode 100644
index 000000000..cfb0e5ff5
--- /dev/null
+++ b/packages/spring/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "@solid-primitives/spring",
+ "version": "0.0.1",
+ "description": "Primitive that creates spring physics functions.",
+ "author": "Carlo Taleon ",
+ "contributors": ["Damian Tarnawski "],
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/spring",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "spring",
+ "stage": 0,
+ "list": [
+ "createSpring",
+ "createDerivedSpring"
+ ],
+ "category": "Animation"
+ },
+ "keywords": [
+ "animate",
+ "tween",
+ "spring",
+ "solid",
+ "primitives"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ },
+ "typesVersions": {},
+ "scripts": {
+ "dev": "tsx ../../scripts/dev.ts",
+ "build": "tsx ../../scripts/build.ts",
+ "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "test": "pnpm run vitest",
+ "test:ssr": "pnpm run vitest --mode ssr"
+ },
+ "peerDependencies": {
+ "solid-js": "^1.6.12"
+ }
+}
diff --git a/packages/spring/src/index.ts b/packages/spring/src/index.ts
new file mode 100644
index 000000000..36a53fd53
--- /dev/null
+++ b/packages/spring/src/index.ts
@@ -0,0 +1,215 @@
+import { Accessor, createEffect, createSignal, onCleanup } from "solid-js";
+import { isServer } from "solid-js/web";
+
+// https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/motion/utils.js
+
+function is_date(obj: any): obj is Date {
+ return Object.prototype.toString.call(obj) === "[object Date]";
+}
+
+// ===========================================================================
+// createSpring hook
+// ===========================================================================
+
+export type SpringOptions = {
+ /**
+ * Stiffness of the spring. Higher values will create more sudden movement.
+ * @default 0.15
+ */
+ stiffness?: number;
+ /**
+ * Strength of opposing force. If set to 0, spring will oscillate indefinitely.
+ * @default 0.8
+ */
+ damping?: number;
+ /**
+ * Precision is the threshold relative to the target value at which the
+ * animation will stop based on the current value.
+ *
+ * From 0, if the target value is 500, and the precision is 500, it will stop
+ * the animation instantly (no animation, similar to `hard: true`).
+ *
+ * From 0, if the target value is 500, and the precision is 0.01, it will stop the
+ * animation when the current value reaches 499.99 or 500.01 (longer animation).
+ *
+ * @default 0.01
+ */
+ precision?: number;
+};
+
+export type SpringTarget =
+ | number
+ | Date
+ | { [key: string]: number | Date | SpringTarget }
+ | readonly (number | Date)[]
+ | readonly SpringTarget[];
+
+/**
+ * "Widen" Utility Type so that number types are not converted to
+ * literal types when passed to `createSpring`.
+ *
+ * e.g. createSpring(0) returns `0`, not `number`.
+ */
+export type WidenSpringTarget = T extends number ? number : T;
+
+export type SpringSetterOptions = { hard?: boolean; soft?: boolean | number }
+export type SpringSetter = (
+ newValue: T | ((prev: T) => T),
+ opts?: SpringSetterOptions,
+) => Promise;
+
+/**
+ * Creates a signal and a setter that uses spring physics when interpolating from
+ * one value to another. This means when the value changes, instead of
+ * transitioning at a steady rate, it "bounces" like a spring would,
+ * depending on the physics paramters provided. This adds a level of realism to
+ * the transitions and can enhance the user experience.
+ *
+ * `T` - The type of the signal. It works for the basic data types that can be
+ * interpolated: `number`, a `Date`, `Array` or a nested object of T.
+ *
+ * @param initialValue The initial value of the signal.
+ * @param options Options to configure the physics of the spring.
+ * @returns Returns the spring value and a setter.
+ *
+ * @example
+ * const [progress, setProgress] = createSpring(0, { stiffness: 0.15, damping: 0.8 });
+ */
+export function createSpring(
+ initialValue: T,
+ options: SpringOptions = {},
+): [Accessor>, SpringSetter>] {
+ const [signal, setSignal] = createSignal(initialValue);
+ const { stiffness = 0.15, damping = 0.8, precision = 0.01 } = options;
+
+ if (isServer) {
+ return [signal as any, ((param: any, opts: SpringSetterOptions = {}) => {
+ if (opts.hard || signal() == null || (stiffness >= 1 && damping >= 1)) {
+ setSignal(param);
+ return Promise.resolve();
+ }
+ return new Promise(() => {});
+ }) as any]
+ }
+
+ let value_current = initialValue;
+ let value_last = initialValue;
+ let value_target = initialValue;
+ let inv_mass = 1;
+ let inv_mass_recovery_rate = 0;
+ let raf_id = 0
+ let settled = true
+ let time_last = 0;
+ let time_delta = 0
+ let resolve = () => {}
+
+ const cleanup = onCleanup(() => {
+ cancelAnimationFrame(raf_id)
+ raf_id = 0
+ resolve()
+ })
+
+ const frame: FrameRequestCallback = time => {
+ time_delta = Math.max(1 / 60, (time - time_last) * 60 / 1000) // guard against d<=0
+ time_last = time
+
+ inv_mass = Math.min(inv_mass + inv_mass_recovery_rate, 1)
+ settled = true
+
+ let new_value = tick(value_last, value_current, value_target)
+ value_last = value_current
+ setSignal(value_current = new_value)
+
+ if (settled) {
+ cleanup()
+ } else {
+ raf_id = requestAnimationFrame(frame)
+ }
+ }
+
+ const set: SpringSetter = (param, opts = {}) => {
+ value_target = typeof param === "function" ? param(value_current) : param;
+
+ if (opts.hard || (stiffness >= 1 && damping >= 1)) {
+ cleanup()
+ setSignal(_ => value_current = value_last = value_target);
+ return Promise.resolve();
+ }
+
+ if (opts.soft) {
+ inv_mass_recovery_rate = 1 / (typeof opts.soft === "number" ? opts.soft * 60 : 30);
+ inv_mass = 0; // Infinite mass, unaffected by spring forces.
+ }
+
+ if (raf_id === 0) {
+ time_last = performance.now()
+ raf_id = requestAnimationFrame(frame)
+ }
+
+ return new Promise(r => resolve = r);
+ };
+
+ const tick = (last: T, current: T, target: T): any => {
+ if (typeof current === "number" || is_date(current)) {
+ const delta = +target - +current;
+ const velocity = (+current - +last) / time_delta;
+ const spring = stiffness * delta;
+ const damper = damping * velocity;
+ const acceleration = (spring - damper) * inv_mass;
+ const d = (velocity + acceleration) * time_delta;
+
+ if (Math.abs(d) < precision && Math.abs(delta) < precision) {
+ return target; // settled
+ }
+
+ settled = false; // signal loop to keep ticking
+ return typeof current === "number" ? current + d : new Date(+current + d);
+ }
+
+ if (Array.isArray(current)) {
+ // @ts-expect-error
+ return current.map((_, i) => tick(last[i], current[i], target[i]));
+ }
+
+ if (typeof current === "object") {
+ const next = {...current};
+ for (const k in current) {
+ // @ts-expect-error
+ next[k] = tick(last[k], current[k], target[k]);
+ }
+ return next;
+ }
+
+ throw new Error(`Cannot spring ${typeof current} values`);
+ };
+
+ return [signal as any, set as any];
+}
+
+// ===========================================================================
+// createDerivedSpring hook
+// ===========================================================================
+
+/**
+ * Creates a spring value that interpolates based on changes on a passed signal.
+ * Works similar to the `@solid-primitives/tween`
+ *
+ * @param target Target to be modified.
+ * @param options Options to configure the physics of the spring.
+ * @returns Returns the spring value only.
+ *
+ * @example
+ * const percent = createMemo(() => current() / total() * 100);
+ *
+ * const springedPercent = createDerivedSignal(percent, { stiffness: 0.15, damping: 0.8 });
+ */
+export function createDerivedSpring(
+ target: Accessor,
+ options?: SpringOptions,
+) {
+ const [springValue, setSpringValue] = createSpring(target(), options);
+
+ createEffect(() => setSpringValue(target() as WidenSpringTarget));
+
+ return springValue;
+}
diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts
new file mode 100644
index 000000000..291e4c396
--- /dev/null
+++ b/packages/spring/test/index.test.ts
@@ -0,0 +1,202 @@
+import { createEffect, createRoot, createSignal } from "solid-js";
+import { describe, expect, it, vi, afterAll, } from "vitest";
+import { createDerivedSpring, createSpring } from "../src/index.js";
+
+let _time = 0
+let _raf_last_id = 0;
+let _raf_callbacks_old = new Map();
+let _raf_callbacks_new = new Map();
+
+function _progress_time(by: number) {
+ _time += by
+ _raf_callbacks_old = _raf_callbacks_new;
+ _raf_callbacks_new = new Map();
+ _raf_callbacks_old.forEach(c => c(_time));
+ _raf_callbacks_old.clear();
+}
+
+let _now = performance.now
+performance.now = () => _time
+afterAll(() => {
+ performance.now = _now
+})
+
+vi.stubGlobal("requestAnimationFrame", function (callback: FrameRequestCallback): number {
+ const id = _raf_last_id++;
+ _raf_callbacks_new.set(id, callback);
+ return id;
+});
+vi.stubGlobal("cancelAnimationFrame", function (id: number): void {
+ _raf_callbacks_new.delete(id);
+});
+
+describe("createSpring", () => {
+
+ it("returns values", () => {
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring({ progress: 0 }), d]);
+ expect(spring().progress).toBe(0);
+ dispose();
+ });
+
+ it("Setter does not subscribe to self", () => {
+ let runs = 0
+ const [signal, setSignal] = createSignal(0)
+
+ const [setSpring, dispose] = createRoot(dispose => {
+ const [, setSpring] = createSpring(0)
+
+ createEffect(() => {
+ runs++
+ setSpring(p => {
+ signal() // this one should be tracked
+ return p+1
+ }, { hard: true })
+ })
+
+ return [setSpring, dispose]
+ });
+ expect(runs).toBe(1)
+
+ setSpring(p => p+1, { hard: true })
+ expect(runs).toBe(1)
+
+ setSignal(1)
+ expect(runs).toBe(2)
+
+ dispose();
+ });
+
+ it("instantly updates `number` when set with hard.", () => {
+ const start = 0;
+ const end = 50;
+
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]);
+
+ expect(spring()).toBe(start);
+ setSpring(end, { hard: true });
+
+ expect(spring()).toBe(end);
+
+ dispose();
+ });
+
+ it("instantly updates `Date` when set with hard.", () => {
+ const start = new Date("2024-04-14T00:00:00.000Z");
+ const end = new Date("2024-04-14T00:00:00.000Z");
+
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]);
+
+ expect(spring().getDate()).toBe(start.getDate());
+ setSpring(end, { hard: true }); // Set to 100 here.
+
+ expect(spring().getDate()).toBe(end.getDate());
+
+ dispose();
+ });
+
+ it("instantly updates `{ progress: 1 }` when set with hard.", () => {
+ const start = { progress: 1 };
+ const end = { progress: 100 };
+
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]);
+
+ expect(spring()).toMatchObject(start);
+ setSpring(end, { hard: true }); // Set to 100 here.
+
+ expect(spring()).toMatchObject(end);
+
+ dispose();
+ });
+
+ it("instantly updates `Array` when set with hard.", () => {
+ const start = [1, 2, 3];
+ const end = [20, 15, 20];
+
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]);
+
+ expect(spring()).toMatchObject(start);
+ setSpring(end, { hard: true }); // Set to 100 here.
+
+ expect(spring()).toMatchObject(end);
+
+ dispose();
+ });
+
+ it("instantly updates `number` when set with hard using a function as an argument.", () => {
+ const start = 0;
+ const end = 50;
+
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]);
+
+ expect(spring()).toBe(start);
+ setSpring(_ => end, { hard: true }); // Using a function as an argument.
+
+ expect(spring()).toBe(end);
+
+ dispose();
+ });
+
+ it("instantly updates `{ progress: 1 }` when set with hard using a function as an argument.", () => {
+ const start = { progress: 1 };
+ const end = { progress: 100 };
+
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]);
+
+ expect(spring()).toMatchObject(start);
+ setSpring(_ => ({ progress: 100 }), { hard: true }); // Using a function as an argument.
+
+ expect(spring()).toMatchObject(end);
+
+ dispose();
+ });
+
+ it("updates toward target", () => {
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(0), d]);
+
+ expect(spring()).toBe(0);
+ setSpring(50);
+ expect(spring()).toBe(0);
+
+ _progress_time(300)
+
+ // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly)
+ expect(spring()).not.toBe(50);
+ expect(spring()).toBeGreaterThan(50 / 2);
+ dispose();
+ });
+
+ it("updates array of objects toward target", () => {
+ const start = [{foo: 1}, {foo: 2}, {foo: 3}];
+ const end = [{foo: 20}, {foo: 15}, {foo: 20}];
+
+ const [[spring, setSpring], dispose] = createRoot(d => [createSpring(start), d]);
+
+ expect(spring()).toMatchObject(start);
+ setSpring(end);
+
+ _progress_time(300)
+ for (let i = 0; i < start.length; i++) {
+ expect(spring()[i]!.foo).toBeGreaterThan(end[i]!.foo/2);
+ }
+
+ dispose();
+ });
+});
+
+describe("createDerivedSpring", () => {
+ it("updates toward accessor target", () => {
+ const [signal, setSignal] = createSignal(0);
+ const [spring, dispose] = createRoot(d => [createDerivedSpring(signal), d]);
+
+ expect(spring()).toBe(0);
+ setSignal(50); // Set to 100 here.
+ expect(spring()).toBe(0);
+
+ _progress_time(300)
+
+ // spring() should move towards 50 but not 50 after 300ms. (This is estimated spring interpolation is hard to pinpoint exactly)
+ expect(spring()).not.toBe(50);
+ expect(spring()).toBeGreaterThan(50 / 2);
+ dispose();
+ });
+});
diff --git a/packages/spring/test/server.test.ts b/packages/spring/test/server.test.ts
new file mode 100644
index 000000000..2637cca72
--- /dev/null
+++ b/packages/spring/test/server.test.ts
@@ -0,0 +1,18 @@
+import { describe, test, expect } from "vitest";
+import { createDerivedSpring, createSpring } from "../src/index.js";
+import { createSignal } from "solid-js";
+
+describe("createSpring", () => {
+ test("doesn't break in SSR", () => {
+ const [value, setValue] = createSpring({ progress: 0 });
+ expect(value().progress, "initial value should be { progress: 0 }").toBe(0);
+ });
+});
+
+describe("createDerivedSpring", () => {
+ test("doesn't break in SSR", () => {
+ const [signal, setSignal] = createSignal({ progress: 0 });
+ const value = createDerivedSpring(signal);
+ expect(value().progress, "initial value should be { progress: 0 }").toBe(0);
+ });
+});
diff --git a/packages/spring/tsconfig.json b/packages/spring/tsconfig.json
new file mode 100644
index 000000000..4082f16a5
--- /dev/null
+++ b/packages/spring/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../tsconfig.json"
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 55770e416..00b14cb42 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -843,6 +843,12 @@ importers:
specifier: ^1.8.7
version: 1.8.20
+ packages/spring:
+ dependencies:
+ solid-js:
+ specifier: ^1.6.12
+ version: 1.8.22
+
packages/start:
devDependencies:
solid-js: