The elastic bullets are back
For my eighth birthday, my parents gave me a domain name. This was around 2000, so the site I put on it had a lot of JavaScript, a lot of Flash, and very little restraint.
I don't remember much of the page now. I do remember the elastic bullets: a little trail of dots that chased the mouse, sagged under gravity, and bounced off the edges of the window.
They did nothing useful. I spent a lot of time moving the mouse around anyway.
Years later, finding that script again was much harder than expected. When a copy finally turned up, the code was exactly as 2000 as you'd hope: an endless timer, top and left updates for every dot, and no idea touchscreens would ever exist.
I kept the springy bit and swapped in animation frames, transforms, and pointer events. That's really it.
Press the button. Move the mouse. On a phone, tap or drag anywhere.
If your page could also use a few bouncing dots, here's the plain-JavaScript version. It expects a few fixed .bullet elements inside an aria-hidden wrapper:
const REST_LENGTH = 25
const SPRING = 0.02
const GRAVITY = 0.5
const DRAG = 0.9
const BOUNCE = 0.75
const RADIUS = 8
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
function startElasticBullets(bulletElements) {
const motionPreference = window.matchMedia(REDUCED_MOTION_QUERY)
if (motionPreference.matches) return null
const controller = new AbortController()
const target = { x: window.innerWidth / 2, y: window.innerHeight / 2 }
const bullets = Array.from(bulletElements, (element) => ({
element,
x: target.x,
y: target.y,
velocityX: 0,
velocityY: 0,
}))
let animationFrame = 0
let previousFrame = performance.now()
function followPointer({ clientX, clientY }) {
target.x = clientX
target.y = clientY
}
function stop() {
controller.abort()
cancelAnimationFrame(animationFrame)
}
function animate(time) {
const frameScale = Math.min((time - previousFrame) / 16.67, 2)
const drag = DRAG ** frameScale
const maximumX = window.innerWidth - RADIUS
const maximumY = window.innerHeight - RADIUS
previousFrame = time
for (const [index, bullet] of bullets.entries()) {
const leader = index === 0 ? target : bullets[index - 1]
const distanceX = leader.x - bullet.x
const distanceY = leader.y - bullet.y
const distance = Math.hypot(distanceX, distanceY)
if (distance > REST_LENGTH) {
const spring =
((distance - REST_LENGTH) * SPRING * frameScale) / distance
bullet.velocityX += distanceX * spring
bullet.velocityY += distanceY * spring
}
bullet.velocityY += GRAVITY * frameScale
bullet.velocityX *= drag
bullet.velocityY *= drag
bullet.x += bullet.velocityX * frameScale
bullet.y += bullet.velocityY * frameScale
if (bullet.x < RADIUS) {
bullet.x = RADIUS
bullet.velocityX = Math.abs(bullet.velocityX) * BOUNCE
} else if (bullet.x > maximumX) {
bullet.x = maximumX
bullet.velocityX = -Math.abs(bullet.velocityX) * BOUNCE
}
if (bullet.y < RADIUS) {
bullet.y = RADIUS
bullet.velocityY = Math.abs(bullet.velocityY) * BOUNCE
} else if (bullet.y > maximumY) {
bullet.y = maximumY
bullet.velocityY = -Math.abs(bullet.velocityY) * BOUNCE
}
bullet.element.style.transform =
`translate3d(${bullet.x - RADIUS}px, ${bullet.y - RADIUS}px, 0)`
}
animationFrame = requestAnimationFrame(animate)
}
function stopForReducedMotion({ matches }) {
if (matches) stop()
}
const pointerOptions = { passive: true, signal: controller.signal }
window.addEventListener("pointerdown", followPointer, pointerOptions)
window.addEventListener("pointermove", followPointer, pointerOptions)
motionPreference.addEventListener(
"change",
stopForReducedMotion,
{ signal: controller.signal }
)
animationFrame = requestAnimationFrame(animate)
return stop
}
const stopElasticBullets = startElasticBullets(
document.querySelectorAll(".bullet")
)The CSS is tiny: each .bullet is a fixed 16px square with border-radius: 50%, a background color, pointer-events: none, and will-change: transform. Call stopElasticBullets?.() when you've had enough. The version above uses the same setup, plus a fixed physics step so the spring feels the same at every refresh rate.
pointerdown is the mobile addition: a tap moves the target, and dragging keeps it moving. Not exactly period-correct, but a website from 2000 was never going to meet an iPhone halfway.
Long live the elastic bullets.