React UI
Free installable React building blocks for cursor.js.
CursorPlayer is a free, installable React UI block for cursor.js. It gives you a visible cursor anchor plus composable play, pause, and stop controls while keeping the code fully editable inside your app.
Preview
DEMO UI
<CursorPlayer createCursor={createSignupCursor} buildSequence={buildSignupSequence}> <div className="flex flex-wrap items-center justify-center gap-4"> <div className="inline-flex items-center gap-3 rounded-full border border-border/70 bg-background/80 px-3 py-2"> <CursorPlayer.Cursor className="size-5" /> <span className="text-sm font-medium text-foreground">Show signup flow</span> <CursorPlayer.PlayPause aria-label="Play or pause signup flow" size="icon-sm" variant="ghost" > <CursorPlayer.PlayIcon asChild> <Play /> </CursorPlayer.PlayIcon> <CursorPlayer.PauseIcon asChild> <Pause /> </CursorPlayer.PauseIcon> </CursorPlayer.PlayPause> </div> <CursorPlayer.StopButton className="rounded-full" /> </div></CursorPlayer>Overlay example
In tighter layouts, you can stack PlayPause on top of the waiting cursor and keep it quiet until hover.
OVERLAY DEMO UI
<div className="group relative inline-flex items-center gap-3 rounded-full border border-border/70 bg-background/80 px-4 py-2 shadow-sm transition-colors hover:bg-background"> <div className="relative inline-grid size-8 place-items-center"> <CursorPlayer.Cursor className="size-4" /> <CursorPlayer.PlayPause aria-label="Play or pause signup flow" size="icon-sm" variant="ghost" className="absolute inset-0 z-[1000000] m-auto rounded-full opacity-0 transition-opacity duration-200 group-hover:opacity-80 data-[state=running]:opacity-100 data-[state=paused]:opacity-100" > <CursorPlayer.PlayIcon asChild> <Play /> </CursorPlayer.PlayIcon> <CursorPlayer.PauseIcon asChild> <Pause /> </CursorPlayer.PauseIcon> </CursorPlayer.PlayPause> </div> <span className="text-sm font-medium text-foreground">Show signup flow</span></div>Installation
pnpm dlx shadcn@latest add https://cursorjs.com/r/cursor-player.jsonCopy the registry files into your app:
@components/cursor-player.tsx@hooks/use-cursor-player.ts
'use client';import * as React from 'react';import { Pause, Play, Square } from 'lucide-react';import { Slot } from 'radix-ui';import { Button } from '@/components/ui/button';import { cn } from '@/lib/utils';import { type CursorPlayerRuntime, useCursorPlayer } from './use-cursor-player';interface CursorPlayerRootProps<TCursor extends CursorPlayerRuntime> { createCursor: (anchorElement: HTMLElement) => TCursor; buildSequence: (cursor: TCursor, anchorElement: HTMLElement) => void; onError?: (error: unknown) => void; children: React.ReactNode;}interface CursorPlayerPartProps extends React.HTMLAttributes<HTMLDivElement> { asChild?: boolean;}type CursorPlayerHotspot = 'top-left' | 'center' | { x: number; y: number };interface CursorPlayerCursorProps extends CursorPlayerPartProps { hotspot?: CursorPlayerHotspot;}interface CursorPlayerButtonProps extends React.ComponentProps<typeof Button> { children?: React.ReactNode;}interface CursorPlayerIconProps extends React.HTMLAttributes<HTMLElement> { asChild?: boolean; children?: React.ReactNode;}const CursorPlayerContext = React.createContext<CursorPlayerContextValue | null>(null);function useCursorPlayerContext() { const value = React.useContext(CursorPlayerContext); if (!value) { throw new Error('CursorPlayer components must be used inside <CursorPlayer>.'); } return value;}function isPlayVisible(state: CursorPlayerContextValue['state']) { return state !== 'running';}function isPauseVisible(state: CursorPlayerContextValue['state']) { return state === 'running';}type CursorPlayerContextValue = ReturnType<typeof useCursorPlayer>;type CursorPlayerRootComponent = <TCursor extends CursorPlayerRuntime>( props: CursorPlayerRootProps<TCursor>,) => React.ReactElement;const CursorPlayerRoot: CursorPlayerRootComponent = ({ createCursor, buildSequence, onError, children,}) => { const controls = useCursorPlayer({ createCursor, buildSequence, onError }); return <CursorPlayerContext.Provider value={controls}>{children}</CursorPlayerContext.Provider>;};function getHotspotPosition(hotspot: CursorPlayerHotspot | undefined): { left: string; top: string;} { if (hotspot === 'center') { return { left: '50%', top: '50%' }; } if (hotspot && typeof hotspot === 'object') { return { left: `${hotspot.x}px`, top: `${hotspot.y}px` }; } return { left: '0px', top: '0px' };}function CursorPlayerCursor({ className, asChild = false, children, hotspot = 'top-left', ...props}: CursorPlayerCursorProps) { const { setAnchorElement, setAnchorFrameElement, setPreviewElement } = useCursorPlayerContext(); const Comp = asChild ? Slot.Root : 'span'; const hotspotPosition = getHotspotPosition(hotspot); return ( <Comp ref={setAnchorFrameElement} className={cn('relative inline-flex size-4', className)} aria-hidden="true" {...props} > <span ref={setAnchorElement} className="absolute h-px w-px" style={{ left: hotspotPosition.left, top: hotspotPosition.top, transform: hotspot === 'center' ? 'translate(-50%, -50%)' : undefined, }} /> <span ref={setPreviewElement} className="pointer-events-none absolute" style={{ left: hotspotPosition.left, top: hotspotPosition.top, transform: hotspot === 'center' ? 'translate(-50%, -50%)' : undefined, }} /> {children} </Comp> );}function CursorPlayerPlayPause({ onClick, ...props }: CursorPlayerButtonProps) { const { state, start, pause } = useCursorPlayerContext(); return ( <Button type="button" variant="outline" data-state={state} onClick={(event) => { onClick?.(event); if (event.defaultPrevented) { return; } if (state === 'running') { pause(); return; } void start(); }} {...props} > {props.children} </Button> );}function CursorPlayerPlayIcon({ className, children, asChild = false, ...props}: CursorPlayerIconProps) { const { state } = useCursorPlayerContext(); const Comp = asChild ? Slot.Root : 'span'; if (!isPlayVisible(state)) { return null; } const content = children ?? <Play className="size-4" />; return ( <Comp className={cn('pointer-events-none inline-flex items-center justify-center', className)} aria-hidden="true" {...props} > {content} </Comp> );}function CursorPlayerPauseIcon({ className, children, asChild = false, ...props}: CursorPlayerIconProps) { const { state } = useCursorPlayerContext(); const Comp = asChild ? Slot.Root : 'span'; if (!isPauseVisible(state)) { return null; } const content = children ?? <Pause className="size-4" />; return ( <Comp className={cn('pointer-events-none inline-flex items-center justify-center', className)} aria-hidden="true" {...props} > {content} </Comp> );}function CursorPlayerStopButton({ className, children, asChild = false, onClick, ...props}: CursorPlayerButtonProps) { const { state, stop, canStop } = useCursorPlayerContext(); const content = children ?? ( <> <Square className="size-4" /> Stop </> ); return ( <Button type="button" variant="ghost" className={className} asChild={asChild} data-state={state} onClick={(event) => { onClick?.(event); if (event.defaultPrevented) { return; } stop(); }} disabled={!canStop} {...props} > {content} </Button> );}function CursorPlayerStatus({ children,}: { children: (controls: CursorPlayerContextValue) => React.ReactNode;}) { const controls = useCursorPlayerContext(); return <>{children(controls)}</>;}export const CursorPlayer = Object.assign(CursorPlayerRoot, { Cursor: CursorPlayerCursor, PlayPause: CursorPlayerPlayPause, PlayIcon: CursorPlayerPlayIcon, PauseIcon: CursorPlayerPauseIcon, StopButton: CursorPlayerStopButton, Status: CursorPlayerStatus,});"use client";import { useCallback, useEffect, useRef, useState } from "react";export type CursorPlayerState = "idle" | "running" | "paused" | "complete" | "error";export interface CursorPlayerRuntime extends PromiseLike<void> { cursor: { el: HTMLElement; x: number; y: number; scale: number; setSize: (scale: number) => void; moveTo: (pageX: number, pageY: number) => void; }; on(event: string, callback: () => void): this; off(event: string, callback: () => void): this; pause(): this; play(): this; destroy(): void;}export type CursorPlayerInstance = CursorPlayerRuntime;export interface UseCursorPlayerOptions<TCursor extends CursorPlayerRuntime> { createCursor: (anchorElement: HTMLElement) => TCursor; buildSequence: (cursor: TCursor, anchorElement: HTMLElement) => void; onError?: (error: unknown) => void;}export interface CursorPlayerControls { state: CursorPlayerState; canStart: boolean; canPause: boolean; canStop: boolean; start: () => Promise<void>; pause: () => void; stop: () => void; setAnchorElement: (element: HTMLElement | null) => void; setAnchorFrameElement: (element: HTMLElement | null) => void; setPreviewElement: (element: HTMLElement | null) => void;}interface CursorBinding<TCursor extends CursorPlayerRuntime> { cursor: TCursor; anchorElement: HTMLElement; anchorFrameElement: HTMLElement; activeScale: number; lastAnchorPosition: { x: number; y: number } | null; previewCursorElement: HTMLElement | null; onPause: () => void; onPlay: () => void; onDestroy: () => void;}function resolveVisualElement(cursorElement: HTMLElement) { return (cursorElement.querySelector(".cursor-theme-wrapper") as HTMLElement | null) ?? cursorElement;}function parseNumericAttribute(value: string | null) { if (!value) { return null; } const parsed = Number.parseFloat(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : null;}function resolveVisualBaseSize(visualElement: HTMLElement) { const svgElement = visualElement.querySelector("svg"); if (svgElement instanceof SVGSVGElement) { const viewBox = svgElement.viewBox.baseVal; if (viewBox && viewBox.width > 0 && viewBox.height > 0) { return { width: viewBox.width, height: viewBox.height, }; } const width = parseNumericAttribute(svgElement.getAttribute("width")); const height = parseNumericAttribute(svgElement.getAttribute("height")); if (width && height) { return { width, height }; } } const width = visualElement.offsetWidth; const height = visualElement.offsetHeight; if (width > 0 && height > 0) { return { width, height }; } return null;}function syncCursorScale(cursor: CursorPlayerRuntime, anchorFrameElement: HTMLElement) { const anchorRect = anchorFrameElement.getBoundingClientRect(); const visualElement = resolveVisualElement(cursor.cursor.el); const visualBaseSize = resolveVisualBaseSize(visualElement); if ( anchorRect.width <= 0 || anchorRect.height <= 0 || !visualBaseSize ) { return false; } const scale = Math.min( anchorRect.width / visualBaseSize.width, anchorRect.height / visualBaseSize.height, ); cursor.cursor.setSize(scale); return scale;}function restoreCursorScale(cursor: CursorPlayerRuntime, scale: number) { cursor.cursor.setSize(scale);}function setCursorVisibility(cursor: CursorPlayerRuntime, isVisible: boolean) { cursor.cursor.el.style.visibility = isVisible ? "visible" : "hidden"; cursor.cursor.el.style.opacity = isVisible ? "1" : "0";}function createPreviewCursorElement(cursor: CursorPlayerRuntime) { const previewCursorElement = cursor.cursor.el.cloneNode(true) as HTMLElement; previewCursorElement.style.position = "absolute"; previewCursorElement.style.top = "0"; previewCursorElement.style.left = "0"; previewCursorElement.style.visibility = "visible"; previewCursorElement.style.opacity = "1"; previewCursorElement.style.pointerEvents = "none"; previewCursorElement.style.zIndex = "0"; previewCursorElement.style.transition = "none"; previewCursorElement.style.transform = `scale(${cursor.cursor.scale})`; return previewCursorElement;}function syncPreviewCursorScale(previewCursorElement: HTMLElement | null, scale: number | false) { if (!previewCursorElement || scale === false) { return; } previewCursorElement.style.transform = `scale(${scale})`;}function resolveAnchorPosition(anchorElement: HTMLElement) { const rect = anchorElement.getBoundingClientRect(); return { x: rect.left + window.scrollX + rect.width / 2, y: rect.top + window.scrollY + rect.height / 2, };}function syncCursorPosition(binding: CursorBinding<CursorPlayerRuntime>) { const nextAnchorPosition = resolveAnchorPosition(binding.anchorElement); const previousAnchorPosition = binding.lastAnchorPosition; binding.lastAnchorPosition = nextAnchorPosition; if (!previousAnchorPosition) { return false; } const deltaX = nextAnchorPosition.x - previousAnchorPosition.x; const deltaY = nextAnchorPosition.y - previousAnchorPosition.y; if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) { return false; } binding.cursor.cursor.moveTo(binding.cursor.cursor.x + deltaX, binding.cursor.cursor.y + deltaY); return true;}function isSameBinding<TCursor extends CursorPlayerRuntime>( currentBinding: CursorBinding<TCursor> | null, binding: CursorBinding<TCursor>,) { return currentBinding?.cursor === binding.cursor;}function scheduleScaleSync( syncScale: () => number | false, isActive: () => boolean, attempts = 3,) { const run = (remaining: number) => { if (!isActive()) { return; } const didSync = syncScale(); if (typeof window === "undefined" || !window.requestAnimationFrame) { return; } if (!didSync && remaining > 0) { window.requestAnimationFrame(() => run(remaining - 1)); } }; run(attempts);}export function useCursorPlayer<TCursor extends CursorPlayerRuntime>({ createCursor, buildSequence, onError,}: UseCursorPlayerOptions<TCursor>): CursorPlayerControls { const [state, setState] = useState<CursorPlayerState>("idle"); const stateRef = useRef<CursorPlayerState>("idle"); const anchorElementRef = useRef<HTMLElement | null>(null); const anchorFrameElementRef = useRef<HTMLElement | null>(null); const previewElementRef = useRef<HTMLElement | null>(null); const bindingRef = useRef<CursorBinding<TCursor> | null>(null); const runIdRef = useRef(0); const resizeObserverRef = useRef<ResizeObserver | null>(null); const animationFrameRef = useRef<number | null>(null); const setPlayerState = useCallback((nextState: CursorPlayerState) => { stateRef.current = nextState; setState(nextState); }, []); const canApplyPreviewScale = useCallback(() => { const currentState = stateRef.current; return currentState === "idle" || currentState === "complete" || currentState === "error"; }, []); const clearBinding = (destroyCursor: boolean) => { const binding = bindingRef.current; if (!binding) return; binding.cursor.off("pause", binding.onPause); binding.cursor.off("play", binding.onPlay); binding.cursor.off("destroy", binding.onDestroy); if (destroyCursor) { binding.cursor.destroy(); } binding.previewCursorElement?.remove(); bindingRef.current = null; }; const cleanupObservers = () => { resizeObserverRef.current?.disconnect(); resizeObserverRef.current = null; if (animationFrameRef.current !== null && typeof window !== "undefined" && window.cancelAnimationFrame) { window.cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }; const initializeCursor = useCallback((nextAnchorElement: HTMLElement, nextAnchorFrameElement: HTMLElement) => { anchorElementRef.current = nextAnchorElement; anchorFrameElementRef.current = nextAnchorFrameElement; runIdRef.current += 1; const runId = runIdRef.current; cleanupObservers(); clearBinding(true); const cursor = createCursor(nextAnchorElement); const onPause = () => setPlayerState("paused"); const onPlay = () => setPlayerState("running"); const onDestroy = () => { if (runIdRef.current === runId) { setPlayerState("idle"); } }; cursor.on("pause", onPause); cursor.on("play", onPlay); cursor.on("destroy", onDestroy); bindingRef.current = { cursor, anchorElement: nextAnchorElement, anchorFrameElement: nextAnchorFrameElement, activeScale: cursor.cursor.scale, lastAnchorPosition: resolveAnchorPosition(nextAnchorElement), previewCursorElement: null, onPause, onPlay, onDestroy, }; const previewElement = previewElementRef.current; if (previewElement) { const previewCursorElement = createPreviewCursorElement(cursor); previewElement.replaceChildren(previewCursorElement); bindingRef.current.previewCursorElement = previewCursorElement; } const syncScale = () => { const scale = syncCursorScale(cursor, nextAnchorFrameElement); if (bindingRef.current?.cursor === cursor) { syncPreviewCursorScale(bindingRef.current.previewCursorElement, scale); } return scale; }; scheduleScaleSync( syncScale, () => bindingRef.current?.cursor === cursor && canApplyPreviewScale(), ); syncScale(); if (typeof ResizeObserver !== "undefined") { const observer = new ResizeObserver(() => { if (bindingRef.current?.cursor === cursor && canApplyPreviewScale()) { syncScale(); } }); observer.observe(nextAnchorFrameElement); resizeObserverRef.current = observer; } if (typeof window !== "undefined" && window.requestAnimationFrame) { const trackAnchorPosition = () => { const binding = bindingRef.current; if (!binding || binding.cursor !== cursor) { animationFrameRef.current = null; return; } syncCursorPosition(binding); animationFrameRef.current = window.requestAnimationFrame(trackAnchorPosition); }; animationFrameRef.current = window.requestAnimationFrame(trackAnchorPosition); } setCursorVisibility(cursor, false); setPlayerState("idle"); }, [canApplyPreviewScale, createCursor, setPlayerState]); useEffect(() => { return () => { cleanupObservers(); clearBinding(true); }; }, []); const syncBinding = useCallback(() => { const anchorElement = anchorElementRef.current; const anchorFrameElement = anchorFrameElementRef.current; if (!anchorElement || !anchorFrameElement) { return; } const currentBinding = bindingRef.current; if ( currentBinding && currentBinding.anchorElement === anchorElement && currentBinding.anchorFrameElement === anchorFrameElement ) { if (canApplyPreviewScale()) { const scale = syncCursorScale(currentBinding.cursor, anchorFrameElement); syncPreviewCursorScale(currentBinding.previewCursorElement, scale); } return; } initializeCursor(anchorElement, anchorFrameElement); }, [canApplyPreviewScale, initializeCursor]); const setAnchorElement = useCallback((element: HTMLElement | null) => { if (!element) { anchorElementRef.current = null; cleanupObservers(); clearBinding(true); return; } anchorElementRef.current = element; syncBinding(); }, [syncBinding]); const setAnchorFrameElement = useCallback((element: HTMLElement | null) => { if (!element) { anchorFrameElementRef.current = null; cleanupObservers(); clearBinding(true); return; } anchorFrameElementRef.current = element; syncBinding(); }, [syncBinding]); const setPreviewElement = useCallback((element: HTMLElement | null) => { previewElementRef.current = element; if (!element) { return; } const binding = bindingRef.current; if (!binding) { element.replaceChildren(); return; } const previewCursorElement = createPreviewCursorElement(binding.cursor); element.replaceChildren(previewCursorElement); binding.previewCursorElement = previewCursorElement; if (canApplyPreviewScale()) { const scale = syncCursorScale(binding.cursor, binding.anchorFrameElement); syncPreviewCursorScale(previewCursorElement, scale); setCursorVisibility(binding.cursor, false); } }, [canApplyPreviewScale]); const pause = () => { if (state !== "running") return; bindingRef.current?.cursor.pause(); }; const stop = () => { const binding = bindingRef.current; if (!binding) return; initializeCursor(binding.anchorElement, binding.anchorFrameElement); }; const start = async () => { const binding = bindingRef.current; if (!binding) return; if (state === "running") return; if (state === "paused") { binding.cursor.play(); return; } try { setPlayerState("running"); setCursorVisibility(binding.cursor, true); restoreCursorScale(binding.cursor, binding.activeScale); buildSequence(binding.cursor, binding.anchorElement); await binding.cursor; if (isSameBinding(bindingRef.current, binding)) { initializeCursor(binding.anchorElement, binding.anchorFrameElement); } } catch (error) { if (isSameBinding(bindingRef.current, binding)) { clearBinding(true); setPlayerState("error"); initializeCursor(binding.anchorElement, binding.anchorFrameElement); } onError?.(error); } }; return { state, canStart: state !== "running", canPause: state === "running", canStop: state === "running" || state === "paused", start, pause, stop, setAnchorElement, setAnchorFrameElement, setPreviewElement, };}Install the peer dependencies used by the block:
pnpm add @cursor.js/core lucide-reactMake sure your app already has shadcn button or adjust the import paths to match your project structure.
Usage
import { Cursor } from '@cursor.js/core';
import { Pause, Play } from 'lucide-react';
import { CursorPlayer } from '@/components/cursor-player';
function createSignupCursor(anchorElement: HTMLElement) {
const cursor = new Cursor({
humanize: true,
speed: 0.7,
startPoint: anchorElement,
});
return cursor;
}
function buildSignupSequence(cursor: Cursor) {
cursor
.hover('#email')
.type('#email', 'hello@cursorjs.com', { delay: 50 })
.click('#join')
.wait(350);
}
export function DemoLauncher() {
return (
<CursorPlayer createCursor={createSignupCursor} buildSequence={buildSignupSequence}>
<CursorPlayer.Cursor className="size-5" />
<CursorPlayer.PlayPause>
<CursorPlayer.PlayIcon asChild>
<Play className="size-4 fill-current" />
</CursorPlayer.PlayIcon>
<CursorPlayer.PauseIcon asChild>
<Pause className="size-4 fill-current" />
</CursorPlayer.PauseIcon>
</CursorPlayer.PlayPause>
<CursorPlayer.StopButton />
</CursorPlayer>
);
}CursorPlayer.Cursor uses its real rendered size as the preview frame. If you change it with classes like size-5 or w-6 h-6, the waiting cursor is scaled to fit that box automatically.
Composition
Use the following composition to build a CursorPlayer:
CursorPlayer
|- CursorPlayer.Cursor
|- CursorPlayer.PlayPause
| |- CursorPlayer.PlayIcon
| `- CursorPlayer.PauseIcon
`- CursorPlayer.StopButtonWhen a run completes, CursorPlayer restores its waiting preview at the anchor automatically. In most cases you should not manually move back to anchorElement at the end of the sequence.
Behavior patterns
The end of a cursor.js demo should usually be modeled in the sequence itself instead of a separate global endBehavior option.
- Stop chaining actions if the demo should finish where it ended.
- Move back to a known anchor and call
.do(buildDemoSequence)if it should loop.
function buildDemoSequence(cursor: Cursor) {
cursor
.hover('#email')
.type('#email', 'hello@cursorjs.com')
.click('#join')
.wait(350);
}Included
The block includes:
CursorPlayerCursorPlayer.CursorCursorPlayer.PlayPauseCursorPlayer.PlayIconCursorPlayer.PauseIconCursorPlayer.StopButtonuseCursorPlayer