Slipstream
A spring-smoothed cursor follower: a bead of molten metal that stretches into a teardrop at speed and cools from amber to violet 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 blob in px. Default 22. */
size?: number;
/** Scale multiplier while hovering an element carrying `data-cursor-target`. Default 2.2. */
targetScale?: number;
/** `[rest, hot]` colors — the blob cools to `rest` and heats toward `hot` 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 bead of molten metal
* that stretches into a teardrop along the direction of travel at speed and
* cools from amber to violet 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 = 22,
targetScale = 2.2,
colors = ["#5a189a", "#ff6b35"],
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;
});
const stretch = useTransform(speed, (s) => 1 + s * 0.9);
const squash = useTransform(speed, (s) => 1 - s * 0.35);
const heatPercent = useTransform(speed, (s) => `${Math.round(s * 100)}%`);
const background = useMotionTemplate`radial-gradient(circle at 35% 30%, color-mix(in oklab, ${colors[1]} ${heatPercent}, ${colors[0]}), transparent 72%)`;
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
)}
>
<motion.div
className="absolute top-0 left-0 rounded-full"
style={{
x,
y,
translateX: "-50%",
translateY: "-50%",
width: size,
height: size,
rotate: angle,
scaleX: stretch,
scaleY: squash,
scale: hoverScale,
opacity: active ? 1 : 0,
background,
transition: "opacity 200ms ease",
filter: "blur(0.5px)",
}}
/>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/slipstreamManual — install dependencies, then copy the source
npm install motionHonors prefers-reduced-motion with a designed static fallback, and passes className through.