# Ink Crowd (`ink-crowd`)

> A procedural canvas crowd for hero sections — tiny ink figures stroll through parallax lanes with hats, multicoloured umbrellas, and multicoloured balloons.

- **Docs:** https://www.mellowui.com/components/ink-crowd
- **Markdown:** https://www.mellowui.com/components/ink-crowd.md
- **Registry:** https://www.mellowui.com/r/ink-crowd.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/ink-crowd
- **Categories:** display, canvas, animation
- **Dependencies:** none

## AI prompt

Add an InkCrowd component from the mellow library — a procedural canvas crowd of tiny ink figures walking through parallax lanes with hats, umbrellas and balloons. Tune `count`, `speed` and `accent`. It fills its container, so give the wrapper a height.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/ink-crowd.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `count` | `number` | `26` | How many figures are out walking. |
| `speed` | `number` | `1` | Walk speed multiplier. |
| `accent` | `string` | `"oklch(0.65 0.25 250)"` | Fill for the balloons and umbrella canopies — the one spot of colour. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

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

export interface InkCrowdProps {
  /** How many figures are out walking. */
  count?: number;
  /** Walk speed multiplier. */
  speed?: number;
  /** Fill for the balloons and umbrella canopies — the one spot of colour. */
  accent?: string;
  className?: string;
  style?: React.CSSProperties;
}

interface Peep {
  x: number;
  dir: 1 | -1;
  vel: number;
  phase: number;
  seed: number;
  lane: number;
  hat: boolean;
  umbrella: boolean;
  balloon: boolean;
}

const LANES = [
  { yOff: 34, s: 0.68, alpha: 0.3 },
  { yOff: 18, s: 0.86, alpha: 0.55 },
  { yOff: 4, s: 1.06, alpha: 0.9 },
];

/**
 * The single spot of colour in an otherwise all-ink scene — every balloon and
 * umbrella carries it, so the crowd reads as one drawing rather than confetti.
 */
const ACCENT = "oklch(0.65 0.25 250)";

/** Reads --ink-rgb at runtime and re-reads on theme change. */
function useInkRgb(): string {
  const [inkRgb, setInkRgb] = useState("235, 235, 228");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-rgb")
        .trim();
      if (v) setInkRgb(v);
    };
    read();
    const observer = new MutationObserver(read);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    });
    return () => observer.disconnect();
  }, []);
  return inkRgb;
}

function hash(n: number) {
  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;
  return s - Math.floor(s);
}

/**
 * A procedurally drawn crowd of ink figures strolling across the bottom of a
 * hero — hats, umbrellas, the occasional balloon — in three parallax lanes.
 * No sprites, no assets: every figure is generated and drawn in code.
 */
