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".
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>Works in Vite, Create React App, Next.js (App Router needs the `'use client'` line, already included), Remix. The engine only touches the page inside `useEffect`, so server rendering is safe.
Create CursorTrail.jsx next to the engine:
'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} />;
}Then use it:
import CursorTrail from './CursorTrail';
export default function Page() {
return <CursorTrail options={{ smoke: '120,170,250', sparks: true }} style={{ height: '260px' }} />;
}Works in Vite + Vue 3 and Nuxt (wrap it in `<ClientOnly>` if you want to be extra safe; it only touches the page in `onMounted`).
Create CursorTrail.vue next to the engine:
<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>Then use it:
<script setup>
import CursorTrail from './CursorTrail.vue';
</script>
<template>
<CursorTrail :options="{ smoke: '120,170,250', sparks: true }" style="height:260px" />
</template>Works in Svelte 4 and 5 (the `export let` style still works in 5) and SvelteKit. Starts in `onMount`, so it never runs on the server.
Create CursorTrail.svelte next to the engine:
<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>Then use it:
<script>
import CursorTrail from './CursorTrail.svelte';
</script>
<CursorTrail options={{ smoke: '120,170,250', sparks: true }} style="height:260px" />Works in Solid and SolidStart. Starts in `onMount`, so it never runs on the server.
Create CursorTrail.jsx next to the engine:
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} />;
}Then use it:
import CursorTrail from './CursorTrail';
export default function Page() {
return <CursorTrail options={{ smoke: '120,170,250', sparks: true }} style={{ height: '260px' }} />;
}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
- A
pointermovelistener spawns an element at most every 30 ms and only after the mouse moved 9px, so it never floods the page. - Each puff and spark is animated with the Web Animations API (
element.animate) and removes itself when finished, so there is no animation loop of its own. - At most 70 pieces exist at once.
- Phones, touch screens and
prefers-reduced-motionget no trail at all.
Options
Every option is optional. These are the defaults.
| 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) |
Tips
- Warm version:
{ smoke: "255,170,90", spark: "#ffe9a8" }. - For the whole page:
mountCursorTrail(document.body, { global: true })once in your layout. - On dark backgrounds use a lighter smoke, on light backgrounds a deeper one.