Touch the grass

Touch the grass

Playground

Label
Density240
Height135px
Reach130px
Bend26px
Spring0.01
Damping0.90
Wind4
Speed1.10
Pivot0.35
Arc0.55
Contrast0.45
Blades
Background

A field of grass on a 2D canvas. Blades lean away as the cursor sweeps through and spring back once it passes, with a slow wind underneath.

Code

"use client";

import { useEffect, useRef } from "react";
import { makeBlades, type Blade } from "./grass/blades";
import { rgbOf, tone } from "./grass/color";

export const meta = {
  title: "Touch the grass",
  date: "2026-07-25",
  size: "wide" as const,
  tags: ["Canvas", "Interaction"],
  description:
    "A field of grass on a 2D canvas. Blades lean away as the cursor sweeps through and spring back once it passes, with a slow wind underneath.",
  prompt:
    "Build a canvas grass field in React: tapered blades of varied height, lean and tone drawn bottom-up with quadratic curves, layered back-to-front for depth. Blades bend away from the cursor within a radius and spring back when it leaves, with a gentle sine wind. Controls for density, height, bend radius and strength, spring and damping, wind, colour and background.",
};

export const params = {
  label: "Touch the grass",
  density: 240,
  height: 135,
  radius: 130,
  strength: 26,
  spring: 0.01,
  damping: 0.9,
  wind: 4,
  windSpeed: 1.1,
  pivot: 0.35,
  arc: 0.55,
  contrast: 0.45,
  color: "#5f9e58",
  bg: "#f3f7f0",
};

export const controls = {
  label: { type: "text", placeholder: "label", label: "Label" },
  density: { type: "range", min: 40, max: 320, step: 10, label: "Density" },
  height: { type: "range", min: 50, max: 170, step: 5, unit: "px", label: "Height" },
  radius: { type: "range", min: 40, max: 300, step: 5, unit: "px", label: "Reach" },
  strength: { type: "range", min: 0, max: 60, step: 1, unit: "px", label: "Bend" },
  spring: { type: "range", min: 0.01, max: 0.2, step: 0.005, label: "Spring" },
  damping: { type: "range", min: 0.7, max: 0.98, step: 0.01, label: "Damping" },
  wind: { type: "range", min: 0, max: 14, step: 0.5, label: "Wind" },
  windSpeed: { type: "range", min: 0.2, max: 3, step: 0.1, label: "Speed" },
  pivot: { type: "range", min: 0, max: 0.8, step: 0.05, label: "Pivot" },
  arc: { type: "range", min: 0.2, max: 0.9, step: 0.05, label: "Arc" },
  contrast: { type: "range", min: 0, max: 0.8, step: 0.05, label: "Contrast" },
  color: { type: "color", label: "Blades" },
  bg: { type: "color", label: "Background" },
} as const;

export const presets = {
  Lawn: { color: "#5f9e58", bg: "#f3f7f0" },
  Meadow: { color: "#8a8a8a", bg: "#f7f7f5" },
  Ink: { color: "#2f2f2f", bg: "#f7f7f5" },
  Night: { color: "#9fb8a6", bg: "#12161a" },
};

type Params = typeof params;

export default function Grass(props: Params) {
  const hostRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const blades = useRef<Blade[]>([]);
  const pointer = useRef({ x: 0, inside: false });
  const live = useRef(props);
  live.current = props;

  useEffect(() => {
    blades.current = makeBlades(props.density);
  }, [props.density]);

  useEffect(() => {
    const host = hostRef.current;
    const ctx = canvasRef.current?.getContext("2d");
    if (!host || !ctx) return;

    let w = 0;
    let h = 0;
    const resize = () => {
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      ({ width: w, height: h } = host.getBoundingClientRect());
      ctx.canvas.width = Math.round(w * dpr);
      ctx.canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(host);

    const start = performance.now();
    let raf = 0;
    const frame = (now: number) => {
      const t = (now - start) / 1000;
      const p = live.current;
      const rgb = rgbOf(p.color);

      ctx.fillStyle = p.bg;
      ctx.fillRect(0, 0, w, h);

      for (const b of blades.current) {
        const bx = b.x * w;
        const bh = b.h * p.height;

        const dx = bx - pointer.current.x;
        const dist = Math.abs(dx);
        let aim = 0;
        if (pointer.current.inside && dist < p.radius) {
          const f = 1 - dist / p.radius;
          aim = Math.sign(dx) * f * f * (3 - 2 * f) * p.strength;
        }
        b.vel = (b.vel + (aim - b.bend) * p.spring) * p.damping;
        b.bend += b.vel;

        const offset =
          b.lean * bh + b.bend + p.wind * Math.sin(t * p.windSpeed + b.phase) * b.h;
        const ctrlX = bx + offset * p.pivot;
        const ctrlY = h - bh * p.arc;

        ctx.beginPath();
        ctx.moveTo(bx - b.w / 2, h);
        ctx.quadraticCurveTo(ctrlX - b.w * 0.25, ctrlY, bx + offset, h - bh);
        ctx.quadraticCurveTo(ctrlX + b.w * 0.25, ctrlY, bx + b.w / 2, h);
        ctx.closePath();
        ctx.fillStyle = tone(rgb, p.contrast - b.depth * p.contrast * 1.67);
        ctx.fill();
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);

    return () => {
      ro.disconnect();
      cancelAnimationFrame(raf);
    };
  }, []);

  const onMove = (e: React.MouseEvent) => {
    const r = hostRef.current?.getBoundingClientRect();
    if (r) pointer.current = { x: e.clientX - r.left, inside: true };
  };

  return (
    <div
      ref={hostRef}
      onMouseMove={onMove}
      onMouseLeave={() => (pointer.current.inside = false)}
      className="relative h-full w-full overflow-hidden"
    >
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />
      {props.label && (
        <span className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/10 bg-white/90 px-4 py-1.5 text-sm text-neutral-800 shadow-sm backdrop-blur-sm">
          {props.label}
        </span>
      )}
    </div>
  );
}
dateJul 25, 2026
tagsCanvas, Interaction