Zum Inhalt springen
UI Kit
Accent

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

3 Variants

Variants

Formular ausfüllen

Klick ins Feld, die Adresse tippt sich, der Knopf wird zu Spinner und Haken, ein Toast bestätigt.

Einstellungen ändern

Zwei Schalter kippen, die Abrechnung gleitet auf jährlich und ein Hinweis erscheint, dann wird Speichern zu Gespeichert.

Karte ziehen

Ein Zeiger mit Namen hebt eine Karte an, trägt sie übers Board und legt sie ab. Die Zähler springen um, die Spalte schließt die Lücke.

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

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.

  • stepsPflicht
    CursorStep[]
  • widthoptional
    number

    Design size in px. The scene scales down below it. Default 640 × 400.

  • heightoptional
    number
  • durationoptional
    number

    Seconds of one cycle at speed 1. Default: the last step plus 1.8 s.

  • nameoptional
    string

    A small name tag next to the pointer, like a collaborator

  • labeloptional
    string

    Accessible description of what happens

  • childrenPflicht
    React.ReactNode

    The 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
components/ui/cursor-scene.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert