# Sakura Drift (`sakura-drift`)

> A gnarled cherry blossom tree on a rocky outcrop, swaying in wind and shedding petals — the cursor is a gust that scatters them.

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

## AI prompt

Add a SakuraDrift background from the mellow library — a procedural cherry blossom tree swaying in wind and shedding petals the cursor scatters. Wrap hero content as `children`, and tune `petalCount`, `wind` and `treeAlign`. Pass `treeImage` plus `blossomAnchors` to swap in your own bare-tree PNG.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `petalCount` | `number` | `90` | Number of falling petals. |
| `wind` | `number` | `0.5` | Wind strength 0–1: sway amplitude and horizontal drift. |
| `treeAlign` | `"left" \| "center" \| "right"` | `"left"` | Horizontal anchor of the tree. |
| `petalColor` | `string` | — | Petal / blossom color. Defaults to a themed sakura pink. |
| `branchColor` | `string` | — | Branch color. Defaults to rgba(var(--ink-rgb), 0.75). |
| `treeImage` | `string` | `"https://www.mellowui.com/trees/gnarled-tree.webp"` | URL of a transparent image of a bare tree. Replaces the procedural tree. |
| `blossomAnchors` | `BlossomAnchor[]` | — | Blossom positions over the image, as fractions of the drawn image box. |
| `children` | `React.ReactNode` | — | Hero content rendered above the canvas. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

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

## Source

