Cursor
An animated cursor that follows a scripted path with click ripple and press effects
A motion atom that renders an animated cursor as a pure function of the CursorStyle it receives. The cursor reads no frame itself — the single time source is useCursorPath on the caller's side, which folds a path of waypoints into a resolved position, ripple, and press-dip visual. This is the value-channel variant of the primitive pattern: instead of a string-state timeline it drives numeric position channels directly.
$ pnpm dlx shadcn@latest add @remocn/cursorWaypoints
Instead of a string-state timeline, useCursorPath takes an array of CursorWaypoint objects that describe a scripted path:
interface CursorWaypoint {
at: number; // LOCAL (Sequence-relative) authored frame the cursor finishes arriving here
x: number; // Tip X in px, relative to the absolute wrapper's parent
y: number; // Tip Y in px, relative to the absolute wrapper's parent
duration?: number; // Frames the move INTO this waypoint takes. Omitted → DEFAULT_DURATION (24)
click?: boolean; // Fire a click on arrival: ripple (~16 frames) + brief press dip
press?: boolean; // Hold the pressed look from this waypoint until the next one (drag)
easing?: EasingName; // Override easing for the move into this waypoint. Default "inOut"
}at is the arrival frame — the frame when the cursor finishes its move. The move itself runs over [at - duration, at). Before the first waypoint arrives, the cursor parks at the first waypoint's position.
Numeric-channel deviation
Cursor differs from state atoms (Button, Input, etc.) in one key way: there is no string state union and no useCurrentState snap path. The cursor's visual is always numeric — a position plus ripple and press scalars — so useCursorPath folds the waypoint path directly into a CursorStyle value, analogous to how useButtonTransition returns a ButtonStyle. The snap equivalent is simply parking the cursor at a fixed position without calling the hook.
Snap usage
Park the cursor at a fixed position without the hook:
import { Cursor } from "@/components/remocn/cursor";
export const Scene = () => (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<Cursor
style={{ x: 200, y: 150, scale: 1, rippleOpacity: 0, rippleScale: 0 }}
variant="pointer"
/>
</div>
);Smooth path usage
Drive the cursor along a scripted path with useCursorPath:
import { Cursor } from "@/components/remocn/cursor";
import { useCursorPath } from "@/components/remocn/use-cursor-path";
export const Scene = () => {
const style = useCursorPath([
{ at: 0, x: 80, y: 60 },
{ at: 40, x: 280, y: 160, duration: 28 },
{ at: 72, x: 280, y: 160, click: true, duration: 0 },
]);
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<Cursor style={style} variant="pointer" />
</div>
);
};Each at is a Sequence-local authored frame. The cursor eases from each waypoint's position to the next. A click: true waypoint fires a 16-frame ripple and a brief press dip starting at its at frame.
Frame-syncing with other components
The power of the waypoint model is composing the cursor timeline with the state timelines of other components. When a click waypoint fires at frame 72, schedule a Button's press step to start a few frames earlier (around 68) so the button begins pressing just before the click lands:
import { Cursor } from "@/components/remocn/cursor";
import { useCursorPath } from "@/components/remocn/use-cursor-path";
import { Button } from "@/components/remocn/button";
import { useButtonTransition } from "@/components/remocn/use-button-transition";
export const Scene = () => {
const cursorStyle = useCursorPath([
{ at: 0, x: 80, y: 60 },
{ at: 40, x: 280, y: 160, duration: 28 },
{ at: 72, x: 280, y: 160, click: true, duration: 0 },
]);
const buttonStyle = useButtonTransition([
{ at: 40, state: "hover", duration: 16 },
{ at: 68, state: "press", duration: 8 },
{ at: 76, state: "loading", duration: 6 },
{ at: 108, state: "success", duration: 16 },
]);
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<div style={{ position: "absolute", left: "50%", top: "50%", transform: "translate(-50%, -50%)" }}>
<Button label="Continue" style={buttonStyle} />
</div>
<Cursor style={cursorStyle} variant="pointer" />
</div>
);
};Drag / held press
Set press: true on a waypoint to hold the pressed look from that waypoint's arrival until the next waypoint arrives. Useful for simulating a drag gesture:
const style = useCursorPath([
{ at: 0, x: 100, y: 100 },
{ at: 30, x: 200, y: 100, press: true }, // hold press from here…
{ at: 60, x: 300, y: 100 }, // …until cursor reaches here
]);Props7
| Prop | Type | Default |
|---|---|---|
| style | CursorStyle— Resolved animated visual. Feed it an interpolated CursorStyle from useCursorPath. When omitted the cursor parks at origin with no ripple. | |
| variant | "arrow" | "pointer""arrow" macOS arrow cursor or hand pointer. Static choice — not animated between waypoints. | |
| size | number28 Rendered cursor height in px. The SVG scales uniformly to this height. | |
| rippleColor | string— Color of the click ripple ring. Defaults to theme.primary. | |
| theme | Partial<RemocnTheme>— Per-component theme override. Must be concrete oklch/hex/rgb values — not CSS custom properties. | |
| mode | "light" | "dark""light" Sets the base palette. Overrides the mode on RemocnUIProvider when both are present. | |
| className | string— Optional className applied to the absolute wrapper div. | |
- styleCursorStyle
Resolved animated visual. Feed it an interpolated CursorStyle from useCursorPath. When omitted the cursor parks at origin with no ripple.
- variant"arrow" | "pointer"
macOS arrow cursor or hand pointer. Static choice — not animated between waypoints.
Default
"arrow" - sizenumber
Rendered cursor height in px. The SVG scales uniformly to this height.
Default
28 - rippleColorstring
Color of the click ripple ring. Defaults to theme.primary.
- themePartial<RemocnTheme>
Per-component theme override. Must be concrete oklch/hex/rgb values — not CSS custom properties.
- mode"light" | "dark"
Sets the base palette. Overrides the mode on RemocnUIProvider when both are present.
Default
"light" - classNamestring
Optional className applied to the absolute wrapper div.