← Playground

Grass

Grass that bends away from your cursor

Hand it to your AI assistant

A ready-made skill: a Markdown file with instructions, the options, the full source and the wrappers for every framework. Save it as SKILL.md inside a folder named nikuma-grass in your assistant's skills folder (for Claude Code: .claude/skills/), or paste it into any chat, then say "add grass that bends away from the cursor to my site".

Download SKILL.md

SKILL.mdpreview
---
name: nikuma-grass
description: Add a procedural grass strip that bends away from the mouse and swings back, with pollen floating up by day. Use when the user asks for this effect or component in any framework (vanilla JS, Astro, React, Vue, Svelte, Solid).
---

# Grass

A meadow made of 330 SVG blades. Move the mouse through it and the blades lean away, then swing back like real grass. It is the footer garden of this site, made standalone.

One dependency-free file. `mountGrass(element, options)` builds everything inside `element` and **returns a `stop()` function** that removes it and cleans up all listeners. The framework wrappers below only call it in the right lifecycle hook.

## When to use

The user wants: grass that bends away from your cursor. Works in plain HTML, Astro, React (incl. Next.js), Vue (incl. Nuxt), Svelte (incl. SvelteKit) and Solid.

## How to add it

1. Save the file(s) under **Source** into the project (for example `src/lib/grass/`), keeping their names. They need no installation.
2. Vanilla / Astro / plain HTML: import the function in a `<script type="module">` and call it with the container element. Other frameworks: create the wrapper from **Framework wrappers**, then render it.
3. Give the container a size where the rules below say so, then check it in both light and dark theme.

```js
import { mountGrass } from './grass.js';
const stop = mountGrass(document.getElementById('stage'), { blades: 330, pollen: 12, mouse: true });
// stop();  // when the element is removed
```

## Options (all optional)

| Option | Default | What it does |
|---|---|---|
| `blades` | `330` | how many blades (the grass looks full from about 200) |
| `pollen` | `12` | floating pollen specks (daytime only) |
| `mouse` | `true` | false = grass only sways, never reacts to the mouse |
| `colors` | `meadow` | four greens, light to dark. A darker set is used in the dark theme |
| `seed` | `20261030` | same number = same meadow |

## Rules

- The container needs a height and works best full width.
- The grass is decorative: it already has `aria-hidden` and `pointer-events: none`.
- Call `stop()` before mounting again on the same element (the framework wrappers do this on unmount).
- Do not edit the engine to change behaviour; use options. Respect `prefers-reduced-motion` (the engine does).

## Source

### `grass.js`

```js
// Procedural grass that bends away from the cursor and swings back, with floating pollen by day.
// mountGrass(el, options) -> stop()   Fills the bottom of `el` (give `el` a height, e.g. 220px). No dependencies.
//
// options (all optional):
//   blades   how many blades                     default 330
//   pollen   floating pollen specks              default 12
//   mouse    grass bends from the cursor         default true
//   colors   four greens, light to dark          default a soft meadow (a darker set is used when <html data-theme="dark">)
//   seed     number: same seed = same meadow     default 20261030
const LIGHT = ['#9ccf8a', '#6fb36a', '#4a9a52', '#2f7d44'], DARK = ['#3f7a47', '#2f6b3d', '#245a33', '#1a4627'];
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

export function mountGrass(el, o = {}) {
  css('grx-css', `.grx{position:absolute;inset:0;overflow:hidden;pointer-events:none;contain:layout style paint}
