Shots in this scene
A Spring by Hand in JavaScript
In one line
A spring is a loop: work out the pull, change the speed, move, repeat. A fixed small time step keeps it stable, and rest thresholds say when to stop.
Think of it like this
A game of tag in slow steps. Every few steps you look at where the other player is, turn toward them and move a little. If you may only look every ten seconds, you run so far in one go that you end up far on the wrong side.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- time step
- How much simulated time one update covers. Smaller is more exact and costs more updates.
- semi-implicit Euler
- Update the speed first, then move with the new speed. More stable than moving first.
- frame gap
- A frame much longer than usual, from a busy page or a background tab.
- rest threshold
- The distance and speed below which a spring counts as arrived: restDelta and restSpeed.
Step by step
01Run the hand-written spring
Press Go to 160. The label shows x and speed each frame. Press Back to 0 halfway: retarget aims the same spring elsewhere.
02Read the frame-gap console
Six frames, one of 300 ms, run two ways. The naive loop ends at -10.63 for a 0 to 1 move; spring.js at 0.88.
03Compare the rest rules
Frames to rest at 60 Hz: 45 for the default, 71 for the strict rule, 22 for the loose one (final snap 5.88 px).
04Find the safety stop
The last rest rule says rest after 1200 frames: 20 s of 60 Hz frames, the limit in spring.js.
Try it yourself
- 1
Change the step
In the first example's
spring.jschange1 / 240to1 / 30: stiffness 170, damping 26 still settles. Also setstiffness: 2000, damping: 90ingoToand the position explodes: every update is about twice as large as the one before and it reaches about 10^19 px within a second (60 Hz frames on a fixed clock). With 1/240 the same spring settles at 160. - 2
Remove the clamp
In "A 300 ms frame" change
MAX_FRAME = 0.064to1. Thespring.jsline becomes 0.09, 0.28, 0.48, 1.00: no explosion, but the whole gap was played at once and the spring is already at the end.
Code & diagrams
`spring.js` of the build inlined (a preview page cannot import your project files). Go, Back, and press Back again while it is moving.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
spring() returns: retarget, stop, velocityStiffness 1000, damping 60. Both runs get the same six frame lengths. The naive loop does one Euler step per frame whatever the frame length.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
frame lengths (ms): 16, 16, 16, 300, 16, 16
one step per frame: 0.26, 0.46, 0.60, -10.63, -7.68, -5.34
spring.js: 0.09, 0.28, 0.48, 0.88, 0.92, 0.95Same spring and the same 100 px move. Only restDelta and restSpeed change. The last rule never says stop, so the 20 s safety limit does.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
restDelta 0.1, restSpeed 1 : rested after 45 frames, final snap 0.11 px
restDelta 0.001, restSpeed 0.01: rested after 71 frames, final snap 0.00 px
restDelta 5, restSpeed 100 : rested after 22 frames, final snap 5.88 px
restDelta 0, restSpeed 0 : rested after 1200 frames, final snap 0.00 pxBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Thresholds of zero
Read the last line of "Four rest rules" (restDelta 0, restSpeed 0).
Myth vs fact
Myth
A smaller time step is always better.
Fact
It is more exact, but 1/240 s is already far below what the eye sees, and every halving doubles the work per frame.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
When a tab is hidden
requestAnimationFramestops, and the first frame after it returns carries a huge gap. The 64 ms clamp turns that into a short continuation (15 fixed steps of work, not thousands).
Remember this
- 1
The problem this solves: the formula of Topic 9.1 needs a start at rest and a fixed target. To retarget or drag a spring you simulate it: keep a position and a speed and update them in small steps. That is
spring.jsof the build. - 2
One step. Compute
acceleration = (-k × (x - target) - c × v) ÷ m. Addacceleration × stepto the speed, then addspeed × stepto the position. Updating the speed first is semi-implicit Euler: it stays stable far longer than moving first with the old speed. - 3
A fixed step. A frame is 8 to 33 ms and varies by device.
spring.jsuses a step of 1/240 s and runs as many whole steps as the frame holds. The leftover time waits for the next frame, so the motion is the same on a 30 Hz and a 120 Hz screen. - 4
Long frames. A busy page or a returning background tab can give a 300 ms frame. A loop with one Euler step per frame uses a 300 ms step and explodes (-10.63 for a 0 to 1 move in the second example). The fixed step cannot explode, but it would spend the whole gap at once and land at 1.00 in one jump. So
spring.jssimulates at most 64 ms per frame: after a pause the spring carries on from where it was (0.88). - 5
When to stop. A spring approaches the target for ever. Rest when the speed is below
restSpeedand the distance belowrestDelta, then snap to the target and callonRest. Too loose and the last frame jumps (5 px thresholds jumped 5.88 px). Too strict and it never stops: thresholds of 0 run to the 20 s safety limit. - 6
Bad numbers. Stiffness 0, a negative mass or a negative damping are not springs:
spring.jsthrows aRangeErrorat once instead of drawing NaN.
Explain it without notes
Why does spring.js use a fixed step instead of the frame length?
What happens if the rest thresholds are too small, and too large?
Practice
Which rest thresholds suit an opacity spring from 0 to 1?
Why must retarget reset the 20 s safety timer?
Done when you can
You can write one semi-implicit Euler step
You can say why the step is fixed and long frames are clamped
You can choose rest thresholds for pixels and for a 0 to 1 value