@madenowhere/photon
v0.0.10
Published
Framework-agnostic motion engine. Analytical springs, seamless momentum handoff, GPGPU spring solver. Drives DOM elements, PhotonValues, and phaze signals out of the box.
Maintainers
Readme
@madenowhere/photon
Framework-agnostic motion engine. Analytical spring solver, seamless momentum handoff between in-flight animations, and a GLSL spring solver for worker-side / GPU animations. Drives DOM elements, PhotonValues, and @madenowhere/phaze signals out of the box.
import { animate, spring, PhotonValue } from '@madenowhere/photon'
import { signal } from '@madenowhere/phaze'
// DOM target
animate(el, 'visible', { stiffness: 200, damping: 30 })
// PhotonValue target
const x = new PhotonValue(0)
animate(x, 100, { stiffness: 100, damping: 30 })
// Phaze signal target — works directly, no wrapper needed
const y = signal(0)
animate(y, 100, { stiffness: 100, damping: 30 })Why
Most spring-animation libraries integrate one signal/state model and one render layer. Photon does neither — it's a numeric engine that knows three target shapes (DOM, PhotonValue, phaze signal) and writes the integrated value through the right mutator each frame. Apps mix and match without a wrapper layer per integration.
Architecturally:
- Analytical spring solver (
spring.ts) — closed-form solution to the harmonic oscillator. Position and velocity are exact for any timet, no integration drift. Underdamped / critically damped / overdamped paths. - Momentum-catching handoff — when you re-target an animation in flight, photon analytically solves the current position+velocity from the previous animation and seeds the new spring with that. Zero discontinuity.
- GPGPU spring solver (
photonShader.ts) — same math in GLSL. Lets a Web Worker animate thousands of points on the GPU usingonUpdatecallbacks to pushSpringPackets across the worker boundary. - Cubic bezier easing (
easing.ts) — Newton-Raphson + binary subdivision, no allocations on the hot path.
SSR as a first-class state
Phaze + Photon = SSR-rendered animation. Phaze server-renders signal-driven JSX; photon extends that guarantee to motion — spring physics, scroll-driven parallax, and declarative
use:directives all render to HTML on the server and pick up live on hydrate, through a singleimportpath. No fork-the-bundle, no separate server entry, no per-call-site guards in user code. The closest comparable stacks (Solid + GSAP, React + Framer Motion + react-server-components) either crash on SSR or strip motion entirely from the SSR'd output.
Most JS animation libraries either:
- Crash hard on SSR — touch
window/requestAnimationFrameat module load and throw on the firstimportin a server runtime. - Get wrapped in
if (typeof window === 'undefined') return nullat every call site by the consumer (motion/react's pattern), leaving SSR'd HTML stripped of any motion-related state. - Ship a separate "server" entry — a parallel no-op stub the bundler swaps in via conditional
exports.
Photon takes a different approach: a single design where SSR is a first-class state. Every entry point — animate, spring, scroll, transform, parallax, photonProp, bindStyle — degrades to "render initial state, schedule nothing" automatically when called without a window. The composition spring(transform(scrollYProgress, …)) keeps working through it because the data flow is decoupled from the timing loop: scroll() returns a PhotonValue at 0, transform() maps pure-functionally, spring() resolves at its initial target, parallax() writes the resulting translate3d(0, yRange[0]px, 0) into the SSR'd HTML.
No fork-the-bundle, no consumer-side guards, no separate server entry — the same import path runs on server and client. Components written for the browser server-render unchanged; hydration picks up live window.scrollY, requestAnimationFrame, and spring physics from the SSR'd initial state. No flash, no re-mount, no diff.
Detection is via typeof window === 'undefined' — the canonical check against @madenowhere/phaze-render-to-string's server runtime, which installs document and the DOM classes but deliberately omits window / requestAnimationFrame / getComputedStyle. Photon recognizes what's missing and degrades; it does not ask the SSR layer to polyfill what isn't there.
Two target protocols
Photon detects targets structurally — no framework imports.
Photon protocol: { value: T }
The classic shape. Anything with .value getter+setter satisfies it. PhotonValue is the bundled implementation; motion/react's MotionValue, KnockoutJS observables, and others fit too.
const x = new PhotonValue(0)
animate(x, 1, { stiffness: 100 })
x.value // current value
x.on('change', cb) // subscribePhaze signal: callable with .set / .subscribe / .current
Detected as: typeof t === 'function' && typeof t.set === 'function' && typeof t.subscribe === 'function'. No phaze import on the photon side.
import { signal } from '@madenowhere/phaze'
const x = signal(0)
animate(x, 1, { stiffness: 100 })
x() // current value (auto-tracks in effects)
x.subscribe(cb)Both shapes coexist. Source argument of spring(), transform(), onPhotonChange() (in the /phaze subpath) accepts either.
API
Core (framework-free)
import {
animate, animateGroup, stop,
spring, // creates a target PhotonValue, drives it via animate
createSpring, // raw analytical solver: (stiffness, damping, mass, x0, xt, v0) => (t) => { position, velocity }
PhotonValue, interpolate,
bezier,
animatePath, animatePathGroup, getPathLength,
photonGLSL, // GLSL spring solver, paste into shader source
isPhaseSignal, // duck-type check for phaze signals
} from '@madenowhere/photon'/phaze subpath (phaze-coupled, scope-bound cleanup)
import {
bindStyle, // PhotonValue or phaze signal → DOM style property
phaseAnimate, // animate() with auto-stop on scope dispose
phaseAnimateGroup,
spring, // ReactiveSource → spring-driven PhotonValue
transform, // ReactiveSource → linearly-mapped PhotonValue
onPhotonChange, // subscribe with auto-cleanup on scope dispose
scroll, // window/element scroll → { scrollY, scrollYProgress } PhotonValues
photonValue, // factory: photonValue(0) === new PhotonValue(0)
PhotonValue,
} from '@madenowhere/photon/phaze'The /phaze subpath peer-deps @madenowhere/phaze. Marked optional via peerDependenciesMeta — the core works without phaze installed; importing /phaze requires it.
Sizes (esbuild-bundled, minified)
| | raw | gzip-9 | brotli |
|---|---|---|---|
| core (full: animate + spring + svg + PhotonValue + GLSL) | 8.3 KB | 3.2 KB | 2.97 KB |
| core (animate + stop only) | 5.1 KB | 2.1 KB | 1.95 KB |
| /phaze subpath (typical surface) | 7.0 KB | 3.0 KB | 2.71 KB |
Phaze is externalized — listed sizes don't include @madenowhere/phaze's own runtime.
Worked example: scroll-driven spring
import { signal } from '@madenowhere/phaze'
import { spring, scroll, bindStyle, transform } from '@madenowhere/photon/phaze'
function Card() {
const ref = signal<HTMLElement | null>(null)
const { scrollYProgress } = scroll({ target: () => ref() })
const y = spring(transform(scrollYProgress, [0, 1], [300, -300]), {
stiffness: 500, damping: 100, mass: 3,
})
effect(() => {
const node = ref()
if (node) bindStyle(node, 'transform', y, (v) => `translate3d(0, ${v}px, 0)`)
})
return <div ref={ref}>...</div>
}scroll() returns PhotonValues for window scroll. transform() linearly maps it through a range. spring() smooths it via the analytical spring. bindStyle() writes the spring's value to el.style.transform with auto-cleanup. The whole chain disposes on component unmount via phaze's cleanup().
Position in the ecosystem
@madenowhere/phaze— signals + JSX runtime + reactive DOM bindings.@madenowhere/photon— (this) — motion engine. Animates targets in any of the three protocols above.@madenowhere/phaze-vite— Vite HMR for phaze components.@madenowhere/phaze-astro— Astro renderer for phaze islands.
Photon and phaze are independent. You can use photon without phaze (DOM-only or PhotonValue-driven), and you can use phaze without photon (no animation, just signals + DOM bindings). They compose cleanly when both are present, via the /phaze subpath.
Status
Pre-alpha. 0.0.x is name reservation + early integration testing. Public API is not yet stable.
License
MIT.
