Skip to content
UI Kit
Accent color

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

3 variants

Variants

Fill a form

Click into the field, the address types itself, the button turns into a spinner and a check, a toast confirms.

Change settings

Two switches flip, the billing pill slides to yearly and a badge appears, then Save turns into Saved.

Drag a card

A named pointer lifts a card, carries it across the board and drops it. The counters update, the column closes the gap.

Details

What Cursor scene brings along, read from the registry and the source.

Category
Animations
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

Props

The CursorSceneProps interface from components/ui/cursor-scene.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.

  • stepsrequired
    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

  • childrenrequired
    React.ReactNode

    The 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
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 };

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.

Often searched

Jump to

Copied to the clipboard