---
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} />;
}
```
