Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

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.

MSource LandMellow UI

Why it stands out

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.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/autumn-drift-demo.tsx

File content: "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>
  );
}



File location: components/mellow/autumn-drift.tsx

File content: "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;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI