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