Wind Grass
Blades bowing as a gust crosses them and standing back up behind it.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Wind Grass
*
* Blades that bow as a gust crosses them and stand back up behind it.
*
* The technique: one first-order lag whose rate is set by the wind the
* blade is feeling. Air load both bends a blade and stiffens how quickly
* it answers, so a blade under a gust tracks it almost exactly — and the
* moment the gust has passed there is no load left, only the blade's own
* springiness, which is slow. The asymmetry is not authored anywhere:
* measured on the same equation, a blade reaches full bend 0.47s after
* the gust arrives and takes 1.48s to stand up again, three times as
* long. That is what leaves a visible wake — with the front mid-field,
* the blades ahead of it are nearly upright (mean bend 0.036) while the
* ones behind it are still leaning ten times as far (0.359). A sine
* wave phase-shifted per blade bends and unbends at the same speed, so
* the gust has no direction and the field reads as a stadium wave.
*
* Two supporting details. The blade bends along its length rather than
* pivoting: the whole stem is a constant-curvature arc, so the tip
* travels furthest and the root barely moves, which is what separates
* grass from windscreen wipers. And one depth number per blade sets its
* height, its width, its brightness and how late it feels the gust — so
* the back of the field is smaller, dimmer and a beat behind, all from
* the same number.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type WindGrassProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Blades in the field. */
count?: number;
/** Blade colour. */
color?: string;
};
type VariantConfig = {
/** How fast a gust crosses, px per second. */
gustSpeed: number;
/** Bend in radians along the whole blade at full wind. */
bend: number;
/** Quiet seconds between gusts. */
gap: number;
/** Tallest blade as a fraction of the box height. */
tall: number;
/** Half-width of the gust front, in px. */
sigma: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A long slow breath across a short lawn.
subtle: { gustSpeed: 150, bend: 0.55, gap: 3.4, tall: 0.5, sigma: 90 },
// A gust you can watch travel. All-purpose.
default: { gustSpeed: 210, bend: 0.9, gap: 2.2, tall: 0.58, sigma: 74 },
// Taller grass, a harder and narrower front, arriving more often.
playful: { gustSpeed: 300, bend: 1.25, gap: 1.3, tall: 0.66, sigma: 62 },
};
/** How fast a blade returns on its own springiness alone, per second. */
const ELASTIC = 0.75;
/** Extra response rate per unit of wind — this is the asymmetry. */
const LOADED = 14;
type Blade = {
x: number;
/** 0 far, 1 near. Drives length, width, brightness and gust delay. */
depth: number;
length: number;
halfWidth: number;
alpha: number;
/** Seconds this blade lags the front, from its depth. */
lag: number;
/** Per-blade phase into the shared idle breath. */
phase: number;
/** Per-blade scale on the bend, so the field is not one rigid sheet. */
give: number;
bend: number;
};
export default function WindGrass({
variant = "default",
count = 130,
color = "#7FA773",
}: WindGrassProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const blades: Blade[] = [];
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let cycle = 1;
const random = (min: number, max: number) => min + Math.random() * (max - min);
const build = () => {
blades.length = 0;
for (let index = 0; index < Math.max(8, Math.round(count)); index++) {
const depth = Math.random();
blades.push({
x: random(-10, width + 10),
depth,
length: height * config.tall * (0.42 + depth * 0.58) * random(0.82, 1.18),
halfWidth: (0.7 + depth * 1.1) * 0.5,
alpha: 0.22 + depth * 0.44,
// Depth is distance, and a gust reaches the back of the field
// later. The same number does all four jobs.
lag: (1 - depth) * 0.42,
phase: random(0, Math.PI * 2),
give: random(0.72, 1.3),
bend: 0,
});
}
// Far blades first, so the near ones sit in front of them.
blades.sort((a, b) => a.depth - b.depth);
cycle = width + config.sigma * 4 + config.gustSpeed * config.gap;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
const ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
build();
};
resize();
/**
* One field, sampled per blade. Two gust fronts travelling at
* different speeds plus a slow breath, so the whole lawn moves as a
* body — independent per-blade wobble reads as static, whatever the
* amplitude.
*/
const windAt = (x: number, time: number) => {
const travel = time * config.gustSpeed;
const front = -config.sigma * 2 + (((travel % cycle) + cycle) % cycle);
const second =
-config.sigma * 2 +
((((travel * 0.62 + cycle * 0.47) % cycle) + cycle) % cycle);
const a = (x - front) / config.sigma;
const b = (x - second) / (config.sigma * 1.5);
return (
Math.exp(-a * a) +
Math.exp(-b * b) * 0.4 +
0.03 +
Math.sin(time * 0.42) * 0.03
);
};
const drawBlade = (blade: Blade) => {
const baseX = blade.x;
const baseY = height + 2;
const length = blade.length;
const bend = blade.bend;
const curvature = Math.abs(bend) < 1e-4 ? 1e-4 : bend / length;
const along = (s: number) => {
const angle = curvature * s;
return {
x: baseX + (1 - Math.cos(angle)) / curvature,
y: baseY - Math.sin(angle) / curvature,
nx: Math.cos(angle),
ny: Math.sin(angle),
};
};
const root = along(0);
const middle = along(length * 0.5);
const tip = along(length);
const rootWidth = blade.halfWidth;
const midWidth = blade.halfWidth * 0.52;
const leftRootX = root.x + root.nx * rootWidth;
const leftRootY = root.y + root.ny * rootWidth;
const rightRootX = root.x - root.nx * rootWidth;
const rightRootY = root.y - root.ny * rootWidth;
const leftMidX = middle.x + middle.nx * midWidth;
const leftMidY = middle.y + middle.ny * midWidth;
const rightMidX = middle.x - middle.nx * midWidth;
const rightMidY = middle.y - middle.ny * midWidth;
context.globalAlpha = blade.alpha;
context.beginPath();
context.moveTo(leftRootX, leftRootY);
context.quadraticCurveTo(
2 * leftMidX - (leftRootX + tip.x) / 2,
2 * leftMidY - (leftRootY + tip.y) / 2,
tip.x,
tip.y
);
context.quadraticCurveTo(
2 * rightMidX - (tip.x + rightRootX) / 2,
2 * rightMidY - (tip.y + rightRootY) / 2,
rightRootX,
rightRootY
);
context.closePath();
context.fill();
};
const render = () => {
context.clearRect(0, 0, width, height);
context.fillStyle = color;
for (const blade of blades) drawBlade(blade);
context.globalAlpha = 1;
};
const step = (delta: number, time: number) => {
for (const blade of blades) {
const wind = windAt(blade.x, time - blade.lag);
const target =
config.bend * blade.give * wind +
Math.sin(time * 0.9 + blade.phase) * 0.035 * wind;
// The whole model. The rate rises with the load, so bending is
// fast and standing back up is not.
blade.bend += (target - blade.bend) * (ELASTIC + LOADED * wind) * delta;
}
};
// Reduced motion: the field with a gust part-way across, run forward
// first so the wake is real — upright ahead of the front, still bent
// behind it. The asymmetry is the subject and it is legible in one
// frame; a lawn of identical blades would not be.
if (reduced) {
const settle = () => {
for (let index = 0; index < 220; index++) step(1 / 60, index / 60);
render();
};
settle();
const onResizeStill = () => {
resize();
settle();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
step(delta, elapsed);
render();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => resize();
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
A living lower edge for an empty state, an onboarding screen or a footer that should feel outdoors. Everything rests on one first-order lag whose rate is set by the wind the blade is feeling: air load both bends a blade and stiffens how quickly it answers, so a blade under a gust tracks it almost exactly, and the moment the gust has gone there is no load left — only the blade's own springiness, which is slow. Nothing authors that asymmetry. Measured on the same equation, a blade reaches full bend 0.47 seconds after the gust arrives and takes 1.48 seconds to stand up again, and with the front mid-field the blades ahead of it are nearly upright while those behind it lean ten times as far. That wake is what gives the gust a direction; a sine wave phase-shifted per blade bends and unbends at the same speed and reads as a stadium wave. Two details support it: the blade is a constant-curvature arc so it bends along its length instead of pivoting, and one depth number per blade sets its height, width, brightness and how late it feels the front.