Context Cursor
A scoped custom cursor for pointer-rich surfaces where each target can register its own label, icon, and interaction intent.
cursorspring-follow
Quarterly report
Revenue, retention, and activation summary.
Backlog column
Reorder workspace lanes
Design brief
Show a quick file preview
"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
type MotionValue,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
export type ContextCursorVariant = "default" | "open" | "drag" | "preview";
export type ContextCursorFollow = "instant" | "spring";
export type ContextCursorState = {
label: React.ReactNode;
icon?: React.ReactNode;
variant?: ContextCursorVariant;
};
export type ContextCursorAnimation = {
edgeFade?: boolean;
edgeFadeDistance?: number;
edgeFadeEasing?: (progress: number) => number;
hideDelay?: number;
opacity?: {
hidden?: number;
visible?: number;
};
scale?: {
hidden?: number;
visible?: number;
};
opacitySpring?: SpringOptions;
scaleSpring?: SpringOptions;
};
export type ContextCursorTargetAnimation = Omit<
ContextCursorAnimation,
"opacitySpring" | "scaleSpring"
>;
type ContextCursorContextValue = {
showCursor: (
cursor: ContextCursorState,
targetId: string,
point?: CursorPoint,
targetBounds?: DOMRectReadOnly,
targetAnimation?: ContextCursorTargetAnimation,
) => void;
hideCursor: (targetId?: string, point?: CursorPoint) => void;
isDisabled: boolean;
};
type CursorPoint = {
x: number;
y: number;
};
type ResolvedContextCursorAnimation = {
edgeFade: boolean;
edgeFadeDistance: number;
edgeFadeEasing: (progress: number) => number;
hideDelay: number;
hiddenOpacity: number;
visibleOpacity: number;
hiddenScale: number;
visibleScale: number;
};
const ContextCursorContext =
React.createContext<ContextCursorContextValue | null>(null);
const defaultSpring: SpringOptions = {
mass: 0.1,
stiffness: 320,
damping: 26,
};
const opacitySpring: SpringOptions = {
mass: 0.1,
stiffness: 480,
damping: 24,
};
const scaleSpring: SpringOptions = {
mass: 0.12,
stiffness: 420,
damping: 22,
};
const nativeCursorStyleId = "context-cursor-native-hidden";
const defaultEdgeFadeDistance = 40;
const defaultCursorHideDelay = 140;
const defaultHiddenOpacity = 0;
const defaultVisibleOpacity = 1;
const defaultHiddenScale = 0.4;
const defaultVisibleScale = 1;
// Once the edge-fade has shrunk the badge below this much progress it is
// effectively gone, so the native cursor is handed back at that exact point.
const nativeHandoffProgress = 0.08;
const variantClassNames: Record<ContextCursorVariant, string> = {
default: "border-border bg-background text-foreground",
open: "border-transparent bg-primary text-primary-foreground",
drag: "border-border bg-muted text-foreground",
preview: "border-border bg-background text-foreground",
};
export type ContextCursorProps = Omit<
React.ComponentPropsWithoutRef<"div">,
"onPointerEnter" | "onPointerLeave" | "onPointerMove"
> & {
follow?: ContextCursorFollow;
spring?: SpringOptions;
animation?: ContextCursorAnimation;
edgeFadeDistance?: number;
cursorClassName?: string;
disabled?: boolean;
};
export function ContextCursor({
children,
className,
cursorClassName,
follow = "instant",
spring = defaultSpring,
animation,
edgeFadeDistance = defaultEdgeFadeDistance,
disabled,
...props
}: ContextCursorProps) {
const initialHiddenOpacity =
animation?.opacity?.hidden ?? defaultHiddenOpacity;
const initialHiddenScale = animation?.scale?.hidden ?? defaultHiddenScale;
const shouldReduceMotion = useReducedMotion();
const supportsFinePointer = useFinePointer();
const [cursor, setCursor] = React.useState<ContextCursorState | null>(null);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const bounds = React.useRef<DOMRectReadOnly | null>(null);
const activeTargetBounds = React.useRef<DOMRectReadOnly | null>(null);
const activeTargetAnimation =
React.useRef<ContextCursorTargetAnimation | null>(null);
const hideTimer = React.useRef<number | null>(null);
const pointerFrame = React.useRef<number | null>(null);
const latestPointerFrame = React.useRef<{
point: CursorPoint;
wrapperBounds: DOMRectReadOnly;
targetBounds: DOMRectReadOnly | null;
} | null>(null);
const hasNativeCursorLock = React.useRef(false);
const activeTargetId = React.useRef<string | null>(null);
const isDisabled = Boolean(
disabled || shouldReduceMotion || !supportsFinePointer,
);
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const opacity = useSpring(
initialHiddenOpacity,
animation?.opacitySpring ?? opacitySpring,
);
const scale = useSpring(
initialHiddenScale,
animation?.scaleSpring ?? scaleSpring,
);
const hideNativeCursor = React.useCallback(() => {
if (hasNativeCursorLock.current) return;
hasNativeCursorLock.current = true;
acquireNativeCursorLock(wrapperRef.current);
}, []);
const showNativeCursor = React.useCallback(() => {
if (!hasNativeCursorLock.current) return;
hasNativeCursorLock.current = false;
releaseNativeCursorLock(wrapperRef.current);
}, []);
const getAnimation = React.useCallback(
(
targetAnimation: ContextCursorTargetAnimation | null =
activeTargetAnimation.current,
): ResolvedContextCursorAnimation => ({
edgeFade: targetAnimation?.edgeFade ?? animation?.edgeFade ?? true,
edgeFadeDistance:
targetAnimation?.edgeFadeDistance ??
animation?.edgeFadeDistance ??
edgeFadeDistance,
edgeFadeEasing:
targetAnimation?.edgeFadeEasing ??
animation?.edgeFadeEasing ??
smoothstep,
hideDelay:
targetAnimation?.hideDelay ??
animation?.hideDelay ??
defaultCursorHideDelay,
hiddenOpacity:
targetAnimation?.opacity?.hidden ??
animation?.opacity?.hidden ??
defaultHiddenOpacity,
visibleOpacity:
targetAnimation?.opacity?.visible ??
animation?.opacity?.visible ??
defaultVisibleOpacity,
hiddenScale:
targetAnimation?.scale?.hidden ??
animation?.scale?.hidden ??
defaultHiddenScale,
visibleScale:
targetAnimation?.scale?.visible ??
animation?.scale?.visible ??
defaultVisibleScale,
}),
[animation, edgeFadeDistance],
);
// Drives the badge by how close the pointer is to the target edge: it shrinks
// and fades as the edge nears, and the native cursor is handed back at the
// exact moment the badge vanishes — so the two never overlap and there is no
// gap where neither cursor is visible.
//
// Scale and opacity are written with `jump` (not `set`), so the badge size is
// an exact function of the pointer's distance to the border with zero spring
// lag. A laggy spring can't keep up with a fast pointer, which is what made
// the badge finish shrinking *outside* the target; jumping ties the shrink to
// position, so it always completes inside the box no matter how fast the move.
const updateCursorPresence = React.useCallback(
(point: CursorPoint, targetBounds: DOMRectReadOnly) => {
const currentAnimation = getAnimation();
const distanceToEdge = Math.min(
point.x - targetBounds.left,
targetBounds.right - point.x,
point.y - targetBounds.top,
targetBounds.bottom - point.y,
);
const fadeDistance = Math.max(1, currentAnimation.edgeFadeDistance);
const edgeProgress = clamp(distanceToEdge / fadeDistance, 0, 1);
const easedProgress = currentAnimation.edgeFade
? clamp(currentAnimation.edgeFadeEasing(edgeProgress), 0, 1)
: 1;
opacity.jump(
interpolate(
currentAnimation.hiddenOpacity,
currentAnimation.visibleOpacity,
easedProgress,
),
);
scale.jump(
interpolate(
currentAnimation.hiddenScale,
currentAnimation.visibleScale,
easedProgress,
),
);
if (easedProgress <= nativeHandoffProgress) {
showNativeCursor();
} else {
hideNativeCursor();
}
},
[getAnimation, hideNativeCursor, opacity, scale, showNativeCursor],
);
const getWrapperBounds = React.useCallback(() => {
const currentBounds =
wrapperRef.current?.getBoundingClientRect() ?? bounds.current ?? null;
if (currentBounds) {
bounds.current = currentBounds;
}
return currentBounds;
}, []);
const updatePointerPosition = React.useCallback(
(
point: CursorPoint,
wrapperBounds: DOMRectReadOnly,
targetBounds: DOMRectReadOnly | null = activeTargetBounds.current,
) => {
rawX.set(snapToDevicePixel(point.x - wrapperBounds.left));
rawY.set(snapToDevicePixel(point.y - wrapperBounds.top));
if (targetBounds) {
updateCursorPresence(point, targetBounds);
}
},
[rawX, rawY, updateCursorPresence],
);
const schedulePointerPosition = React.useCallback(
(
point: CursorPoint,
wrapperBounds: DOMRectReadOnly,
targetBounds: DOMRectReadOnly | null = activeTargetBounds.current,
) => {
latestPointerFrame.current = {
point,
wrapperBounds,
targetBounds,
};
if (pointerFrame.current !== null) return;
pointerFrame.current = window.requestAnimationFrame(() => {
pointerFrame.current = null;
const nextFrame = latestPointerFrame.current;
latestPointerFrame.current = null;
if (!nextFrame) return;
updatePointerPosition(
nextFrame.point,
nextFrame.wrapperBounds,
nextFrame.targetBounds,
);
});
},
[updatePointerPosition],
);
const showCursor = React.useCallback(
(
nextCursor: ContextCursorState,
targetId: string,
point?: CursorPoint,
targetBounds?: DOMRectReadOnly,
targetAnimation?: ContextCursorTargetAnimation,
) => {
if (isDisabled) return;
if (hideTimer.current) {
window.clearTimeout(hideTimer.current);
hideTimer.current = null;
}
activeTargetId.current = targetId;
activeTargetBounds.current = targetBounds ?? null;
activeTargetAnimation.current = targetAnimation ?? null;
setCursor(nextCursor);
const currentWrapperBounds = getWrapperBounds();
if (point && currentWrapperBounds) {
updatePointerPosition(point, currentWrapperBounds, targetBounds ?? null);
}
if (!point || !targetBounds) {
hideNativeCursor();
const currentAnimation = getAnimation(targetAnimation ?? null);
opacity.set(currentAnimation.visibleOpacity);
scale.set(currentAnimation.visibleScale);
}
},
[
hideNativeCursor,
getWrapperBounds,
isDisabled,
opacity,
scale,
getAnimation,
updatePointerPosition,
],
);
const hideCursor = React.useCallback(
(targetId?: string, point?: CursorPoint) => {
const targetIdAtLeave = targetId ?? activeTargetId.current;
if (targetIdAtLeave && activeTargetId.current !== targetIdAtLeave) {
return;
}
const currentAnimation = getAnimation(activeTargetAnimation.current);
activeTargetBounds.current = null;
activeTargetAnimation.current = null;
if (hideTimer.current) {
window.clearTimeout(hideTimer.current);
hideTimer.current = null;
}
const currentBounds = bounds.current;
if (point && currentBounds) {
updatePointerPosition(point, currentBounds, null);
}
// Leaving the target snaps the badge away (no spring tail) and hands the
// native cursor back on the same frame — so crossing the border reads as
// an instant switch back to the normal cursor, never a badge shrinking
// outside the box on a fast exit that skipped the edge-fade band.
opacity.jump(currentAnimation.hiddenOpacity);
scale.jump(currentAnimation.hiddenScale);
showNativeCursor();
// The badge is already invisible; this only clears the React state once
// we're sure the pointer hasn't re-entered another target in the meantime.
hideTimer.current = window.setTimeout(() => {
if (targetIdAtLeave && activeTargetId.current !== targetIdAtLeave) {
return;
}
activeTargetId.current = null;
setCursor(null);
hideTimer.current = null;
}, currentAnimation.hideDelay);
},
[getAnimation, opacity, scale, showNativeCursor, updatePointerPosition],
);
React.useEffect(() => {
return () => {
if (hideTimer.current) {
window.clearTimeout(hideTimer.current);
}
if (pointerFrame.current !== null) {
window.cancelAnimationFrame(pointerFrame.current);
}
showNativeCursor();
};
}, [showNativeCursor]);
React.useEffect(() => {
if (!isDisabled) return;
if (hideTimer.current) {
window.clearTimeout(hideTimer.current);
hideTimer.current = null;
}
if (pointerFrame.current !== null) {
window.cancelAnimationFrame(pointerFrame.current);
pointerFrame.current = null;
}
latestPointerFrame.current = null;
activeTargetId.current = null;
activeTargetBounds.current = null;
activeTargetAnimation.current = null;
bounds.current = null;
const currentAnimation = getAnimation(null);
opacity.set(currentAnimation.hiddenOpacity);
scale.set(currentAnimation.hiddenScale);
showNativeCursor();
hideTimer.current = window.setTimeout(() => {
setCursor(null);
hideTimer.current = null;
}, 0);
}, [getAnimation, isDisabled, opacity, scale, showNativeCursor]);
const updateBounds = React.useCallback((element: HTMLDivElement) => {
bounds.current = element.getBoundingClientRect();
}, []);
const handlePointerEnter = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isDisabled || event.pointerType !== "mouse") return;
updateBounds(event.currentTarget);
},
[isDisabled, updateBounds],
);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isDisabled || event.pointerType !== "mouse") return;
const currentBounds =
bounds.current ?? event.currentTarget.getBoundingClientRect();
schedulePointerPosition(
{ x: event.clientX, y: event.clientY },
currentBounds,
);
},
[isDisabled, schedulePointerPosition],
);
const handlePointerLeave = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType !== "mouse") return;
hideCursor(undefined, { x: event.clientX, y: event.clientY });
bounds.current = null;
},
[hideCursor],
);
const handlePointerCancel = React.useCallback(() => {
bounds.current = null;
hideCursor();
}, [hideCursor]);
const contextValue = React.useMemo<ContextCursorContextValue>(
() => ({
showCursor,
hideCursor,
isDisabled,
}),
[hideCursor, isDisabled, showCursor],
);
const variant = cursor?.variant ?? "default";
return (
<ContextCursorContext.Provider value={contextValue}>
<div
{...props}
ref={wrapperRef}
data-slot="context-cursor"
className={cn("relative", className)}
onPointerEnter={handlePointerEnter}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
onPointerCancel={handlePointerCancel}
>
{children}
{follow === "spring" ? (
<SpringCursorIndicator
cursor={cursor}
cursorClassName={cursorClassName}
opacity={opacity}
rawX={rawX}
rawY={rawY}
scale={scale}
spring={spring}
variant={variant}
/>
) : (
<ContextCursorIndicator
cursor={cursor}
cursorClassName={cursorClassName}
opacity={opacity}
scale={scale}
variant={variant}
x={rawX}
y={rawY}
/>
)}
</div>
</ContextCursorContext.Provider>
);
}
type ContextCursorIndicatorProps = {
cursor: ContextCursorState | null;
cursorClassName?: string;
opacity: MotionValue<number>;
scale: MotionValue<number>;
variant: ContextCursorVariant;
x: MotionValue<number>;
y: MotionValue<number>;
};
function ContextCursorIndicator({
cursor,
cursorClassName,
opacity,
scale,
variant,
x,
y,
}: ContextCursorIndicatorProps) {
return (
<motion.div
aria-hidden="true"
data-slot="context-cursor-indicator"
style={{
x,
y,
opacity,
scale,
willChange: "transform, opacity",
}}
className={cn(
// Anchored to the pointer coordinate; the badge inside is centered on it
// so it reads as the cursor itself, and the scale pops from that point.
"pointer-events-none absolute left-0 top-0 z-50 origin-center will-change-transform",
!cursor && "invisible",
cursorClassName,
)}
>
<span
data-slot="context-cursor-label"
className={cn(
"inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-medium leading-5 shadow-sm",
variantClassNames[variant],
)}
>
{cursor?.icon ? (
<span
data-slot="context-cursor-icon"
className="flex size-3.5 items-center justify-center"
>
{cursor.icon}
</span>
) : null}
{cursor?.label}
</span>
</motion.div>
);
}
type SpringCursorIndicatorProps = Omit<
ContextCursorIndicatorProps,
"x" | "y"
> & {
rawX: MotionValue<number>;
rawY: MotionValue<number>;
spring: SpringOptions;
};
function SpringCursorIndicator({
rawX,
rawY,
spring,
...props
}: SpringCursorIndicatorProps) {
const x = useSpring(rawX, spring);
const y = useSpring(rawY, spring);
return <ContextCursorIndicator {...props} x={x} y={y} />;
}
function useFinePointer() {
const [supportsFinePointer, setSupportsFinePointer] = React.useState(false);
React.useEffect(() => {
const mediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)");
const updateSupportsFinePointer = () => {
setSupportsFinePointer(mediaQuery.matches);
};
updateSupportsFinePointer();
mediaQuery.addEventListener("change", updateSupportsFinePointer);
return () => {
mediaQuery.removeEventListener("change", updateSupportsFinePointer);
};
}, []);
return supportsFinePointer;
}
function acquireNativeCursorLock(element: HTMLElement | null) {
if (!element) return;
ensureNativeCursorStyle();
element.dataset.contextCursorNativeHidden = "true";
}
function releaseNativeCursorLock(element: HTMLElement | null) {
if (!element) return;
delete element.dataset.contextCursorNativeHidden;
}
function ensureNativeCursorStyle() {
if (document.getElementById(nativeCursorStyleId)) return;
const style = document.createElement("style");
style.id = nativeCursorStyleId;
style.textContent = `
[data-context-cursor-native-hidden="true"],
[data-context-cursor-native-hidden="true"] * {
cursor: none !important;
}
`;
document.head.appendChild(style);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function smoothstep(value: number) {
return value * value * (3 - 2 * value);
}
function interpolate(from: number, to: number, progress: number) {
return from + (to - from) * progress;
}
function snapToDevicePixel(value: number) {
const ratio = window.devicePixelRatio || 1;
return Math.round(value * ratio) / ratio;
}
export type ContextCursorTargetProps =
React.ComponentPropsWithoutRef<"div"> & {
label: React.ReactNode;
icon?: React.ReactNode;
variant?: ContextCursorVariant;
animation?: ContextCursorTargetAnimation;
};
export function ContextCursorTarget({
children,
className,
label,
icon,
variant = "default",
animation,
onPointerEnter,
onPointerLeave,
...props
}: ContextCursorTargetProps) {
const context = React.useContext(ContextCursorContext);
const targetId = React.useId();
return (
<div
data-slot="context-cursor-target"
className={className}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") {
context?.showCursor(
{ label, icon, variant },
targetId,
{
x: event.clientX,
y: event.clientY,
},
event.currentTarget.getBoundingClientRect(),
animation,
);
}
onPointerEnter?.(event);
}}
onPointerLeave={(event) => {
context?.hideCursor(targetId, {
x: event.clientX,
y: event.clientY,
});
onPointerLeave?.(event);
}}
{...props}
>
{children}
</div>
);
}