# Demo Cursor

> An animated demo cursor for React and shadcn, a scripted pointer that glides to real elements and presses them.

A pointer for product demos over live UI, not a video. It glides on a spring along a shallow arc to an element by ref or data-demo name, stays on it through resizes, presses it so the element depresses, drags in a straight line, and wears an optional name pill. Under reduced motion it jumps to each target.

- Tier: Free (MIT)
- Interaction: Press
- Page: https://eased.dev/components/demo-cursor
- Installs: motion
- Registry: https://eased.dev/r/demo-cursor.json

## Install

```bash
npx shadcn add https://eased.dev/r/demo-cursor.json
```

A scripted pointer for product demos that plays over your live UI, built on Motion and the Product demo set's shared timeline (eased-demo-core).

## When to use

- A landing page, docs page or empty state that shows a flow happening in the real UI, not in a video.
- An onboarding hint that walks the pointer to the control that matters, then presses it.
- A launch clip recorded from the page itself: the cursor stays sharp at any zoom and follows the layout.
- Multiplayer-style presence in a mock, with a name pill beside the pointer.

## When not to use

- A guided tour the person steps through themselves, with Back and Next: use [Spotlight Tour](/components/spotlight-tour).
- Pointing at one control with words: use a [Tooltip](/components/tooltip) or a [Popover](/components/popover).
- Anything the person is meant to operate while the cursor plays: make the stage `inert`, so the real pointer and the script never fight.

## Usage

```tsx example.tsx
import { useRef } from "react";
import { useDemoTimeline } from "@/components/eased/_primitives/demo-core/demo-core";
import { DemoCursor } from "@/components/eased/demo-cursor/demo-cursor";
import { Button } from "@/components/eased/button/button";

const STEPS = [
  { id: "rest", duration: 1000 },
  { id: "aim", duration: 800 },
  { id: "press", duration: 160 },
  { id: "done", duration: 1600 },
] as const;

export function Example() {
  const stage = useRef<HTMLDivElement>(null);
  const { step } = useDemoTimeline(STEPS, { endStep: "done", observe: stage });

  return (
    <div ref={stage} inert className="relative flex h-64 items-center justify-center gap-3">
      <Button data-demo="cancel">Cancel</Button>
      <Button data-demo="deploy" variant="primary" className="data-demo-hover:bg-brand-hover">
        {step === "done" ? "Deployed" : "Deploy"}
      </Button>
      <DemoCursor to={step === "rest" ? "cancel" : "deploy"} pressed={step === "press"} />
    </div>
  );
}
```

The cursor covers its positioned parent (the stage) and looks for `data-demo` names inside it only, so two scenes on one page never collide.

## Examples

### Drag

Press first, then move: while `pressed` the cursor travels in a straight line, and `carry` puts a drag preview under the pointer.

```tsx
<DemoCursor to={step === "grab" ? "file" : "drop"} pressed={step === "grab" || step === "drag"} carry={dragging && <FileChip />} />
```

### Click for real

`click` calls the target's `click()` on release, so its own handler runs. Leave it off when the timeline already drives the result.

```tsx
<DemoCursor to="deploy" pressed={step === "press"} click />
```

### A name pill

```tsx
<DemoCursor to={target} label="Ama" />
```

## API reference

### DemoCursor

| Prop | Type | Default | Description |
|---|---|---|---|
| `to` | `DemoTarget \| null` | | Where it goes: a ref, a `data-demo` name in the stage, or `{ x, y }` in the stage's px. `null` hides it. |
| `at` | `[number, number]` | `[0.5, 0.5]` | Where in an element target the tip lands, as fractions of its box. |
| `pressed` | `boolean` | `false` | Holds the button down. The target depresses and gets `data-demo-pressed`; moving while pressed is a drag. |
| `click` | `boolean` | `false` | Calls the target's `click()` on release. |
| `label` | `ReactNode` | | A pill beside the pointer. It swaps in place when it changes. |
| `carry` | `ReactNode` | | A drag preview under the pointer while set. |
| `onArrive` | `(target: Element \| null) => void` | | Called when it lands. |
| `onPress` | `(point: DemoPoint, target: Element \| null) => void` | | Called as it presses, with the point in the stage's px. |
| `className` | `string` | `""` | Appended to the layer's classes. |

On arrival the target gets `data-demo-hover`, since a script cannot trigger `:hover`: style it with `data-demo-hover:` classes. Each press also fires an `eased-demo-press` event on the target, which a [Click Ripple](/components/click-ripple) rings at.

### useDemoTimeline

`useDemoTimeline(steps, options)` returns `{ step, index, playing, running, play, pause, restart, seek }`. Options: `loop` (true), `autoplay` (true), `delay` (600ms before the first step), `endStep` (the result, shown under reduced motion), `observe` (a ref; it pauses while that element is off-screen) and `onStep`.

## Accessibility

- The cursor layer is `aria-hidden` and never takes pointer input.
- A scripted scene is a performance: make the stage `inert` and give its region a text alternative that says what it shows.
- Loops pause off-screen and in a hidden tab. Give anything that plays longer than five seconds a Pause control (rule 85); the timeline's `pause` and `play` are there for it.

## Motion

The glide rides the `smooth` spring (no overshoot) along an arc that bows 6% of the distance, like a wrist turning; a drag travels straight. A press scales the target down at Eased's press scales (0.97, 0.96 for icons, 0.985 past 220px; none for popup triggers) in 100ms and releases on `snappy`; the glyph dips with it. The label pill enters on `snappy` with a fade and leaves faster. The timeline schedules each step from when the last one was due, so it never drifts. Under reduced motion the cursor jumps to each target, nothing scales, and the timeline rests on `endStep`.

