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:

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:

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:

  1. creating 42 horizontal stripes (one for each color in the palette)
  2. using svg <clipPath> to cut them into a ghost shape
  3. converting click coordinates from screen space → svg viewBox coordinates
  4. 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

  1. open props hsl vars + transparency is a very flexible combo
  2. svg <animate> support is better than i expected (firefox included)
  3. mix-blend-mode: screen gives you beautiful overlaps that feel like watercolor
  4. css custom properties (--float-x) are perfect for per-element animation randomization
  5. svg clipPath can turn boring UI into fun shapes (even if it’s impractical)
  6. calculating svg viewBox coordinates: (screenCoord / domSize) * viewBoxSize

ghost color palette

42 total colors across the spectrum: