reelcn
GitHubBack to landing

Cursor

Pointer that glides between waypoints over whatever is behind it, pressing down and rippling on a click.

Cursor, click-path
Cursor, click-path thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/cursor.json

Files land in src/reelcn/ with the items it builds on. Dependencies: remotion.

Usage

<AbsoluteFill>
  <BrowserWindow url="app.dev">
    <Dashboard />
  </BrowserWindow>
  <Cursor
    waypoints={[
      { x: 20, y: 30, frame: 0 },
      { x: 62, y: 48, frame: 30, click: true },
      { x: 62, y: 70, frame: 60 },
    ]}
  />
</AbsoluteFill>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames. Default false.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
waypoints*CursorWaypoint[]
target{ anchors: Record<string, AnchorRect>; id: string; frame?: number; click?: boolean; }A kit anchor as one more destination, appended after waypoints, equivalent to a literal waypoint, not a replacement for one (§2's anchor proof: a cursor pointed at a kit anchor renders pixel-identical to the same cursor pointed at the anchor's own literal `%` rect). Arrives frame frames after the last waypoint (default 20) and clicks, unless overridden.
sizenumberCursor size in design units. Default 34.
colorstring
ringColorstringRipple ring color. Defaults to the theme accent.
styleCSSProperties
classNamestring

Built from

Use

  • Demonstrating a click path through a UI mockup, dashboard or website
  • Drawing the eye to a button just before a `toast` or `callout` appears

Avoid

  • A static pointer aimed at one target. Use arrow

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/cursor.tsx
/**
 * @title Cursor
 * @category product
 * @description Pointer that glides between waypoints over whatever is behind it, pressing down and rippling on a click.
 * @duration data-driven
 * @use Demonstrating a click path through a UI mockup, dashboard or website
 * @use Drawing the eye to a button just before a `toast` or `callout` appears
 * @avoid A static pointer aimed at one target. Use `arrow`
 * @tags cursor, pointer, mouse, click, demo, walkthrough
 * @example
 * <AbsoluteFill>
 *   <BrowserWindow url="app.dev">
 *     <Dashboard />
 *   </BrowserWindow>
 *   <Cursor
 *     waypoints={[
 *       { x: 20, y: 30, frame: 0 },
 *       { x: 62, y: 48, frame: 30, click: true },
 *       { x: 62, y: 70, frame: 60 },
 *     ]}
 *   />
 * </AbsoluteFill>
 */
import type React from "react";
import { AbsoluteFill } from "remotion";
import {
  type AnchorRect,
  alpha,
  type MotionProps,
  type PoseKey,
  requireAnchor,
  tween,
  useKeyframePath,
  useMotion,
  useTheme,
  useViewport,
} from "./core";

export type CursorWaypoint = {
  /** Position in % of the canvas (0–100 on each axis). */
  x: number;
  y: number;
  /** Frame the cursor arrives here, counted from `delay`. */
  frame: number;
  /** Presses the cursor down and rings a ripple as it arrives. */
  click?: boolean;
};

/**
 * Timing comes from `waypoints`. Of the motion props, `delay` shifts every waypoint and `motion` picks the easing
 * between them; `exit` is off by default so the cursor holds its last position.
 */
export type CursorProps = MotionProps & {
  waypoints: CursorWaypoint[];
  /**
   * A kit anchor as one more destination, appended after `waypoints`, equivalent to a literal waypoint,
   * not a replacement for one (§2's anchor proof: a cursor pointed at a kit anchor renders pixel-identical
   * to the same cursor pointed at the anchor's own literal `%` rect). Arrives `frame` frames after the
   * last waypoint (default 20) and clicks, unless overridden.
   */
  target?: { anchors: Record<string, AnchorRect>; id: string; frame?: number; click?: boolean };
  /** Cursor size in design units. */
  size?: number;
  color?: string;
  /** Ripple ring color. Defaults to the theme accent. */
  ringColor?: string;
  style?: React.CSSProperties;
  className?: string;
};

const clamp01 = (value: number) => Math.min(Math.max(value, 0), 1);