```tsx
"use client";

import React, { useEffect, useRef, 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;
  };
}

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;
}

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

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

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

/** Blossom placement over a `treeImage`, as fractions of the drawn image box. */
export interface BlossomAnchor {
  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: BlossomAnchor[] = [
  { 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 pink, 2 deep rose shadow; shape: 0 soft blob, 1 floret */
  blossoms: { dx: number; dy: number; r: number; a: number; tone: number; shape: 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
): {
  branches: Branch[];
  clusters: BlossomCluster[];
  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: BlossomCluster[] = [];
  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 blossom 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,
      });
      // ...plus a few sharp petal speckles for texture
      const offsets: BlossomCluster["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,
        });
      }
      clusters.push({ branch: idx, offsets });
      return;
    }
    // soft masses hang on every bough, so the whole crown wears blossom
    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,
        });
      }
    }
    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 blossom 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);
    }
  }

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

export interface SakuraDriftProps {
  /** Number of falling petals. */
  petalCount?: number;
  /** Wind strength 0–1: sway amplitude and horizontal drift. */
  wind?: number;
  /** Horizontal anchor of the tree. */
  treeAlign?: "left" | "center" | "right";
  /** Petal / blossom color. Defaults to a themed sakura pink. */
  petalColor?: string;
  /** Branch color. Defaults to rgba(var(--ink-rgb), 0.75). */
  branchColor?: string;
  /**
   * URL of a transparent PNG of a bare tree. Replaces the procedural tree and
   * stamps blossom masses at `blossomAnchors`. Defaults to the hosted gnarled
   * tree used in the docs — self-host it and point this at your own copy, or
   * pass `treeImage=""` to fall back to the procedural tree.
   */
  treeImage?: string;
  /** Blossom positions over the image, as fractions of the drawn image box. */
  blossomAnchors?: BlossomAnchor[];
  className?: string;
  style?: React.CSSProperties;
  /** Hero content rendered above the canvas. */
  children?: React.ReactNode;
}

export function SakuraDrift({
  petalCount = 90,
  wind = 0.5,
  treeAlign = "left",
  petalColor,
  branchColor,
  treeImage = "https://www.mellowui.com/trees/gnarled-tree.webp",
  blossomAnchors,
  className,
  style,
  children,
}: SakuraDriftProps) {
  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 petalsRef = useRef<Petal[]>([]);
  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);

  // petals read live prop values from a ref so the rAF loop never restarts
  const propsRef = useRef({ petalCount, wind });
  propsRef.current = { petalCount, 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);
      const anchors: ImgAnchor[] = (blossomAnchors ?? DEFAULT_ANCHORS).map((a) => {
        const r = a.r * rMin;
        // dense individual blossoms, count scaled to the anchor's area
        const blossoms: ImgAnchor["blossoms"] = [];
        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 rose 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;
          // florets read at the fringe, blobs build the mass
          const shape = d > r * 0.8 ? (rand() < 0.5 ? 1 : 0) : rand() < 0.12 ? 1 : 0;
          blossoms.push({
            dx,
            dy,
            r: shape === 0 ? 3.5 + rand() * 3.8 : 2.6 + rand() * 2.6,
            a: 0.7 + rand() * 0.3,
            tone,
            shape,
          });
        }
        // paint shadows first, highlights last
        blossoms.sort((p, q) => q.tone - p.tone);
        return {
          x: bx + a.x * bw,
          y: by + a.y * bh,
          r,
          phase: rand() * Math.PI * 2,
          blossoms,
        };
      });
      imgSceneRef.current = { box: { x: bx, y: by, w: bw, h: bh }, anchors };
      blossomLayerRef.current = null;
    },
    [treeAlign, blossomAnchors]
  );

  const resolvedPetal = petalColor ?? "232, 148, 178";
  // accept either "r, g, b" or any css color; rgb triple gets alpha composed
  const petalIsTriple = /^\s*\d+\s*,\s*\d+\s*,\s*\d+\s*$/.test(resolvedPetal);

  const petalFill = useCallback(
    (alpha: number) =>
      petalIsTriple ? `rgba(${resolvedPetal}, ${alpha.toFixed(3)})` : resolvedPetal,
    [petalIsTriple, resolvedPetal]
  );

  // pre-rendered blossom sprites: [tone][shape], tones pale/base/deep,
  // shapes soft blob / five-petal floret — layered they read as real foliage
  const blossomSpritesRef = useRef<{ key: string; sprites: HTMLCanvasElement[][] } | null>(
    null
  );
  const getBlossomSprites = useCallback(() => {
    if (blossomSpritesRef.current?.key === resolvedPetal)
      return blossomSpritesRef.current.sprites;
    let base = [232, 148, 178];
    if (petalIsTriple) base = resolvedPetal.split(",").map((v) => parseInt(v, 10));
    const pale = base.map((v) => Math.round(v + (255 - v) * 0.62));
    const deep = [
      Math.round(base[0] * 0.88),
      Math.round(base[1] * 0.6),
      Math.round(base[2] * 0.8),
    ];
    const rgba = (t: number[], a: number) => `rgba(${t[0]}, ${t[1]}, ${t[2]}, ${a})`;
    const S = 64;
    const half = S / 2;
    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 = [pale, base, deep].map((tone, ti) => [
      // 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);
      }),
      // five-petal floret
      make((g) => {
        for (let k = 0; k < 5; k++) {
          const ang = (k / 5) * Math.PI * 2 - Math.PI / 2;
          g.save();
          g.translate(half + Math.cos(ang) * 11, half + Math.sin(ang) * 11);
          g.rotate(ang);
          g.beginPath();
          g.fillStyle = rgba(tone, 0.95);
          g.ellipse(0, 0, 12, 8, 0, 0, Math.PI * 2);
          g.fill();
          g.restore();
        }
        g.beginPath();
        g.fillStyle = rgba(ti === 0 ? base : deep, 0.9);
        g.arc(half, half, 5, 0, Math.PI * 2);
        g.fill();
      }),
    ]);
    blossomSpritesRef.current = { key: resolvedPetal, sprites };
    return sprites;
  }, [resolvedPetal, petalIsTriple]);

  // The canopy is thousands of blossom stamps and it never changes shape — only
  // the whole mass shimmers a pixel or so. Stamping it every frame costs ~10k
  // drawImage calls at 60fps, so it is baked once into an offscreen layer and
  // blitted with the shimmer as an offset.
  const blossomLayerRef = useRef<{ key: string; canvas: HTMLCanvasElement } | null>(null);
  const getBlossomLayer = useCallback(() => {
    const sc = imgSceneRef.current;
    if (!sc) return null;
    const { w: W, h: H } = sizeRef.current;
    if (!W || !H) return null;
    const key = `${W}x${H}|${resolvedPetal}`;
    if (blossomLayerRef.current?.key === key) return blossomLayerRef.current.canvas;
    const dpr = Math.min(window.devicePixelRatio, 2);
    const c = document.createElement("canvas");
    c.width = Math.ceil(W * dpr);
    c.height = Math.ceil(H * dpr);
    const g = c.getContext("2d");
    if (!g) return null;
    g.scale(dpr, dpr);
    const sprites = getBlossomSprites();
    for (const a of sc.anchors) {
      for (const b of a.blossoms) {
        g.globalAlpha = b.a;
        g.drawImage(
          sprites[b.tone][b.shape],
          a.x + b.dx - b.r,
          a.y + b.dy - b.r,
          b.r * 2,
          b.r * 2
        );
      }
    }
    g.globalAlpha = 1;
    blossomLayerRef.current = { key, canvas: c };
    return c;
  }, [resolvedPetal, getBlossomSprites]);

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

  // soft watercolor blossom mass, rendered once and stamped per puff
  const getPuffSprite = useCallback(() => {
    if (spriteRef.current?.key === resolvedPetal) return spriteRef.current.canvas;
    const size = 256;
    const c = document.createElement("canvas");
    c.width = size;
    c.height = size;
    const g = c.getContext("2d");
    if (!g) return null;
    const rand = mulberry32(7);
    let deep = resolvedPetal;
    if (petalIsTriple) {
      const [r, gr, b] = resolvedPetal.split(",").map((v) => parseInt(v, 10));
      deep = `${Math.round(r * 0.93)}, ${Math.round(gr * 0.68)}, ${Math.round(b * 0.85)}`;
    }
    const stop = (triple: string, a: number) =>
      petalIsTriple ? `rgba(${triple}, ${a})` : resolvedPetal;
    for (let i = 0; i < 4; i++) {
      // keep center offset + radius under size/2 so the stamp has no square edge
      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(resolvedPetal, 0.55));
      grad.addColorStop(0.55, stop(resolvedPetal, 0.28));
      grad.addColorStop(1, petalIsTriple ? `rgba(${resolvedPetal}, 0)` : "transparent");
      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 = petalIsTriple
        ? `rgba(${deep}, ${0.2 + rand() * 0.3})`
        : resolvedPetal;
      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();
    }
    spriteRef.current = { key: resolvedPetal, canvas: c };
    return c;
  }, [resolvedPetal, petalIsTriple]);

  const spawnPetal = useCallback((p: Petal, 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;
    p.vy = 8 + Math.random() * 14;
    p.rot = Math.random() * Math.PI * 2;
    p.rotV = (Math.random() - 0.5) * 2.4;
    p.size = 2.4 + Math.random() * 2.6;
    p.phase = Math.random() * Math.PI * 2;
    p.alpha = 0.5 + Math.random() * 0.45;
  }, []);

  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 drawPetals = useCallback(
    (ctx: CanvasRenderingContext2D, t: number) => {
      const { h: H } = sizeRef.current;
      for (const p of petalsRef.current) {
        const fade = p.y > H - 60 ? Math.max(0, (H - p.y) / 60) : 1;
        ctx.save();
        ctx.translate(p.x, p.y);
        ctx.rotate(p.rot + Math.sin(t * 2 + p.phase) * 0.4);
        ctx.beginPath();
        ctx.fillStyle = petalFill(p.alpha * fade);
        ctx.ellipse(0, 0, p.size, p.size * 0.58, 0, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
      }
    },
    [petalFill]
  );

  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 blossom
        ctx.drawImage(img, sc.box.x, sc.box.y, sc.box.w, sc.box.h);
        const windNow = propsRef.current.wind;
        const layer = getBlossomLayer();
        if (layer) {
          // barely-there shimmer: the artwork is static, blossoms shouldn't
          // drift off it. The whole baked canopy breathes together rather than
          // each anchor on its own phase — a sub-pixel difference, one blit
          // instead of ~10k stamps.
          const sx = Math.sin(t * 0.6) * 1.1 * (0.3 + windNow);
          const sy = Math.cos(t * 0.45) * 0.7 * (0.3 + windNow);
          ctx.drawImage(layer, sx, sy, W, H);
        }
        drawPetals(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 blossom masses behind the wood, so twigs cut through the pink
      const sprite = getPuffSprite();
      if (sprite) {
        for (const p of tree.puffs) {
          const b = tree.branches[p.branch];
          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 petal 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 = petalFill(o.a);
          ctx.arc(b.x1 + o.dx, b.y1 + o.dy, o.r, 0, Math.PI * 2);
          ctx.fill();
        }
      }

      drawPetals(ctx, t);
    },
    [branchColor, inkRgb, bgRgb, petalFill, getPuffSprite, getBlossomLayer, drawPetals]
  );

  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, petalCount: countNow } = propsRef.current;

      computeTree(t, windNow);

      // keep petal pool sized to the prop
      const petals = petalsRef.current;
      while (petals.length < countNow) {
        const p = {} as Petal;
        spawnPetal(p, W, H, false);
        petals.push(p);
      }
      if (petals.length > countNow) petals.length = countNow;

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

      for (const p of petals) {
        // gentle horizontal wind + per-petal flutter
        const targetVx = drift + Math.sin(t * 1.6 + p.phase) * 12;
        p.vx += (targetVx - p.vx) * Math.min(1, dt * 1.5);
        const targetVy = 14 + Math.sin(t * 1.1 + p.phase * 2) * 6;
        p.vy += (targetVy - p.vy) * Math.min(1, dt * 1.2);

        // cursor gust: push petals 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) * 6 * 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) {
          spawnPetal(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, spawnPetal]
  );

  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 petals = petalsRef.current;
      petals.length = 0;
      while (petals.length < propsRef.current.petalCount) {
        const p = {} as Petal;
        spawnPetal(p, W, H, false);
        petals.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);
        }
        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);

    // Only animate while the scene is actually on screen — a page that stacks
    // several of these shouldn't pay for the ones nobody is looking at.
    let running = false;
    const start = () => {
      if (running || reduced) return;
      running = true;
      lastRef.current = performance.now();
      rafRef.current = requestAnimationFrame(step);
    };
    const stop = () => {
      if (!running) return;
      running = false;
      cancelAnimationFrame(rafRef.current);
    };

    let io: IntersectionObserver | null = null;
    if (typeof IntersectionObserver === "undefined") {
      start();
    } else {
      io = new IntersectionObserver(
        ([entry]) => (entry.isIntersecting ? start() : stop()),
        { rootMargin: "200px" }
      );
      io.observe(parent);
    }

    return () => {
      stop();
      cancelAnimationFrame(rafRef.current);
      io?.disconnect();
      ro.disconnect();
      parent.removeEventListener("mousemove", onMove);
      parent.removeEventListener("mouseleave", onLeave);
    };
  }, [step, computeTree, drawScene, spawnPetal, treeAlign, treeImage, buildImgScene, imgReady]);

  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 SakuraDrift;

```

## Demo

```tsx
"use client";

import React from "react";
import { SakuraDrift } from "../mellow/sakura-drift";

export default function SakuraDriftDemo() {
  return (
    <SakuraDrift treeImage="/trees/gnarled-tree.webp" 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)]">
          花見 — Hanami
        </p>
        <h2 className="text-4xl italic text-[var(--ink)] [font-family:var(--font-serif)]">
          One time,
          <br />
          one meeting
        </h2>
        <p className="text-sm text-[rgba(var(--ink-rgb),0.55)]">
          A season measured in petals — brief, weightless, gone by morning.
        </p>
        <p className="text-xs text-[rgba(var(--ink-rgb),0.3)]">
          Move the cursor through the fall — a gust scatters the petals
        </p>
      </div>
    </SakuraDrift>
  );
}

```
