Generated banner for Falling Leaves
Generated logo for Falling Leaves

Falling Leaves

Meng ToCommunityCreated by Meng To

  • Skill
  • Guidelines
  • Design and creative
  • MIT
  • No issues found

From the author

Build falling leaves that read as leaves, with each one tumbling on its own axis so it presents a face, thins to an edge, and opens out again, and with its sideways slip driven by that same tumble. Covers the 2-D canvas build and the instanced-3-D variant, where leaves are recycled from, density-versus-count maths, depth layering, colour under a tone-mapped composite, reduced motion, and visibility pausing. Use for autumn maple, sakura petals, blossom, ash, snowfall shapes, or any drifting foliage where a generic particle field reads as confetti.
— the description by Meng To

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a comprehensive set of techniques for rendering convincing falling foliage. It emphasizes the importance of physical realism—such as coupling lateral slip to the tumble axis and using distinct front and back sprites—to ensure the animation reads as organic leaves rather than generic confetti. The guide covers implementation strategies for both 2D canvas and 3D instanced meshes, including optimization techniques like viewport-aware density scaling, efficient recycling patterns, and handling visibility states to maintain performance.

What it does

  • Explains the mathematical coupling of tumble and slip for aerodynamic motion.
  • Provides implementation patterns for 2D canvas and 3D instanced rendering.
  • Details optimization strategies for particle recycling and density management.
  • Outlines visual fidelity techniques like front/back sprite baking and depth layering.
  • Addresses performance budgeting, including visibility pausing and frame-time clamping.
  • Defines accessibility requirements for reduced motion settings.

When to use it

  • When you need to implement falling foliage like maple leaves, sakura petals, or ash.
  • When generic particle systems fail to look realistic or feel mechanical.
  • When building atmospheric background effects that must remain performant.
  • When you need to ensure consistent visual density across different screen sizes.

Advantages

  • Achieves high visual realism through physics-based motion coupling.
  • Optimizes performance by avoiding unnecessary draw calls and off-screen processing.
  • Ensures consistent visual density across diverse device resolutions.
  • Provides clear, actionable verification steps for quality control.

Usage examples

“How do I make my falling leaf animation look more realistic?”
The agent will guide you to couple the leaf's lateral slip to its tumble angle and ensure the leaf thins to an edge during its rotation, preventing the 'confetti' look.
“How should I manage particle recycling in a 3D scene?”
The agent will explain how to recycle particles in a disc ahead of the camera frustum rather than around the camera to maximize the number of visible leaves.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/mengto/falling-leaves/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip falling-leaves.zip -d .agents/skills

Documentation by Meng To

Falling Leaves

Make the falling thing read as a leaf. Reach for ambient-section-particles when you want a bounded atmosphere of generic motes. Reach for this when the shape has to be recognisable.

Build the tumble first

Turn each leaf about its own long axis so it shows its face, thins to nothing edge-on, then opens out on the other side. That instant of near-disappearance is what the eye reads as "leaf". A sprite that only spins in the picture plane reads as confetti, a coin, or a paper scrap, however good the artwork is.

On 2-D canvas the tumble is a horizontal scale that crosses zero:

ctx.save();
ctx.translate(l.x, l.y);
ctx.rotate(l.roll);              // long axis drifting in-plane
ctx.scale(Math.cos(l.spin), 1);  // the tumble: cos crosses 0, edge-on
ctx.globalAlpha = l.alpha;
ctx.drawImage(sprite, -w / 2, -h / 2, w, h);
ctx.restore();

Drive two axes, not one. roll turns the leaf within the picture plane; spin turns it through the plane. Give each its own rate per leaf, or the motion reads as mechanical however you ease it.

In 3-D, instance quads, never point sprites. A point sprite always faces the camera and can never turn away, so it can never go edge-on. That single constraint decides the whole implementation.

Couple the slip to the tumble

Drive lateral motion from the same angle as the tumble, ninety degrees out of phase. A leaf slides sideways when it knifes through the air edge-on and stalls when it presents its face flat:

l.x += Math.sin(l.spin) * l.slip * dt;   // fastest when cos(spin) ≈ 0
l.y += l.fall * dt;

Do not put an independent sine on x. It reads as wind or as an easing bug. This coupling costs one term and is what makes the path look aerodynamic.

Bake both faces

Bake two sprites per colour and pick by the sign of the tumble:

const img = Math.cos(l.spin) < 0 ? sprite.back : sprite.face;

Make the back duller and paler than the front. Without this the leaf reads as a flat cut-out spinning; with it, as a solid object with a front and a back. It is the cheapest realism in the system.

Vary every parameter per leaf

Randomise fall speed, tumble rate, roll rate, slip amount, phase, scale, and opacity at spawn. Share any one of them and the field stops being leaves and becomes a texture scrolling down the screen. The eye finds the common rhythm in about two seconds.

Choose where leaves come back from

This decides how many leaves are actually on screen.

In 2-D, recycle across the viewport. When a leaf passes the bottom, respawn it above the top at a new random x. Wrap x as well, so wind does not empty one side.

In 3-D, recycle ahead of the camera, not around it. A band centred on the camera spends nearly all its volume behind and beside the frustum; on a 36° camera, a couple of hundred leaves put barely a dozen in frame. Drop them into a disc hung down the camera's own sight line instead and the same count appears several times over:

camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
const cx = camera.position.x + fwd.x * AHEAD;
const cz = camera.position.z + fwd.z * AHEAD;
const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * SPREAD;
l.x = cx + Math.cos(a) * r;  l.z = cz + Math.sin(a) * r;  l.y = camera.position.y + 16;

Keep a far wrap as a backstop for a camera that walks out from under its own weather, and put it well outside the fog so nothing is seen to jump.

Set density by band area, not by count

On-screen density goes as count ÷ band area. Tighten the band before raising the count:

  • Halving the recycle radius quadruples on-screen density at the same count.

  • Doubling the count doubles draw cost for the same on-screen gain.

Scale an authored count by viewport area rather than taking it literally, or a figure that reads as a drift on desktop arrives as a blizzard on a phone:

const k = clamp(Math.sqrt((W * H) / (1440 * 900)), .5, 1.3);
const n = Math.round(authored * layerShare * k);

Layer for depth

Use two or three layers, each with its own scale, speed, opacity, and blur:

layerscalefallopacitynote
far0.3–0.5slow0.22–0.40drawn first, may sit behind content
mid0.5–0.85medium0.46–0.78the body of the effect
near1.05–1.9fast0.50–0.82few, optionally blurred, drawn over content

Cross the near layer in front of the type. That crossing is the depth cue. Use two or three leaves there, not a curtain.

Handle colour and light

  • Sample each leaf from a small ramp — deep oxblood through vermilion to dry amber — and vary saturation per leaf. One red for every leaf is the giveaway.

  • Alpha-test rather than alpha-blend for 3-D leaves so they sort correctly at any angle without a per-frame depth sort.

  • An emissive red comes back out of a tone-mapped composite pink. If leaves self-illuminate in a dark scene, drive green and blue to zero (0x780200, not 0x8c1410), or the whole fall turns candy-coloured.

Budget the cost

The per-leaf update is free; a few hundred leaves of trigonometry does not register above a CPU profiler's sampling floor. The cost is fill and draw:

  • 2-D canvas: one drawImage per leaf. Pre-render the sprite once at the largest size you will draw. Never re-path the leaf per frame.

  • 3-D: one InstancedMesh, matrices composed into a shared Matrix4. Hoist scratch Matrix4/Quaternion/Euler/Vector3 to module scope.

  • Alpha-tested leaves lose early-Z, so they cost more per pixel than their triangle count suggests. Prefer more, smaller leaves to fewer huge ones.

Stop invisible work

  • Pause on document.hidden and when the section leaves the viewport (IntersectionObserver). Reset lastTime on resume so the first frame after does not integrate the whole pause.

  • Clamp dt to about 1/30 s so a stall does not teleport the field.

  • Cap device pixel ratio at 2.

  • Size from a ResizeObserver on the root element, not from a one-shot measurement at script time. A page laid out later leaves the canvas 0×0 forever, because the resize event it was waiting for has already fired.

  • Guard the build against a zero viewport. Controls that wire up by running once reach the builder before first layout and otherwise spawn the whole field stacked at the origin.

