← Playground

Cursor trail

Smoke and sparks that follow the mouse

Hand it to your AI assistant

A ready-made skill: a Markdown file with instructions, the options, the full source and the wrappers for every framework. Save it as SKILL.md inside a folder named nikuma-cursor-trail in your assistant's skills folder (for Claude Code: .claude/skills/), or paste it into any chat, then say "add smoke and sparks behind the mouse to my site".

Download SKILL.md

SKILL.mdpreview
---
name: nikuma-cursor-trail
description: Add a soft smoke and spark trail behind the mouse over a box or the whole page. Use when the user asks for this effect or component in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---

# Cursor trail

Every move of the mouse leaves a puff of soft smoke that swells, drifts up and fades, plus the odd glowing spark. It is the cursor magic of this site, in one file.

One dependency-free file. `mountCursorTrail(element, options)` builds everything inside `element` and **returns a `stop()` function** that removes it and cleans up all listeners. The framework wrappers below only call it in the right lifecycle hook.

## When to use

The user wants: smoke and sparks that follow the mouse. Works in plain HTML, Astro, React (incl. Next.js), Vue (incl. Nuxt), Svelte (incl. SvelteKit) and Solid.

## How to add it

1. Save the file(s) under **Source** into the project (for example `src/lib/cursor-trail/`), keeping their names. They need no installation.
2. Vanilla / Astro / plain HTML: import the function in a `<script type="module">` and call it with the container element. Other frameworks: create the wrapper from **Framework wrappers**, then render it.
3. Give the container a size where the rules below say so, then check it in both light and dark theme.

```js
import { mountCursorTrail } from './cursor-trail.js';
const stop = mountCursorTrail(document.getElementById('stage'), { smoke: '120,170,250', sparks: true });
// stop();  // when the element is removed
```

## Options (all optional)

| Option | Default | What it does |
|---|---|---|
| `smoke` | `'120,170,250'` | smoke colour as `r,g,b` (soft blue) |
| `spark` | `'#fff'` | spark colour |
| `sparks` | `true` | false = smoke only |
| `global` | `false` | true = the trail covers the whole window (pass `document.body`) |

## Rules

- Only runs with a mouse (`(hover: hover) and (pointer: fine)`), by design.
- With `global: true` the layer is `position: fixed` above the page (z-index 95) but never catches clicks.
- Call `stop()` before mounting again on the same element (the framework wrappers do this on unmount).
- Do not edit the engine to change behaviour; use options. Respect `prefers-reduced-motion` (the engine does).

## Source

### `cursor-trail.js`

```js
// Mouse trail: soft smoke puffs and glowing sparks that follow the cursor.
// mountCursorTrail(el, options) -> stop()   Reacts to the mouse over `el`. For the whole page pass document.body and { global: true }.
//
// options (all optional):
//   smoke   'r,g,b' colour of the smoke      default '120,170,250' (soft blue)
//   spark   CSS colour of the sparks         default '#fff'
//   sparks  show sparks too                  default true
//   global  trail over the whole window      default false (only over `el`)
const rand = (a, b) => a + Math.random() * (b - a);
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

export function mountCursorTrail(el, o = {}) {
  css('ctx-css', `.ctx-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:5}.ctx-layer.fixed{position:fixed;z-index:95}
