Repository: solidjs-community/solid-primitives
Pull request #629 (2024-05-12 11:13:02)

Add createSpring primitive.
## Summary
- This PR adds the `createSpring` primitive. Inspired and Forked from https://svelte.dev/docs/svelte-motion#spring
- The API works exactly like createSignal, just that the setter will actually interpolate the value.
- It also has familiar opts as the one with svelte-motion/spring.
- I come from using Svelte and it's one of the most essential primitives I use for animation. I've been using Solid more and more and have come to love it. I think this primitive is one of the few missing for me here. I think Solid would greatly benefit from a primitive like this since we already have a `tween` primitive.

Example Usage:
```ts
export default function SpringyPage() {
  const [progress, setProgress] = createSpring(0);

  const [radialProgress, setRadialProgress] = createSpring(0, {
    stiffness: 0.05,
  });

  const [xy, setXY] = createSpring(
    { x: 50, y: 50 },
    { stiffness: 0.08, damping: 0.2, precision: 0.01 }
  );

  function toggleProgress() {
    if (progress() === 0) setProgress(1);
    else setProgress(0);
  }
  // ...
```

Witness, the springiness:

https://github.com/solidjs-community/solid-primitives/assets/38070918/7c4fa01f-7959-4a67-9588-e28448f7f20d

https://github.com/solidjs-community/solid-primitives/assets/38070918/4dcf9e6f-7a2e-44f8-b15a-d29d6551f561

Todos:
- [x] Tests
- [x] README.md
- [x] package.json 


Patch:
diff --git a/packages/spring/LICENSE b/packages/spring/LICENSE
new file mode 100644
index 00000000..38b41d97
--- /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 00000000..065d2a42
--- /dev/null
+++ b/packages/spring/README.md
@@ -0,0 +1,63 @@
+<p>
+  <img width="100%" src="https://assets.solidjs.com/banner?type=Primitives&background=tiles&project=spring" alt="Solid Primitives spring">
+</p>
+
+# @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 00000000..d5925957
--- /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 <>
+  <style>{`
+progress {
+  color: #a3e635;
+}
+progress::-moz-progress-bar {
+  background: #a3e635;
+}
+progress::-moz-progress-bar {
+  background: #a3e635;
+}
+
+.circular-progress {
+  --size: 250px;
+  --half-size: calc(var(--size) / 2);
+  --stroke-width: 20px;
+  --radius: calc((var(--size) - var(--stroke-width)) / 2);
+  --circumference: calc(var(--radius) * pi * 2);
+  --dash: calc((var(--progress) * var(--circumference)) / 100);
+  animation: progress-animation 5s linear 0s 1 forwards;
+}
+
+.circular-progress circle {
+  cx: var(--half-size);
+  cy: var(--half-size);
+  r: var(--radius);
+  stroke-width: var(--stroke-width);
+  fill: none;
+  stroke-linecap: round;
+}
+
+.circular-progress circle.bg {
+  stroke: #111;
+}
+
+.circular-progress circle.fg {
+  transform: rotate(-90deg);
+  transform-origin: var(--half-size) var(--half-size);
+  stroke-dasharray: var(--dash) calc(var(--circumference) - var(--dash));
+  transition: stroke-dasharray 0.3s linear 0s;
+  stroke: #a3e635;
+}
+`}</style>
+    <div class="flex flex-col items-center justify-center gap-8">
+      <div class="flex gap-3 p-2">
+        <button onClick={toggleProgress}>Toggle progress</button>
+        <button onClick={toggleRadialProgress}>Toggle Radial progress</button>
+        <button onClick={toggleXY}>Toggle XY</button>
+        <button onClick={toggleDate}>Toggle Date</button>
+      </div>
+
+      {/* Progress */}
+      <div class="flex flex-col items-center">
+        <progress
+          class="progress w-56"
+          value={progress() * 100}
+          max="100"
+        ></progress>
+
+        <p class="text-white">{(progress() * 100).toFixed(0)}%</p>
+      </div>
+
+      {/* Radial progress */}
+      <div class="flex flex-col items-center gap-4">
+        <svg
+          width="120"
+          height="120"
+          viewBox="0 0 250 250"
+          class="circular-progress"
+          style={`--progress:${radialProgress() * 100};`}
+        >
+          <circle class="bg"></circle>
+          <circle class="fg"></circle>
+        </svg>
+        <span class="text-white">{(radialProgress() * 100).toFixed(0)}%</span>
+      </div>
+
+      {/* XY */}
+      <div
+        class="bg-lime-400 rounded-lg flex items-center justify-center whitespace-nowrap text-black text-sm font-bold"
+        style={{
+          width: xy().x + "px",
+          height: 100 + "px",
+        }}
+      >
+        {xy().x.toFixed(0)}x{xy().y.toFixed(0)}
+      </div>
+
+      {/* Date */}
+      <div>{date()+""}</div>
+    </div>
+  </>
+};
diff --git a/packages/spring/package.json b/packages/spring/package.json
new file mode 100644
index 00000000..cfb0e5ff
--- /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 <carloantonioct@gmail.com>",
+  "contributors": ["Damian Tarnawski <gthetarnav@gmail.com>"],
+  "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 00000000..36a53fd5
--- /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> = T extends number ? number : T;
+
+export type SpringSetterOptions = { hard?: boolean; soft?: boolean | number }
+export type SpringSetter<T> = (
+  newValue: T | ((prev: T) => T),
+  opts?: SpringSetterOptions,
+) => Promise<void>;
+
+/**
+ * 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<T>` 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<T extends SpringTarget>(
+  initialValue: T,
+  options: SpringOptions = {},
+): [Accessor<WidenSpringTarget<T>>, SpringSetter<WidenSpringTarget<T>>] {
+  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<T> = (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<void>(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<T extends SpringTarget>(
+  target: Accessor<T>,
+  options?: SpringOptions,
+) {
+  const [springValue, setSpringValue] = createSpring(target(), options);
+
+  createEffect(() => setSpringValue(target() as WidenSpringTarget<T>));
+
+  return springValue;
+}
diff --git a/packages/spring/tsconfig.json b/packages/spring/tsconfig.json
new file mode 100644
index 00000000..4082f16a
--- /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 a213e402..e6f8578b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -846,6 +846,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:


Test patch:
diff --git a/packages/spring/test/index.test.ts b/packages/spring/test/index.test.ts
new file mode 100644
index 00000000..291e4c39
--- /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<number, FrameRequestCallback>();
+let _raf_callbacks_new = new Map<number, FrameRequestCallback>();
+
+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<number>` 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 00000000..2637cca7
--- /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);
+  });
+});