.grx svg{position:absolute;inset:auto 0 0 0;width:100%;height:100%;display:block}
.grx-b{fill:var(--c);transform:rotate(var(--p,0deg));transform-box:fill-box;transform-origin:50% 100%}
.grx-s{animation:grx-sway var(--d) ease-in-out var(--dl) infinite alternate}.grx-b.back{opacity:.55}.grx-b.off{display:none}
@keyframes grx-sway{from{rotate:calc(var(--a)*-1)}to{rotate:var(--a)}}
.grx-m{position:absolute;bottom:26px;left:var(--x);width:3px;height:3px;border-radius:50%;background:#fff6c4;box-shadow:0 0 6px 2px rgba(255,236,150,.75);opacity:0;animation:grx-mote var(--t) linear var(--dl) infinite}
.grx.dark .grx-m{display:none}.grx.idle .grx-s,.grx.idle .grx-m{animation-play-state:paused}
@keyframes grx-mote{0%{opacity:0;transform:translate(0,0)}15%{opacity:.9}100%{opacity:0;transform:translate(var(--dx),-180px)}}
@media(prefers-reduced-motion:reduce){.grx:not(.force) .grx-s,.grx:not(.force) .grx-m{animation:none}}`);
  if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
  const W = 1440, H = 230, still = !o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches;
  let s = o.seed ?? 20261030;
  const rnd = () => { s |= 0; s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
  const between = (a, b) => a + rnd() * (b - a);
  const total = o.blades ?? 330, mix = [[.45, 24, 80, [0, 1], 'back'], [.37, 26, 112, [1, 2], 'mid'], [.18, 40, 140, [2, 3], 'front']];
  let paths = '';
  for (const [share, hMin, hMax, cols, cls] of mix) for (let i = 0; i < Math.round(total * share); i++) {
    const x = between(-10, W + 10), h = between(hMin, hMax), w = between(2, 5), lean = between(-20, 20), c = cols[Math.floor(rnd() * 2)];
    paths += `<path class="grx-b grx-s ${cls}" data-x="${x.toFixed(0)}" style="--c:var(--grx-${c});--a:${between(1.5, 5).toFixed(1)}deg;--d:${between(2.8, 5.5).toFixed(1)}s;--dl:${(-between(0, 5)).toFixed(1)}s" d="M${x - w},${H} Q${x - w / 2 + lean * .3},${H - h * .6} ${x + lean},${H - h} Q${x + w / 2 + lean * .3},${H - h * .6} ${x + w},${H}Z"/>`;
  }
  const box = document.createElement('div'); box.className = 'grx' + (o.ignoreMotionPref ? ' force' : ''); box.setAttribute('aria-hidden', 'true');
  const motes = Array.from({ length: o.pollen ?? 12 }, () => `<span class="grx-m" style="--x:${between(2, 98).toFixed(1)}%;--dx:${between(-40, 40).toFixed(0)}px;--t:${between(9, 18).toFixed(1)}s;--dl:${(-between(0, 14)).toFixed(1)}s"></span>`).join('');
  box.innerHTML = `${motes}<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMax slice">${paths}</svg>`;
  el.append(box);
  const svg = box.querySelector('svg');
  const root = document.documentElement;
  const paint = () => {
    const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
    (o.colors || (dark ? DARK : LIGHT)).forEach((c, i) => box.style.setProperty('--grx-' + i, c));
    box.classList.toggle('dark', dark);
  };
  paint();
  const mo = new MutationObserver(paint); mo.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
  const blades = [...svg.querySelectorAll('.grx-b')].map((b) => ({ el: b, x: +b.dataset.x, p: 0, v: 0, l: 0, hide: false }));
  // blades outside the visible (cropped) part are switched off: a big win on phones
  const cull = () => {
    const r = svg.getBoundingClientRect(); if (!r.width) return;
    const sc = Math.max(r.width / W, r.height / H), off = (r.width - W * sc) / 2, lo = -off / sc - 50, hi = (r.width - off) / sc + 50;
    for (const b of blades) { const h = b.x < lo || b.x > hi; if (h !== b.hide) { b.hide = h; b.el.classList.toggle('off', h); } }
  };
  let cr = 0; const ro = new ResizeObserver(() => { cancelAnimationFrame(cr); cr = requestAnimationFrame(cull); }); ro.observe(svg); cull();
  let visible = true, wake = () => {};
  const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; box.classList.toggle('idle', !visible); if (visible) wake(); }); io.observe(el);
  const done = () => { mo.disconnect(); ro.disconnect(); io.disconnect(); cancelAnimationFrame(cr); box.remove(); };
  if (o.mouse === false || still) return done;

  const R = 140, PUSH = 30; let mx = null, my = null, raf = 0, alive = true;
  const tick = () => {
    raf = 0; if (!alive) return;
    let px = null;
    if (mx !== null) {                                   // pointer position in the grass's own coordinates (measured now, so scrolling works)
      const r = svg.getBoundingClientRect();
      if (my > r.top - 100 && my < r.bottom + 4 && r.width) px = (mx - r.left - (r.width - W * Math.max(r.width / W, r.height / H)) / 2) / Math.max(r.width / W, r.height / H);
    }
    let busy = false;
    for (const b of blades) {
      if (b.hide) continue;
      let t = 0;
      if (px !== null) { const d = b.x - px, a = Math.abs(d); if (a < R) t = Math.sign(d || 1) * (1 - a / R) ** 1.4 * PUSH; }
      b.v += (t - b.p) * .12; b.v *= .8; b.p += b.v;                  // spring towards the push, friction so it swings back softly
      if (Math.abs(b.p - b.l) > .02) { b.l = b.p; b.el.style.setProperty('--p', b.p.toFixed(2) + 'deg'); }
      if (t || Math.abs(b.p) > .04 || Math.abs(b.v) > .02) busy = true;
    }
    if (busy) raf = requestAnimationFrame(tick);
  };
  wake = () => { if (!raf && alive && visible) raf = requestAnimationFrame(tick); };
  const move = (e) => { mx = e.clientX; my = e.clientY; wake(); }, away = () => { mx = my = null; wake(); };
  const opt = { passive: true, capture: true };
  addEventListener('pointermove', move, opt); addEventListener('pointerdown', move, opt); addEventListener('scroll', wake, opt); addEventListener('blur', away);
  document.documentElement.addEventListener('mouseleave', away);
  return () => { alive = false; cancelAnimationFrame(raf); removeEventListener('pointermove', move, opt); removeEventListener('pointerdown', move, opt); removeEventListener('scroll', wake, opt); removeEventListener('blur', away); document.documentElement.removeEventListener('mouseleave', away); done(); };
}
```

## Framework wrappers

### React: `Grass.jsx`

```jsx
'use client'; // only needed in Next.js
import { useEffect, useRef } from 'react';
import { mountGrass } from './grass.js';