## Notes for AI

Pick Demo Cursor when a page should show a flow happening in its own UI: "a demo cursor that clicks the Deploy button" is `data-demo="deploy"` on the button and `<DemoCursor to="deploy" pressed={step === "press"} />` inside a `relative` stage, driven by `useDemoTimeline`. Derive everything on screen from `step`, so looping, seeking and reduced motion agree.

Do not position the cursor with hard-coded pixels when an element exists to point at, do not animate it with your own timers, and do not let the real pointer operate the stage while it plays (`inert`). It already follows resizes, marks hover and press, and pauses off-screen.

## Related

- [Click Ripple](/components/click-ripple): rings at each press the cursor makes.
- [Zoom Spotlight](/components/zoom-spotlight): pushes in on the result.
- [Caption Ticker](/components/caption-ticker): narrates each step.
- [Product demo set](/sets/product-demo): all of them on one timeline.

## Example

`components/eased/demo-cursor/demo-cursor-demo.tsx`

```tsx
"use client";

import { useRef } from "react";
import { useDemoTimeline } from "../_primitives/demo-core/demo-core";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { DemoCursor } from "./demo-cursor";

/** Rest on the file, pick it up, drag it into the drop zone, let go, then press Attach. */
const STEPS = [
  { id: "rest", duration: 900 },
  { id: "grab", duration: 260 },
  { id: "drag", duration: 900 },
  { id: "drop", duration: 500 },
  { id: "aim", duration: 700 },
  { id: "press", duration: 160 },
  { id: "done", duration: 1800 },
  { id: "reset", duration: 800 },
] as const;
type Step = (typeof STEPS)[number]["id"];

const FILE = { name: "Q3 board deck.pdf", size: "2.4 MB" };

const TARGET: Record<Step, string> = {
  rest: "file",
  grab: "file",
  drag: "drop",
  drop: "drop",
  aim: "attach",
  press: "attach",
  done: "attach",
  reset: "file",
};

function FileIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden className="size-4 shrink-0 text-text-muted">
      <path d="M4 1.75h5.25L12.5 5v8.25c0 .55-.45 1-1 1H4c-.55 0-1-.45-1-1V2.75c0-.55.45-1 1-1Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
      <path d="M9 1.75V5.25h3.5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
    </svg>
  );
}

function FileChip({ className = "" }: { className?: string }) {
  return (
    <span className={`inline-flex h-8 items-center gap-2 rounded-control-sm border border-line bg-surface-raised px-2.5 text-ui-sm text-text ${className}`}>
      <FileIcon />
      {FILE.name}
    </span>
  );
}

/**
 * Ama's cursor attaches a file: it rests on the file, presses to pick it up, drags it in a
 * straight line into the drop zone with the file under the pointer, lets go, then glides to
 * Attach and presses it. Every target is a `data-demo` name, so the script survives any layout.
 */
export function DemoCursorDemo() {
  const stage = useRef<HTMLDivElement>(null);
  const timeline = useDemoTimeline(STEPS, { endStep: "done", observe: stage });
  const { step } = timeline;
  const holding = step === "grab" || step === "drag";
  const dropped = !(step === "rest" || step === "reset" || holding);
  const attached = step === "done";

  return (
    <DemoFrame
      title="Attachments"
      actions={
        <Button variant="ghost" size="sm" edge="end" onClick={timeline.restart}>
          Replay
        </Button>
      }
    >
      <div ref={stage} inert className="relative flex min-h-0 flex-1 flex-col gap-4 p-(--pad)">
        <section className="flex flex-col gap-1.5">
          <p className="text-ui-sm text-text-muted">Recent files</p>
          <div
            data-demo="file"
            className={`flex h-11 items-center gap-2 rounded-control border border-line-subtle px-3 transition-opacity duration-150 ease-standard ${
              holding || dropped ? "opacity-40" : ""
            }`}
          >
            <FileIcon />
            <span className="min-w-0 flex-1 truncate text-ui text-text">{FILE.name}</span>
            <span className="text-ui-sm text-text-muted tabular-nums">{FILE.size}</span>
          </div>
        </section>

        <div
          data-demo="drop"
          className={`flex min-h-0 flex-1 flex-col items-center justify-center gap-2 rounded-panel border border-dashed transition-colors duration-150 ease-standard ${
            step === "drag" ? "border-brand bg-brand-subtle" : "border-line-strong bg-surface-sunken"
          }`}
        >
          {dropped ? <FileChip /> : <p className="text-ui-sm text-text-muted">Drop files to attach</p>}
        </div>

        <div className="flex items-center justify-between gap-3">
          <p className="text-ui-sm text-text-muted">{attached ? "Attached to the board update" : dropped ? "1 file ready" : "No files yet"}</p>
          <Button data-demo="attach" variant="primary" disabled={!dropped && !attached} className="data-demo-hover:bg-brand-hover data-demo-pressed:bg-brand-active">
            {attached ? "Attached" : "Attach"}
          </Button>
        </div>

        <DemoCursor
          to={TARGET[step]}
          at={step === "rest" || step === "grab" || step === "reset" ? [0.32, 0.55] : [0.5, 0.55]}
          pressed={holding || step === "press"}
          label="Ama"
          carry={holding ? <FileChip className="shadow-floating" /> : undefined}
        />
      </div>
    </DemoFrame>
  );
}
```
