<!-- https://getkonjo.com/design/brand/ninja-motion · source: docs/design/brand/ninja-motion.md -->

# Ninja motion

How the ninja moves, when, for how long, and what it does when someone has asked the phone to hold still.

> Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine
> apply here unless this chapter contradicts them. Durations come from
> [motion](../foundations/motion.md); where the ninja may appear at all comes from
> [imagery](../foundations/imagery.md).

**Status:** spec, 2026-10-02. The art is split and ready to rig; the Rive file does not exist yet,
and the app still shows the static poses. Nothing on this page is live until
`assets/brand/ninja/konjo-ninja.riv` lands.

The ninja is the mark. It moves the way a black belt moves: once, cleanly, then still. It never
fidgets to fill a screen, never loops for attention, and never moves while someone is reading a
sentence beside it. If you noticed the animation, it was too long.

## The file

One Rive file: **`assets/brand/ninja/konjo-ninja.riv`**. One artboard per pose, because each pose
is its own drawing and a rig cannot morph one drawing into another.

| Artboard | Drawn from | Used for |
|---|---|---|
| `welcome` | `01-welcome` | Onboarding hero, sign-in |
| `ready` | `02-ready` | Idle default, auth screens, bow |
| `promotion` | `05-promotion` | Belt promotion |
| `jump` | `06-jump` | Streaks, badges, milestones |
| `point` | `08-point` | Onboarding hints, "start here" empty states |
| `oops` | `09-oops` | Errors. Not rigged yet; static until it is |
| `bow` | `04-bow` | Not rigged yet; `ready` carries the bow until it is |

Every artboard has one state machine, named `ninja`, with the same inputs, so the app never needs
to know which pose it is talking to.

| Input | Type | Does |
|---|---|---|
| `play` | trigger | Runs the artboard's one action, then returns to `hold` |
| `blink` | trigger | One blink. The app fires it; the file never blinks on its own |
| `dark` | boolean | Shows the grey keyline layer, the same one the dark PNGs carry |

Rigging starts from the layered SVGs in [`assets/brand/ninja/rig/`](../../../assets/brand/ninja/rig/README.md),
which name every layer, give every pivot and fix the draw order. They are generated by
[`scripts/brand/ninja-rig.py`](../../../scripts/brand/ninja-rig.py); never split a pose by hand.

## The state machine

```
entry ─► arrive ─► hold ◄──┐
                   │  ▲ │  │
              play │  │ └──┘ blink
                   ▼  │ done
                 action
```

- **`arrive`** plays once when the ninja first appears. It is the pose settling into place, not
  an entrance from off-screen.
- **`hold`** is the resting state. It is the static pose. Nothing moves in it.
- **`action`** is the artboard's one move: bow, celebrate, tie, point or shrug. It always ends back in `hold`, on the same frame
  `arrive` ended on.
- **`blink`** is layered over `hold`. It only touches `eye-left` and `eye-right`.

### Idle: breathe and blink

**Idle is a hold, not a loop.** [Motion](../foundations/motion.md) bans breathing and floating
everywhere, and the ninja is not an exception to it.

- **Breathe** happens once, as the last beat of `arrive`: the torso rises 1% and settles. It does
  not repeat.
- **Blink** is fired by the app, at most twice per visit to a screen, the first one 2–4 seconds
  after arrival and never while the person is typing. A blink is a state change of the eyes, not
  decoration, and two is the ceiling.

### The actions

All timings are tokens from [motion](../foundations/motion.md). A step's duration is the token
named; a sequence's total stays at or under 400ms, with one exception.

- **Arrive** · all artboards · 400ms. The pose settles from 8pt low: `enter` (250), ease out. Torso breath: `quick` (150).
- **Blink** · all artboards · 100ms. The eyes close and open: `instant` (100).
- **Bow** · `ready` · 380ms. Head and torso dip 30° from the hips: `base` (200), ease out. Rise: `exit` (180), ease in.
- **Celebrate** · `jump` · 400ms. Crouch: `instant` (100). Leave the ground: `quick` (150). Land and settle: `quick` (150).
- **Point** · `point` · 400ms. The arm extends from the elbow: `enter` (250). One nudge toward the target: `quick` (150).
- **Shrug** · `oops` · 330ms. Shoulders and hands up: `quick` (150). Down: `exit` (180).
- **Tie the belt** · `promotion` · 900ms. Hands pull the knot: `enter` (250). Belt tails flick out: `slow` (350). Proud blink: `quick` (150). Settle: `quick` (150).

