# Autumn Drift (`autumn-drift`)

> A gnarled tree on a rocky outcrop in full fall color, swaying in wind and shedding leaves — the cursor is a gust that scatters them.

- **Docs:** https://www.mellowui.com/components/autumn-drift
- **Markdown:** https://www.mellowui.com/components/autumn-drift.md
- **Registry:** https://www.mellowui.com/r/autumn-drift.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/autumn-drift
- **Categories:** background, canvas, interactive, animation
- **Dependencies:** none

## AI prompt

Add an AutumnDrift component from the mellow library — a gnarled tree on a rocky outcrop in full fall color, swaying in wind and shedding leaves — the cursor is a gust that scatters them. Key props: `leafCount`, `wind`, `treeAlign`, `leafColors`, `branchColor`. Copy the file into your project; it is self-contained and respects prefers-reduced-motion.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `leafCount` | `number` | `90` | Number of falling leaves. |
| `wind` | `number` | `0.5` | Wind strength 0–1: sway amplitude and horizontal drift. |
| `treeAlign` | `"left" \| "center" \| "right"` | `"left"` | Horizontal anchor of the tree. |
| `leafColors` | `string[]` | — | Fall foliage palette, as "r, g, b" triples. Defaults to pumpkin/brick/gold/maroon/amber. |
| `branchColor` | `string` | — | Branch color. Defaults to rgba(var(--ink-rgb), 0.75). |
| `treeImage` | `string` | — | URL of a transparent PNG of a bare tree. When set, it replaces the procedural tree and foliage masses are stamped at `foliageAnchors`. |
| `foliageAnchors` | `FoliageAnchor[]` | — | Foliage positions over the image, as fractions of the drawn image box. |
| `className` | `string` | — | Additional CSS classes. |
| `children` | `ReactNode` | — | Hero content rendered above the canvas. |

## 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, useCallback, useState } from "react";

function useThemeRgb(): { ink: string; bg: string } {
  const [rgb, setRgb] = useState({ ink: "244, 241, 236", bg: "20, 19, 18" });
  useEffect(() => {
    const read = () => {
      const s = getComputedStyle(document.documentElement);
      const ink = s.getPropertyValue("--ink-rgb").trim();
      const bg = s.getPropertyValue("--background-rgb").trim();
      if (ink || bg) setRgb((p) => ({ ink: ink || p.ink, bg: bg || p.bg }));
    };
    read();
    const obs = new MutationObserver(read);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => obs.disconnect();
  }, []);
  return rgb;
}

function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// first half reads as the "red zone", second half the "gold zone" — the
// crown is painted left-to-right across these two groups, not scattered
const DEFAULT_LEAF_COLORS = [
  "176, 28, 32", // crimson
  "136, 20, 26", // deep maroon
  "202, 60, 38", // red-orange
  "232, 150, 18", // amber gold
  "212, 120, 18", // burnt orange
  "236, 184, 38", // yellow gold
];

interface Branch {
  parent: number; // -1 for trunk
  angleRel: number; // radians relative to parent's absolute angle
  length: number;
  width: number; // width at the base of this branch
  tipWidth: number; // width where the children take over
  depth: number;
  phase: number; // sway phase offset
  bow: number; // perpendicular bend of the branch, as a fraction of length
  // per-frame computed
  absAngle: number;
  x0: number;
  y0: number;
  x1: number;
  y1: number;
}

// soft foliage mass, stamped from a pre-rendered sprite
interface Puff {
  branch: number;
  dx: number;
  dy: number;
  r: number;
  a: number;
  color: number;
}

interface FoliageCluster {
  branch: number;
  offsets: { dx: number; dy: number; r: number; a: number; color: number }[];
}

interface Leaf {
  x: number;
  y: number;
  vx: number;
  vy: number;
  rot: number;
  rotV: number;
  size: number;
  phase: number;
  alpha: number;
  color: number;
}

/** Foliage placement over a `treeImage`, as fractions of the drawn image box. */
export interface FoliageAnchor {
  x: number;
  y: number;
  /** Mass radius as a fraction of the image box's smaller side. */
  r: number;
}

// tuned for a bare crown filling the upper two thirds of the image
const DEFAULT_ANCHORS: FoliageAnchor[] = [
  { x: 0.42, y: 0.1, r: 0.09 },
  { x: 0.56, y: 0.08, r: 0.08 },
  { x: 0.3, y: 0.16, r: 0.09 },
  { x: 0.66, y: 0.15, r: 0.09 },
  { x: 0.19, y: 0.25, r: 0.08 },
  { x: 0.79, y: 0.22, r: 0.09 },
  { x: 0.12, y: 0.34, r: 0.07 },
  { x: 0.36, y: 0.29, r: 0.08 },
  { x: 0.53, y: 0.27, r: 0.08 },
  { x: 0.7, y: 0.32, r: 0.08 },
  { x: 0.87, y: 0.36, r: 0.08 },
  { x: 0.45, y: 0.39, r: 0.07 },
  { x: 0.62, y: 0.44, r: 0.08 },
  { x: 0.78, y: 0.5, r: 0.08 },
  { x: 0.88, y: 0.55, r: 0.06 },
  { x: 0.25, y: 0.21, r: 0.08 },
  { x: 0.48, y: 0.17, r: 0.08 },
  { x: 0.61, y: 0.21, r: 0.08 },
  { x: 0.27, y: 0.37, r: 0.07 },
  { x: 0.55, y: 0.34, r: 0.08 },
  { x: 0.72, y: 0.41, r: 0.07 },
  { x: 0.84, y: 0.44, r: 0.06 },
  { x: 0.16, y: 0.29, r: 0.07 },
  { x: 0.38, y: 0.22, r: 0.08 },
  { x: 0.5, y: 0.46, r: 0.06 },
  { x: 0.23, y: 0.16, r: 0.07 },
  { x: 0.13, y: 0.25, r: 0.065 },
  { x: 0.07, y: 0.31, r: 0.055 },
  { x: 0.11, y: 0.45, r: 0.07 },
  { x: 0.78, y: 0.37, r: 0.075 },
];