export default function Grass({ options = {}, className, style }) {
  const ref = useRef(null);
  useEffect(() => mountGrass(ref.current, options), []);   // the engine returns its own cleanup
  return <div ref={ref} className={className} style={style} />;
}
```

### Vue: `Grass.vue`

```vue
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { mountGrass } from './grass.js';

const props = defineProps({ options: { type: Object, default: () => ({}) } });
const root = ref(null);
let stop;
onMounted(() => { stop = mountGrass(root.value, props.options); });
onBeforeUnmount(() => stop && stop());
</script>

<template><div ref="root"></div></template>
```

### Svelte: `Grass.svelte`

```svelte
<script>
  import { onMount } from 'svelte';
  import { mountGrass } from './grass.js';

  export let options = {};
  export let style = '';
  let klass = '';
  export { klass as class };
  let root;
  onMount(() => mountGrass(root, options));   // returning the cleanup from onMount stops it on destroy
</script>

<div bind:this={root} class={klass} {style}></div>
```

### Solid: `Grass.jsx`

```jsx
import { onMount, onCleanup } from 'solid-js';
import { mountGrass } from './grass.js';

export default function Grass(props) {
  let root;
  onMount(() => onCleanup(mountGrass(root, props.options || {})));
  return <div ref={root} class={props.class} style={props.style} />;
}
```

Get the code

One small file does all the work and has no dependencies. React, Vue, Svelte and Solid only add a few lines around it. Pick your tool below.

1. Copy the engine (same for every framework)

grass.js7 KB
// Procedural grass that bends away from the cursor and swings back, with floating pollen by day.
// mountGrass(el, options) -> stop()   Fills the bottom of `el` (give `el` a height, e.g. 220px). No dependencies.
//
// options (all optional):
//   blades   how many blades                     default 330
//   pollen   floating pollen specks              default 12
//   mouse    grass bends from the cursor         default true
//   colors   four greens, light to dark          default a soft meadow (a darker set is used when <html data-theme="dark">)
//   seed     number: same seed = same meadow     default 20261030
const LIGHT = ['#9ccf8a', '#6fb36a', '#4a9a52', '#2f7d44'], DARK = ['#3f7a47', '#2f6b3d', '#245a33', '#1a4627'];
const css = (id, t) => { if (!document.getElementById(id)) { const s = document.createElement('style'); s.id = id; s.textContent = t; document.head.append(s); } };

export function mountGrass(el, o = {}) {
  css('grx-css', `.grx{position:absolute;inset:0;overflow:hidden;pointer-events:none;contain:layout style paint}
