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".
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>Works in Vite, Create React App, Next.js (App Router needs the `'use client'` line, already included), Remix. The engine only touches the page inside `useEffect`, so server rendering is safe.
Create Grass.jsx next to the engine:
'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} />;
}Then use it:
import Grass from './Grass';
export default function Page() {
return <Grass options={{ blades: 330, pollen: 12, mouse: true }} style={{ height: '240px' }} />;
}Works in Vite + Vue 3 and Nuxt (wrap it in `<ClientOnly>` if you want to be extra safe; it only touches the page in `onMounted`).
Create Grass.vue next to the engine:
<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>Then use it:
<script setup>
import Grass from './Grass.vue';
</script>
<template>
<Grass :options="{ blades: 330, pollen: 12, mouse: true }" style="height:240px" />
</template>Works in Svelte 4 and 5 (the `export let` style still works in 5) and SvelteKit. Starts in `onMount`, so it never runs on the server.
Create Grass.svelte next to the engine:
<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>Then use it:
<script>
import Grass from './Grass.svelte';
</script>
<Grass options={{ blades: 330, pollen: 12, mouse: true }} style="height:240px" />Works in Solid and SolidStart. Starts in `onMount`, so it never runs on the server.
Create Grass.jsx next to the engine:
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} />;
}Then use it:
import Grass from './Grass';
export default function Page() {
return <Grass options={{ blades: 330, pollen: 12, mouse: true }} style={{ height: '240px' }} />;
}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
- The blades are drawn once from a seeded random number generator, so the same
seedalways gives the same meadow. - Each blade is a tiny spring. The mouse pushes it, a spring pulls it upright, friction makes it settle with a soft swing.
- Blades outside the visible, cropped area are switched off (
.off). That is the big saving on phones. - All animation pauses when the grass is scrolled off screen, and the loop sleeps until the mouse moves again.
Options
Every option is optional. These are the defaults.
| 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 |
Tips
- Place it as the last element of the page, full width, height 200 to 260px.
- Want another season? Pass
colors: ["#f3d58a","#e0b95a","#c9953a","#9b6b24"]for dry golden grass. - Respects
prefers-reduced-motion: the grass stands still.