Respect the reader

Under prefers-reduced-motion: reduce, render one still, well-composed frame and do not animate. Do not simply hide the leaves; the composition was designed with them in it. Redraw that still when a control changes so the controls still do something.

Verify

  • [ ] Tumble crosses edge-on; leaves visibly thin and vanish once per turn

  • [ ] Slip is driven by the tumble angle, not an independent sine

  • [ ] Front and back faces differ

  • [ ] Every parameter varies per leaf

  • [ ] Recycle band is as tight as the composition allows before count goes up

  • [ ] 3-D: recycled ahead of the camera, not around it

  • [ ] 3-D: emissive reds have green and blue at zero

  • [ ] Count scales with viewport area; the readout reports what was actually built

  • [ ] Paused when hidden or off-screen; dt clamped; DPR capped at 2

  • [ ] A designed still frame under reduced motion

  • [ ] Console clean at 390px and 1440px

Files

demo/index.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Late Autumn — The Maple Fall</title>
<style>
  :root{
    --ink:#05070a;
    --bone:#dfe7e0;
    --bone-dim:#a8b3ac;
    --muted:#6d7872;
    --vermilion:#e0231c;
    --line:rgba(223,231,224,.13);
    --line-soft:rgba(223,231,224,.07);
    --pad:clamp(22px,5vw,74px);
    --ease-out:cubic-bezier(.22,.65,.28,1);
  }
  *,*::before,*::after{ box-sizing:border-box; }
  html{ -webkit-text-size-adjust:100%; }
  body{
    margin:0; min-height:100svh; background:var(--ink); color:var(--bone);
    font-family:'Onest',system-ui,-apple-system,'Helvetica Neue',sans-serif;
    font-weight:300; font-size:16px; line-height:1.6;
    -webkit-font-smoothing:antialiased; overflow:hidden;
  }

  /* ============================================================ the night */
  .sky{ position:fixed; inset:0; z-index:0; pointer-events:none;
    background:
      radial-gradient(52vw 40vw at 76% 22%, rgba(150,36,26,.30), rgba(90,18,16,.10) 46%, transparent 70%),
      radial-gradient(120% 80% at 50% 0%, rgba(96,124,132,.13), transparent 58%),
      linear-gradient(#070b10, #05070a 52%, #04060a);
  }
  /* the moon: a disc with a limb, not a blur */
  .moon{ position:fixed; z-index:1; right:16vw; top:14vh; width:clamp(96px,13vw,208px);
    aspect-ratio:1; border-radius:50%; pointer-events:none;
    background:radial-gradient(circle at 42% 38%, #d4685c, #b8402f 46%, #8d2419 76%, #5f1410);
    box-shadow:0 0 6vw 1.4vw rgba(176,54,38,.20), 0 0 16vw 5vw rgba(140,34,26,.10);
    animation:moon-breathe 13s ease-in-out infinite alternate;
  }
  @keyframes moon-breathe{ from{ opacity:.88 } to{ opacity:1 } }
  /* the ridge, three-quarters eaten by fog */
  .ridge{ position:fixed; left:-2%; right:-2%; bottom:0; height:34vh; z-index:1; pointer-events:none;
    background:linear-gradient(transparent, rgba(4,7,11,.72) 42%, #04060a 88%);
  }
  .ridge::before{ content:''; position:absolute; left:0; right:0; bottom:26%; height:44%;
    background:#070c11; opacity:.9;
    clip-path:polygon(0 62%,6% 44%,12% 56%,19% 30%,26% 48%,33% 24%,41% 46%,48% 18%,56% 44%,63% 28%,71% 50%,78% 32%,86% 52%,93% 38%,100% 58%,100% 100%,0 100%);
  }

  /* ======================================================== the leaf fall */
  #fall,#fallNear{ position:fixed; inset:0; z-index:2; display:block; pointer-events:none; }
  /* the near layer is drawn over the type — that crossing is the depth cue */
  #fallNear{ z-index:6; }

  /* ================================================================ type */
  .stage{ position:relative; z-index:5; min-height:100svh; padding:0 var(--pad);
    display:flex; flex-direction:column; }
  .top{ padding-top:clamp(20px,5vh,52px); display:flex; align-items:baseline;
    justify-content:space-between; gap:16px; border-bottom:1px solid var(--line-soft);
    padding-bottom:16px; }
  .k{ font-size:10px; font-weight:500; letter-spacing:.24em; text-transform:uppercase;
    color:var(--muted); }
  .k b{ color:var(--vermilion); font-weight:500; }
  .mid{ flex:1 1 auto; display:flex; align-items:center; }
  .lede{ max-width:min(880px,64vw); }
  /* no text-wrap:balance — the line break here is authored, and balance
     fights an explicit <br> */
  h1{ margin:0 0 22px; font-size:clamp(36px,6vw,94px); line-height:.96;
    letter-spacing:-.022em; font-weight:300;
    text-shadow:0 2px 40px rgba(4,7,11,.9); }
  h1 em{ font-style:normal; color:var(--bone-dim); }
  .sub{ margin:0; max-width:44ch; color:var(--bone-dim); font-size:clamp(14px,1.15vw,17px);
    text-shadow:0 1px 24px rgba(4,7,11,.95); }
  .foot{ padding-bottom:clamp(20px,4vh,40px); }

  /* ============================================================ controls */
  .hud{ display:flex; flex-wrap:wrap; align-items:center; gap:clamp(14px,2.4vw,34px);
    border-top:1px solid var(--line-soft); padding-top:16px; }
  .ctl{ display:flex; align-items:center; gap:10px; }
  .ctl label{ font-size:9px; letter-spacing:.22em; text-transform:uppercase; color:var(--muted); }
  .ctl output{ font-size:11px; color:var(--bone-dim); min-width:3.4ch; font-variant-numeric:tabular-nums; }
  input[type=range]{ -webkit-appearance:none; appearance:none; width:clamp(74px,9vw,132px);
    height:14px; background:none; cursor:pointer; }
  input[type=range]::-webkit-slider-runnable-track{ height:1px; background:var(--line); }
  input[type=range]::-moz-range-track{ height:1px; background:var(--line); }
  input[type=range]::-webkit-slider-thumb{ -webkit-appearance:none; appearance:none;
    width:9px; height:9px; margin-top:-4px; border-radius:50%; background:var(--bone); }
  input[type=range]::-moz-range-thumb{ width:9px; height:9px; border:0; border-radius:50%;
    background:var(--bone); }
  button{ font:inherit; color:var(--bone-dim); background:none; cursor:pointer;
    border:1px solid var(--line); border-radius:100px; padding:8px 20px;
    font-size:9px; letter-spacing:.22em; text-transform:uppercase;
    transition:color .4s var(--ease-out), border-color .4s var(--ease-out); }
  button:hover{ color:var(--bone); border-color:rgba(223,231,224,.34); }
  :focus-visible{ outline:1px solid var(--vermilion); outline-offset:3px; }
  .sr{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0);
    clip-path:inset(50%); white-space:nowrap; }

  @media (max-width:720px){
    .top{ flex-direction:column; align-items:flex-start; gap:6px; }
    .lede{ max-width:none; }
    .hud{ gap:14px 20px; }
  }
</style>
</head>
<body>

<div class="sky"></div>
<div class="moon" aria-hidden="true"></div>
<div class="ridge" aria-hidden="true"></div>

<!-- Two surfaces, one simulation: the far and mid layers sit behind the type,
     the near layer in front of it. That crossing is the depth cue. -->
<canvas id="fall" aria-hidden="true"></canvas>
<canvas id="fallNear" class="near" aria-hidden="true"></canvas>

<main class="stage">
  <header class="top">
    <span class="k"><b>01</b> — Late Autumn</span>
    <span class="k">The maple fall</span>
  </header>

  <div class="mid">
    <div class="lede">
      <h1>The maples let go<br><em>all through the walk.</em></h1>
      <p class="sub">A leaf is read by its tumble, not its path. Each one turns on
        its own axis until it thins to an edge and disappears, then opens out on
        its duller side — and slides fastest at exactly the moment it is knifing
        through the air.</p>
    </div>
  </div>

  <footer class="foot">
    <div class="hud">
      <div class="ctl">
        <label for="count">Leaves</label>
        <input id="count" type="range" min="40" max="420" step="10" value="200">
        <output id="countOut">200</output>
      </div>
      <div class="ctl">
        <label for="wind">Wind</label>
        <input id="wind" type="range" min="-60" max="60" step="2" value="14">
        <output id="windOut">14</output>
      </div>
      <div class="ctl">
        <label for="tumble">Tumble</label>
        <input id="tumble" type="range" min="0" max="200" step="5" value="100">
        <output id="tumbleOut">100</output>
      </div>
      <button id="gust" type="button">Gust</button>
      <p class="sr" role="status" aria-live="polite" id="say"></p>
    </div>
  </footer>
</main>

<script>
(function () {
'use strict';

const TAU = Math.PI * 2;
const REDUCE = matchMedia('(prefers-reduced-motion: reduce)').matches;
const rnd = (a, b) => a + Math.random() * (b - a);
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);

/* ------------------------------------------------------------- 1 · the leaf
   Drawn once at boot, never re-pathed per frame. Both faces are baked: the
   underside of a maple is duller and paler, and picking between them by the
   sign of the tumble is what makes the leaf read as a solid object with a
   front and a back rather than a spinning cut-out. */
function leafSprite(rgb, back) {
  const S = 96, c = document.createElement('canvas');
  c.width = c.height = S;
  const x = c.getContext('2d');
  x.translate(S / 2, S * .94);
  x.scale(S / 2.25, -S / 2.25);

  x.beginPath();
  const lobes = 5, spread = 1.94;
  for (let i = 0; i < lobes; i++) {
    const a = -spread / 2 + spread * (i / (lobes - 1)) + Math.PI / 2;
    const len = i === 2 ? .98 : (i === 1 || i === 3 ? .84 : .60);
    const w = .17;
    x.moveTo(0, .03);
    x.lineTo(Math.cos(a - w) * len * .55, Math.sin(a - w) * len * .55);
    x.lineTo(Math.cos(a) * len, Math.sin(a) * len);
    x.lineTo(Math.cos(a + w) * len * .55, Math.sin(a + w) * len * .55);
    x.closePath();
  }
  /* the blade, lit from the top of the frame */
  const g = x.createLinearGradient(0, 1, 0, -.1);
  const [r, gr, b] = rgb;
  const lift = back ? 46 : 0, k = back ? .82 : 1;
  g.addColorStop(0, `rgb(${r * k + lift | 0},${gr * k + lift | 0},${b * k + lift | 0})`);
  g.addColorStop(1, `rgb(${r * .42 | 0},${gr * .42 | 0},${b * .46 | 0})`);
  x.fillStyle = g; x.fill();
  x.lineWidth = .045; x.strokeStyle = `rgba(${r * .5 | 0},${gr * .5 | 0},${b * .5 | 0},.9)`;
  x.stroke();

  /* ribs and stem */
  x.lineWidth = .028;
  x.strokeStyle = back ? 'rgba(255,228,214,.34)' : 'rgba(28,6,6,.42)';
  x.beginPath();
  for (let i = 0; i < lobes; i++) {
    const a = -spread / 2 + spread * (i / (lobes - 1)) + Math.PI / 2;
    const len = i === 2 ? .92 : (i === 1 || i === 3 ? .78 : .55);
    x.moveTo(0, .03); x.lineTo(Math.cos(a) * len, Math.sin(a) * len);
  }
  x.moveTo(0, .03); x.lineTo(0, -.30);
  x.stroke();
  return c;
}

/* a small autumn ramp — one red for every leaf is the giveaway */
const RAMP = [[164, 26, 20], [198, 44, 26], [176, 34, 30], [212, 74, 28],
              [150, 22, 24], [196, 96, 34], [130, 20, 26]];
const SPRITES = RAMP.map(c => ({ face: leafSprite(c, false), back: leafSprite(c, true) }));

/* --------------------------------------------------------- 2 · the layers
   Three depths. Density on screen goes as count over band area, so the layers
   differ by scale and speed rather than by having their own counts tuned by
   hand — share is what buys the parallax. */
const LAYERS = [
  { key: 'far',  surf: 'back',  share: .42, scale: [.30, .48], fall: [26, 46],  alpha: [.22, .40], blur: 0 },
  { key: 'mid',  surf: 'back',  share: .44, scale: [.52, .84], fall: [46, 78],  alpha: [.46, .78], blur: 0 },
  { key: 'near', surf: 'front', share: .14, scale: [1.05, 1.9], fall: [92, 150], alpha: [.50, .82], blur: 1.4 }
];

const back = document.getElementById('fall');
const front = document.getElementById('fallNear');
const ctxB = back.getContext('2d');
const ctxF = front.getContext('2d');

let W = 0, H = 0, dpr = 1;
const P = { count: 200, wind: 14, tumble: 1 };
let leaves = [], gust = 0;

function spawn(l, layer, seeded) {
  const L = LAYERS[layer];
  l.layer = layer;
  l.sprite = SPRITES[(Math.random() * SPRITES.length) | 0];
  l.s = rnd(L.scale[0], L.scale[1]);
  l.fall = rnd(L.fall[0], L.fall[1]);
  l.alpha = rnd(L.alpha[0], L.alpha[1]);
  /* every parameter varies per leaf — share any one of them and the field
     stops being leaves and becomes a texture scrolling down the screen */
  l.spin = Math.random() * TAU;
  l.spinSp = rnd(.5, 2.3) * (Math.random() < .5 ? -1 : 1);
  l.roll = Math.random() * TAU;
  l.rollSp = rnd(-.5, .5);
  l.slip = rnd(18, 52);
  l.drag = rnd(.7, 1.3);
  l.x = Math.random() * W;
  l.y = seeded ? Math.random() * H : -70 * l.s - Math.random() * 160;
}

/* On-screen density goes as count over band area, and here the band is the
   viewport — so the authored figure is scaled by area rather than taken
   literally, or the same number that reads as a drift on a desktop arrives as
   a blizzard on a phone. The readout reports what was actually built. */
function areaScale() {
  return clamp(Math.sqrt((W * H) / (1440 * 900)), .5, 1.3);
}
function build() {
  leaves = [];
  /* The controls wire themselves up by running once, which reaches build()
     before the first layout in an embed. Without this guard that pass spawns a
     field at 0x0 — every leaf stacked at the origin — and reports a leaf count
     that was never the one on screen. */
  if (!W || !H) { if (countOut) countOut.textContent = '—'; return 0; }
  const k = areaScale();
  LAYERS.forEach((L, i) => {
    const c = Math.round(P.count * L.share * k);
    for (let j = 0; j < c; j++) { const l = {}; spawn(l, i, true); leaves.push(l); }
  });
  if (countOut) countOut.textContent = leaves.length;
  return leaves.length;
}

/* innerWidth can be 0 when the script runs — an iframe, a preview pane, a
   print context, a tab laid out after the document parses. innerWidth||
   clientWidth covers the cases where only one of them is up yet. */
function viewport() {
  const de = document.documentElement;
  return [innerWidth || de.clientWidth || 0, innerHeight || de.clientHeight || 0];
}
function applySize() {
  dpr = Math.min(devicePixelRatio || 1, 2);
  [back, front].forEach(c => {
    c.width = Math.round(W * dpr); c.height = Math.round(H * dpr);
    c.style.width = W + 'px'; c.style.height = H + 'px';
  });
  ctxB.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctxF.setTransform(dpr, 0, 0, dpr, 0, 0);
}

/* ------------------------------------------------------------ 3 · the fall */
function step(dt) {
  gust *= 1 - 2.2 * dt;
  const wind = P.wind + gust;
  for (let i = 0; i < leaves.length; i++) {
    const l = leaves[i];
    l.spin += l.spinSp * P.tumble * dt;
    l.roll += l.rollSp * dt;
    l.y += l.fall * dt;
    /* The slip is driven by the tumble, ninety degrees out of phase: a leaf
       slides when it is edge-on to the air and stalls when it presents its
       face. An independent sine here reads as a wobble, not as flight. */
    l.x += (Math.sin(l.spin) * l.slip + wind * l.drag) * dt;

    const m = 90 * l.s;
    if (l.y - m > H) spawn(l, l.layer, false);
    if (l.x < -m) l.x += W + m * 2; else if (l.x > W + m) l.x -= W + m * 2;
  }
}

function draw() {
  if (!W || !H) return;
  ctxB.clearRect(0, 0, W, H);
  ctxF.clearRect(0, 0, W, H);
  ctxF.filter = 'blur(1.4px)';
  for (let i = 0; i < leaves.length; i++) {
    const l = leaves[i];
    const L = LAYERS[l.layer];
    const ctx = L.surf === 'front' ? ctxF : ctxB;
    const t = Math.cos(l.spin);
    const w = 96 * l.s, h = 96 * l.s;
    ctx.save();
    ctx.translate(l.x, l.y);
    ctx.rotate(l.roll);
    /* the tumble. cos crosses zero — that is the leaf edge-on, and that
       instant of near-disappearance is the entire read */
    ctx.scale(t, 1);
    ctx.globalAlpha = l.alpha;
    ctx.drawImage(t < 0 ? l.sprite.back : l.sprite.face, -w / 2, -h / 2, w, h);
    ctx.restore();
  }
  ctxF.filter = 'none';
}

/* ------------------------------------------------------------ 4 · the loop */
let last = 0, running = false, raf = 0;
function frame(now) {
  if (!running) return;
  const dt = clamp((now - last) / 1000, 0, 1 / 30);   /* a stall must not teleport the field */
  last = now;
  step(dt); draw();
  raf = requestAnimationFrame(frame);
}
function start() {
  if (running || REDUCE || !sized) return;
  running = true; last = performance.now();
  raf = requestAnimationFrame(frame);
}
function stop() { running = false; cancelAnimationFrame(raf); }

document.addEventListener('visibilitychange', () => document.hidden ? stop() : start());


/* ------------------------------------------------------------ 5 · controls */
const say = document.getElementById('say');
const countOut = document.getElementById('countOut');
function wire(id, fmt, apply) {
  const el = document.getElementById(id), out = document.getElementById(id + 'Out');
  const run = () => {
    const v = +el.value;
    out.textContent = fmt(v);
    apply(v);
    say.textContent = el.previousElementSibling.textContent + ' ' + fmt(v);
    /* Under reduced motion the field never advances, so redrawing alone would
       re-render the identical poses and wind and tumble would read as dead
       controls. One step recomposes the still frame without animating it. */
    if (REDUCE) { step(1 / 30); draw(); }
  };
  el.addEventListener('input', run);
  run();
}
wire('count', v => v, v => { P.count = v; build(); });   /* build() overwrites the readout with the real total */
wire('wind', v => v, v => { P.wind = v; });
wire('tumble', v => v, v => { P.tumble = v / 100; });
document.getElementById('gust').addEventListener('click', () => {
  gust = 260 * (Math.random() < .5 ? -1 : 1);
  say.textContent = 'Gust';
  if (REDUCE) { step(1 / 60); draw(); }
});

/* ------------------------------------------------------------ 6 · sizing
   Measure once at script time and trust it, and a page that is laid out later
   leaves the canvas 0x0 for ever — the resize event it was waiting for has
   already fired. A ResizeObserver on the root element fires whenever the page
   actually gets a size, whatever the reason, and covers the first layout as
   well as every later one. */
let sized = false;
function fit() {
  const [w, h] = viewport();
  if (!w || !h) return;                    /* not laid out yet — wait */
  if (sized && w === W && h === H) return; /* the observer also fires on no-ops */
  W = w; H = h;
  applySize();
  build();
  if (!sized) {
    sized = true;
    /* Under reduced motion the fall is composed, not hidden: the frame was
       designed with leaves in it, so it gets one still one. */
    if (REDUCE) draw(); else start();
  } else if (REDUCE) draw();
}
if (typeof ResizeObserver === 'function') new ResizeObserver(fit).observe(document.documentElement);
addEventListener('resize', fit, { passive: true });
fit();
})();
</script>
</body>
</html>

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Meng To
Publisher
Meng To (Community)
Source
MengTo/Skills · agent-skills/web-design/falling-leaves · commit 5ea1661
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Meng To or Meng To.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download falling-leaves.zip