01 — The Trail System
Every frame, the cursor position is pushed to the front of an array. This creates a historical path that older (further back) images follow with increasing delay.
animation.js
// Add current mouse to trail head (index 0 = newest)
const last = state.trail.length > 0 ? state.trail[0] : null;
if (!last || Math.abs(state.mouseX - last.x) > 1
|| Math.abs(state.mouseY - last.y) > 1) {
state.trail.unshift({ x: state.mouseX, y: state.mouseY });
if (state.trail.length > TRAIL_LENGTH) state.trail.length = TRAIL_LENGTH;
}
The trail stores up to 3,000 points. This is the history that gives the snake its organic, curving shape — every twist of your wrist is preserved for the tail to trace through later.
02 — Trail Position Solver
Each image needs to find a point at a specific distance along the trail from the head. This function walks the trail segment-by-segment, accumulating distance until it reaches the target.
trail.js
export function getTrailPos(distance) {
let remaining = distance;
for (let i = 0; i < state.trail.length - 1; i++) {
const dx = state.trail[i + 1].x - state.trail[i].x;
const dy = state.trail[i + 1].y - state.trail[i].y;
const segLen = Math.sqrt(dx * dx + dy * dy);
if (segLen < 0.001) continue;
if (remaining <= segLen) {
const t = remaining / segLen;
return {
x: state.trail[i].x + dx * t,
y: state.trail[i].y + dy * t,
angle: Math.atan2(dy, dx),
};
}
remaining -= segLen;
}
}
The angle return value is critical — it’s what allows each photo to subtly rotate to match the direction of the trail at its position, creating the organic slither effect.
03 — Front-to-Back Recycling
Instead of shifting all images simultaneously, only the lead image moves — it detaches from the front of the chain and teleports to the back, receiving a new photo.
slots.js
export function recycleFront() {
if (state.order.length < 2) return;
// Take the front element, push it to back of order
const frontIdx = state.order.shift();
state.order.push(frontIdx);
const p = state.photoEls[frontIdx];
// Assign the next image in the library
p.imgEl.setAttribute('src', state.images[state.nextImageIndex % state.images.length]);
state.nextImageIndex = (state.nextImageIndex + 1) % state.images.length;
// Snap position to current tail so it doesn't fly across
const tailIdx = state.order[state.order.length - 2];
const tailP = state.photoEls[tailIdx];
p.cx = tailP.cx;
p.cy = tailP.cy;
updateSlotProperties();
}
This is triggered every 220px of cursor travel. The position snap to the previous tail prevents the recycled element from visually flying across the screen — it appears seamlessly at the end.
04 — Bell Curve Sizing
Images aren’t uniformly sized. They follow a bell curve peaking at 65% of the chain, creating a natural focal bulge in the middle with smaller ends.
slots.js
export function getSlotSize(slotIndex) {
const progress = slotIndex / Math.max(SLOT_COUNT - 1, 1);
const s0 = seed(slotIndex, 0);
const peak = 0.65;
const sizeCurve = progress <= peak
? progress / peak // ramp up
: 1 - (progress - peak) / (1 - peak) * 0.6; // taper down
const baseSize = 40 + sizeCurve * 210 + s0 * 35;
}
The 0.6 multiplier on the taper means the tail end never shrinks back to head size — it stays at roughly 40% of peak, maintaining visual weight at both ends of the chain.
05 — Differential Easing
Each slot has its own easing speed based on position. The head tracks the cursor tightly while the tail lags behind — this single trick creates most of the snake-like feel.
animation.js
// Head follows fast (0.25), tail is lazier (0.06)
const ease = 0.25 - progress * 0.12;
p.cx += (pos.x - p.cx) * Math.max(ease, 0.06);
p.cy += (pos.y - p.cy) * Math.max(ease, 0.06);
// Size also interpolates smoothly during recycle
p.cw += (p.tw - p.cw) * 0.07;
p.ch += (p.th - p.ch) * 0.07;
Without this differential easing, all photos would move in lockstep and the effect would feel rigid. The easing gradient from 0.25 to 0.06 is what makes the tail whip and oscillate when you change direction quickly.