Cursor-Szene
Ein Zeiger arbeitet sich durch eine Oberfläche, und die Oberfläche antwortet im selben Takt. Zeigt einen Ablauf ohne Video.
Eine von 4 Components in Animations
Details
Was Cursor-Szene mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Animations
- Bereich
- Visuals
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/cursor-scene.tsx
- Bringt mit
- utilskitmotion-scene
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CursorSceneProps aus components/ui/cursor-scene.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von SceneMotionProps an.
stepsPflichtCursorStep[]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
childrenPflichtReact.ReactNodeThe interface, drawn at the design size, with <Cue>, <Track> and <Typed> inside
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle label.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 161 Zeilen
'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 };Häufige Fragen
Wie installiere ich Cursor-Szene?
Mit aiw add @ai-website/cursor-scene. Der Befehl legt die Datei components/ui/cursor-scene.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Cursor-Szene?
3 Varianten, Formular ausfüllen, Einstellungen ändern und Karte ziehen.
Kann ein KI-Assistent Cursor-Szene einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Cursor-Szene mit aiw_components und installieren sie mit aiw_add @ai-website/cursor-scene.