export function InkCrowd({
  count = 26,
  speed = 1,
  accent = ACCENT,
  className,
  style,
}: InkCrowdProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const inkRgb = useInkRgb();
  const safeCount = Math.max(0, Math.floor(count));

  useEffect(() => {
    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const ctxOrNull = canvas.getContext("2d");
    if (!ctxOrNull) return;
    const ctx: CanvasRenderingContext2D = ctxOrNull;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const sizeRef = { w: 0, h: 0 };

    const resize = () => {
      const rect = canvas.getBoundingClientRect();
      sizeRef.w = rect.width;
      sizeRef.h = rect.height;
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(() => {
      resize();
      if (reduced) draw(0);
    });
    ro.observe(canvas);

    const seedPeep = (i: number, spawnEdge: boolean): Peep => {
      const seed = i * 13.7 + Math.random() * 100;
      const r = hash(seed);
      const lane = r < 0.4 ? 0 : r < 0.73 ? 1 : 2;
      const dir: 1 | -1 = hash(seed + 1) > 0.5 ? 1 : -1;
      const variant = hash(seed + 2);
      return {
        x: spawnEdge
          ? dir === 1
            ? -30
            : sizeRef.w + 30
          : hash(seed + 3) * sizeRef.w,
        dir,
        vel: (20 + hash(seed + 4) * 22) * LANES[lane].s,
        phase: hash(seed + 5) * Math.PI * 2,
        seed,
        lane,
        hat: variant > 0.3 && variant < 0.52,
        umbrella: variant >= 0.12 && variant < 0.3,
        balloon: variant < 0.09,
      };
    };

    let peeps: Peep[] = Array.from({ length: safeCount }, (_, i) =>
      seedPeep(i, false)
    );
    peeps.sort((a, b) => a.lane - b.lane);

    const drawPeep = (p: Peep, t: number) => {
      const lane = LANES[p.lane];
      const groundY = sizeRef.h - lane.yOff;
      const bob = Math.abs(Math.sin(p.phase)) * 2.4;
      const a = lane.alpha;

      ctx.save();
      ctx.translate(p.x, groundY - bob * lane.s);
      ctx.scale(p.dir * lane.s, lane.s);
      ctx.lineWidth = 2;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      ctx.strokeStyle = `rgba(${inkRgb}, ${a})`;
      ctx.fillStyle = `rgba(${inkRgb}, ${a})`;

      const sw = Math.sin(p.phase) * 5.5;
      const liftA = Math.max(0, Math.sin(p.phase)) * 2.5;
      const liftB = Math.max(0, -Math.sin(p.phase)) * 2.5;
      const asw = Math.sin(p.phase + Math.PI) * 3.5;

      ctx.beginPath();
      // legs
      ctx.moveTo(1, -13);
      ctx.lineTo(sw + 2, -liftA);
      ctx.moveTo(1, -13);
      ctx.lineTo(-sw + 1, -liftB);
      // torso
      ctx.moveTo(1, -13);
      ctx.lineTo(2.4, -22);
      // arms
      ctx.moveTo(2.4, -20);
      ctx.lineTo(asw + 3.5, -14);
      ctx.moveTo(2.4, -20);
      ctx.lineTo(-asw - 0.5, -14);
      ctx.stroke();

      // head
      ctx.beginPath();
      ctx.arc(2.9, -26.5, 3.6, 0, Math.PI * 2);
      ctx.fill();

      if (p.hat) {
        ctx.beginPath();
        ctx.moveTo(-2.4, -29.8);
        ctx.lineTo(8.2, -29.8);
        ctx.moveTo(0.6, -29.8);
        ctx.lineTo(1, -33.4);
        ctx.lineTo(4.8, -33.4);
        ctx.lineTo(5.2, -29.8);
        ctx.stroke();
      }

      if (p.umbrella) {
        const hx = asw + 3.5;
        ctx.beginPath();
        ctx.moveTo(hx, -14);
        ctx.lineTo(hx + 0.5, -37);
        ctx.stroke();
        ctx.beginPath();
        ctx.arc(hx + 0.5, -37, 10, Math.PI, 0);
        ctx.closePath();
        ctx.fillStyle = accent;
        ctx.globalAlpha = Math.min(1, a + 0.1);
        ctx.fill();
        ctx.globalAlpha = 1;
        ctx.stroke();
      }

      if (p.balloon) {
        const bx = -asw - 0.5 + Math.sin(t * 1.4 + p.seed) * 2;
        ctx.beginPath();
        ctx.moveTo(-asw - 0.5, -14);
        ctx.quadraticCurveTo(bx - 1, -28, bx, -40);
        ctx.stroke();
        ctx.beginPath();
        ctx.arc(bx, -44, 4.5, 0, Math.PI * 2);
        ctx.fillStyle = accent;
        ctx.globalAlpha = Math.min(1, a + 0.15);
        ctx.fill();
        ctx.globalAlpha = 1;
      }

      ctx.restore();
    };

    const draw = (t: number) => {
      ctx.clearRect(0, 0, sizeRef.w, sizeRef.h);
      for (const p of peeps) drawPeep(p, t);
    };

    if (reduced) {
      draw(0);
      return () => ro.disconnect();
    }

    let raf = 0;
    let last = performance.now();
    const loop = (now: number) => {
      raf = requestAnimationFrame(loop);
      const dt = Math.min((now - last) / 1000, 0.05) * speed;
      last = now;

      for (let i = 0; i < peeps.length; i++) {
        const p = peeps[i];
        p.x += p.vel * p.dir * dt;
        p.phase += p.vel * 0.32 * dt;
        if ((p.dir === 1 && p.x > sizeRef.w + 30) || (p.dir === -1 && p.x < -30)) {
          peeps[i] = seedPeep(i, true);
        }
      }
      peeps.sort((a, b) => a.lane - b.lane);
      draw(now / 1000);
    };
    raf = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
    };
  }, [safeCount, speed, inkRgb, accent]);

  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label="A drawn crowd of figures strolling by"
      className={["block h-full w-full", className].filter(Boolean).join(" ")}
      style={style}
    />
  );
}

export default InkCrowd;

```

## Demo

```tsx
"use client";

import React from "react";
import { InkCrowd } from "../mellow/ink-crowd";

export default function InkCrowdDemo() {
  return (
    <div className="relative h-full min-h-[360px] w-full overflow-hidden bg-[var(--background)]">
      <div className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,rgba(var(--ink-rgb),0.05)_1px,transparent_1px),linear-gradient(0deg,rgba(var(--ink-rgb),0.035)_1px,transparent_1px)] bg-size-[56px_56px]" />
      <div className="absolute inset-x-0 top-0 bottom-[12%] z-10 flex flex-col items-center justify-center px-6 text-center">
        <p className="[font-family:var(--font-mono)] text-[0.62rem] uppercase tracking-[0.24em] text-[rgba(var(--ink-rgb),0.42)]">
          Market open / footfall study
        </p>
        <h2 className="mt-4 max-w-[9ch] [font-family:var(--font-serif)] text-6xl leading-[0.85] tracking-[-0.08em] text-[var(--ink)] italic sm:text-7xl">
          Watch the room move.
        </h2>
        <p className="mt-5 max-w-sm text-sm leading-6 text-[rgba(var(--ink-rgb),0.56)]">
          Procedural ink figures drift through three parallax lanes — no sprite
          sheet, just canvas linework.
        </p>
      </div>

      <div className="absolute inset-x-0 bottom-0 h-[58%]">
        <InkCrowd count={34} speed={0.9} className="h-full w-full" />
      </div>
      <div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-[linear-gradient(to_top,var(--background),transparent)]" />
    </div>
  );
}

```
