Slipstream
A spring-smoothed cursor follower: a soft bead of light that stretches into a teardrop at speed, brightening to white in motion and settling neutral at rest. Scoped to its container by default, opt into page-wide with `global`, auto-disabled on coarse pointers, and scales up over [data-cursor-target] elements.
motionfree
"use client";
import * as React from "react";
import { motion, useMotionValue, useMotionTemplate, useSpring, useTransform, useVelocity } from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface SlipstreamProps {
className?: string;
/** Resting diameter of the follower bead in px. Default 26. */
size?: number;
/** Scale multiplier while hovering an element carrying `data-cursor-target`. Default 2.2. */
targetScale?: number;
/** `[rest, peak]` colors — the bead settles to `rest` and brightens toward `peak` with speed. */
colors?: [string, string];
/** Spring physics for the chase motion. */
spring?: { stiffness?: number; damping?: number; mass?: number };
/**
* Attach to the whole viewport instead of the nearest containing element.
* Off by default — Slipstream is scoped to its parent (place it inside a
* `relative` container, like Crucible's backgrounds) so it never hijacks
* the cursor page-wide unless you explicitly opt in.
*/
global?: boolean;
/** Hide the native system cursor while active. Default true ("replace" vs. "augment"). */
hideNativeCursor?: boolean;
}
const DEFAULT_SPRING = { stiffness: 380, damping: 32, mass: 0.6 };
/**
* Slipstream — a spring-smoothed cursor follower: a gradient-cored bead of
* light (hot white center, soft haloed edge) that stretches into a teardrop
* along the direction of travel at speed — brightening to its peak color in
* motion, trailed by a lagging after-glow — and settles to a quiet
* cyan-tinted neutral at rest. Scoped to its nearest containing element by
* default (`global` opts into page-wide), auto-disables on coarse/touch
* pointers via `matchMedia('(pointer: fine)')`, scales up over any element
* carrying `data-cursor-target`, and fully disables itself (restoring the
* native cursor) under `prefers-reduced-motion`.
*/
export function Slipstream({
className,
size = 26,
targetScale = 2.2,
colors = ["#bae6fd", "#ffffff"],
spring,
global = false,
hideNativeCursor = true,
}: SlipstreamProps) {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const [finePointer, setFinePointer] = React.useState(false);
const [hovering, setHovering] = React.useState(false);
const [active, setActive] = React.useState(false);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(pointer: fine)");
const sync = () => setFinePointer(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
// Auto-disabled on coarse pointers and under reduced motion — this is the
// one Crucible component that fully turns off rather than falling back to
// a static frame, per the cursor category's accessibility contract.
const enabled = finePointer && !reducedMotion;
const springCfg = { ...DEFAULT_SPRING, ...spring };
const mx = useMotionValue(0);
const my = useMotionValue(0);
const x = useSpring(mx, springCfg);
const y = useSpring(my, springCfg);
const vx = useVelocity(x);
const vy = useVelocity(y);
const speed = useTransform([vx, vy], (latest) => {
const [lvx, lvy] = latest as number[];
return Math.min(Math.hypot(lvx, lvy) / 900, 1);
});
const angle = useTransform([vx, vy], (latest) => {
const [lvx, lvy] = latest as number[];
return Math.hypot(lvx, lvy) > 40 ? (Math.atan2(lvy, lvx) * 180) / Math.PI : 0;
});
// Velocity stretch is deliberately pronounced — the teardrop elongation IS
// the component's signature, so it must read at a glance.
const stretch = useTransform(speed, (s) => 1 + s * 1.5);
const squash = useTransform(speed, (s) => 1 - s * 0.45);
const heatPercent = useTransform(speed, (s) => `${Math.round(s * 100)}%`);
const bodyColor = useMotionTemplate`color-mix(in oklab, ${colors[1]} ${heatPercent}, ${colors[0]})`;
// Gradient core: a hot white center falling off through the speed-mixed body
// color — presence at rest, incandescent in motion.
const coreBackground = useMotionTemplate`radial-gradient(circle at 50% 45%, #ffffff 0%, ${bodyColor} 38%, transparent 72%)`;
const haloOpacity = useTransform(speed, (s) => 0.4 + s * 0.5);
// Trailing after-glow: a second, softer bead chasing the main one on a much
// looser spring, fading in with speed and vanishing at rest.
const trailX = useSpring(x, {
stiffness: springCfg.stiffness * 0.35,
damping: springCfg.damping * 0.9,
mass: springCfg.mass * 1.4,
});
const trailY = useSpring(y, {
stiffness: springCfg.stiffness * 0.35,
damping: springCfg.damping * 0.9,
mass: springCfg.mass * 1.4,
});
const trailOpacity = useTransform(speed, (s) => Math.min(s * 0.9, 0.5));
const hoverScale = useSpring(1, { stiffness: 300, damping: 24 });
React.useEffect(() => {
hoverScale.set(hovering ? targetScale : 1);
}, [hovering, targetScale, hoverScale]);
React.useEffect(() => {
if (!enabled) return;
const target: Window | HTMLElement | null = global ? window : (wrapperRef.current?.parentElement ?? null);
if (!target) return;
const cursorRoot: HTMLElement = global ? document.documentElement : (target as HTMLElement);
const onMove = (e: PointerEvent) => {
let px = e.clientX;
let py = e.clientY;
if (!global) {
const rect = (target as HTMLElement).getBoundingClientRect();
px -= rect.left;
py -= rect.top;
}
mx.set(px);
my.set(py);
setActive(true);
setHovering(!!(e.target as Element | null)?.closest("[data-cursor-target]"));
};
const onLeave = () => setActive(false);
target.addEventListener("pointermove", onMove as EventListener);
if (!global) target.addEventListener("pointerleave", onLeave as EventListener);
if (hideNativeCursor) cursorRoot.style.cursor = "none";
return () => {
target.removeEventListener("pointermove", onMove as EventListener);
if (!global) target.removeEventListener("pointerleave", onLeave as EventListener);
if (hideNativeCursor) cursorRoot.style.cursor = "";
};
}, [enabled, global, hideNativeCursor, mx, my]);
if (!enabled) return null;
return (
<div
ref={wrapperRef}
aria-hidden
data-crucible="slipstream"
className={cn(
"pointer-events-none",
global ? "fixed inset-0 z-[999]" : "absolute inset-0 z-50 overflow-hidden",
className
)}
>
<div
className="absolute inset-0"
style={{ opacity: active ? 1 : 0, transition: "opacity 200ms ease" }}
>
{/* Trailing after-glow — lags the main bead, only visible in motion. */}
<motion.div
className="absolute top-0 left-0 rounded-full"
style={{
x: trailX,
y: trailY,
translateX: "-50%",
translateY: "-50%",
width: size * 0.9,
height: size * 0.9,
rotate: angle,
scaleX: stretch,
scaleY: squash,
opacity: trailOpacity,
background: `radial-gradient(circle, ${colors[1]} 0%, transparent 70%)`,
filter: "blur(5px)",
}}
/>
{/* Main bead: soft halo + gradient core, stretched along travel. */}
<motion.div
className="absolute top-0 left-0"
style={{
x,
y,
translateX: "-50%",
translateY: "-50%",
width: size,
height: size,
rotate: angle,
scaleX: stretch,
scaleY: squash,
scale: hoverScale,
}}
>
<motion.div
className="absolute rounded-full"
style={{
inset: -size * 0.55,
background: `radial-gradient(circle, ${colors[0]} 0%, transparent 70%)`,
opacity: haloOpacity,
filter: "blur(6px)",
}}
/>
<motion.div
className="absolute inset-0 rounded-full"
style={{ background: coreBackground, filter: "blur(0.4px)" }}
/>
</motion.div>
</div>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/slipstreamManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | ||
| size | number | 26 | Resting diameter of the follower bead in px. |
| targetScale | number | 2.2 | Scale multiplier while hovering an element carrying data-cursor-target. |
| colors | [string, string] | [rest, peak] colors — the bead settles to rest and brightens toward peak with speed. | |
| spring | { stiffness?: number; damping?: number; mass?: number } | Spring physics for the chase motion. | |
| global | boolean | Attach to the whole viewport instead of the nearest containing element. Off by default — Slipstream is scoped to its parent (place it inside a relative container, like Crucible's backgrounds) so it never hijacks the cursor page-wide unless you explicitly opt in. | |
| hideNativeCursor | boolean | true ("replace" vs. "augment") | Hide the native system cursor while active. |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.