PixelTrail
Cursor-tracked decaying pixel trail.
Live demo
011. Mono-white hero crackle
Move the cursor.
Pixels follow you, then fade.
2. Cyber-cyan terminal
$ ant deploy --target=mainnet β build: 16.4s β tests: 1.9s 1877/1877 β lint: 0.6s 0 errors β release: v2.4.0
3. Sunset-warm marketing strip
Glide through golden hour.
4. Three sizes side-by-side
Small
4px pixels
6px throttle
densely stippled
Medium
8px pixels
10px throttle
editorial default
Large
16px pixels
18px throttle
arcade chunky
Implementation
02<script>
import PixelTrail from '$lib/components/PixelTrail.svelte';
</script>
β
<PixelTrail size="medium" palette="cyber-cyan" trailLength={20}>
<div class="hero">β¦</div>
</PixelTrail>PixelTrail wraps any region and spawns a small <span> at the cursor every few pixels of travel. Each span has a CSS keyframe animation that fades, scales, and drifts before the per-pixel setTimeout removes it. Distance throttling keeps trail density consistent at any cursor speed; reduced-motion users see the underlying region untouched.
Logic explainer
03What Does It Do? (Plain English)
PixelTrail wraps any region β a hero section, a card, a whole page β and emits a chain of small coloured pixels that follow the cursor as it moves. Each pixel fades, scales down, and drifts upward on a CSS keyframe before self-cleaning, so the visible trail looks like a comet tail behind the pointer. The density is throttled by distance (one pixel per ~size pixels of cursor travel) so a fast drag and a slow drag produce visually similar trails.
It is a decoration only. The wrapped content keeps its native click and focus semantics; the trail layer is aria-hidden and has pointer-events: none. Reduced-motion users see a static wrapper, no movement.
How It Works (Pseudo-Code)
state:
pixels[] = [] // active pixel sprites
lastX, lastY // last cursor position (for throttle)
hasLast = false
colorIndex = 0 // walks through palette colours
reducedMotion // capability flag
derived:
sizeConfig = pickSize(size) // {px, throttlePx}
paletteConfig = pickPalette(palette) // {colors[], shadow}
cappedLength = round(clamp trailLength to [0, 64])
safeDuration = round(clamp duration to [0, 2000])
on mount:
reducedMotion = isReducedMotion()
on mousemove(event):
if reducedMotion: return
rect = wrapper.getBoundingClientRect()
x = event.clientX - rect.left
y = event.clientY - rect.top
if hasLast:
thresholdΒ² = throttlePxΒ²
if distanceSquared(lastX, lastY, x, y) < thresholdΒ²: return // throttle
lastX, lastY, hasLast = x, y, true
color = paletteConfig.colors[colorIndex % colors.length]
colorIndex++
id = nextTrailId() // module-scoped counter
pixels.push({ id, x, y, color })
while pixels.length > cappedLength: pixels.shift() // drop oldest
schedule(setTimeout, safeDuration + 60):
pixels = pixels.filter(p => p.id !== id) // self-clean
on mouseleave:
hasLast = false // reset throttle anchor
render:
wrapper { onmousemove, onmouseleave }
{ children }
div.trail-layer aria-hidden
for each pixel:
span.pixel
left, top, --color, --shadow, --size, --duration
CSS:
.pixel { animation: pixel-fade var(--duration) cubic-bezier(.32, 0, .67, 0) forwards; }
@keyframes pixel-fade {
0% { transform: scale(1) translateY(0); opacity: 1; }
60% { opacity: 0.8; }
100% { transform: scale(0.2) translateY(-6px); opacity: 0; }
}The Core Concept: Distance-Throttled Spawning Plus Self-Cleaning Sprites
Two bits of logic make the trail feel right and stay cheap.
1. Distance throttling. A naΓ―ve "spawn one pixel per mousemove" implementation produces a thick clump on slow drags and a sparse line on fast drags β opposite to the desired behaviour. Throttling by distance travelled (rather than by time) decouples the visible density from cursor speed:
if distanceSquared(lastX, lastY, x, y) < throttlePxΒ²: skipdistanceSquared is used in preference to Math.hypot because we only need a comparison β squaring both sides of dist < threshold avoids the sqrt. With the medium preset (throttlePx = 10), a slow cursor barely beating the threshold spawns ~100 pixels per second; a fast flick of 800 px in 16 ms spawns ~80 pixels β visually similar.
2. Self-cleaning sprites. Each pixel carries a unique id (nextTrailId() from a module-scoped counter), and a setTimeout removes it from pixels[] after duration + 60 ms. The 60 ms cushion absorbs rAF jitter at the tail end so a pixel finishing late doesn't briefly render at scale 0.2 before being garbage-collected.
The pixels.shift() cap on cappedLength is the second guardrail: even if the cursor moves so fast that timeouts haven't fired yet, the array never grows past 64 entries (the hard cap), so memory stays bounded under any cursor pattern.
slow cursor fast cursor
ββββββββββββ β β β β β β
β density throttled β spacing matches throttlePx
β to β₯ throttlePx between β
β consecutive spawns βThe visible colour walks through the palette one entry at a time per spawn, so a stationary cursor that emits one pixel will use one colour; a moving cursor cycles through the palette in order, giving the trail a chromatic wash rather than a uniform stripe.
CSS Animation Strategy
The pixel itself is a single <span> with five CSS variables. The keyframe runs once per pixel β forwards means the final keyframe sticks, but we self-remove before it would matter visually.
.pixel {
position: absolute;
width: var(--size);
height: var(--size);
margin-left: calc(var(--size) / -2); /* centred on the cursor */
margin-top: calc(var(--size) / -2);
background: var(--color);
box-shadow: 0 0 calc(var(--size) * 1.25) var(--shadow);
animation: pixel-fade var(--duration) cubic-bezier(0.32, 0, 0.67, 0) forwards;
pointer-events: none;
will-change: opacity, transform;
}
@keyframes pixel-fade {
0% { transform: scale(1) translateY(0); opacity: 1; }
60% { opacity: 0.8; }
100% { transform: scale(0.2) translateY(-6px); opacity: 0; }
}The cubic-bezier (0.32, 0, 0.67, 0) is a deliberately ugly-feeling ease β fast in, slow out, with a held middle. It makes the pixel linger visibly before it dies, which is what gives the trail its presence. A linear or ease-out curve makes the pixels look like they're being deleted on a timer rather than fading.
The translateY offset (0 β -6px) is small but important β the pixel drifts upward as it dies, so the trail has a slight buoyancy. Without it the pixels would just fade in place, which reads as static.
@media (prefers-reduced-motion: reduce) { .pixel { display: none; } } is the safety net; the JS handler also short-circuits before any pixel is created.
State Flow Diagram
ββββββββββββββββββββββββββ
β IDLE β
β pixels = [] β
β hasLast = false β
ββββββββββββ¬ββββββββββββββ
β mousemove enters wrapper
βΌ
ββββββββββββββββββββββββββ
β THROTTLE CHECK β
β distΒ² >= thresholdΒ² ? β
βββ¬βββββββββββββββ¬ββββββββ
β no (skip) β yes
β βΌ
β ββββββββββββββββββββββββββ
β β SPAWN PIXEL β
β β push { id, x, y, c } β
β β cap to cappedLength β
β β schedule self-clean β
β ββββββββββββ¬ββββββββββββββ
β β duration + 60ms timer
βΌ βΌ
βββββ back to THROTTLE CHECK or IDLE βββββΊ
mouseleave: hasLast = false (next move starts fresh)
prefers-reduced-motion: reduce: handler bails immediately; pixels[] never populatedProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
size |
'small' | 'medium' | 'large' |
'medium' |
Pixel size preset. Maps to {px, throttlePx} via pickSize. small=4/6, medium=8/10, large=16/18. |
palette |
'mono-white' | 'cyber-cyan' | 'sunset-warm' |
'mono-white' |
Colour palette. Pixels cycle through the palette in spawn order. |
trailLength |
number |
16 |
Maximum live pixels at once. Clamped to [0, 64]. |
duration |
number |
700 |
Per-pixel lifetime in ms. Clamped to [0, 2000]. |
class |
string |
'' |
Extra classes on the wrapper. |
children |
Snippet |
required | Wrapped content. Trail floats over it but does not intercept events. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Very fast cursor flick | Distance throttle limits spawn rate; old pixels are dropped via pixels.shift() once cappedLength is hit. Memory stays bounded. |
| Cursor stops for several seconds | No more mousemove events fire; existing pixels finish their animation and self-clean. The trail dissolves. |
| Cursor leaves and re-enters quickly | mouseleave resets hasLast. The next move spawns immediately rather than throttling against a stale anchor on the other side of the wrapper. |
trailLength = 0 |
cappedLength falls back to 16 via the ` |
| Wrapped element internal scrolling | The wrapper's bounding rect doesn't move with the inner scroll, so the trail lands on cursor-position relative to the wrapper, not the scrolled content. Usually correct for hero/card use cases. |
prefers-reduced-motion: reduce |
Mousemove handler bails before touching state. CSS @media rule additionally hides .pixel if any somehow leak into the DOM. |
| Touch device with no mouse events | Mousemove never fires; trail layer stays empty. Wrapper renders as plain children. |
| Component unmounts mid-trail | Active timeouts reference state owned by the unmounted instance β they no-op when they fire. The DOM nodes go with the component. |
Dependencies
- Svelte 5 β
$state,$derived,$props,Snippet,onMount. <script module>exports βpickSize,pickPalette,clamp01,clampPositive,distanceSquared,nextTrailId,isReducedMotion. All pure, testable without a DOM.- Zero external libraries β no animation library, no canvas. Pure CSS keyframes + DOM sprites.
File Structure
src/lib/components/PixelTrail.svelte # implementation
src/lib/components/PixelTrail.md # this explainer
src/lib/components/PixelTrail.test.ts # unit tests for exported helpers
src/routes/pixeltrail/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
size | 'small' | 'medium' | 'large' | 'medium' | Bundles pixel size + throttle distance. |
palette | 'mono-white' | 'cyber-cyan' | 'sunset-warm' | 'mono-white' | Three-colour cycling palette. |
trailLength | number | 16 | Maximum live pixels (FIFO eviction). |
duration | number | 700 | Per-pixel lifetime in milliseconds. |