// A plain arrow pointer, tip at the origin, no OS or brand cursor glyph.
const POINTER = "M0 0 L0 15.5 L3.6 12.1 L6.1 18.3 L8.6 17.3 L6.2 11.2 L11 11.2 Z";

export function Cursor({
  waypoints,
  target,
  size = 34,
  color,
  ringColor,
  exit = false,
  style,
  className,
  ...motion
}: CursorProps) {
  const theme = useTheme();
  const { u } = useViewport();
  const m = useMotion({ ...motion, exit });
  const fill = color ?? theme.colors.foreground;
  const ring = ringColor ?? theme.colors.accent;
  // `target` resolves to one literal waypoint, appended after `waypoints`, computed independently from
  // the same `anchors` map a kit component's own render already produced this frame; no publishing.
  // Throws on a missing id (ponytail-review should-fix 4: the same `requireAnchor` contract every other
  // `target` consumer uses, a silent no-op here would hide a typo instead of surfacing it).
  const resolved = target ? requireAnchor(target.anchors, target.id, "Cursor") : undefined;
  const withTarget: CursorWaypoint[] = resolved
    ? waypoints.concat([
        {
          x: resolved.x + resolved.width / 2,
          y: resolved.y + resolved.height / 2,
          frame: target?.frame ?? (waypoints.length > 0 ? waypoints[waypoints.length - 1].frame : 0) + 20,
          click: target?.click ?? true,
        },
      ])
    : waypoints;
  const points = withTarget.slice().sort((a, b) => a.frame - b.frame);

  const keys: PoseKey[] = points.map((p) => ({ frame: m.delay + p.frame, x: p.x, y: p.y }));
  const path = useKeyframePath(keys, { motion: m.preset });
  const x = points.length > 0 ? path.x : 50;
  const y = points.length > 0 ? path.y : 50;

  const appear =
    points.length > 0
      ? tween(m.frame, m.fps, { from: m.delay + points[0].frame, duration: Math.round(m.fps * 0.2), motion: "snappy" })
      : 1;
  const pressWindow = Math.round(m.fps * 0.18);
  let press = 0;
  for (const point of points) {
    if (!point.click) continue;
    const t = m.frame - (m.delay + point.frame);
    press = Math.max(press, clamp01(1 - Math.abs(t) / pressWindow));
  }

  const pointerSize = u(size);
  return (
    <AbsoluteFill
      className={className}
      style={{ pointerEvents: "none", opacity: clamp01(appear) * (1 - m.exit), ...style }}
    >
      {points
        .filter((point) => point.click)
        .map((point, index) => {
          const rippleFrom = m.delay + point.frame;
          const p = tween(m.frame, m.fps, { from: rippleFrom, duration: Math.round(m.fps * 0.5), motion: "smooth" });
          if (m.frame < rippleFrom || p >= 1) return null;
          const ringSize = u(60) * (0.3 + p * 1.4);
          return (
            <div
              key={index}
              style={{
                position: "absolute",
                left: `${point.x}%`,
                top: `${point.y}%`,
                width: ringSize,
                height: ringSize,
                translate: "-50% -50%",
                borderRadius: "50%",
                border: `${u(2)}px solid ${alpha(ring, 1 - p)}`,
                background: alpha(ring, (1 - p) * 0.12),
              }}
            />
          );
        })}
      <div
        style={{
          position: "absolute",
          left: `${x}%`,
          top: `${y}%`,
          translate: "-8% -6%",
          scale: String(1 - 0.18 * press),
          filter: `drop-shadow(0 ${u(3)}px ${u(6)}px ${alpha("#000000", 0.4)})`,
        }}
      >
        <svg aria-hidden="true" width={pointerSize} height={pointerSize * 1.18} viewBox="0 0 12 18.3">
          <path d={POINTER} fill={fill} stroke={alpha("#000000", 0.5)} strokeWidth={0.6} strokeLinejoin="round" />
        </svg>
      </div>
    </AbsoluteFill>
  );
}

On this page