**Tying the belt is the one animation allowed past 400ms**, for the same reason the
[promotion certificate](../patterns/celebration.md) is: the moment is the point. It plays once,
when the certificate appears, and is opt-out under reduce motion like every other move here.

### Rules for every move

- **Pivots are the joints.** Rotate a part about the pivot the rig gives it. Nothing scales a
  limb to fake a bend.
- **Travel is small.** The whole figure never moves more than 24pt. Arrive is 8pt.
- **Ease out on the way in, ease in on the way out.** The standard, enter and exit curves from
  the token file; no springs, no overshoot past the final pose.
- **One move at a time.** The ninja does not move while a sheet is rising or a list is loading.
- **Interruptible.** A new `play` mid-action jumps to `hold` first. Nothing queues.
- **The face only changes through the eyes.** No mouth, no eyebrows, per the
  [canon](ninja-prompt-pack.md).

## Reduce motion

When the system's reduce-motion setting is on, **the ninja does not animate at all.**

| Normally | Reduced |
|---|---|
| Arrive | The pose is simply there |
| Blink, breathe | Never |
| Bow, celebrate, point, shrug | The static pose of that moment |
| Tie the belt | The `promotion` pose, presented statically beside the certificate |
| One pose replacing another | A cross-fade of `base` (200) in place |

The static pose is the PNG the app already ships, from `src/components/ninjaArt.ts`. Nothing is
lost: every message the ninja accompanies is carried by the words and the button beside it.

## Where each state is used

The ninja accompanies a message; it never replaces it. The rules on where it may appear are in
[imagery](../foundations/imagery.md), and they hold here.

- **Onboarding welcome, sign-in:** `welcome`, arrive, then up to two blinks. The only screen where a blink may fire without a tap.
- **Auth screens:** `ready`, arrive. No action.
- **Class start, respect moments:** `ready`, bow. Plays once on arrival.
- **Onboarding hints, "start here" empty states:** `point`, arrive then point. Points toward the one way in. See [states](../patterns/states.md).
- **Error states:** `oops`, shrug. Static until `09-oops` is rigged. Never on a payment, waiver or safety error.
- **Streak, badge, milestone:** `jump`, celebrate. Plays once when the milestone is shown, never again on revisit.
- **Belt promotion:** `promotion`, tie the belt. Plays with the [certificate](../patterns/celebration.md) reveal. The sensei stays the subject.

**Never:** in chrome, on billing, waivers or safety flags, as a speaker, or on a loop.

## In the app

The runtime is not installed yet. When it is:

- **Native:** `rive-react-native`. It is a native module, so it ships in the next EAS build and
  does not run in Expo Go. Metro needs `riv` added to its asset extensions.
- **Web:** `@rive-app/react-canvas`, through a `.web.tsx` platform file, the same way the app
  already splits other components for React Native Web.
- **One component**, `NinjaMotion`, in `src/components/`. It takes a moment (`welcome`, `ready`,
  `bow`, `point`, `oops`, `celebrate`, `promotion`), a size and a `testID`, and picks the
  artboard and action.
- **The PNG is the floor.** `NinjaMotion` renders the pose from `ninjaArt(pose, theme)` first, in
  the same box, and swaps to the Rive canvas only once the file has loaded. If the runtime is
  missing, the file fails, or reduce motion is on, the PNG simply stays. Nothing jumps when the
  animation arrives.
- **Dark mode** sets the `dark` input, so the keyline matches the dark PNGs.
- **Hidden from screen readers**, like every decorative image. The message carries the meaning.
- **Budget:** the `.riv` stays under 150 KB, all artboards together.

Until `NinjaMotion` exists, keep using `ninjaArt` directly. It is already the fallback, so
nothing needs undoing when the animation lands.
