Signature
An animated SVG signature effect that draws out text as if hand-written.
Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.
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 UIWhy 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.
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
An animated SVG signature effect that draws out text as if hand-written.
More from Mellow UI