.ctx-mist{position:absolute;border-radius:50%;pointer-events:none;background:radial-gradient(closest-side,rgba(var(--ctx-smoke),.55),rgba(var(--ctx-smoke),.22) 55%,transparent)}
.ctx-spark{position:absolute;border-radius:50%;pointer-events:none;background:var(--ctx-spark);box-shadow:0 0 6px 2px rgba(120,190,255,.95),0 0 14px 5px rgba(80,160,255,.5)}
@media(prefers-reduced-motion:reduce){.ctx-layer:not(.force){display:none}}`);
  if ((!o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches) || !matchMedia('(hover: hover) and (pointer: fine)').matches) return () => {};   // phones and "reduce motion": no trail
  const global = !!o.global;
  if (!global && getComputedStyle(el).position === 'static') el.style.position = 'relative';
  const layer = document.createElement('div'); layer.className = 'ctx-layer' + (global ? ' fixed' : '') + (o.ignoreMotionPref ? ' force' : ''); layer.setAttribute('aria-hidden', 'true');
  layer.style.setProperty('--ctx-smoke', o.smoke || '120,170,250'); layer.style.setProperty('--ctx-spark', o.spark || '#fff');
  el.append(layer);
  let last = 0, lx = 0, ly = 0, live = 0;
  const add = (node, frames, opts) => { live++; layer.append(node); const a = node.animate(frames, opts); a.onfinish = () => { node.remove(); live--; }; };
  const move = (e) => {
    const now = performance.now();
    if (now - last < 30 || Math.hypot(e.clientX - lx, e.clientY - ly) < 9 || live > 70) return;       // thin out: not every pixel makes smoke
    last = now; lx = e.clientX; ly = e.clientY;
    const r = global ? { left: 0, top: 0 } : layer.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
    const m = document.createElement('i'), z = rand(34, 64);                                        // a puff that swells, drifts up and fades
    m.className = 'ctx-mist'; m.style.cssText = `left:${x + rand(-5, 5)}px;top:${y + rand(-5, 5)}px;width:${z}px;height:${z}px`;
    add(m, [
      { opacity: 0, transform: 'translate(-50%,-50%) scale(.4)' },
      { opacity: 1, transform: 'translate(-50%,-50%) scale(.9)', offset: .25 },
      { opacity: 0, transform: `translate(calc(-50% + ${rand(-20, 20)}px),calc(-50% + ${rand(-48, -16)}px)) scale(${rand(1.7, 2.2).toFixed(2)})` },
    ], { duration: rand(1100, 1700), easing: 'ease-out' });
    if (o.sparks !== false && Math.random() < .6) {                                                  // and now and then a spark that falls away
      const s = document.createElement('i'), q = rand(2, 5);
      s.className = 'ctx-spark'; s.style.cssText = `left:${x + rand(-8, 8)}px;top:${y + rand(-8, 8)}px;width:${q}px;height:${q}px`;
      add(s, [{ opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }, { opacity: 0, transform: `translate(calc(-50% + ${rand(-26, 26)}px),calc(-50% + ${rand(6, 38)}px)) scale(0)` }], { duration: rand(900, 1500), easing: 'ease-out' });
    }
  };
  const target = global ? window : el;
  target.addEventListener('pointermove', move, { passive: true });
  return () => { target.removeEventListener('pointermove', move); layer.remove(); };
}
```

## Framework wrappers

### React: `CursorTrail.jsx`

```jsx
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountCursorTrail } from './cursor-trail.js';

export default function CursorTrail({ options = {}, className, style }) {
  const ref = useRef(null);
  useEffect(() => mountCursorTrail(ref.current, options), []);   // the engine returns its own cleanup
  return <div ref={ref} className={className} style={style} />;
}
```

### Vue: `CursorTrail.vue`

```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountCursorTrail } from './cursor-trail.js';

const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountCursorTrail(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>

<template><div ref="root"></div></template>
```

### Svelte: `CursorTrail.svelte`

```svelte
<script>
  import { onMount } from 'svelte';
  import { mountCursorTrail } from './cursor-trail.js';

  export let options = {};
  export let style = '';
  let klass = '';
  export { klass as class };
  let root;
  onMount(() => mountCursorTrail(root, options));   // returning the cleanup from onMount stops it on destroy
</script>

<div bind:this={root} class={klass} {style}></div>
```

### Solid: `CursorTrail.jsx`

```jsx
import { onMount, onCleanup } from 'solid-js';
import { mountCursorTrail } from './cursor-trail.js';

export default function CursorTrail(props) {
  let root;
  onMount(() => onCleanup(mountCursorTrail(root, props.options || {})));
  return <div ref={root} class={props.class} style={props.style} />;
}
```

Get the code

One small file does all the work and has no dependencies. React, Vue, Svelte and Solid only add a few lines around it. Pick your tool below.

1. Copy the engine (same for every framework)

cursor-trail.js4 KB
// Mouse trail: soft smoke puffs and glowing sparks that follow the cursor.
// mountCursorTrail(el, options) -> stop()   Reacts to the mouse over `el`. For the whole page pass document.body and { global: true }.
//
// options (all optional):
//   smoke   'r,g,b' colour of the smoke      default '120,170,250' (soft blue)
//   spark   CSS colour of the sparks         default '#fff'
//   sparks  show sparks too                  default true
//   global  trail over the whole window      default false (only over `el`)
const rand = (a, b) => a + Math.random() * (b - a);
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

