# Motion

How animated ASCII runs, pauses and holds still, including under reduced motion.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The asciicn agent skill, for building any ASCII effect on the engine, is at [/.well-known/agent-skills/asciicn/SKILL.md](/.well-known/agent-skills/asciicn/SKILL.md).

Animated ASCII in asciicn moves slowly on purpose. A background should drift, not perform. Every renderer follows the same rules, and they come from `ascii-core`, so you get them for free when you build your own.

```tsx title="examples/engine-motion.tsx"
"use client";

import { useEffect, useRef, useState } from "react";

import { AsciiGrid, SPINNERS, eighthsBar, EIGHTHS_UP } from "@/lib/ascii";
import type { AsciiGridDrawContext } from "@/lib/ascii";
import { cn } from "@/lib/utils";

// A slow wave of eighth blocks with a clock and a spinner, so you can see
// time stop when the grid pauses, holds still or runs slower.
const drawWave = ({ frame, columns, rows, time }: AsciiGridDrawContext) => {
  const floor = rows - 2;
  for (let x = 0; x < columns; x += 1) {
    const height =
      (0.5 +
        0.28 * Math.sin(x * 0.11 + time * 0.9) +
        0.18 * Math.sin(x * 0.037 - time * 0.4)) *
      (floor - 2);
    const full = Math.floor(height);
    for (let y = 0; y < full; y += 1) {
      frame.put(x, floor - y, "█");
    }
    frame.put(x, floor - full, eighthsBar(height - full, 1, EIGHTHS_UP));
  }
  const spinner =
    SPINNERS.orbit[Math.floor(time * 10) % SPINNERS.orbit.length] ?? " ";
  frame.write(2, 1, `${spinner} t = ${time.toFixed(2)}s`);
};

const STATES = ["running", "paused", "static", "fallback"] as const;

// The speed button cycles through these.
const SPEEDS = [1, 0.25, 3] as const;

export default function EngineMotion() {
  const [animate, setAnimate] = useState(true);
  const [speed, setSpeed] = useState<(typeof SPEEDS)[number]>(1);
  const [state, setState] = useState<string>("fallback");
  const ref = useRef<HTMLDivElement>(null);

  // Mirror the grid's data-state attribute into the readout.
  useEffect(() => {
    const node = ref.current;
    if (!node) {
      return;
    }
    const read = () => setState(node.dataset.state ?? "fallback");
    read();
    const observer = new MutationObserver(read);
    observer.observe(node, {
      attributeFilter: ["data-state"],
      attributes: true,
    });
    return () => observer.disconnect();
  }, []);

  return (
    <div className="relative size-full">
      <AsciiGrid
        ref={ref}
        className="absolute inset-0"
        cell={12}
        animate={animate}
        speed={speed}
        stillTime={3}
        draw={drawWave}
      />
      <div className="bg-background/90 absolute inset-x-3 bottom-3 flex flex-wrap items-center gap-2 rounded-md border px-3 py-2 font-mono text-xs backdrop-blur-sm sm:inset-x-auto sm:right-4 sm:bottom-4">
        <span className="text-muted-foreground">data-state</span>
        {STATES.map((name) => (
          <span
            key={name}
            className={cn(
              "rounded-sm px-1.5 py-0.5",
              name === state
                ? "bg-ascii-ink text-background"
                : "text-muted-foreground"
            )}
          >
            {name}
          </span>
        ))}
        <button
          type="button"
          aria-pressed={!animate}
          onClick={() => setAnimate(!animate)}
          className="border-border hover:bg-muted focus-visible:ring-ring ml-1 rounded-sm border px-2 py-0.5 outline-none focus-visible:ring-2"
        >
          {animate ? "hold" : "play"}
        </button>
        <button
          type="button"
          onClick={() =>
            setSpeed(SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length] ?? 1)
          }
          className="border-border hover:bg-muted focus-visible:ring-ring rounded-sm border px-2 py-0.5 tabular-nums outline-none focus-visible:ring-2"
        >
          speed {speed}×
        </button>
      </div>
    </div>
  );
}
```

## When a renderer moves [#when-a-renderer-moves]

`AsciiShader` and `AsciiGrid` animate only while someone can see them. Their `data-state` attribute says what they are doing:

| `data-state` | Meaning                                                                                                                             |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `running`    | Drawing up to `fps` frames a second, 30 by default.                                                                                 |
| `paused`     | Scrolled out of view or in a hidden tab. Time stops, so it resumes where it left off.                                               |
| `static`     | Motion is off. Reduced motion draws `stillTime`. `animate={false}` holds the current frame, or draws `stillTime` if it never moved. |
| `fallback`   | Nothing drawn yet. For `AsciiShader`, also when WebGL is unavailable. The `fallback` content shows instead.                         |

Time only advances while running, and a long frame (a tab switch, a debugger) counts as at most a tenth of a second, so motion never jumps.

## Reduced motion [#reduced-motion]

When the visitor turns on reduced motion in their system settings, every renderer draws one still frame and stops. `stillTime` picks which moment of the animation that frame shows, so you can choose a good one:

```tsx
<AsciiGrid draw={drawScene} stillTime={14} />
```

The switch is live: turning the setting on or off while the page is open takes effect at once. CRT flicker and glitch stop too. Interface pieces follow the same rule: their transitions become instant, and anything that loops holds still.

## Speed and frame rate [#speed-and-frame-rate]

`speed` scales time, and changing it never jumps the motion, because each frame adds its own slice of time. `fps` caps the frame rate. The default of 30 keeps slow drifts smooth at half the cost of 60. Raise it for fast motion such as particles, rain or anything you plan to [record](/docs/engine/export#video).

## Your own loops [#your-own-loops]

`useFrameLoop` is the loop both renderers use. Give it a callback and an element, and it calls you once per frame while that element is on screen:

```tsx
const ref = useRef<HTMLDivElement>(null);
const state = useFrameLoop(
  (time, delta) => {
    // draw the frame for `time` seconds
  },
  { target: ref, fps: 30, stillTime: 0 }
);
```

Under reduced motion or with `enabled: false`, it calls the callback once with `stillTime` and a `delta` of 0. It returns `"running"`, `"paused"` or `"static"`, and only re-renders when that changes. `usePrefersReducedMotion`, `useInView` and `useDocumentVisible` are exported too.

In `AsciiGrid`, the same values arrive in the draw context: `time` is seconds scaled by `speed`, and `delta` is the scaled time since the last frame (0 for a still frame). Use `time` for anything that should look the same at a given moment, such as waves and orbits, and `delta` for simulations that step forward.

## Easing and timing [#easing-and-timing]

For transitions in interface pieces, `MOTION` holds the shared values:

```ts
MOTION.ease; // "cubic-bezier(0.23, 1, 0.32, 1)", a strong ease-out
MOTION.fast; // 150ms, for presses and colour changes
MOTION.base; // 240ms, for small reveals
MOTION.slow; // 600ms, for large surfaces fading in
```

Hover effects use Tailwind's `hover:` variant, which only applies on devices that can hover, so a tap on a phone never leaves an element stuck in its hover state.