interface ImgAnchor {
  x: number;
  y: number;
  r: number;
  phase: number;
  /** tone: 0 pale highlight, 1 base, 2 deep shadow; shape: 0 soft blob, 1 leaf silhouette */
  foliage: { dx: number; dy: number; r: number; a: number; tone: number; shape: number; color: number }[];
}

interface ImgScene {
  box: { x: number; y: number; w: number; h: number };
  anchors: ImgAnchor[];
}

const MAX_DEPTH = 6;

function growTree(
  W: number,
  H: number,
  align: "left" | "center" | "right",
  wind: number,
  colorCount: number
): {
  branches: Branch[];
  clusters: FoliageCluster[];
  puffs: Puff[];
  terminals: number[];
  rock: [number, number][];
  rockFacets: [number, number][][];
} {
  const rand = mulberry32(align === "left" ? 1337 : align === "center" ? 4242 : 9001);
  const branches: Branch[] = [];
  const clusters: FoliageCluster[] = [];
  const puffs: Puff[] = [];
  const terminals: number[] = [];

  const baseX = align === "left" ? W * 0.22 : align === "center" ? W * 0.5 : W * 0.8;
  const scale = Math.min(H, W * 0.9);

  // rocky outcrop the tree grips
  const rockH = scale * 0.16;
  const rockW = scale * 0.46;
  const topY = H - rockH;
  const j = (m: number) => (rand() - 0.5) * m;
  const rock: [number, number][] = [
    [baseX - rockW * 0.62, H + 8],
    [baseX - rockW * 0.46 + j(16), H - rockH * 0.4 + j(10)],
    [baseX - rockW * 0.28 + j(12), H - rockH * 0.8 + j(8)],
    [baseX - rockW * 0.1 + j(8), topY + j(6)],
    [baseX + rockW * 0.12 + j(8), topY + rockH * 0.1 + j(6)],
    [baseX + rockW * 0.3 + j(12), H - rockH * 0.55 + j(10)],
    [baseX + rockW * 0.52 + j(12), H - rockH * 0.18 + j(8)],
    [baseX + rockW * 0.64, H + 8],
  ];
  const rockFacets: [number, number][][] = [];
  for (let f = 0; f < 3; f++) {
    const start = rock[2 + f];
    rockFacets.push([
      [start[0] + j(10), start[1] + 4],
      [start[0] + j(26), (start[1] + H) / 2 + j(12)],
      [start[0] + j(34), H - 6],
    ]);
  }

  const grow = (
    parent: number,
    angleRel: number,
    length: number,
    width: number,
    depth: number,
    absHint: number
  ) => {
    const idx = branches.length;
    branches.push({
      parent,
      angleRel,
      length,
      width,
      tipWidth: width * 0.55,
      depth,
      phase: rand() * Math.PI * 2,
      bow: (rand() - 0.5) * (depth < 2 ? 0.5 : 0.36),
      absAngle: 0,
      x0: 0,
      y0: 0,
      x1: 0,
      y1: 0,
    });
    if (depth >= MAX_DEPTH || length < 6) {
      terminals.push(idx);
      // every twig tip carries a small foliage mass...
      puffs.push({
        branch: idx,
        dx: (rand() - 0.5) * 14,
        dy: (rand() - 0.5) * 12,
        r: 18 + rand() * 16,
        a: 0.13 + rand() * 0.11,
        color: -1, // resolved after the baseline position pass, below
      });
      // ...plus a few sharp leaf speckles for texture
      const offsets: FoliageCluster["offsets"] = [];
      const n = 3 + Math.floor(rand() * 3);
      for (let i = 0; i < n; i++) {
        offsets.push({
          dx: (rand() - 0.5) * 26,
          dy: (rand() - 0.5) * 22,
          r: 1.4 + rand() * 2.2,
          a: 0.3 + rand() * 0.4,
          color: -1,
        });
      }
      clusters.push({ branch: idx, offsets });
      return;
    }
    // soft masses hang on every bough, so the whole crown wears foliage
    if (rand() < (depth >= 4 ? 0.6 : 0.45)) {
      const n = 1 + (rand() < 0.4 ? 1 : 0);
      for (let i = 0; i < n; i++) {
        puffs.push({
          branch: idx,
          dx: (rand() - 0.5) * length * 1.1,
          dy: (rand() - 0.5) * length * 0.8 - length * 0.1,
          r: Math.min(16 + length * (0.4 + rand() * 0.45), 100),
          a: 0.09 + rand() * 0.09,
          color: -1,
        });
      }
    }
    const kids = 2 + (rand() < 0.55 ? 1 : 0);
    for (let i = 0; i < kids; i++) {
      const spreadBase = depth < 2 ? 0.66 : 0.5;
      const spread = (i - (kids - 1) / 2) * (spreadBase + rand() * 0.25);
      const jitter = (rand() - 0.5) * 0.3;
      let rel = spread + jitter + 0.04 * wind;
      // outer twigs droop toward the horizontal like laden branches
      const childAbs = absHint + rel;
      if (depth >= 3) {
        const target = Math.cos(childAbs) >= 0 ? 0.3 : Math.PI - 0.3;
        let da = target - childAbs;
        while (da > Math.PI) da -= 2 * Math.PI;
        while (da < -Math.PI) da += 2 * Math.PI;
        rel += da * 0.24;
      }
      grow(
        idx,
        rel,
        length * (0.62 + rand() * 0.16),
        Math.max(width * 0.58, 0.7),
        depth + 1,
        absHint + rel
      );
    }
  };

  // serpentine trunk: a chain of thick segments bending alternately,
  // with major limbs peeling off at the bends
  const bends = [-Math.PI / 2 + 0.14, 0.42, -0.6, 0.44];
  const segW = Math.max(scale * 0.062, 14);
  let prev = -1;
  let abs = 0;
  const joints: { idx: number; abs: number }[] = [];
  for (let s = 0; s < bends.length; s++) {
    const idx = branches.length;
    abs += bends[s];
    branches.push({
      parent: prev,
      angleRel: bends[s],
      length: scale * 0.15 * (1 - s * 0.12),
      width: segW * (1 - s * 0.18),
      tipWidth: segW * (1 - (s + 1) * 0.18),
      depth: 0,
      phase: rand() * Math.PI * 2,
      bow: (s % 2 ? -0.16 : 0.16) + (rand() - 0.5) * 0.1,
      absAngle: 0,
      x0: 0,
      y0: 0,
      x1: 0,
      y1: 0,
    });
    joints.push({ idx, abs });
    prev = idx;
  }
  branches[0].x0 = baseX;
  branches[0].y0 = topY + 6;

  // limbs: one low on the left, a long bough sweeping right, a fan at the top
  const limbW = segW * 0.34;
  grow(joints[1].idx, -0.95, scale * 0.15, limbW * 0.85, 1, joints[1].abs - 0.95);
  grow(joints[2].idx, 1.05, scale * 0.21, limbW, 1, joints[2].abs + 1.05);
  grow(joints[3].idx, -0.5, scale * 0.16, limbW * 0.9, 1, joints[3].abs - 0.5);
  grow(joints[3].idx, 0.08, scale * 0.19, limbW, 1, joints[3].abs + 0.08);
  grow(joints[3].idx, 0.62, scale * 0.18, limbW * 0.95, 1, joints[3].abs + 0.62);

  // taper: each branch narrows to meet its widest child
  for (let i = branches.length - 1; i > 0; i--) {
    const p = branches[i].parent;
    if (p >= 0) {
      branches[p].tipWidth = Math.max(branches[p].tipWidth, branches[i].width);
    }
  }

  // baseline (no-sway) position pass, used only to zone-color the canopy
  // left (red) to right (gold) instead of scattering hues at random —
  // parent index is always < child index, so one forward pass resolves it
  for (let i = 0; i < branches.length; i++) {
    const b = branches[i];
    if (b.parent === -1) {
      b.absAngle = b.angleRel;
    } else {
      const p = branches[b.parent];
      b.x0 = p.x1;
      b.y0 = p.y1;
      b.absAngle = p.absAngle + b.angleRel;
    }
    b.x1 = b.x0 + Math.cos(b.absAngle) * b.length;
    b.y1 = b.y0 + Math.sin(b.absAngle) * b.length;
  }
  let minX = Infinity;
  let maxX = -Infinity;
  for (const b of branches) {
    if (b.x1 < minX) minX = b.x1;
    if (b.x1 > maxX) maxX = b.x1;
  }
  const spanX = Math.max(maxX - minX, 1);
  const midGroup = Math.ceil(colorCount / 2);
  const zoneColor = (branchIdx: number) => {
    if (colorCount < 2) return 0;
    const t = Math.min(1, Math.max(0, (branches[branchIdx].x1 - minX) / spanX));
    return rand() < t
      ? midGroup + Math.floor(rand() * (colorCount - midGroup))
      : Math.floor(rand() * midGroup);
  };
  for (const p of puffs) p.color = zoneColor(p.branch);
  for (const c of clusters) {
    for (const o of c.offsets) o.color = zoneColor(c.branch);
  }

  return { branches, clusters, puffs, terminals, rock, rockFacets };
}

