• English
  • MarkStageProvider

    Shares one animation between every character that looks the same.

    import { MarkStageProvider, StagedMark, COLOR_IDS } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <MarkStageProvider>
          <div className="site-demo">
            {Array.from({ length: 44 }, (_, i) => (
              <StagedMark
                key={i}
                shape="pebble"
                state={i % 3 === 0 ? 'happy' : i % 3 === 1 ? 'thinking' : 'idle'}
                color={COLOR_IDS[i % COLOR_IDS.length]}
                sizePx={34}
              />
            ))}
          </div>
        </MarkStageProvider>
      );
    }

    Forty-four characters above. Three running engines.

    How it works

    The provider keeps a hidden character for each distinct appearance and every visible instance becomes <use href="#…"> pointing at it. The browser reuses whatever the source is rendering this frame, so a mirror costs one element and no animation work.

    Appearance is shape, state, face and pen

    The key is shape, state, face style, nose, line weight, emphasis and pause. Colour and size are excluded, deliberately. A mark with a hud leaves the stage altogether and runs its own engine: a dial is a live readout, and keying on its value would mint a new source every tick.

    Face style and nose are included, because a mirror shows exactly what its source draws: a line-face mark cannot borrow a source with another face. Two line-face blobs with different noses are two sources. A nose on the default face is moot, so it is dropped from the key and does not split the stage.

    Ink is a CSS custom property, and inherited custom properties resolve at the point of use rather than the point of definition — so every mirror can be a different ink while sharing one animation. Size is set on the wrapper element, so it varies freely too.

    This is the whole design, and it is easy to get wrong. Keying the stage by agent identity reads naturally, because an agent id is what an application has a list of. But it shares nothing: every agent is unique by definition, so fifty agents would mean fifty engines. Keying by appearance means fifty agents who are all idle blobs cost exactly one.

    Capping

    maxSources limits concurrent engines, defaulting to 36. Past the cap, claims are still honoured but no new source is hosted, so the page degrades by leaving some characters still rather than by slowing all of them down.

    Claims are coalesced

    An IntersectionObserver firing during a scroll can produce dozens of claims in a single frame. Committing each one separately would re-render the provider dozens of times at exactly the moment the main thread is busiest, so claims are batched into one update per frame.

    Source size

    Hidden sources render at 96 pixels rather than collapsing to nothing. Anything that measures its own element reads the source's box, not the mirror's, so a source shrunk to a few pixels would make every mirror behave as though it were tiny.

    Outside a provider

    StagedMark falls back to its own CharacterMark, so a component using it works in isolation — in a test, or in a documentation example — without a provider in the tree.

    Style and ref

    style is applied last, so a --aora-fg set there overrides the ink derived from color. That is the only way to hand a mark an animatable custom property instead of a fixed ink — the group activity ink transition relies on it.

    ref exposes the same CharacterHandle as CharacterMark, but only when the mark owns an engine — which, for a StagedMark, means outside a provider. Inside one every mark is a mirror of the stage's hidden source; it shares its animation with every other mark of the same appearance and so cannot move alone. The handle is simply never attached in that case, and usePokeCycle reports back that there was nothing to poke.

    import { useRef, type CSSProperties } from 'react';
    import { MarkStageProvider, StagedMark, type CharacterHandle } from '@aora-bot/react';
    
    export default function Demo() {
      const own = useRef<CharacterHandle>(null);
      const mirror = useRef<CharacterHandle>(null);
      return (
        <div className="site-demo site-demo--center">
          <MarkStageProvider>
            <StagedMark
              shape="hex"
              color="cyan"
              state="happy"
              sizePx={72}
              style={{ '--aora-fg': '#0ea5e9' } as CSSProperties}
            />
            <StagedMark ref={mirror} shape="hex" color="cyan" state="happy" sizePx={72} />
          </MarkStageProvider>
          <StagedMark ref={own} shape="hex" color="cyan" state="happy" sizePx={72} />
          <button className="site-btn" onClick={() => (mirror.current ?? own.current)?.bounce()}>
            bounce
          </button>
        </div>
      );
    }

    The first mark restyles its ink through style; the second is a mirror whose handle stays null, so the button falls through to the third, which runs its own engine outside the provider. To move a whole roster on purpose, go through the shared mark commands rather than individual refs.

    API

    PropTypeDefaultDescription
    children*string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null——
    maxSourcesnumber36Cap on simultaneously running engines. Claims past the cap fall back to a static render rather than degrading every animation on the page.

    StagedMark

    PropTypeDefaultDescription
    classNamestring——
    color"black" | "brown" | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "violet" | "magenta" | "pink" | "gray"——
    emotionIdstring—Protocol id, for hosts that speak the wire format. Ignored when `state` is given.
    emphasisbooleanfalseEnlarge and converge the eyes. Part of the shared appearance.
    eyeColorstring——
    face"cute" | "eyes" | "line"—Face style. Part of the shared appearance, so line-face marks share a line-face source.
    hudHudSpec | nullnullA dial in the ring outside the body. `null` removes it. A HUD spec is a live readout whose value moves every tick, so unlike the other geometry props it cannot be folded into the appearance key — each new value would mint a source and orphan the last one. A mark with a HUD therefore leaves the stage and runs its own engine, which is what a per-instance readout needs anyway.
    lineWeightnumber—Multiplier on the shared line weight. Part of the shared appearance, so a heavier-pen mark gets its own source rather than mirroring a light one.
    nose"slant" | "angular" | "hook" | "long" | "curve" | "bow" | null—Nose for the line face. Unlike `CharacterMark`, the default here is `null` rather than seed-derived: mirrors of one source must agree on a nose, and "the seed's nose" differs per mark.
    pausedbooleanfalseHold the current frame. Part of the shared appearance.
    preset"mochi" | "sprout" | "boba" | "dot" | "pip" | "byte" | "momo" | "bao" | "teal" | "tato" | "hexa" | "tri" | "gemma" | "nimbo" | "dew" | "leafy" | "bloom" | "lucky" | "shroom" | "oak" | "pokey" | "nashi" | "pomme" | "berry" | "cheri" | "twinkle" | "luv" | "ember" | "chime" | "jelly" | "beep" | "luna" | "finn" | "inky" | "miso" | "hop" | "bruno" | "chirp" | "waddle" | "oink" | "rusty" | "kiki" | "maru" | "zip" | "fuzz" | "lulu"—A named character from the cast. Supplies `shape`, `color`, `face` and `nose` when they are not given.
    refRef<CharacterHandle>—Flourishes, when this mark owns an engine. A mirror shares its animation with every other mark of the same appearance and so cannot move alone; the handle is simply never attached in that case, and `usePokeCycle` reports back that there was nothing to poke.
    shape"mochi" | "circle" | "blob" | "pebble" | "egg" | "bun" | "tofu" | "pill" | "hex" | "onigiri" | "gem" | "cloud" | "drop" | "leaf" | "flower" | "clover" | "sprout" | "mushroom" | "acorn" | "cactus" | "pear" | "apple" | "strawberry" | "cherry" | "star" | "heart" | "flame" | "bell" | "ghost" | "bot" | "crescent" | "boba" | "fish" | "octopus" | "cat" | "bunny" | "bear" | "chick" | "penguin" | "pig" | "fox" | "bob" | "topknot" | "spiky" | "curly" | "twinbuns"——
    sizePxnumber28—
    state"idle" | "sleeping" | "drowsy" | "bored" | "waking" | "powering-down" | "listening" | "thinking" | "searching" | "reading" | "writing" | "working" | "loading" | "progress" | "radar" | "orbit" | "dictating" | "uploading" | "sending" | "receiving" | "messaging" | "waiting" | "spawning" | "composing" | "dragging" | "happy" | "excited" | "laughing" | "playful" | "proud" | "celebrate" | "bouncing" | "shy" | "sad" | "angry" | "confused" | "suspicious" | "surprised" | "scared" | "curious" | "humming" | "alerting" | "notifying" | "refusing"——
    styleCSSProperties—Applied last, so `--aora-fg` here overrides the ink derived from `color`. That is the only way to hand a mark an animatable custom property instead of a fixed colour, which is what the group activity ink transition needs.
    styleIdStyleId—Style series. Part of the shared appearance, so a pixel mark gets a pixel source. Named `styleId` because `style` is the inline CSS. Falls back to the surrounding `AppearanceProvider`.
    styleOptionsRecord<string, unknown>—Parameters of the series. Part of the shared appearance, compared by value after the engine's own clamping, so `{}` and the defaults share a source.
    variantStyleVariant—Paint variant within the series. Part of the shared appearance.

    Next