Cursor Trail
A canvas wake that follows the pointer — frame-sampled positions drawn as a tapering ribbon, an even bead chain or drifting sparks, dissolving on their own lifetime and never mounted on touch or under reduced motion.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/cursor-trail.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CursorTrail" component: a canvas wake
that follows the pointer across the surface it wraps. Only dependency is a `cn`
class merger — the sampler, the decay model and the teardown are the product.
Contract
- export const CursorTrail = React.forwardRef<HTMLDivElement, CursorTrailProps>,
extends React.HTMLAttributes<HTMLDivElement>, remaining props spread on the
root,
className merged with cn(), children rendered as-is.
variant?: "ribbon" | "dots" | "sparks" (ribbon) — ribbon strokes a tapering
path through the samples, dots leaves an evenly spaced bead chain, sparks
throws drifting embers off the motion.
length?: number (28) — retained path samples for ribbon/dots, live sparks for
sparks. Clamped 2..160.
thickness?: number (14) — head size in CSS px: stroke width at the pointer for
ribbon, bead diameter for dots, largest spark diameter for sparks. Clamped
0.5..96.
decay?: number (0.6) — seconds a sample survives before it has fully
dissolved. Clamped 0.05..4. length and decay are two ends of one tail:
whichever runs out first is the one you feel.
color?: string ("var(--chart-1)") — any CSS colour; pass a token, not a
literal, so dark mode comes free. "currentColor" inherits the container's
text colour.
disabled?: boolean (false) — turns the wake off without unmounting: no
canvas, no listeners, no loop.
- Also export the CursorTrailVariant union.
Behavior
- Sample on the ANIMATION clock, not on the event. pointermove writes
{clientX, clientY} to a ref and nothing else; one rAF callback per frame
converts against the CANVAS's box — the surface actually being painted, since
the canvas fills the root's padding box and measuring the root would offset
the whole wake by the root's border width — and appends at most one sample
(ribbon) or a short interpolated run (dots/sparks). A 1000 Hz gaming mouse
and a 60 Hz trackpad therefore draw the same trail, and no event ever paints.
- Every node stores its birth time on that clock. Per frame: expire nodes older
than decay, then trim the buffer to length. Both limits are prefix
operations because nodes are appended in order and share one lifetime — one
splice, no per-frame allocation, no filter garbage.
- Taper and fade are one number: f = clamp(min(1 - age/decay, (i+1)/count), 0,
1). The age term makes the tail thin out as it dies; the index term takes
over when the buffer cap binds instead of the lifetime, so the far end is
never a stroke cut off at full width. All three variants read the same f — a
spark's radius and alpha ride it exactly as a ribbon segment's width does, so
embers spliced off by the length cap fade out instead of blinking away.
- A resting pointer emits nothing: travel under ~0.75 CSS px in a frame is not
a sample. That is what lets the trail dissolve while the cursor sits still,
and it is what lets the loop park itself the moment the buffer empties. The
next pointermove wakes it. A cursor parked on the hero costs zero frames.
- Discontinuities are not drawn. The first sample after pointerenter (or after
a resize, a prop change, or the loop being suspended) only anchors the last
position; without that, re-entering the box streaks one straight line across
it from wherever the pointer left.
- Ribbon geometry: each segment runs midpoint(i-1,i) -> control node i ->
midpoint(i,i+1) as a quadratic curve with round caps and joins, so
consecutive segments meet tangentially and a wide stroke reads as one smooth
brush rather than a chain of facets. Width and alpha change per segment,
which is why they cannot share one path.
- Dots interpolate along each frame's travel at spacing = max(2, thickness *
0.45) so the rhythm is speed-independent; sparks spawn one ember per ~12 px
of travel (capped per frame), each inheriting ~22% of the pointer's velocity
plus a seeded random kick, then dragging exponentially to a halt and rising
slightly as it fades. The scatter comes from a fixed-seed LCG, never
Math.random(), so the same gesture makes the same wake everywhere.
- Colour resolution: the color prop is written to the canvas element's CSS
color, and the loop reads getComputedStyle(canvas).color. The browser
resolves the token (and its dark-mode value) and hands back a concrete
colour string that goes straight to fill/strokeStyle, with alpha riding on
globalAlpha — no colour parsing anywhere. A MutationObserver on <html>
re-reads it when the theme flips mid-flight.
- Sizing is a ResizeObserver on the canvas: backing store = CSS size x
min(devicePixelRatio, 2), then ctx.setTransform(dpr,...) so all drawing code
stays in CSS px. The box is re-read once per frame with
getBoundingClientRect, because scrolling moves it without firing a resize —
that is the loop's only layout read, and nothing in the loop writes DOM
style, so it cannot thrash.
- Suspension: an IntersectionObserver and visibilitychange park the loop and
drop the buffer off-screen or in a hidden tab, so the wake can never
reappear frozen when the section scrolls back. dt is clamped to 1/30 s so a
stalled frame cannot teleport the sparks.
- Pointer reality: the whole layer is skipped on (pointer: coarse), and any
event with pointerType === "touch" is ignored even on a hybrid laptop that
reports a fine pointer, so a finger drag stays a scroll. Every listener is
registered passive and lives on the root element, never on window.
- Reduced motion: under (prefers-reduced-motion: reduce) the canvas is not
mounted at all and no loop or listener is created. The wake carries no
information — the children, their buttons and their links are untouched, so
the surface works identically with the motion off. Both media queries are
read through useSyncExternalStore with a matchMedia change listener (server
snapshot: motion on, fine pointer), so a mid-session system change flips the
component live and the subscription is released on unmount.
- Cleanup on unmount, and on every prop change that rebuilds the loop: cancel
the rAF, disconnect the ResizeObserver / IntersectionObserver /
MutationObserver, remove the visibilitychange listener and all four pointer
listeners, and clear the canvas so no frame is left painted.
- Numeric props are clamped, not trusted: decay 0 divides the fade by zero,
a negative thickness strokes nothing, length 9999 costs ten thousand paths a
frame, and one NaN would poison every later frame.
Rendering & styling
- Semantic tokens only, no hex/rgb/oklch literals anywhere. The wake's colour
is a token passed through the color prop (var(--chart-1..5), var(--primary),
or currentColor); everything else is the host's own classes.
- Root: relative isolate overflow-hidden + the consumer's className (the
overflow clip is what keeps the wake inside a rounded card). Canvas:
aria-hidden, pointer-events-none, absolute inset-0 z-10 block size-full — it
paints ABOVE the children so the trail follows the pointer over the copy,
while every button and link underneath stays clickable and keyboard
reachable with its own focus-visible ring.
- Accessibility: the canvas is decoration and is marked as such. It adds no
role, no focus stop and no announcement, because it says nothing a screen
reader user would miss — and it must never be the only signal for anything.
Customization levers
- Feel: decay is the single strongest knob (0.3 s reads as a crisp streak,
1.5 s as a lingering smear); length caps how much of that history survives;
thickness sets the head. Lower MIN_STEP for a trail that reacts to the
smallest nudge, raise it to ignore hand tremor.
- Shape: variant picks the wake. Reuse the sampler for your own by adding one
draw function — it only needs the node list, the shared clock and the same
f factor.
- Colour: swap the color prop per section (var(--chart-1..5) for accents,
var(--primary) to stay on brand, currentColor to inherit). For a two-tone
wake, stroke the ribbon twice: once wide at low alpha, once at half the
thickness for the core.
- Depth: move the canvas below the children (z-0 and a relative wrapper on the
content) if the trail should read as a background rather than an overlay.
- Reach: to run the wake across the whole viewport, render it once in the
layout as fixed inset-0 pointer-events-none and attach the pointer listeners
to window instead of the root — the sampler, the decay and the teardown are
unchanged.
- Sparks physics: SPARK_LIFT flips embers to falling dust if you negate it,
SPARK_DRAG decides whether they hang in the air or stop dead, and
SPARK_INHERIT is how much the gesture is felt in the scatter.Concepts
- Frame-sampled pointer — the event stream is only a source of coordinates; a ref catches the newest pair and one rAF callback per frame decides what becomes history. A 1000 Hz mouse and a 60 Hz trackpad draw the same trail, and no listener ever paints.
- Lifetime decay vs buffer length — two ends of one tail.
decayis how long a sample is allowed to exist,lengthis how many are kept at all; whichever runs out first is the one you feel. Because both are prefix operations on an in-order buffer, expiry is one splice a frame, not a filter that allocates. - Age-based taper — width and alpha come from the same number,
min(1 - age/decay, (i+1)/count). The age term dissolves the tail as it dies; the index term takes over when the buffer cap binds, so the far end is never a stroke cut off at full width. - Self-parking loop — a resting pointer emits nothing, so the buffer empties and the loop cancels itself; the next
pointermoverestarts it. Add an IntersectionObserver andvisibilitychangeand a cursor parked on the hero, a scrolled-away section and a hidden tab all cost exactly zero frames. - Anchor, don't streak — the first sample after entering the box (or after a resize, a prop change, a resumed loop) only records a position. Skip it and every re-entry draws one straight line across the panel from wherever the pointer left.
- Coarse-pointer opt-out — the wake is decoration with no touch equivalent, so on
(pointer: coarse)and underprefers-reduced-motion: reducethe canvas is never mounted and no listener is attached. Nothing degrades, because nothing depended on it.
Custom Cursor
A pointer replacement scoped to one container — a spring-driven dot that opens into a difference-blended disc over anything interactive, hides the native arrow only while you are inside the box, and never draws on a coarse pointer or under reduced motion.
Cursor Glow Card
A card whose surface and border light up around the pointer, with an optional group where the hovered card stays lit and its siblings dim.