packages/component/docs/spring.md
A physics-based spring animation function that returns an iterator with CSS easing.
import { spring } from './spring.ts'
// Using a preset
spring('bouncy') // bouncy with overshoot
spring('snappy') // quick, no overshoot (default)
spring('smooth') // gentle, overdamped
// Custom spring
spring({ duration: 400, bounce: 0.3 })
spring() returns a SpringIterator:
interface SpringIterator extends IterableIterator<number> {
duration: number // CSS duration in ms (e.g., 550)
easing: string // CSS linear() function
toString(): string // "550ms linear(...)"
}
The iterator can be:
String() (for CSS transitions)mix={[css({
transition: `width ${spring('bouncy')}`
})]}
// → "width 550ms linear(...)"
mix={[css({
transition: `transform ${spring('bouncy')}, opacity ${spring('bouncy')}`
})]}
mix={[css({
transition: spring.transition('width', 'bouncy')
})]}
// → "width 550ms linear(...)"
mix={[css({
transition: spring.transition(['left', 'top'], 'snappy')
})]}
// → "left 385ms linear(...), top 385ms linear(...)"
Spread the spring value to get both duration and easing:
mix={[
animateEntrance({
opacity: 0,
transform: 'scale(0.9)',
...spring('bouncy')
}),
animateExit({
opacity: 0,
...spring('snappy')
}),
]}
| Preset | Bounce | Duration | Character |
|---|---|---|---|
smooth | -0.3 | 400ms | Overdamped, no overshoot |
snappy | 0 | 200ms | Critically damped, quick |
bouncy | 0.3 | 300ms | Underdamped, visible bounce |
spring('bouncy', { duration: 300 }) // faster bouncy
spring('smooth', { duration: 800 }) // slower smooth
spring({
duration: 500, // perceived duration in milliseconds
bounce: 0.3, // -1 to 1 (negative = overdamped, 0 = critical, positive = bouncy)
velocity: 0, // initial velocity in units per second
})
bounce < 0: Overdamped (slower settling, no overshoot)bounce = 0: Critically damped (fastest settling without overshoot)bounce > 0: Underdamped (bouncy, overshoots target)spring({ bounce: -0.5 }) // very smooth, slow
spring({ bounce: 0 }) // snappy, no bounce
spring({ bounce: 0.3 }) // slight bounce
spring({ bounce: 0.7 }) // very bouncy
Use velocity to continue momentum from a gesture:
// Positive = moving toward target (more overshoot)
// Negative = moving away from target (takes longer)
spring('bouncy', { velocity: 2 }) // fast start
spring('bouncy', { velocity: -1 }) // initially going backward
// velocity is in px/s, distance is in px
let normalizedVelocity = velocityTowardTarget / distanceToTarget
spring('bouncy', { velocity: normalizedVelocity })
The spring iterator yields position values from 0 to 1, one per frame (~60fps):
let s = spring('bouncy')
for (let t of s) {
console.log(t) // 0, 0.015, 0.058, 0.121, ... 1
}
Use the 0→1 progress to interpolate any value:
let from = 100
let to = 500
for (let t of spring('bouncy')) {
let value = from + (to - from) * t // 100 → 500
updateSomething(value)
await nextFrame()
}
let s = spring('bouncy')
function draw() {
let { value, done } = s.next()
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.beginPath()
ctx.arc(value * 400, 100, 20, 0, Math.PI * 2) // x: 0 → 400
ctx.fill()
if (!done) requestAnimationFrame(draw)
}
draw()
let fromX = 0,
toX = 200
let fromY = 0,
toY = 100
let fromScale = 0.5,
toScale = 1
for (let t of spring('bouncy')) {
let x = fromX + (toX - fromX) * t
let y = fromY + (toY - fromY) * t
let scale = fromScale + (toScale - fromScale) * t
render({ x, y, scale })
await nextFrame()
}
let fromRGB = [255, 0, 0] // red
let toRGB = [0, 0, 255] // blue
for (let t of spring('smooth')) {
let r = Math.round(fromRGB[0] + (toRGB[0] - fromRGB[0]) * t)
let g = Math.round(fromRGB[1] + (toRGB[1] - fromRGB[1]) * t)
let b = Math.round(fromRGB[2] + (toRGB[2] - fromRGB[2]) * t)
element.style.backgroundColor = `rgb(${r}, ${g}, ${b})`
await nextFrame()
}
let { duration, easing } = spring('bouncy')
// duration: 550 (ms)
// easing: "linear(0.0000, 0.0156, ...)"
spring.presets
// {
// smooth: { duration: 400, bounce: -0.3 },
// snappy: { duration: 200, bounce: 0 },
// bouncy: { duration: 300, bounce: 0.3 }
// }
element.animate(keyframes, {
...spring('bouncy'),
})
function AnimatedCard(handle: Handle) {
let isExpanded = false
return () => (
<div
mix={[
css({
transition: spring.transition(['width', 'height'], 'bouncy'),
}),
on('click', () => {
isExpanded = !isExpanded
handle.update()
}),
]}
style={{
width: isExpanded ? '300px' : '100px',
height: isExpanded ? '200px' : '100px',
}}
>
Click me
</div>
)
}