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.
- Pointing at one control with words: use a Tooltip or a 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.
Installation
npx shadcn add https://eased.dev/r/demo-cursor.jsonThe command adds Demo Cursor, everything it builds on and Eased’s tokens to your project.
Usage
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.
<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.
<DemoCursor to="deploy" pressed={step === "press"} click />A name pill
<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 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-hiddenand never takes pointer input. - A scripted scene is a performance: make the stage
inertand 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
pauseandplayare 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: rings at each press the cursor makes.
- Zoom Spotlight: pushes in on the result.
- Caption Ticker: narrates each step.
- Product demo set: all of them on one timeline.