export function mountCursorTrail(el, o = {}) {
  css('ctx-css', `.ctx-layer{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:5}.ctx-layer.fixed{position:fixed;z-index:95}
.ctx-mist{position:absolute;border-radius:50%;pointer-events:none;background:radial-gradient(closest-side,rgba(var(--ctx-smoke),.55),rgba(var(--ctx-smoke),.22) 55%,transparent)}
.ctx-spark{position:absolute;border-radius:50%;pointer-events:none;background:var(--ctx-spark);box-shadow:0 0 6px 2px rgba(120,190,255,.95),0 0 14px 5px rgba(80,160,255,.5)}
@media(prefers-reduced-motion:reduce){.ctx-layer:not(.force){display:none}}`);
  if ((!o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches) || !matchMedia('(hover: hover) and (pointer: fine)').matches) return () => {};   // phones and "reduce motion": no trail
  const global = !!o.global;
  if (!global && getComputedStyle(el).position === 'static') el.style.position = 'relative';
  const layer = document.createElement('div'); layer.className = 'ctx-layer' + (global ? ' fixed' : '') + (o.ignoreMotionPref ? ' force' : ''); layer.setAttribute('aria-hidden', 'true');
  layer.style.setProperty('--ctx-smoke', o.smoke || '120,170,250'); layer.style.setProperty('--ctx-spark', o.spark || '#fff');
  el.append(layer);
  let last = 0, lx = 0, ly = 0, live = 0;
  const add = (node, frames, opts) => { live++; layer.append(node); const a = node.animate(frames, opts); a.onfinish = () => { node.remove(); live--; }; };
  const move = (e) => {
    const now = performance.now();
    if (now - last < 30 || Math.hypot(e.clientX - lx, e.clientY - ly) < 9 || live > 70) return;       // thin out: not every pixel makes smoke
    last = now; lx = e.clientX; ly = e.clientY;
    const r = global ? { left: 0, top: 0 } : layer.getBoundingClientRect(), x = e.clientX - r.left, y = e.clientY - r.top;
    const m = document.createElement('i'), z = rand(34, 64);                                        // a puff that swells, drifts up and fades
    m.className = 'ctx-mist'; m.style.cssText = `left:${x + rand(-5, 5)}px;top:${y + rand(-5, 5)}px;width:${z}px;height:${z}px`;
    add(m, [
      { opacity: 0, transform: 'translate(-50%,-50%) scale(.4)' },
      { opacity: 1, transform: 'translate(-50%,-50%) scale(.9)', offset: .25 },
      { opacity: 0, transform: `translate(calc(-50% + ${rand(-20, 20)}px),calc(-50% + ${rand(-48, -16)}px)) scale(${rand(1.7, 2.2).toFixed(2)})` },
    ], { duration: rand(1100, 1700), easing: 'ease-out' });
    if (o.sparks !== false && Math.random() < .6) {                                                  // and now and then a spark that falls away
      const s = document.createElement('i'), q = rand(2, 5);
      s.className = 'ctx-spark'; s.style.cssText = `left:${x + rand(-8, 8)}px;top:${y + rand(-8, 8)}px;width:${q}px;height:${q}px`;
      add(s, [{ opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }, { opacity: 0, transform: `translate(calc(-50% + ${rand(-26, 26)}px),calc(-50% + ${rand(6, 38)}px)) scale(0)` }], { duration: rand(900, 1500), easing: 'ease-out' });
    }
  };
  const target = global ? window : el;
  target.addEventListener('pointermove', move, { passive: true });
  return () => { target.removeEventListener('pointermove', move); layer.remove(); };
}

Save it in your project (for example src/cursor-trail/). You never need to edit it: everything is an option.

2. Use it in your project

Plain HTML, Astro, Eleventy, WordPress, anything. No build step needed: a `<script type="module">` is enough.

Use it:

<div id="stage" style="height:260px"></div>

<script type="module">
  import { mountCursorTrail } from './cursor-trail.js';

  const stop = mountCursorTrail(document.getElementById('stage'), { smoke: '120,170,250', sparks: true });
  // when you are done (for example when a page closes): stop();
</script>

Every move of the mouse leaves a puff of soft smoke that swells, drifts up and fades, plus the odd glowing spark. It is the cursor magic of this site, in one file.

How it works

Options

Every option is optional. These are the defaults.

OptionDefaultWhat it does
smoke'120,170,250'smoke colour as r,g,b (soft blue)
spark'#fff'spark colour
sparkstruefalse = smoke only
globalfalsetrue = the trail covers the whole window (pass document.body)

Tips

Nature does not hurry, yet everything is accomplished.
Lao Tzu