export interface AutumnDriftProps {
  /** Number of falling leaves. */
  leafCount?: number;
  /** Wind strength 0–1: sway amplitude and horizontal drift. */
  wind?: number;
  /** Horizontal anchor of the tree. */
  treeAlign?: "left" | "center" | "right";
  /** Fall foliage palette, as "r, g, b" triples. Defaults to pumpkin/brick/gold/maroon/amber. */
  leafColors?: string[];
  /** Branch color. Defaults to rgba(var(--ink-rgb), 0.75). */
  branchColor?: string;
  /**
   * URL of a transparent PNG of a bare tree. When set, it replaces the
   * procedural tree and foliage masses are stamped at `foliageAnchors`.
   */
  treeImage?: string;
  /** Foliage positions over the image, as fractions of the drawn image box. */
  foliageAnchors?: FoliageAnchor[];
  className?: string;
  style?: React.CSSProperties;
  /** Hero content rendered above the canvas. */
  children?: React.ReactNode;
}

export function AutumnDrift({
  leafCount = 90,
  wind = 0.5,
  treeAlign = "left",
  leafColors,
  branchColor,
  treeImage,
  foliageAnchors,
  className,
  style,
  children,
}: AutumnDriftProps) {
  const { ink: inkRgb, bg: bgRgb } = useThemeRgb();
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rafRef = useRef<number>(0);
  const sizeRef = useRef({ w: 0, h: 0 });
  const treeRef = useRef<ReturnType<typeof growTree> | null>(null);
  const leavesRef = useRef<Leaf[]>([]);
  const mouseRef = useRef<{ x: number; y: number; vx: number; vy: number; t: number } | null>(
    null
  );
  const timeRef = useRef(0);
  const lastRef = useRef(0);

  const imgRef = useRef<HTMLImageElement | null>(null);
  const imgSceneRef = useRef<ImgScene | null>(null);
  const [imgReady, setImgReady] = useState(0);

  const palette = leafColors && leafColors.length ? leafColors : DEFAULT_LEAF_COLORS;
  const paletteKey = palette.join("|");

  // leaves read live prop values from a ref so the rAF loop never restarts
  const propsRef = useRef({ leafCount, wind });
  propsRef.current = { leafCount, wind };

  useEffect(() => {
    imgRef.current = null;
    imgSceneRef.current = null;
    if (!treeImage) return;
    const im = new Image();
    im.crossOrigin = "anonymous";
    im.src = treeImage;
    im.onload = () => {
      imgRef.current = im;
      setImgReady((v) => v + 1);
    };
    return () => {
      im.onload = null;
    };
  }, [treeImage]);

  const buildImgScene = useCallback(
    (W: number, H: number) => {
      const im = imgRef.current;
      if (!im) {
        imgSceneRef.current = null;
        return;
      }
      const ar = im.naturalWidth / im.naturalHeight;
      let bh = H * 1.04;
      let bw = bh * ar;
      if (bw > W * 0.86) {
        bw = W * 0.86;
        bh = bw / ar;
      }
      const bx = treeAlign === "left" ? 0 : treeAlign === "center" ? (W - bw) / 2 : W - bw;
      const by = H - bh + H * 0.06;
      const rMin = Math.min(bw, bh);
      const rand = mulberry32(99);
      // left (red) to right (gold) zoning, like a real maple canopy turning
      // unevenly — not scattered single leaves of every hue
      const midGroup = Math.ceil(palette.length / 2);
      const zoneColor = (t: number) => {
        if (palette.length < 2) return 0;
        const jittered = Math.min(1, Math.max(0, t + (rand() - 0.5) * 0.22));
        return rand() < jittered
          ? midGroup + Math.floor(rand() * (palette.length - midGroup))
          : Math.floor(rand() * midGroup);
      };
      const anchors: ImgAnchor[] = (foliageAnchors ?? DEFAULT_ANCHORS).map((a) => {
        const r = a.r * rMin;
        // dense individual leaves, count scaled to the anchor's area
        const foliage: ImgAnchor["foliage"] = [];
        const n = Math.min(420, Math.max(100, Math.round((r * r) / 9)));
        for (let i = 0; i < n; i++) {
          const ang = rand() * Math.PI * 2;
          const d = Math.sqrt(rand()) * r * 1.25;
          const dx = Math.cos(ang) * d;
          const dy = Math.sin(ang) * d * 0.85;
          // volumetric shading: highlights ride the top of the cluster,
          // deep shadow settles underneath
          const ny = dy / r;
          let tone = 1;
          if (ny < -0.2 && rand() < 0.65) tone = 0;
          else if (ny > 0.3 && rand() < 0.6) tone = 2;
          else if (rand() < 0.12) tone = rand() < 0.5 ? 0 : 2;
          // leaf silhouettes read at the fringe, blobs build the mass
          const shape = d > r * 0.8 ? (rand() < 0.5 ? 1 : 0) : rand() < 0.14 ? 1 : 0;
          foliage.push({
            dx,
            dy,
            r: shape === 0 ? 3.5 + rand() * 3.8 : 3.2 + rand() * 3,
            a: 0.7 + rand() * 0.3,
            tone,
            shape,
            color: zoneColor(a.x),
          });
        }
        // paint shadows first, highlights last
        foliage.sort((p, q) => q.tone - p.tone);
        return {
          x: bx + a.x * bw,
          y: by + a.y * bh,
          r,
          phase: rand() * Math.PI * 2,
          foliage,
        };
      });
      imgSceneRef.current = { box: { x: bx, y: by, w: bw, h: bh }, anchors };
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [treeAlign, foliageAnchors, palette.length]
  );

  const parseTriple = (s: string): [number, number, number] => {
    const parts = s.split(",").map((v) => parseInt(v, 10));
    return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
  };

  const leafFill = useCallback(
    (colorIdx: number, alpha: number) => {
      const [r, g, b] = parseTriple(palette[colorIdx % palette.length]);
      return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
    },
    [palette]
  );

  // pre-rendered foliage sprites: [colorIdx][tone][shape] — tones pale/base/deep,
  // shapes soft blob / single leaf silhouette — layered they read as real foliage
  const foliageSpritesRef = useRef<{ key: string; sprites: HTMLCanvasElement[][][] } | null>(
    null
  );
  const getFoliageSprites = useCallback(() => {
    if (foliageSpritesRef.current?.key === paletteKey) return foliageSpritesRef.current.sprites;
    const S = 64;
    const half = S / 2;
    const rgba = (t: number[], a: number) => `rgba(${t[0]}, ${t[1]}, ${t[2]}, ${a})`;
    const make = (draw: (g: CanvasRenderingContext2D) => void) => {
      const c = document.createElement("canvas");
      c.width = S;
      c.height = S;
      const g = c.getContext("2d");
      if (g) draw(g);
      return c;
    };
    const sprites = palette.map((triple) => {
      const base = parseTriple(triple);
      const pale = base.map((v) => Math.round(v + (255 - v) * 0.55));
      const deep = [Math.round(base[0] * 0.62), Math.round(base[1] * 0.55), Math.round(base[2] * 0.5)];
      return [pale, base, deep].map((tone) => [
        // soft cloud blob
        make((g) => {
          const grad = g.createRadialGradient(half, half, 0, half, half, half);
          grad.addColorStop(0, rgba(tone, 0.95));
          grad.addColorStop(0.62, rgba(tone, 0.55));
          grad.addColorStop(1, rgba(tone, 0));
          g.fillStyle = grad;
          g.fillRect(0, 0, S, S);
        }),
        // single pointed leaf silhouette with a center vein
        make((g) => {
          g.save();
          g.translate(half, half);
          g.rotate((Math.random() - 0.5) * 0.6);
          g.beginPath();
          g.moveTo(0, -18);
          g.quadraticCurveTo(13, -8, 0, 18);
          g.quadraticCurveTo(-13, -8, 0, -18);
          g.closePath();
          g.fillStyle = rgba(tone, 0.95);
          g.fill();
          g.strokeStyle = rgba(deep, 0.5);
          g.lineWidth = 1;
          g.beginPath();
          g.moveTo(0, -16);
          g.lineTo(0, 16);
          g.stroke();
          g.restore();
        }),
      ]);
    });
    foliageSpritesRef.current = { key: paletteKey, sprites };
    return sprites;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [paletteKey]);

  const spriteRef = useRef<{ key: string; canvases: HTMLCanvasElement[] } | null>(null);

  // soft blurred foliage mass, one per palette color, stamped per puff
  const getPuffSprites = useCallback(() => {
    if (spriteRef.current?.key === paletteKey) return spriteRef.current.canvases;
    const size = 256;
    const canvases = palette.map((triple, ci) => {
      const c = document.createElement("canvas");
      c.width = size;
      c.height = size;
      const g = c.getContext("2d");
      if (!g) return c;
      const rand = mulberry32(7 + ci * 101);
      const base = parseTriple(triple);
      const deep = [Math.round(base[0] * 0.72), Math.round(base[1] * 0.6), Math.round(base[2] * 0.55)];
      const stop = (t: number[], a: number) => `rgba(${t[0]}, ${t[1]}, ${t[2]}, ${a})`;
      for (let i = 0; i < 4; i++) {
        const bx = size / 2 + (rand() - 0.5) * size * 0.24;
        const by = size / 2 + (rand() - 0.5) * size * 0.24;
        const br = size * (0.24 + rand() * 0.12);
        const grad = g.createRadialGradient(bx, by, 0, bx, by, br);
        grad.addColorStop(0, stop(base, 0.55));
        grad.addColorStop(0.55, stop(base, 0.28));
        grad.addColorStop(1, stop(base, 0));
        g.fillStyle = grad;
        g.fillRect(0, 0, size, size);
      }
      for (let i = 0; i < 48; i++) {
        const a = rand() * Math.PI * 2;
        const d = Math.sqrt(rand()) * size * 0.36;
        g.beginPath();
        g.fillStyle = stop(deep, 0.2 + rand() * 0.3);
        g.ellipse(
          size / 2 + Math.cos(a) * d,
          size / 2 + Math.sin(a) * d,
          2 + rand() * 3.4,
          1.4 + rand() * 2.2,
          rand() * Math.PI,
          0,
          Math.PI * 2
        );
        g.fill();
      }
      return c;
    });
    spriteRef.current = { key: paletteKey, canvases };
    return canvases;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [paletteKey]);

  const spawnLeaf = useCallback(
    (p: Leaf, W: number, H: number, atCanopy: boolean) => {
      const sc = imgSceneRef.current;
      const tree = treeRef.current;
      if (sc && sc.anchors.length) {
        const a = sc.anchors[Math.floor(Math.random() * sc.anchors.length)];
        p.x = a.x + (Math.random() - 0.5) * 2 * a.r;
        p.y = a.y + (Math.random() - 0.5) * 1.6 * a.r;
      } else if (tree && tree.terminals.length) {
        const b =
          tree.branches[tree.terminals[Math.floor(Math.random() * tree.terminals.length)]];
        p.x = b.x1 + (Math.random() - 0.5) * 24;
        p.y = b.y1 + (Math.random() - 0.5) * 18;
      } else {
        p.x = Math.random() * W;
        p.y = Math.random() * H * 0.5;
      }
      if (!atCanopy) {
        // initial fill: scatter along the fall path so the scene starts alive
        p.y = p.y + Math.random() * (H - p.y);
        p.x += Math.random() * W * 0.3;
      }
      p.vx = 0;
      // leaves fall slower and heavier than petals
      p.vy = 5 + Math.random() * 8;
      p.rot = Math.random() * Math.PI * 2;
      p.rotV = (Math.random() - 0.5) * 1.1;
      p.size = 3.4 + Math.random() * 3.4;
      p.phase = Math.random() * Math.PI * 2;
      p.alpha = 0.6 + Math.random() * 0.4;
      p.color = Math.floor(Math.random() * palette.length);
    },
    [palette.length]
  );

  const computeTree = useCallback((t: number, windNow: number) => {
    const tree = treeRef.current;
    if (!tree) return;
    const { branches } = tree;
    for (let i = 0; i < branches.length; i++) {
      const b = branches[i];
      const sway =
        Math.sin(t * 1.1 + b.phase) *
        0.012 *
        (b.depth * b.depth * 0.22 + 0.2) *
        (0.3 + windNow);
      if (b.parent === -1) {
        b.absAngle = b.angleRel + sway * 0.3;
      } else {
        const p = branches[b.parent];
        b.x0 = p.x1;
        b.y0 = p.y1;
        b.absAngle = p.absAngle + b.angleRel + sway;
      }
      b.x1 = b.x0 + Math.cos(b.absAngle) * b.length;
      b.y1 = b.y0 + Math.sin(b.absAngle) * b.length;
    }
  }, []);

  const drawLeafShape = (ctx: CanvasRenderingContext2D, r: number, fill: string, vein: string) => {
    ctx.beginPath();
    ctx.moveTo(0, -r);
    ctx.quadraticCurveTo(r * 0.72, -r * 0.44, 0, r);
    ctx.quadraticCurveTo(-r * 0.72, -r * 0.44, 0, -r);
    ctx.closePath();
    ctx.fillStyle = fill;
    ctx.fill();
    ctx.strokeStyle = vein;
    ctx.lineWidth = Math.max(0.6, r * 0.08);
    ctx.beginPath();
    ctx.moveTo(0, -r * 0.85);
    ctx.lineTo(0, r * 0.85);
    ctx.stroke();
  };

  const drawLeaves = useCallback(
    (ctx: CanvasRenderingContext2D, t: number) => {
      const { h: H } = sizeRef.current;
      for (const p of leavesRef.current) {
        const fade = p.y > H - 60 ? Math.max(0, (H - p.y) / 60) : 1;
        // tumble: a slow flip that scales the leaf horizontally, like a coin turning
        const flip = Math.cos(t * 1.6 + p.phase);
        ctx.save();
        ctx.translate(p.x, p.y);
        ctx.rotate(p.rot + Math.sin(t * 1.1 + p.phase) * 0.5);
        ctx.scale(Math.max(0.28, Math.abs(flip)), 1);
        const [r, g, b] = parseTriple(palette[p.color % palette.length]);
        const deep = `rgba(${Math.round(r * 0.6)}, ${Math.round(g * 0.55)}, ${Math.round(b * 0.5)}, ${(
          p.alpha * fade
        ).toFixed(3)})`;
        drawLeafShape(ctx, p.size, leafFill(p.color, p.alpha * fade), deep);
        ctx.restore();
      }
    },
    [leafFill, palette]
  );

  const drawScene = useCallback(
    (ctx: CanvasRenderingContext2D, t: number) => {
      const { w: W, h: H } = sizeRef.current;
      const tree = treeRef.current;
      ctx.clearRect(0, 0, W, H);

      const img = imgRef.current;
      const sc = imgSceneRef.current;
      if (img && sc) {
        // provided artwork: draw the bare tree, then dress it in fall foliage
        ctx.drawImage(img, sc.box.x, sc.box.y, sc.box.w, sc.box.h);
        const windNow = propsRef.current.wind;
        const sprites = getFoliageSprites();
        for (const a of sc.anchors) {
          // barely-there shimmer: the artwork is static, foliage shouldn't drift off it
          const sx = Math.sin(t * 0.6 + a.phase) * 1.1 * (0.3 + windNow);
          const sy = Math.cos(t * 0.45 + a.phase) * 0.7 * (0.3 + windNow);
          for (const f of a.foliage) {
            ctx.globalAlpha = f.a;
            ctx.drawImage(
              sprites[f.color][f.tone][f.shape],
              a.x + sx + f.dx - f.r,
              a.y + sy + f.dy - f.r,
              f.r * 2,
              f.r * 2
            );
          }
        }
        ctx.globalAlpha = 1;
        drawLeaves(ctx, t);
        return;
      }

      if (!tree) return;

      const branchStroke = branchColor ?? `rgba(${inkRgb}, 0.75)`;

      // rocky outcrop behind everything
      ctx.beginPath();
      ctx.moveTo(tree.rock[0][0], tree.rock[0][1]);
      for (let i = 1; i < tree.rock.length; i++) {
        ctx.lineTo(tree.rock[i][0], tree.rock[i][1]);
      }
      ctx.closePath();
      ctx.fillStyle = `rgba(${inkRgb}, 0.38)`;
      ctx.fill();
      ctx.strokeStyle = `rgba(${inkRgb}, 0.55)`;
      ctx.lineWidth = 1.5;
      ctx.stroke();
      ctx.strokeStyle = `rgba(${bgRgb}, 0.3)`;
      ctx.lineWidth = 1;
      for (const facet of tree.rockFacets) {
        ctx.beginPath();
        ctx.moveTo(facet[0][0], facet[0][1]);
        for (let i = 1; i < facet.length; i++) ctx.lineTo(facet[i][0], facet[i][1]);
        ctx.stroke();
      }

      // soft foliage masses behind the wood, so twigs cut through the color
      const puffSprites = getPuffSprites();
      for (const p of tree.puffs) {
        const b = tree.branches[p.branch];
        const sprite = puffSprites[p.color % puffSprites.length];
        if (!sprite) continue;
        ctx.globalAlpha = p.a;
        ctx.drawImage(sprite, b.x1 + p.dx - p.r, b.y1 + p.dy - p.r, p.r * 2, p.r * 2);
      }
      ctx.globalAlpha = 1;

      // branches as tapered bowed fills — uniform strokes read as a diagram
      ctx.fillStyle = branchStroke;
      for (const b of tree.branches) {
        const dx = b.x1 - b.x0;
        const dy = b.y1 - b.y0;
        const len = Math.hypot(dx, dy) || 1;
        const cx = (b.x0 + b.x1) / 2 - dy * b.bow;
        const cy = (b.y0 + b.y1) / 2 + dx * b.bow;
        let n0x = -(cy - b.y0);
        let n0y = cx - b.x0;
        const n0l = Math.hypot(n0x, n0y) || 1;
        n0x /= n0l;
        n0y /= n0l;
        let n1x = -(b.y1 - cy);
        let n1y = b.x1 - cx;
        const n1l = Math.hypot(n1x, n1y) || 1;
        n1x /= n1l;
        n1y /= n1l;
        const ncx = -dy / len;
        const ncy = dx / len;
        const h0 = (Math.max(b.width, 0.6) / 2) * (b.parent === -1 ? 1.3 : 1);
        const h1 = Math.max(b.tipWidth, 0.5) / 2;
        const hc = (h0 + h1) / 2;
        ctx.beginPath();
        ctx.moveTo(b.x0 + n0x * h0, b.y0 + n0y * h0);
        ctx.quadraticCurveTo(cx + ncx * hc, cy + ncy * hc, b.x1 + n1x * h1, b.y1 + n1y * h1);
        ctx.lineTo(b.x1 - n1x * h1, b.y1 - n1y * h1);
        ctx.quadraticCurveTo(cx - ncx * hc, cy - ncy * hc, b.x0 - n0x * h0, b.y0 - n0y * h0);
        ctx.closePath();
        ctx.fill();
        // round the joint so children meeting at an angle don't notch
        if (b.parent >= 0) {
          ctx.beginPath();
          ctx.arc(b.x0, b.y0, h0, 0, Math.PI * 2);
          ctx.fill();
        }
      }

      // bark striations along the thick trunk segments, over all the wood
      ctx.strokeStyle = `rgba(${bgRgb}, 0.3)`;
      for (const b of tree.branches) {
        if (b.depth !== 0) continue;
        const dx = b.x1 - b.x0;
        const dy = b.y1 - b.y0;
        const len = Math.hypot(dx, dy) || 1;
        const cx = (b.x0 + b.x1) / 2 - dy * b.bow;
        const cy = (b.y0 + b.y1) / 2 + dx * b.bow;
        const ncx = -dy / len;
        const ncy = dx / len;
        const h0 = (Math.max(b.width, 0.6) / 2) * (b.parent === -1 ? 1.3 : 1);
        const h1 = Math.max(b.tipWidth, 0.5) / 2;
        const hc = (h0 + h1) / 2;
        for (const f of [-0.42, 0.05, 0.4]) {
          ctx.beginPath();
          ctx.lineWidth = Math.max(1, h0 * 0.13);
          ctx.moveTo(b.x0 + ncx * h0 * f, b.y0 + ncy * h0 * f);
          ctx.quadraticCurveTo(
            cx + ncx * hc * f,
            cy + ncy * hc * f,
            b.x1 + ncx * h1 * f,
            b.y1 + ncy * h1 * f
          );
          ctx.stroke();
        }
      }

      // sharp leaf speckles over the masses for texture
      for (const c of tree.clusters) {
        const b = tree.branches[c.branch];
        for (const o of c.offsets) {
          ctx.beginPath();
          ctx.fillStyle = leafFill(o.color, o.a);
          ctx.arc(b.x1 + o.dx, b.y1 + o.dy, o.r, 0, Math.PI * 2);
          ctx.fill();
        }
      }

      drawLeaves(ctx, t);
    },
    [
      branchColor,
      inkRgb,
      bgRgb,
      leafFill,
      getPuffSprites,
      getFoliageSprites,
      drawLeaves,
    ]
  );

  const step = useCallback(
    (timestamp: number) => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;

      const { w: W, h: H } = sizeRef.current;
      if (!W || !H || (!treeRef.current && !imgSceneRef.current)) {
        rafRef.current = requestAnimationFrame(step);
        return;
      }

      const dt = Math.min((timestamp - lastRef.current) / 1000 || 0.016, 0.05);
      lastRef.current = timestamp;
      timeRef.current += dt;
      const t = timeRef.current;
      const { wind: windNow, leafCount: countNow } = propsRef.current;

      computeTree(t, windNow);

      // keep leaf pool sized to the prop
      const leaves = leavesRef.current;
      while (leaves.length < countNow) {
        const p = {} as Leaf;
        spawnLeaf(p, W, H, false);
        leaves.push(p);
      }
      if (leaves.length > countNow) leaves.length = countNow;

      const mouse = mouseRef.current;
      const gustR = 140;
      // constant rightward flow toward the hero copy, wind adds on top
      const drift = 14 + (Math.sin(t * 0.4) * 0.5 + 0.8) * 14 * windNow;

      for (const p of leaves) {
        // gentle horizontal wind + a wider zigzag than falling petals
        const targetVx = drift + Math.sin(t * 1.1 + p.phase) * 16;
        p.vx += (targetVx - p.vx) * Math.min(1, dt * 1.1);
        const targetVy = 9 + Math.sin(t * 0.8 + p.phase * 2) * 4;
        p.vy += (targetVy - p.vy) * Math.min(1, dt * 1);

        // cursor gust: push leaves away, scaled by pointer speed
        if (mouse) {
          const dx = p.x - mouse.x;
          const dy = p.y - mouse.y;
          const dist = Math.hypot(dx, dy);
          if (dist < gustR && dist > 0.01) {
            const f = Math.pow(1 - dist / gustR, 2);
            const speed = Math.min(Math.hypot(mouse.vx, mouse.vy), 1400);
            const push = f * (40 + speed * 0.5);
            p.vx += (dx / dist) * push * dt * 4;
            p.vy += (dy / dist) * push * dt * 4 - f * 20 * dt;
            p.rotV += f * (Math.random() - 0.5) * 5 * dt * 10;
          }
        }

        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.rot += p.rotV * dt;
        p.rotV *= Math.exp(-dt * 1.2);

        if (p.y > H + 12 || p.x < -40 || p.x > W + 60) {
          spawnLeaf(p, W, H, true);
        }
      }

      // decay pointer velocity so a stopped cursor stops gusting
      if (mouse) {
        mouse.vx *= Math.exp(-dt * 6);
        mouse.vy *= Math.exp(-dt * 6);
      }

      drawScene(ctx, t);
      rafRef.current = requestAnimationFrame(step);
    },
    [computeTree, drawScene, spawnLeaf]
  );

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const parent = canvas.parentElement;
    if (!parent) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const staticFrame = () => {
      const ctx = canvas.getContext("2d");
      if (!ctx) return;
      computeTree(0, propsRef.current.wind);
      const { w: W, h: H } = sizeRef.current;
      const leaves = leavesRef.current;
      leaves.length = 0;
      while (leaves.length < propsRef.current.leafCount) {
        const p = {} as Leaf;
        spawnLeaf(p, W, H, false);
        leaves.push(p);
      }
      drawScene(ctx, 0);
    };

    const ro = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        if (!width || !height) continue;
        const dpr = Math.min(window.devicePixelRatio, 2);
        sizeRef.current = { w: width, h: height };
        canvas.width = width * dpr;
        canvas.height = height * dpr;
        const ctx = canvas.getContext("2d");
        if (ctx) ctx.scale(dpr, dpr);
        canvas.style.width = `${width}px`;
        canvas.style.height = `${height}px`;
        if (treeImage) {
          treeRef.current = null;
          buildImgScene(width, height);
        } else {
          imgSceneRef.current = null;
          treeRef.current = growTree(width, height, treeAlign, propsRef.current.wind, palette.length);
        }
        if (reduced) staticFrame();
      }
    });
    ro.observe(parent);

    const onMove = (e: MouseEvent) => {
      if (reduced) return;
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const now = performance.now();
      const prev = mouseRef.current;
      if (prev) {
        const dt = Math.max((now - prev.t) / 1000, 0.001);
        mouseRef.current = {
          x,
          y,
          vx: (x - prev.x) / dt,
          vy: (y - prev.y) / dt,
          t: now,
        };
      } else {
        mouseRef.current = { x, y, vx: 0, vy: 0, t: now };
      }
    };
    const onLeave = () => {
      mouseRef.current = null;
    };
    parent.addEventListener("mousemove", onMove);
    parent.addEventListener("mouseleave", onLeave);

    if (!reduced) {
      lastRef.current = performance.now();
      rafRef.current = requestAnimationFrame(step);
    }

    return () => {
      cancelAnimationFrame(rafRef.current);
      ro.disconnect();
      parent.removeEventListener("mousemove", onMove);
      parent.removeEventListener("mouseleave", onLeave);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    step,
    computeTree,
    drawScene,
    spawnLeaf,
    treeAlign,
    treeImage,
    buildImgScene,
    imgReady,
    palette.length,
  ]);

  return (
    <div
      className={["relative overflow-hidden", className].filter(Boolean).join(" ")}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="absolute inset-0 h-full w-full pointer-events-none"
      />
      {children && <div className="absolute inset-0 z-1">{children}</div>}
    </div>
  );
}

export default AutumnDrift;

```

## Demo

```tsx
"use client";

import React from "react";
import { AutumnDrift } from "../mellow/autumn-drift";

export default function AutumnDriftDemo() {
  return (
    <AutumnDrift treeImage="/trees/gnarled-tree.png" className="w-full h-full">
      <div className="absolute right-[8%] top-1/2 -translate-y-1/2 flex max-w-sm flex-col items-end gap-3 text-right">
        <p className="text-[10px] uppercase tracking-[0.25em] text-[rgba(var(--ink-rgb),0.4)] [font-family:var(--font-mono)]">
          紅葉 — Kōyō
        </p>
        <h2 className="text-4xl italic text-[var(--ink)] [font-family:var(--font-serif)]">
          The turning
          <br />
          of the leaves
        </h2>
        <p className="text-sm text-[rgba(var(--ink-rgb),0.55)]">
          A season measured in color — amber, rust, and gold before the frost.
        </p>
        <p className="text-xs text-[rgba(var(--ink-rgb),0.3)]">
          Move the cursor through the fall — a gust scatters the leaves
        </p>
      </div>
    </AutumnDrift>
  );
}

```
