# Stirred Ink
[Full-page experience](/experience/stirred-ink/) · [Open playground](/playground/#/examples/composition/stirred-ink)


**Advanced Pibbl application.** [Open Stirred Ink in the editor](/playground/#/examples/composition/stirred-ink). The example already exists in the learning catalog; this page reuses its canonical source.

## What to inspect

Watch the moving body disturb the ink and compare the material swatches. Pause the simulation and toggle stirring in the existing controls. Changing the connection between motion and texture should change the visual field, not turn the texture into a physics solver.

## Prerequisites

Understand [texture ownership](/reference/use-texture/), [reaction-diffusion messages](/reference/reaction-diffusion/), and [composition boundaries](/guides/foundations/). The source imports only public Pibbl entries, including `@pibbl/core/physics/2d`; no private helpers or external image assets are needed. The physics runtime is supplied by the package.

## How the composition works

The physics body exposes committed position and generation signals. A `useReaction` maps the position into the texture region, which starts at `(0, 180)` and spans 600 by 300 logical units. `u = x / 600` and `v = (y - 180) / 300` are normalized coordinates. Displacement becomes `du` and `dv`, scaled by an application-chosen factor of 2.5.

The bridge skips the first sample, a generation change, zero motion, and a jump larger than 40 units. These guards prevent a reset or teleport from smearing across the entire texture. They are recipe choices, not universal Pibbl thresholds.

The ink's 256 by 128 simulation is independent of the display size. The same root owns the texture, physics world, and playback. Dispose its returned controller to release the composition. The source shows pause control explicitly.

## Adapt carefully

When moving or resizing the ink region, update the coordinate conversion as well as the rectangle. When changing body speed, inspect bounded stir displacement and teleport handling. Test pause, reset, resize, and repeated mount/dispose before adding another body.

This is a one-way visual coupling. Texture forces do not move the body, and the field is not a physical fluid simulation. The existing showcase is Canvas-centered; its painted controls do not create native HTML semantics. For a public HTML site, keep control semantics in native HTML as in the [button recipe](/guides/native-button/).

[Detailed companion](/agents/stirred-ink/) · [Playground instructions](/playground/)

## Related APIs

- [useTexture](/reference/types/texture-resources/)
- [reactionDiffusion](/reference/types/textures/)
- [marble](/reference/types/textures/)
- [paper](/reference/types/textures/)
- [cellular](/reference/types/textures/)
- [useReaction](/reference/hooks/use-reaction/)
- [usePlayback](/reference/hooks/use-playback/)
- [PhysicsWorld2D](/reference/functions/physics-2d/)

## Complete source

Host setup for this source: use a canvas with width 600 and height 580, compile with `jsx: "react-jsx"` and `jsxImportSource: "@pibbl/core"`, import its default `mount`, and call `const controller = mount(canvas)` after attaching the canvas. Call `controller.dispose()` before removing it.

### stirred-ink.example.tsx

```tsx
/** @jsxImportSource @pibbl/core */
import {
  pibbl,
  Group,
  Rectangle,
  Circle,
  useSignal,
  useReaction,
  useRef,
  useTexture,
  usePlayback,
  drive,
  tween,
  repeat,
  sequence,
  wait,
  type Texture,
  type PibblController,
  Text,
  signal,
} from "@pibbl/core";
import {
  marble,
  paper,
  cellular,
  reactionDiffusion,
  type InkMessage,
} from "@pibbl/core/textures";
import {
  PhysicsWorld2D,
  pibblPhysicsBody2D,
  collider2D,
  dynamicBody2D,
  staticBody2D,
} from "@pibbl/core/physics/2d";
export default function mount(canvas: HTMLCanvasElement): PibblController {
  const paused = signal(false);
  const stirring = signal(true);
  let ink: Texture<InkMessage>;
  function Materials() {
    const detail = useSignal(0.2);
    usePlayback(
      repeat(
        sequence(
          drive(detail, tween({ from: 0.2, to: 0.9, duration: 3000 })),
          wait(16),
        ),
        { iterations: Infinity, direction: "alternate" },
      ),
      { autoplay: true },
    );
    const value = detail.get();
    const a = useTexture(marble({ detail: value }), {
      resolution: { width: 96, height: 96 },
    });
    const b = useTexture(paper({ detail: value }), {
      resolution: { width: 96, height: 96 },
    });
    const c = useTexture(cellular({ detail: value }), {
      resolution: { width: 96, height: 96 },
    });
    return (
      <Group>
        {[a, b, c].map((texture, i) => (
          <Rectangle
            key={i}
            style={{ left: i * 200, top: 0, width: 192, height: 160, fill: texture }}
          />
        ))}
      </Group>
    );
  }
  function Ink() {
    const body = pibblPhysicsBody2D();
    ink = useTexture(reactionDiffusion({ seed: 42, preset: "stripes" }), {
      resolution: { width: 256, height: 128 },
      palette: ["#101f2b", "#265869", "#77b8ac", "#f3d69d"],
    });
    const previous = useRef<readonly number[] | undefined>(undefined);
    useReaction(() => {
      const active = stirring.get();
      const [x, y] = body.position.get();
      const generation = body.generation.get();
      const last = previous.current;
      previous.current = [x, y, generation];
      if (
        !active ||
        !last ||
        last[2] !== generation ||
        Math.hypot(x - last[0], y - last[1]) > 40 ||
        (x === last[0] && y === last[1])
      )
        return;
      ink.send({
        type: "stir",
        u: x / 600,
        v: (y - 180) / 300,
        du: ((x - last[0]) / 600) * 2.5,
        dv: ((y - last[1]) / 300) * 2.5,
        radius: 22,
      });
    });
    return (
      <PhysicsWorld2D gravity={[0, 0]}>
        <Rectangle
          style={{ left: 0, top: 180, width: 600, height: 300, fill: ink }}
        />
        <Rectangle
          physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })}
          style={{ left: 0, top: 180, width: 600, height: 8, fill: "#6c9699" }}
        />
        <Rectangle
          physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })}
          style={{ left: 0, top: 472, width: 600, height: 8, fill: "#6c9699" }}
        />
        <Rectangle
          physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })}
          style={{ left: 0, top: 180, width: 8, height: 300, fill: "#6c9699" }}
        />
        <Rectangle
          physics={staticBody2D({ collider: { restitution: 1, friction: 0 } })}
          style={{ left: 592, top: 180, width: 8, height: 300, fill: "#6c9699" }}
        />
        <Group
          physics={dynamicBody2D({
            handle: body,
            linearVelocity: [180, 130],
            allowSleep: false,
            collider: false,
            mass: 1,
          })}
          style={{ translateX: 300, translateY: 300 }}
        >
          <Circle
            physics={collider2D({
              restitution: 1,
              friction: 0,
            })}
            style={{
              radius: 9,
              fill: "#77b8ac66",
              stroke: "#e3fff1",
              strokeWidth: 2,
            }}
          />
        </Group>
      </PhysicsWorld2D>
    );
  }
  function Button({
    x,
    label,
    onClick,
  }: {
    x: number;
    label: string;
    onClick: () => void;
  }) {
    return (
      <Group style={{ translateX: x, translateY: 520 }}>
        <Rectangle
          keyboardFocusable
          onClick={onClick}
          style={{
            width: 192,
            height: 40,
            cornerRadius: 8,
            fill: "#265869",
            cursor: "pointer",
          }}
        />
        <Text
          pointerEvents="none"
          style={{
            left: 96,
            top: 25,
            font: "14px Arial",
            textAlign: "center",
            fill: "#ffffff",
          }}
        >
          {label}
        </Text>
      </Group>
    );
  }
  function Controls() {
    return (
      <>
        <Text style={{ left: 0, top: 500, font: "14px Arial", fill: "#cadfd9" }}>
          A moving body drags the ink; the reaction reshapes its wake.
        </Text>
        <Button
          x={0}
          label={paused.get() ? "Resume reaction" : "Pause reaction"}
          onClick={() => {
            paused.set(!paused.get());
            ink.send({ type: "pause", paused: paused.get() });
          }}
        />
        <Button
          x={200}
          label={stirring.get() ? "Stop stirring" : "Start stirring"}
          onClick={() => stirring.set(!stirring.get())}
        />
        <Button
          x={400}
          label="Reset ink"
          onClick={() => ink.send({ type: "reset" })}
        />
      </>
    );
  }
  return pibbl(
    canvas,
    <>
      <Rectangle
        pointerEvents="none"
        style={{ width: 600, height: 580, fill: "#101f2b" }}
      />
      <Materials />
      <Ink />
      <Controls />
    </>,
  );
}

```

## Documentation version

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.
