Cursor scene
A pointer works through an interface, and the interface answers on the same timeline. Shows a flow without a video.
One of 4 components in Animations
Details
What Cursor scene brings along, read from the registry and the source.
- Category
- Animations
- Area
- Visuals
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/cursor-scene.tsx
- Brings along
- utilskitmotion-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CursorSceneProps interface from components/ui/cursor-scene.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.
stepsrequiredCursorStep[]widthoptionalnumberDesign size in px. The scene scales down below it. Default 640 × 400.
heightoptionalnumberdurationoptionalnumberSeconds of one cycle at speed 1. Default: the last step plus 1.8 s.
nameoptionalstringA small name tag next to the pointer, like a collaborator
labeloptionalstringAccessible description of what happens
childrenrequiredReact.ReactNodeThe interface, drawn at the design size, with <Cue>, <Track> and <Typed> inside
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 161 lines
'use client';
import { cn, round } from '@/lib/kit';
import { Cue, FitCanvas, PlayToggle, SCENE_CSS, SceneTimeline, Track, Typed, keyframes, useSceneId, useScenePlay, type CueProps, type SceneMotionProps, type TrackProps, type TypedProps } from '@/components/ui/motion-scene';
/*
* A pointer that works through an interface: it glides, rests, clicks and
* drags, and the interface answers at the same moments. Everything runs on
* one CSS timeline in seconds. The scene is drawn at a design size and
* scales as one picture, so the choreography fits every width.
*
* <CursorScene steps={[{ x: 120, y: 80, at: 0.6 }, { x: 300, y: 220, at: 1.8, click: true }]}>
* <Cue at={1.8} from="opacity:0" to="opacity:1">Saved</Cue>
* <Typed at={1.0} text="lena@atelier.de" />
* </CursorScene>
*
* Without JavaScript and with reduced motion the scene shows its end: the
* pointer at its last position and every cue in its `to` state.
*/
export interface CursorStep {
/** Position of the pointer tip in design pixels */
x: number;
y: number;
/** Seconds from the start when the pointer arrives */
at: number;
/** Click on arrival */
click?: boolean;
/** Hand instead of arrow, e.g. over a button or while dragging */
hand?: boolean;
}
export interface CursorSceneProps extends SceneMotionProps {
steps: CursorStep[];
/** Design size in px. The scene scales down below it. Default 640 × 400. */
width?: number;
height?: number;
/** Seconds of one cycle at speed 1. Default: the last step plus 1.8 s. */
duration?: number;
/** A small name tag next to the pointer, like a collaborator */
name?: string;
/** Accessible description of what happens */
label?: string;
className?: string;
/** The interface, drawn at the design size, with <Cue>, <Track> and <Typed> inside */
children: React.ReactNode;
}
const EASE = 'animation-timing-function:cubic-bezier(.6,0,.3,1)';
export function CursorScene({ steps, width = 640, height = 400, duration, name, speed = 1, autoPlay = 'in-view', loop = true, controls = true, label, className, children }: CursorSceneProps) {
const sid = useSceneId('kvcs');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const sp = Math.max(0.1, speed);
const last = steps[steps.length - 1] ?? { x: width / 2, y: height / 2, at: 0 };
const T = round((duration ?? last.at + 1.8) / sp, 3);
const P = (s: number) => round(((s / sp) / T) * 100, 2);
const pos = (s: CursorStep) => `transform:translate(${s.x}px,${s.y}px)`;
const move: [number, string][] = [];
const first = steps[0];
if (first) {
move.push([0, `transform:translate(${first.x + 70}px,${first.y + 90}px);opacity:0;${EASE}`]);
move.push([P(Math.max(0.05, first.at - 0.7)), `transform:translate(${first.x + 70}px,${first.y + 90}px);opacity:0;${EASE}`]);
steps.forEach((s, i) => {
move.push([P(s.at), `${pos(s)};opacity:1;${EASE}`]);
const next = steps[i + 1];
if (next) {
const travel = Math.min(0.9, (next.at - s.at) * 0.7);
move.push([P(next.at - travel), `${pos(s)};opacity:1;${EASE}`]);
}
});
if (loop) {
move.push([P(last.at) + (100 - P(last.at)) * 0.7, `${pos(last)};opacity:1`]);
move.push([100, `${pos(last)};opacity:0`]);
} else move.push([100, `${pos(last)};opacity:1`]);
}
const clicks = steps.filter((s) => s.click);
const press: [number, string][] = [[0, 'transform:scale(1)']];
clicks.forEach((c) => {
press.push([P(c.at), 'transform:scale(1)']);
press.push([P(c.at + 0.08), 'transform:scale(.84)']);
press.push([P(c.at + 0.24), 'transform:scale(1)']);
});
press.push([100, 'transform:scale(1)']);
// arrow or hand, switched by opacity
const handFrames: [number, string][] = [[0, 'opacity:0']];
steps.forEach((s, i) => {
const prev = steps[i - 1];
if (!!s.hand !== !!prev?.hand) {
handFrames.push([Math.max(0, P(s.at) - 0.6), `opacity:${prev?.hand ? 1 : 0}`]);
handFrames.push([P(s.at), `opacity:${s.hand ? 1 : 0}`]);
}
});
handFrames.push([100, `opacity:${last.hand ? 1 : 0}`]);
const arrowFrames = handFrames.map(([p, d]) => [p, d.replace(/opacity:(\d)/, (_, v) => `opacity:${v === '1' ? 0 : 1}`)] as [number, string]);
const iter = loop ? 'infinite' : '1';
const css = `${SCENE_CSS}
${keyframes(`${sid}-move`, move)}${keyframes(`${sid}-press`, press)}${keyframes(`${sid}-hand`, handFrames)}${keyframes(`${sid}-arrow`, arrowFrames)}
.${sid} .${sid}-cur { transform: translate(${last.x}px, ${last.y}px); }
.${sid} .${sid}-hand { opacity: ${last.hand ? 1 : 0}; }
.${sid} .${sid}-arrow { opacity: ${last.hand ? 0 : 1}; }
.${sid}:not([data-kv="off"]) .${sid}-cur { animation: ${sid}-move ${T}s linear ${iter} both; }
.${sid}:not([data-kv="off"]) .${sid}-press { animation: ${sid}-press ${T}s linear ${iter} both; }
.${sid}:not([data-kv="off"]) .${sid}-hand { animation: ${sid}-hand ${T}s step-end ${iter} both; }
.${sid}:not([data-kv="off"]) .${sid}-arrow { animation: ${sid}-arrow ${T}s step-end ${iter} both; }`;
return (
<SceneTimeline sid={sid} T={T} speed={sp} loop={loop}>
<div className={cn('relative w-full', className)}>
<div ref={play.ref} data-kv={play.state} role={label ? 'img' : undefined} aria-label={label} className={cn(sid, 'relative w-full select-none')}>
<style dangerouslySetInnerHTML={{ __html: css }} />
<FitCanvas width={width} height={height}>
<div key={play.cycle} className="relative size-full" aria-hidden={label ? true : undefined}>
{children}
{clicks.map((c, i) => (
<Track
key={`r${i}`}
frames={[
[0, 'opacity:0;transform:translate(-50%,-50%) scale(.2)'],
[c.at, 'opacity:0;transform:translate(-50%,-50%) scale(.2)'],
[c.at + 0.03, 'opacity:.6;transform:translate(-50%,-50%) scale(.35)'],
[c.at + 0.7, 'opacity:0;transform:translate(-50%,-50%) scale(1.7)'],
]}
end="opacity:0"
className="pointer-events-none absolute z-30 size-12 rounded-full border-2 border-[color-mix(in_oklab,var(--primary)_80%,transparent)] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)]"
style={{ left: c.x, top: c.y }}
/>
))}
<div className={`${sid}-cur pointer-events-none absolute left-0 top-0 z-40`}>
<div className={`${sid}-press origin-top-left`}>
<svg className={`${sid}-arrow absolute left-0 top-0 h-[26px] w-[20px] drop-shadow-[0_2px_4px_rgb(0_0_0/0.3)]`} viewBox="0 0 20 26" aria-hidden>
<path d="M2 1.6v19.6c0 .7.8 1 1.3.5l4.6-4.7 3.3 7.3c.2.5.8.7 1.3.5l2.1-.9c.5-.2.7-.8.5-1.3l-3.3-7.2 6.5-.4c.7 0 1-.9.5-1.4L3.3 1.1C2.8.6 2 .9 2 1.6Z" fill="var(--foreground)" stroke="var(--background)" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
<svg className={`${sid}-hand absolute -left-[7px] -top-[2px] h-[26px] w-[24px] drop-shadow-[0_2px_4px_rgb(0_0_0/0.3)]`} viewBox="0 0 24 26" aria-hidden>
<path d="M8.5 2.2c1 0 1.8.8 1.8 1.8v7.2l.6-.2V8.8c0-1 .8-1.8 1.8-1.8s1.8.8 1.8 1.8v2.6l.5-.1v-1.4c0-1 .8-1.8 1.8-1.8s1.8.8 1.8 1.8v1.9h.2c1-.1 1.9.7 1.9 1.8v4.2c0 4-3 7-7 7h-2c-2.3 0-4.3-1.2-5.6-3l-3.5-5.2c-.6-.9-.3-2 .6-2.5.8-.4 1.8-.2 2.3.5l1.2 1.6V4c0-1 .8-1.8 1.8-1.8Z" fill="var(--foreground)" stroke="var(--background)" strokeWidth="1.4" strokeLinejoin="round" />
</svg>
{name && (
<span className="absolute left-[18px] top-[22px] whitespace-nowrap rounded-full rounded-tl-sm bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground shadow-[var(--shadow-md)]">{name}</span>
)}
</div>
</div>
</div>
</FitCanvas>
</div>
{controls && (
<div className="mt-2 flex justify-end">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
</SceneTimeline>
);
}
export { Track, Cue, Typed };
export type { TrackProps, CueProps, TypedProps };Frequent questions
How do I install Cursor scene?
With aiw add @ai-website/cursor-scene. The command puts components/ui/cursor-scene.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.
Which variants does Cursor scene have?
3 variants, Fill a form, Change settings and Drag a card.
Can an AI assistant build with Cursor scene?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Cursor scene with aiw_components and install it with aiw_add @ai-website/cursor-scene.