i built a multiplayer ghost cursor tracker. every visitor gets a semi-transparent ghost that follows their mouse, and you can see everyone else’s ghosts too. it feels like a haunted google doc (i haven’t had to be in a google doc in months 🙏).
the web tech
hsl colors w/ alpha transparency
this was my first time really leaning into open props’ hsl variables and they’re honestly great. instead of hardcoding a hex like #ff69b4, i’m doing:
fill="hsl(var(--pink-5-hsl) / 70%)"
that gets me:
- 42 color variations across the spectrum (pink → red, 3 intensities each)
- consistent 70% transparency so they overlap softly
mix-blend-mode: screenfor nice color mixing when ghosts pile up
svg animation
each ghost is an inline svg with eyes that look around:
<animate attributeName="cx" values="22;24;22;20;22" dur="5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="30;28;32;30;29;30" dur="4s" repeatCount="indefinite"/>
the eyes move left/right and up/down independently, making them look around curiously. i added random start delays (0–3s) so each ghost has unique eye movement.
css animations
your ghost hovers up and down with a shadow underneath:
@keyframes ghostHover {
0%, 100% { opacity: 0.75; margin-top: 0; transform: scale(0.98); }
50% { opacity: 0.9; margin-top: -8px; transform: scale(1.02); }
}
the shadow stays on the ground and shrinks/fades as the ghost rises, creating depth.
other ghosts float around randomly using css custom properties:
@keyframes ghostFloat {
0%, 100% { opacity: 0.25; transform: translate(0, 0) scale(0.95); }
50% { opacity: 0.6; transform: translate(var(--float-x), var(--float-y)) scale(1.05); }
}
each ghost gets randomized --float-x and --float-y values and animation duration (3-5s) for organic movement.
realtime sync & historical ghosts
backend is a bun + hono server with bun:sqlite:
- optimistic position updates on mousemove (instant feedback!)
- every 500ms: update your position + poll for everyone else’s ghosts
- active ghosts older than 5 minutes get removed
- historical ghosts: every minute, positions are archived and kept for 24 hours
- server returns one random historical ghost per minute (changes every 60s)
- historical ghosts appear at 60% opacity and fade to 5% over 10 minutes
- max ~60 historical ghosts on screen before oldest ones disappear
- kept it simple—plain http polling, no websockets/webrtc
color picker
wanted to make an interesting color picker, i’m not sure this is… a great method.
i created a ghost-shaped color wheel by:
- creating 42 horizontal stripes (one for each color in the palette)
- using svg
<clipPath>to cut them into a ghost shape - converting click coordinates from screen space → svg viewBox coordinates
- calculating which stripe you clicked based on Y position
const clickY = ((e.clientY - svgRect.top) / svgHeight) * 80;
const stripeIndex = Math.floor((clickY - 10) / stripeHeight);
the ghost scales 3× on hover so you can actually see the colors. it works! but clicking a tiny rainbow ghost to pick colors feels deeply weird. 10/10 would do again.
things i learned
- open props hsl vars + transparency is a very flexible combo
- svg
<animate>support is better than i expected (firefox included) mix-blend-mode: screengives you beautiful overlaps that feel like watercolor- css custom properties (
--float-x) are perfect for per-element animation randomization - svg clipPath can turn boring UI into fun shapes (even if it’s impractical)
- calculating svg viewBox coordinates:
(screenCoord / domSize) * viewBoxSize
ghost color palette
42 total colors across the spectrum:
- your ghost: 90% opacity (vibrant and easy to spot)
- other active ghosts: 70% opacity with floating animation
- historical ghosts: 60% → 5% opacity (fading over 10 minutes from when they appeared)