• English
  • CastStage

    Several characters choreographed through a scripted scene.

    Part of @aora-bot/surfaces, not @aora-bot/react: pnpm add @aora-bot/surfaces @aora-bot/react, and import @aora-bot/surfaces/styles.css after the React stylesheet.

    1/4, enter
    import { AgentAvatar, MarkStageProvider } from '@aora-bot/react';
    import { AvatarSlot, CastStage, useCastScene, type CastStep } from '@aora-bot/surfaces';
    
    const members = [
      { id: 'lead', shape: 'blob', color: 'blue', bobPx: 4 },
      { id: 'helper', shape: 'hex', color: 'green', bobPx: 3, bobMs: 2600 },
      { id: 'scout', shape: 'star', color: 'orange', sizePx: 56 },
    ] as const;
    
    const steps: CastStep[] = [
      { id: 'enter', positions: { lead: { x: 0, y: -40, state: 'waking' } }, durationMs: 1400 },
      {
        id: 'meet',
        positions: { lead: { x: -60, y: 0 }, helper: { x: 60, y: 10, state: 'happy' } },
        durationMs: 1600,
      },
      {
        id: 'work',
        positions: {
          lead: { x: -80, y: -20, state: 'thinking' },
          helper: { x: 0, y: 30, state: 'working' },
          scout: { x: 80, y: -30, state: 'searching' },
        },
        durationMs: 2000,
      },
      { id: 'land', positions: { lead: { x: 110, y: 90, scale: 0.7, state: 'happy' } }, durationMs: 1800 },
    ];
    
    export default function Demo() {
      const scene = useCastScene(steps, { autoplay: true, loop: true });
      const landed = scene.step?.id === 'land';
      return (
        <MarkStageProvider>
          <div className="site-demo site-demo--column">
            <CastStage members={members} steps={steps} stepIndex={scene.stepIndex} widthPx={320} heightPx={260}>
              <div style={{ position: 'absolute', right: 16, bottom: 16 }}>
                <AvatarSlot arrived={landed} sizePx={56} label="Your agent">
                  <AgentAvatar agent={{ id: 'lead', shape: 'blob', color: 'blue' }} sizePx={56} state="happy" />
                </AvatarSlot>
              </div>
            </CastStage>
            <div className="site-controls">
              <button className="site-btn" onClick={scene.prev}>
                ‹
              </button>
              <button className="site-btn" onClick={scene.playing ? scene.pause : scene.play}>
                {scene.playing ? 'pause' : 'play'}
              </button>
              <button className="site-btn" onClick={scene.next}>
                ›
              </button>
              <span className="site-demo__label">
                {scene.stepIndex + 1}/{steps.length}, {scene.step?.id}
              </span>
            </div>
          </div>
        </MarkStageProvider>
      );
    }

    The script is data

    A scene is a list of members and a list of steps. Each step says where everybody stands — x and y in pixels from the centre of the stage, an optional scale, and an optional state to adopt for that step — and how long it holds. A member left out of a step's positions is off stage for that step: it shrinks and fades rather than vanishing.

    Because both halves are plain data, a host writes its own choreography and this component never learns what the scene is about. An onboarding flow can reorder its scenes without any of the movement logic knowing.

    Springs without a frame loop

    Moves are CSS transitions, but the easing is sampled from the engine's own spring into a linear() function. A real spring needs a frame loop, and a frame loop for four members that are merely gliding would compete with the animation each of them is already running for its face. Sampling the analytic solution gives the same curve — overshoot included — for the price of a string. spring tunes stiffness (omega) and damping (zeta); moveMs is the transition length.

    onArrive(memberId, stepIndex) fires once per member per step when its transform has finished, so a host can trigger the next thing exactly when a character has landed rather than on a timer that has to guess.

    useCastScene

    The hook decides when, never where. It holds the current stepIndex, advances after each step's durationMs while playing, and stops at the last step unless loop is set. next, prev, play, pause and goTo are the transport; onStep reports each change. autoplay starts it running on mount.

    CastMember and AvatarSlot

    CastMember is one character in two nested boxes: the outer box owns the journey — position, scale and opacity under one transition — and the inner box owns the idle bob (bobPx, bobMs), so a member can drift on the spot while it is also gliding across the stage. Vary bobMs per member so the group does not breathe in unison. Members are StagedMarks, so the stage wants a MarkStageProvider above it.

    AvatarSlot is the empty circle a character flies into at the end: a dashed outline that says "something belongs here" while the scene runs, and a cross-fade to children once arrived is set — two layers, so the box is never empty in between. It does not know where it is; position it with ordinary CSS, inside the stage or in a form beside it.

    API

    PropTypeDefaultDescription
    members*readonly CastMemberSpec[]——
    steps*readonly CastStep[]——
    childrenstring | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null—Scenery: a device frame, an `AvatarSlot`, a caption. Rendered over the stage.
    classNamestring——
    face"cute" | "eyes" | "line"——
    heightPxnumber320—
    lineWeightnumber——
    moveMsnumber560How long a move takes. Also the transition duration.
    nose"slant" | "angular" | "hook" | "long" | "curve" | "bow" | null——
    onArrive((memberId: string, stepIndex: number) => void)—Called when a member finishes a move. Fires once per member per step.
    sizePxnumber80Default member size, for members that do not set their own.
    spring{ omega: number; zeta: number; }DEFAULT_SPRINGSpring the easing is sampled from: stiffness and damping.
    stepIndexnumber0Which step is on screen. Drive it with `useCastScene`.
    styleCSSProperties——
    widthPxnumber320Stage box. Positions are measured from its centre, so both are needed.

    CastMember

    PropTypeDefaultDescription
    durationMs*number——
    easing*string—Easing for the move, as a `linear()` function from the engine's spring.
    member*CastMemberSpec——
    position*CastPosition | null—Where to stand. `null` takes the member off stage.
    classNamestring——
    face"cute" | "eyes" | "line"——
    lineWeightnumber——
    nose"slant" | "angular" | "hook" | "long" | "curve" | "bow" | null——
    onArrive((id: string) => void)—Called when this member finishes moving.
    sizePxnumber——
    styleCSSProperties——

    AvatarSlot

    PropTypeDefaultDescription
    arrivedbooleanfalseThe member has landed: the outline fades out and `children` fade in.
    childrenstring | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null—What the slot holds once something has landed in it.
    classNamestring——
    labelstring——
    sizePxnumber64—
    styleCSSProperties——

    Next