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: 260,
damping: 28,
};
const scaleSpring: SpringOptions = {
mass: 0.1,
stiffness: 300,
damping: 24,
};
const nativeCursorStyleId = "context-cursor-native-hidden";
const defaultEdgeFadeDistance = 56;
const defaultCursorHideDelay = 120;
const defaultHiddenOpacity = 0;
const defaultVisibleOpacity = 1;
const defaultHiddenScale = 0.96;
const defaultVisibleScale = 1;
let nativeCursorLockCount = 0;
const variantClassNames: Record<ContextCursorVariant, string> = {
default: "border-border bg-background text-foreground shadow-sm",
open: "border-transparent bg-primary text-primary-foreground shadow-sm",
drag: "border-border bg-muted text-foreground shadow-sm",
preview: "border-border bg-background text-foreground shadow-sm",
};
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 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();
}, []);
const showNativeCursor = React.useCallback(() => {
if (!hasNativeCursorLock.current) return;
hasNativeCursorLock.current = false;
releaseNativeCursorLock();
}, []);
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],
);
const updateCursorPresence = React.useCallback(
(point: CursorPoint, currentBounds: DOMRectReadOnly) => {
const currentAnimation = getAnimation();
const distanceToEdge = Math.min(
point.x - currentBounds.left,
currentBounds.right - point.x,
point.y - currentBounds.top,
currentBounds.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;
const nextOpacity = interpolate(
currentAnimation.hiddenOpacity,
currentAnimation.visibleOpacity,
easedProgress,
);
const nextScale = interpolate(
currentAnimation.hiddenScale,
currentAnimation.visibleScale,
easedProgress,
);
opacity.set(nextOpacity);
scale.set(nextScale);
},
[getAnimation, opacity, scale],
);
const getWrapperBounds = React.useCallback(() => {
const currentBounds =
wrapperRef.current?.getBoundingClientRect() ?? bounds.current ?? null;
if (currentBounds) {
bounds.current = currentBounds;
}
return currentBounds;
}, []);
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);
hideNativeCursor();
const currentWrapperBounds = getWrapperBounds();
if (point && currentWrapperBounds) {
rawX.set(Math.round(point.x - currentWrapperBounds.left));
rawY.set(Math.round(point.y - currentWrapperBounds.top));
}
if (point && targetBounds) {
updateCursorPresence(point, targetBounds);
} else {
const currentAnimation = getAnimation(targetAnimation ?? null);
opacity.set(currentAnimation.visibleOpacity);
scale.set(currentAnimation.visibleScale);
}
},
[
hideNativeCursor,
getWrapperBounds,
isDisabled,
opacity,
rawX,
rawY,
scale,
getAnimation,
updateCursorPresence,
],
);
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;
const exitX =
point && currentBounds ? point.x - currentBounds.left : rawX.get();
const exitY =
point && currentBounds ? point.y - currentBounds.top : rawY.get();
rawX.set(Math.round(exitX));
rawY.set(Math.round(exitY));
opacity.set(currentAnimation.hiddenOpacity);
scale.set(currentAnimation.hiddenScale);
showNativeCursor();
hideTimer.current = window.setTimeout(() => {
if (targetIdAtLeave && activeTargetId.current !== targetIdAtLeave) {
return;
}
activeTargetId.current = null;
setCursor(null);
showNativeCursor();
opacity.set(currentAnimation.hiddenOpacity);
scale.set(currentAnimation.hiddenScale);
hideTimer.current = null;
}, currentAnimation.hideDelay);
},
[
getAnimation,
opacity,
rawX,
rawY,
scale,
showNativeCursor,
],
);
React.useEffect(() => {
return () => {
if (hideTimer.current) {
window.clearTimeout(hideTimer.current);
}
showNativeCursor();
};
}, [showNativeCursor]);
React.useEffect(() => {
if (!isDisabled) return;
if (hideTimer.current) {
window.clearTimeout(hideTimer.current);
hideTimer.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();
rawX.set(Math.round(event.clientX - currentBounds.left));
rawY.set(Math.round(event.clientY - currentBounds.top));
const currentTargetBounds = activeTargetBounds.current;
if (currentTargetBounds) {
updateCursorPresence(
{ x: event.clientX, y: event.clientY },
currentTargetBounds,
);
}
},
[isDisabled, rawX, rawY, updateCursorPresence],
);
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,
}}
className={cn(
"pointer-events-none absolute left-0 top-0 z-20 inline-flex -translate-x-1/2 -translate-y-1/2 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-5 will-change-transform",
variantClassNames[variant],
!cursor && "invisible",
cursorClassName,
)}
>
{cursor?.icon ? (
<span
data-slot="context-cursor-icon"
className="flex size-3.5 items-center justify-center"
>
{cursor.icon}
</span>
) : null}
{cursor?.label}
</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() {
nativeCursorLockCount += 1;
ensureNativeCursorStyle();
document.documentElement.dataset.contextCursorNativeHidden = "true";
}
function releaseNativeCursorLock() {
nativeCursorLockCount = Math.max(0, nativeCursorLockCount - 1);
if (nativeCursorLockCount === 0) {
delete document.documentElement.dataset.contextCursorNativeHidden;
}
}
function ensureNativeCursorStyle() {
if (document.getElementById(nativeCursorStyleId)) return;
const style = document.createElement("style");
style.id = nativeCursorStyleId;
style.textContent = `
html[data-context-cursor-native-hidden="true"],
html[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;
}
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={cn(
!context?.isDisabled && "cursor-none [&_*]:cursor-none",
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>
);
}Installation
pnpm dlx shadcn@latest add https://ui.ericts.com/r/context-cursor.json