• English
  • CharacterMark

    A single animated character. Everything else in the library is built on it.

    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      return <CharacterMark shape="blob" color="orange" state="excited" sizePx={150} />;
    }

    Paint styles

    styleId picks the style and variant picks a variant within it. The geometry, the animation and the face are identical across all of them — only the paint changes, so a new state or a new character arrives in every style at once. Each style has a page: Vector, Pixel, Kaomoji. Here is every vector variant:

    solid
    outline
    glass
    sticker
    paper
    soft
    riso
    import { getStyle } from '@aora-bot/engine';
    import { CharacterMark } from '@aora-bot/react';
    
    const variants = getStyle('vector').variants;
    
    export default function Demo() {
      return (
        <div className="site-demo">
          {variants.map((variant) => (
            <div className="site-demo__cell" key={variant}>
              <CharacterMark shape="mochi" color="blue" state="happy" sizePx={96} variant={variant} />
              <span className="site-demo__label">{variant}</span>
            </div>
          ))}
        </div>
      );
    }

    solid is the default: the body is a flat filled mass and the cute face is white.

    outline and glass turn that around. When the body is no longer filled, a white face would vanish, so these two variants draw the face in the ink instead — the same silhouette read from the other side.

    import { CharacterMark, SHAPE_IDS } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <div className="site-demo">
          {SHAPE_IDS.slice(0, 9).map((shape) => (
            <CharacterMark key={shape} shape={shape} color="violet" sizePx={54} variant="outline" />
          ))}
        </div>
      );
    }

    The stroke is a constant width in shape space rather than in pixels, so it scales with the character. A stroke pinned to device pixels looks spindly on a large character and swamps a small one.

    Faces

    face chooses among three faces: the default cute, and eyes and the line face line, which adds a nose and a mouth as strokes at one shared weight. nose only applies to line and picks one of six; omit it for one derived from seed, or pass null for none.

    cute
    eyes
    line, slant
    line, angular
    line, hook
    line, no nose
    import { CharacterMark, NOSE_IDS } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <div className="site-demo">
          <div className="site-demo__cell">
            <CharacterMark shape="bun" color="orange" state="happy" sizePx={96} />
            <span className="site-demo__label">cute</span>
          </div>
          <div className="site-demo__cell">
            <CharacterMark shape="bun" color="orange" state="happy" sizePx={96} face="eyes" />
            <span className="site-demo__label">eyes</span>
          </div>
          {NOSE_IDS.slice(0, 3).map((nose) => (
            <div className="site-demo__cell" key={nose}>
              <CharacterMark shape="bun" color="orange" state="happy" sizePx={96} face="line" nose={nose} />
              <span className="site-demo__label">line, {nose}</span>
            </div>
          ))}
          <div className="site-demo__cell">
            <CharacterMark shape="bun" color="orange" state="happy" sizePx={96} face="line" nose={null} />
            <span className="site-demo__label">line, no nose</span>
          </div>
        </div>
      );
    }

    Both props can change on a mounted character: the face eases between styles and the nose crossfades. The root element carries data-face for styling.

    When to use it

    Use CharacterMark for a character that is on its own — a hero, an empty state, a single avatar in a header. For several characters at once, prefer MarkStageProvider, which shares one animation between every character that looks the same.

    Following the pointer

    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <div className="site-demo site-demo--center">
          <CharacterMark shape="blob" color="violet" sizePx={120} followPointer />
          <CharacterMark shape="egg" color="cyan" sizePx={120} followPointer />
        </div>
      );
    }

    The mapping is not linear. Distance is passed through a square root, which keeps the eyes responsive to small movements near the character while still reaching their limit for anything far away — a linear mapping makes nearby motion feel unresponsive.

    Emphasis

    hover me
    import { useState } from 'react';
    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      const [hover, setHover] = useState(false);
      return (
        <div
          className="site-demo site-demo--center"
          onPointerEnter={() => setHover(true)}
          onPointerLeave={() => setHover(false)}
        >
          <CharacterMark shape="tofu" color="green" state="happy" sizePx={120} emphasis={hover} />
          <span className="site-demo__label">hover me</span>
        </div>
      );
    }

    Pausing

    paused stops the character's subscription to the shared ticker and holds the current frame. It does not reset to a neutral pose, so a character scrolled out of view and back looks like it was waiting rather than like it restarted.

    import { useState } from 'react';
    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      const [paused, setPaused] = useState(false);
      return (
        <div className="site-demo site-demo--center">
          <CharacterMark shape="cloud" color="blue" state="celebrate" sizePx={110} paused={paused} />
          <button className="site-btn" onClick={() => setPaused((p) => !p)}>
            {paused ? 'Resume' : 'Pause'}
          </button>
        </div>
      );
    }

    Imperative actions

    import { useRef } from 'react';
    import { CharacterMark, type CharacterHandle } from '@aora-bot/react';
    
    export default function Demo() {
      const ref = useRef<CharacterHandle>(null);
      return (
        <div className="site-demo site-demo--center">
          <CharacterMark ref={ref} shape="drop" color="magenta" state="playful" sizePx={110} />
          <div className="site-controls">
            <button className="site-btn" onClick={() => ref.current?.spin(1)}>
              spin(1)
            </button>
            <button className="site-btn" onClick={() => ref.current?.spin(3)}>
              spin(3)
            </button>
            <button className="site-btn" onClick={() => ref.current?.bounce()}>
              bounce()
            </button>
            <button className="site-btn" onClick={() => ref.current?.burst(24)}>
              burst(24)
            </button>
          </div>
        </div>
      );
    }

    burst() is suppressed under prefers-reduced-motion, since particles radiating from a centre point are a vestibular trigger.

    Following a point the host already knows

    followPointer listens for the pointer itself. followTarget takes a point in viewport pixels instead — another element's centre, or a pointer position the host is already tracking for its own reasons — and the engine converts it with the same square-root mapping every frame. gazeTarget wins when both are set.

    one listener, two characters
    import { useState } from 'react';
    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      const [target, setTarget] = useState<{ x: number; y: number } | null>(null);
      return (
        <div
          className="site-demo site-demo--center"
          onPointerMove={(e) => setTarget({ x: e.clientX, y: e.clientY })}
          onPointerLeave={() => setTarget(null)}
        >
          <CharacterMark shape="hex" color="green" sizePx={110} followTarget={target} />
          <CharacterMark shape="bun" color="orange" sizePx={110} followTarget={target} />
          <span className="site-demo__label">one listener, two characters</span>
        </div>
      );
    }

    Ink, eyes and line weight

    ink overrides the body paint with a flat colour or a two-stop gradient, for a character that has to match a brand swatch rather than the palette; null hands it back to the theme token. eyeScale is a static multiplier on eye size, applied on top of the emphasis spring. lineWeight scales the nose, the mouth and the HUD dials together, within LINE_WEIGHT_RANGE (0.8–1.25) — one pen per character, so the face and the HUD stay one stroke.

    ink flat
    ink gradient
    eyeScale 1.35
    lineWeight 0.8
    lineWeight 1.25
    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <div className="site-demo">
          <div className="site-demo__cell">
            <CharacterMark shape="blob" color="blue" state="happy" sizePx={96} ink={{ flat: '#1d4ed8' }} />
            <span className="site-demo__label">ink flat</span>
          </div>
          <div className="site-demo__cell">
            <CharacterMark
              shape="blob"
              color="blue"
              state="happy"
              sizePx={96}
              ink={{ from: '#f97316', to: '#db2777', angle: 30 }}
            />
            <span className="site-demo__label">ink gradient</span>
          </div>
          <div className="site-demo__cell">
            <CharacterMark shape="blob" color="blue" state="happy" sizePx={96} eyeScale={1.35} />
            <span className="site-demo__label">eyeScale 1.35</span>
          </div>
          <div className="site-demo__cell">
            <CharacterMark
              shape="blob"
              color="blue"
              state="happy"
              sizePx={96}
              face="line"
              nose="curve"
              lineWeight={0.8}
            />
            <span className="site-demo__label">lineWeight 0.8</span>
          </div>
          <div className="site-demo__cell">
            <CharacterMark
              shape="blob"
              color="blue"
              state="happy"
              sizePx={96}
              face="line"
              nose="curve"
              lineWeight={1.25}
            />
            <span className="site-demo__label">lineWeight 1.25</span>
          </div>
        </div>
      );
    }

    HUD

    hud draws a dial in the ring outside the body; null removes it. While a HUD is on, the face is hidden — the body stays a silhouette, the ring is the reading. The spec is the same HudSpec the protocol carries and HudPlate validates, and the dial is stroked at the character's lineWeight.

    import { CharacterMark } from '@aora-bot/react';
    
    export default function Demo() {
      return (
        <div className="site-demo">
          <CharacterMark
            shape="tofu"
            color="violet"
            state="working"
            sizePx={110}
            hud={{ kind: 'progress', value: 0.6 }}
          />
          <CharacterMark
            shape="tofu"
            color="violet"
            state="waiting"
            sizePx={110}
            hud={{ kind: 'timer', remainingMs: 12_000, totalMs: 60_000 }}
          />
          <CharacterMark
            shape="tofu"
            color="violet"
            state="notifying"
            sizePx={110}
            hud={{ kind: 'notify', count: 3 }}
          />
        </div>
      );
    }

    The spec is compared by value, not identity, so hud={{ kind: 'progress', value }} written inline does not restart the dial on every render.

    Knowing when a state has settled

    Finite states — spawning, waking — run their course (see STATE_SETTLE_TO) and the engine moves on by itself. onSettle(from, to) fires at that moment, so a host's own idea of the state can follow without keeping a duplicate timer.

    state idle, settled —
    import { useState } from 'react';
    import { CharacterMark, type CharacterState } from '@aora-bot/react';
    
    export default function Demo() {
      const [state, setState] = useState<CharacterState>('idle');
      const [log, setLog] = useState('—');
      return (
        <div className="site-demo site-demo--center">
          <CharacterMark
            shape="star"
            color="yellow"
            state={state}
            sizePx={110}
            onSettle={(from, to) => {
              setLog(`${from} → ${to}`);
              setState(to);
            }}
          />
          <button className="site-btn" onClick={() => setState('waking')}>
            waking
          </button>
          <span className="site-demo__label">
            state {state}, settled {log}
          </span>
        </div>
      );
    }

    The opposite problem — an input that changes faster than the character can follow — is what useMarkSettle is for.

    Why it never re-renders

    React mounts an empty host element and the engine takes over. Prop changes are pushed into the controller imperatively rather than re-rendering:

    useEffect(() => {
      controllerRef.current?.update({ shape, state, paused, emphasis });
    }, [shape, state, paused, emphasis]);

    Going through the reconciler sixty times a second to set a dozen attributes would cost considerably more than the animation itself. The mount effect deliberately has no dependencies — recreating the controller on a prop change would restart the animation.

    API

    PropTypeDefaultDescription
    anticsbooleanfalseLet an unattended character occasionally hop or spin on its own. Default false.
    autoPauseboolean | AutoPauseOptionsfalseStop animating while hidden, scrolled out of view, or under reduced motion. Read at mount. Off by default.
    badgeColorstring'var(--aora-accent)'Corner badge colour, shown in the `notifying` state.
    childrenstring | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | null——
    classNamestring——
    color"black" | "brown" | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "violet" | "magenta" | "pink" | "gray"—Ink colour from the palette.
    emotionIdstring—Protocol id, for hosts that speak the wire format. Ignored when `state` is given.
    emphasisbooleanfalseEnlarge and converge the eyes, for hover states.
    entryEffectsbooleantruePlay the one-off flourish some states define on entry. Default true.
    eyeColorstring—Colour of the eye cut-outs. Defaults to the surrounding background token.
    eyeScalenumber—Static multiplier on eye size, on top of the emphasis spring.
    face"cute" | "eyes" | "line"—Face style: `cute` (the default), the plain `eyes`, or `line` — eyes plus a stroked nose and mouth.
    faceTunePartial<FaceTune>—Multipliers on the fitted face box: overall size, eye gap, height, eye width and height.
    followPointerbooleanfalseFollow the pointer automatically.
    followTarget{ x: number; y: number; } | nullnullEyes track this point, in viewport pixels — another element's centre, or a pointer position the host is already tracking for its own reasons. `gazeTarget` wins when both are set.
    gazeTarget{ x: number; y: number; } | nullnullEyes track this point. Components in [-1, 1] relative to the centre.
    hudHudSpec | nullnullA dial drawn in the ring outside the body. `null` removes it.
    inkInkPaint | nullnullOverride the body paint: a flat colour or a two-stop gradient. `null` returns it to the theme token.
    lineWeightnumber—Multiplier on the shared line weight, 0.8–1.25. Applies to the nose, the mouth and the HUD dials together, so one character can be drawn with a heavier pen without its features disagreeing with each other.
    nose"slant" | "angular" | "hook" | "long" | "curve" | "bow" | null—Nose for the line face. Defaults to one derived from the seed; `null` draws none.
    onSettle((from: "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", to: "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") => void)—Called when a finite state has run its course and the engine has moved on by itself. Lets a host's own idea of the state follow without keeping a duplicate timer.
    pausedbooleanfalseFreeze in place. The current frame is held rather than reset.
    poseHomePartial<PoseHome>—Rest pose — the slight head turn held when nothing else is happening.
    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>——
    seedstring—Seed for per-instance randomness so neighbours do not blink in unison.
    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"—Body shape.
    sizeCssstring | number—Any CSS length, taking precedence over `sizePx`.
    sizePxnumber28Rendered size in pixels. Defaults to 28.
    sourceIdstring—Id for the root SVG group, letting `CharacterMarkMirror` reuse this instance instead of starting its own animation.
    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"—Animation state.
    styleCSSProperties——
    styleIdStyleId—The style series: `vector` (default), `pixel` or `kaomoji`. Named `styleId` because `style` is already the inline CSS. Switching it at runtime replaces the renderer and keeps the character's state and motion.
    styleOptionsRecord<string, unknown>—Parameters of the series, such as `{ grid: 32 }` for `pixel`. Clamped by the engine.
    variant"solid" | "outline" | "glass" | "sticker" | "paper" | "soft" | "riso" | (string & Record<never, never>)—Paint variant within the style series, such as `outline` for `vector`, `dot` for `pixel` or `bubble` for `kaomoji`. Falls back to the surrounding `AppearanceProvider`, then to the series' own default.

    Next