.grx svg{position:absolute;inset:auto 0 0 0;width:100%;height:100%;display:block}
.grx-b{fill:var(--c);transform:rotate(var(--p,0deg));transform-box:fill-box;transform-origin:50% 100%}
.grx-s{animation:grx-sway var(--d) ease-in-out var(--dl) infinite alternate}.grx-b.back{opacity:.55}.grx-b.off{display:none}
@keyframes grx-sway{from{rotate:calc(var(--a)*-1)}to{rotate:var(--a)}}
.grx-m{position:absolute;bottom:26px;left:var(--x);width:3px;height:3px;border-radius:50%;background:#fff6c4;box-shadow:0 0 6px 2px rgba(255,236,150,.75);opacity:0;animation:grx-mote var(--t) linear var(--dl) infinite}
.grx.dark .grx-m{display:none}.grx.idle .grx-s,.grx.idle .grx-m{animation-play-state:paused}
@keyframes grx-mote{0%{opacity:0;transform:translate(0,0)}15%{opacity:.9}100%{opacity:0;transform:translate(var(--dx),-180px)}}
@media(prefers-reduced-motion:reduce){.grx:not(.force) .grx-s,.grx:not(.force) .grx-m{animation:none}}`);
  if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
  const W = 1440, H = 230, still = !o.ignoreMotionPref && matchMedia('(prefers-reduced-motion: reduce)').matches;
  let s = o.seed ?? 20261030;
  const rnd = () => { s |= 0; s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), 1 | s); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
  const between = (a, b) => a + rnd() * (b - a);
  const total = o.blades ?? 330, mix = [[.45, 24, 80, [0, 1], 'back'], [.37, 26, 112, [1, 2], 'mid'], [.18, 40, 140, [2, 3], 'front']];
  let paths = '';
  for (const [share, hMin, hMax, cols, cls] of mix) for (let i = 0; i < Math.round(total * share); i++) {
    const x = between(-10, W + 10), h = between(hMin, hMax), w = between(2, 5), lean = between(-20, 20), c = cols[Math.floor(rnd() * 2)];
    paths += `<path class="grx-b grx-s ${cls}" data-x="${x.toFixed(0)}" style="--c:var(--grx-${c});--a:${between(1.5, 5).toFixed(1)}deg;--d:${between(2.8, 5.5).toFixed(1)}s;--dl:${(-between(0, 5)).toFixed(1)}s" d="M${x - w},${H} Q${x - w / 2 + lean * .3},${H - h * .6} ${x + lean},${H - h} Q${x + w / 2 + lean * .3},${H - h * .6} ${x + w},${H}Z"/>`;
  }
  const box = document.createElement('div'); box.className = 'grx' + (o.ignoreMotionPref ? ' force' : ''); box.setAttribute('aria-hidden', 'true');
  const motes = Array.from({ length: o.pollen ?? 12 }, () => `<span class="grx-m" style="--x:${between(2, 98).toFixed(1)}%;--dx:${between(-40, 40).toFixed(0)}px;--t:${between(9, 18).toFixed(1)}s;--dl:${(-between(0, 14)).toFixed(1)}s"></span>`).join('');
  box.innerHTML = `${motes}<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMax slice">${paths}</svg>`;
  el.append(box);
  const svg = box.querySelector('svg');
  const root = document.documentElement;
  const paint = () => {
    const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
    (o.colors || (dark ? DARK : LIGHT)).forEach((c, i) => box.style.setProperty('--grx-' + i, c));
    box.classList.toggle('dark', dark);
  };
  paint();
  const mo = new MutationObserver(paint); mo.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
  const blades = [...svg.querySelectorAll('.grx-b')].map((b) => ({ el: b, x: +b.dataset.x, p: 0, v: 0, l: 0, hide: false }));
  // blades outside the visible (cropped) part are switched off: a big win on phones
  const cull = () => {
    const r = svg.getBoundingClientRect(); if (!r.width) return;
    const sc = Math.max(r.width / W, r.height / H), off = (r.width - W * sc) / 2, lo = -off / sc - 50, hi = (r.width - off) / sc + 50;
    for (const b of blades) { const h = b.x < lo || b.x > hi; if (h !== b.hide) { b.hide = h; b.el.classList.toggle('off', h); } }
  };
  let cr = 0; const ro = new ResizeObserver(() => { cancelAnimationFrame(cr); cr = requestAnimationFrame(cull); }); ro.observe(svg); cull();
  let visible = true, wake = () => {};
  const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; box.classList.toggle('idle', !visible); if (visible) wake(); }); io.observe(el);
  const done = () => { mo.disconnect(); ro.disconnect(); io.disconnect(); cancelAnimationFrame(cr); box.remove(); };
  if (o.mouse === false || still) return done;

  const R = 140, PUSH = 30; let mx = null, my = null, raf = 0, alive = true;
  const tick = () => {
    raf = 0; if (!alive) return;
    let px = null;
    if (mx !== null) {                                   // pointer position in the grass's own coordinates (measured now, so scrolling works)
      const r = svg.getBoundingClientRect();
      if (my > r.top - 100 && my < r.bottom + 4 && r.width) px = (mx - r.left - (r.width - W * Math.max(r.width / W, r.height / H)) / 2) / Math.max(r.width / W, r.height / H);
    }
    let busy = false;
    for (const b of blades) {
      if (b.hide) continue;
      let t = 0;
      if (px !== null) { const d = b.x - px, a = Math.abs(d); if (a < R) t = Math.sign(d || 1) * (1 - a / R) ** 1.4 * PUSH; }
      b.v += (t - b.p) * .12; b.v *= .8; b.p += b.v;                  // spring towards the push, friction so it swings back softly
      if (Math.abs(b.p - b.l) > .02) { b.l = b.p; b.el.style.setProperty('--p', b.p.toFixed(2) + 'deg'); }
      if (t || Math.abs(b.p) > .04 || Math.abs(b.v) > .02) busy = true;
    }
    if (busy) raf = requestAnimationFrame(tick);
  };
  wake = () => { if (!raf && alive && visible) raf = requestAnimationFrame(tick); };
  const move = (e) => { mx = e.clientX; my = e.clientY; wake(); }, away = () => { mx = my = null; wake(); };
  const opt = { passive: true, capture: true };
  addEventListener('pointermove', move, opt); addEventListener('pointerdown', move, opt); addEventListener('scroll', wake, opt); addEventListener('blur', away);
  document.documentElement.addEventListener('mouseleave', away);
  return () => { alive = false; cancelAnimationFrame(raf); removeEventListener('pointermove', move, opt); removeEventListener('pointerdown', move, opt); removeEventListener('scroll', wake, opt); removeEventListener('blur', away); document.documentElement.removeEventListener('mouseleave', away); done(); };
}

Save it in your project (for example src/grass/). You never need to edit it: everything is an option.

2. Use it in your project

Plain HTML, Astro, Eleventy, WordPress, anything. No build step needed: a `<script type="module">` is enough.

Use it:

<div id="stage" style="height:240px"></div>

<script type="module">
  import { mountGrass } from './grass.js';

  const stop = mountGrass(document.getElementById('stage'), { blades: 330, pollen: 12, mouse: true });
  // when you are done (for example when a page closes): stop();
</script>

A meadow made of 330 SVG blades. Move the mouse through it and the blades lean away, then swing back like real grass. It is the footer garden of this site, made standalone.

How it works

Options

Every option is optional. These are the defaults.

OptionDefaultWhat it does
blades330how many blades (the grass looks full from about 200)
pollen12floating pollen specks (daytime only)
mousetruefalse = grass only sways, never reacts to the mouse
colorsmeadowfour greens, light to dark. A darker set is used in the dark theme
seed20261030same number = same meadow

Tips

Nature does not hurry, yet everything is accomplished.
Lao Tzu