@underlying/svg
v1.2.1
Published
SVG path animation for @underlying/core: ride a path, draw a stroke on, or morph one shape into another, physics-first - flick, interrupt, retarget, or scrub.
Maintainers
Readme
SVG path animation for @underlying/core - ride a path, draw a stroke, and a resampling morph. The progress of each is a single live value, so where other libraries bake a path tween, here you can flick a marker down a path and let it settle, interrupt a stroke mid-draw, scrub a morph, or hand the same progress to scroll. No new engine: it samples the path with the native getPointAtLength/getTotalLength and drives core's animatable.
npm install @underlying/svg @underlying/coremotionPath()
Ride an element along a path. Progress t is a live Animatable, so spring it, flick it, or retarget it mid-flight - velocity is conserved across the change. autoRotate turns the element to face along the path.
import { motionPath } from '@underlying/svg'
const ride = motionPath(marker, '#track', { autoRotate: true })
ride.spring(1) // travel to the end with a real spring
ride.flick(2.4) // fling it down the path; it decays to a stop
ride.spring(0.4) // retarget mid-flight, momentum kept
ride.t // the live 0..1 Animatable - compose it (scroll, timeline)
ride.revert() // unbind and restore the element's transformdraw()
Draw a stroke on (0 = hidden, 1 = fully drawn). The fraction is a live Animatable too - it can overshoot, and you can interrupt it mid-draw.
import { draw } from '@underlying/svg'
const line = draw('#signature')
line.spring(1) // draw it on
line.to(0, { duration: 400 })// erase with a timed tween, still interruptiblemorph()
Turn one shape into another. Both outlines are resampled into points along their length and interpolated, so any two paths morph - you do not have to match their commands by hand. The fraction is live, so you can scrub it or grab it mid-morph.
import { morph } from '@underlying/svg'
const m = morph(blob, starPathData, { closed: true }) // target: a `d` string or an element
m.spring(1) // morph to the star
m.spring(0) // morph back - interruptible
m.set(0.5) // hold it halfwayThis is a resampling morph (smooth, handles arbitrary shapes). When you need corners to stay sharp, reach for morphCommands() below.
morphCommands()
A command-preserving morph that keeps corners razor-sharp. Instead of resampling both outlines into points, it parses the d commands into cubic segments, subdivides the sparser shape by arc length so anchors map to anchors (de Casteljau, so original corners stay crisp), aligns closed rings by rotation and winding (matched on centroid/scale-normalized anchors), pairs multi-piece shapes by similarity, and interpolates each anchor and control. Elliptical arcs (A) are converted to cubics. The result is real curves with sharp corners the whole way across. The fraction is live, so you can scrub it or grab it mid-morph.
import { morphCommands } from '@underlying/svg'
const m = morphCommands(icon, stopSquareData) // target: a `d` string or an element with a `d`
m.spring(1) // play triangle -> stop square, every corner stays sharp
m.spring(0) // back - interruptible
m.set(0.5) // hold it halfwayMulti-piece logos work too: subpaths pair by similarity (position + area), and a surplus piece shrinks to (or grows from) a point rather than snapping to an unrelated shape. For arbitrary blobby shapes where corners do not matter, morph() (resampling) is the simpler fallback.
The familiar one-call form
motionPath, draw, morph, and morphCommands all accept a { to } kickoff that reads like a one-call tween - but springs under the hood, and the handle is still there for the live wins.
motionPath(marker, '#track', { to: 1, autoRotate: true })
draw('#signature', { to: 1 })
morph(blob, starPathData, { to: 1, closed: true })
morphCommands(icon, stopSquareData, { to: 1 })Composing - drive the same path from scroll or a timeline
motionPath and draw own a driver Animatable (.t / .fraction) you can hand to anything that drives a value.
import { motionPath } from '@underlying/svg'
import { createScroll } from '@underlying/scroll'
const ride = motionPath(marker, '#track', { autoRotate: true })
createScroll({ scroller }).scrub(ride.t) // marker follows the path as you scrollBring your own driver
The handles are built on thin binders. If you already have an Animatable (or want to control the value yourself), bind it directly:
import { bindPath, bindDraw, samplePath } from '@underlying/svg'
import { animatable } from '@underlying/core'
const t = animatable(0)
bindPath(marker, '#track', t, { autoRotate: true }) // maps t -> transform
t.decay({ velocity: 2.4 })
samplePath('#track').at(0.5) // low-level: { x, y, angle } at progress 0.5resolvePathGeometry(input) is exported too - the low-level resolver that turns a selector or element into a geometry source (the same step samplePath does internally), if you want to measure or sample a path yourself.
Notes
- Reduced motion is inherited from core: a
spring/decay/toon the driver auto-degrades underprefers-reduced-motion, so the element jumps to the target with no travel. - Coordinate space.
motionPathwrites the sampled point straight to the element'stransform, so the element and the path should share a coordinate space (e.g. both inside the same SVG, or the element absolutely positioned over it). - SSR. Sampling needs the DOM; pass an element rather than a selector on the server, or call from an effect.
- Morph comes in two flavours.
morph()resamples both outlines into points and interpolates - it handles any two shapes, but sharp corners can soften (raisesamplesfor fidelity).morphCommands()preserves the commands and keeps corners crisp (arcs, multi-piece shapes, and wildly different scales all handled); reach formorph()when corners do not matter.
License
MIT (c) underlyi.ng
