PixelTrail

Cursor-tracked decaying pixel trail.

Live demo

01

1. Mono-white hero crackle

A NICE TERMINAL

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

SUMMER Β· 26

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
PixelTrail.svelte
<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

03

What 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Β²: skip

distanceSquared 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 populated

Props 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 page

API

04
PropTypeDefaultDescription
size'small' | 'medium' | 'large''medium'Bundles pixel size + throttle distance.
palette'mono-white' | 'cyber-cyan' | 'sunset-warm''mono-white'Three-colour cycling palette.
trailLengthnumber16Maximum live pixels (FIFO eviction).
durationnumber700Per-pixel lifetime in milliseconds.