Reed field
Fine gold stems bend in layered wind and part around a passing pointer.

Brush across the reeds or change the wind strength
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
*
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import { useEffect, useRef, useState } from "react"
type Stem = { x: number; length: number; base: number; shade: number; seed: number; angle: number; speed: number }
const noise = (n: number) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453
return x - Math.floor(x)
}
export default function ReedField({ active = true }: { active?: boolean }) {
const canvas = useRef<HTMLCanvasElement>(null)
const time = useRef(0)
const field = useRef<{ width: number; height: number; stems: Stem[] }>({ width: 0, height: 0, stems: [] })
const [wind, setWind] = useState(1)
const [paused, setPaused] = useState(false)
const [available, setAvailable] = useState(true)
useEffect(() => {
const surface = canvas.current
const ctx = surface?.getContext("2d", { alpha: false })
if (!surface || !ctx) {
setAvailable(false)
return
}
let width = 1,
height = 1,
frame = 0,
previous = 0,
disposed = false
let stems = field.current.stems
let pointer: { x: number; y: number; id: number | null } = { x: -1000, y: -1000, id: null }
const media = matchMedia("(prefers-reduced-motion: reduce)")
let reduced = media.matches
const running = () => active && !paused && !reduced && !document.hidden && !disposed
const draw = (dt = 0) => {
ctx.fillStyle = "#101c22"
ctx.fillRect(0, 0, width, height)
const light = ctx.createRadialGradient(width * 0.64, height * 0.68, 0, width * 0.64, height * 0.68, width * 0.65)
light.addColorStop(0, "#273438")
light.addColorStop(1, "#101c22")
ctx.fillStyle = light
ctx.fillRect(0, 0, width, height)
for (const stem of stems) {
const phase = stem.x * 0.012 + stem.seed * 3
const breeze = Math.sin(time.current * 0.75 + phase) * 0.09 + Math.sin(time.current * 1.4 + phase * 0.35) * 0.04
let desired = (breeze + 0.085) * [0.15, 1, 2.8][wind]
const distance = Math.hypot(pointer.x - stem.x, pointer.y - (stem.base - stem.length * 0.65))
if (!reduced && distance < 105) desired += (stem.x < pointer.x ? -1 : 1) * Math.pow(1 - distance / 105, 2) * 1.1
if (dt) {
const step = dt / 2
for (let i = 0; i < 2; i++) {
stem.speed += ((desired - stem.angle) * 75 - stem.speed * 10) * step
stem.angle += stem.speed * step
}
} else if (reduced) {
stem.angle = desired
stem.speed = 0
}
const reach = Math.sin(stem.angle) * stem.length
const tipX = stem.x + reach,
tipY = stem.base - Math.cos(stem.angle) * stem.length
const thickness = 0.5 + stem.shade * 1.7
ctx.fillStyle = `hsl(${43 + stem.shade * 16} ${24 + stem.shade * 23}% ${24 + stem.shade * 43}%)`
ctx.beginPath()
ctx.moveTo(stem.x - thickness, stem.base)
ctx.bezierCurveTo(
stem.x - thickness,
stem.base - stem.length * 0.4,
tipX - thickness * 0.7,
tipY + stem.length * 0.24,
tipX,
tipY,
)
ctx.bezierCurveTo(
tipX + thickness * 0.6,
tipY + stem.length * 0.28,
stem.x + thickness,
stem.base - stem.length * 0.38,
stem.x + thickness,
stem.base,
)
ctx.closePath()
ctx.fill()
if (stem.seed > 0.78) {
ctx.strokeStyle = `hsl(42 40% ${50 + stem.shade * 28}%)`
ctx.lineWidth = 0.55
for (let j = 0; j < 9; j++) {
const t = j / 9,
y = tipY + t * 22,
x = tipX - reach * t * 0.08,
spread = Math.sin(t * Math.PI) * 4.5
ctx.beginPath()
ctx.moveTo(x, y + 5)
ctx.lineTo(x - spread, y - 1)
ctx.moveTo(x, y + 5)
ctx.lineTo(x + spread, y - 2)
ctx.stroke()
}
}
}
}
const tick = (now: number) => {
frame = 0
if (!running()) return
const dt = Math.min(0.032, previous ? (now - previous) / 1000 : 1 / 60)
previous = now
time.current += dt
draw(dt)
frame = requestAnimationFrame(tick)
}
const sync = () => {
cancelAnimationFrame(frame)
frame = 0
previous = 0
draw()
if (running()) frame = requestAnimationFrame(tick)
}
const updateSize = () => {
width = surface.clientWidth
height = surface.clientHeight
const dpr = Math.min(devicePixelRatio || 1, 2)
const display = surface.getBoundingClientRect()
surface.width = Math.max(1, Math.round(display.width * dpr))
surface.height = Math.max(1, Math.round(display.height * dpr))
ctx.setTransform(surface.width / Math.max(1, width), 0, 0, surface.height / Math.max(1, height), 0, 0)
if (field.current.width !== width || field.current.height !== height) {
const count = Math.min(480, Math.max(180, Math.round(width * 0.75)))
stems = Array.from({ length: count }, (_, i) => {
const depth = Math.floor(i / (count / 4)) / 3
return {
x: noise(i + 1) * (width + 40) - 20,
length: height * (0.22 + noise(i + 4) * 0.6) * (0.65 + depth * 0.35),
base: height + depth * 18,
shade: 0.12 + depth * 0.65 + noise(i + 2) * 0.22,
seed: noise(i + 3),
angle: 0.08,
speed: 0,
}
})
field.current = { width, height, stems }
}
sync()
}
let resizeFrame = 0
const scheduleResize = () => {
cancelAnimationFrame(resizeFrame)
// Ancestor transforms can settle after layout observers have run.
resizeFrame = requestAnimationFrame(() => {
resizeFrame = requestAnimationFrame(updateSize)
})
}
const transforms = new MutationObserver(scheduleResize)
for (let ancestor = surface.parentElement; ancestor; ancestor = ancestor.parentElement) {
transforms.observe(ancestor, { attributes: true, attributeFilter: ["style", "class"] })
}
window.addEventListener("resize", scheduleResize)
const resize = new ResizeObserver(updateSize)
resize.observe(surface)
const move = (e: PointerEvent) => {
if (!active || paused || reduced || !e.isPrimary || (pointer.id !== null && pointer.id !== e.pointerId)) return
const box = surface.getBoundingClientRect()
pointer.x = ((e.clientX - box.left) / box.width) * width
pointer.y = ((e.clientY - box.top) / box.height) * height
}
const down = (e: PointerEvent) => {
if (!active || paused || reduced || !e.isPrimary || e.button !== 0 || pointer.id !== null) return
pointer.id = e.pointerId
surface.setPointerCapture(e.pointerId)
move(e)
}
const leave = (e: PointerEvent) => {
if (pointer.id !== null && e.pointerId !== pointer.id) return
if (pointer.id !== null && surface.hasPointerCapture(pointer.id)) surface.releasePointerCapture(pointer.id)
pointer = { x: -1000, y: -1000, id: null }
}
const hoverLeave = (e: PointerEvent) => {
if (pointer.id === null) leave(e)
}
const change = () => {
reduced = media.matches
if (pointer.id !== null && surface.hasPointerCapture(pointer.id)) surface.releasePointerCapture(pointer.id)
pointer = { x: -1000, y: -1000, id: null }
sync()
}
const visibility = () => sync()
surface.addEventListener("pointermove", move)
surface.addEventListener("pointerdown", down)
surface.addEventListener("pointerup", leave)
surface.addEventListener("pointercancel", leave)
surface.addEventListener("lostpointercapture", leave)
surface.addEventListener("pointerleave", hoverLeave)
media.addEventListener("change", change)
document.addEventListener("visibilitychange", visibility)
return () => {
disposed = true
cancelAnimationFrame(frame)
resize.disconnect()
transforms.disconnect()
window.removeEventListener("resize", scheduleResize)
cancelAnimationFrame(resizeFrame)
if (pointer.id !== null && surface.hasPointerCapture(pointer.id)) surface.releasePointerCapture(pointer.id)
media.removeEventListener("change", change)
document.removeEventListener("visibilitychange", visibility)
surface.removeEventListener("pointermove", move)
surface.removeEventListener("pointerdown", down)
surface.removeEventListener("pointerup", leave)
surface.removeEventListener("pointercancel", leave)
surface.removeEventListener("lostpointercapture", leave)
surface.removeEventListener("pointerleave", hoverLeave)
}
}, [active, wind, paused])
return (
<section className="sf-reed-field" aria-label="Wind through a field of reeds">
<canvas
ref={canvas}
role="img"
aria-label="Gold and olive reeds bend with the breeze and respond to your pointer"
/>
{!available && <p className="fallback">A field at rest.</p>}
<div className="weather">
<span>Wind</span>
<strong>
{["0.4", "3.8", "8.6"][wind]} <small>m/s</small>
</strong>
</div>
<footer>
<div aria-label="Wind strength">
{["Still", "Breeze", "Gust"].map((label, i) => (
<button key={label} type="button" aria-pressed={wind === i} disabled={!active} onClick={() => setWind(i)}>
{label}
</button>
))}
</div>
<button
type="button"
aria-label={paused ? "Play wind" : "Pause wind"}
aria-pressed={paused}
disabled={!active}
onClick={() => setPaused(!paused)}
>
{paused ? "Play" : "Pause"}
</button>
</footer>
<style>{`.sf-reed-field{width:100%;height:100%;position:relative;background:#101c22;color:#e7dfc8;overflow:hidden;font:11px Arial,sans-serif;box-sizing:border-box}.sf-reed-field *{box-sizing:border-box}.sf-reed-field canvas{display:block;width:100%;height:calc(100% - 53px);touch-action:pan-y}.sf-reed-field .weather{position:absolute;left:25px;top:25px;pointer-events:none}.sf-reed-field .weather>span{display:block;font-size:10px;color:#b8c2ba;margin-bottom:7px}.sf-reed-field strong{font:400 31px/1 Georgia,serif;letter-spacing:-1px}.sf-reed-field small{font:10px Arial,sans-serif;letter-spacing:0;color:#b8c2ba;margin-left:3px}.sf-reed-field footer{height:53px;display:flex;align-items:center;justify-content:space-between;padding:0 19px;border-top:1px solid #ffffff1c}.sf-reed-field footer>div{display:flex;gap:5px}.sf-reed-field button{min-height:44px;padding:0 12px;border:0;background:none;color:#9ca99e;font:inherit;cursor:pointer;touch-action:manipulation;position:relative}.sf-reed-field button[aria-pressed=true]{color:#eee3bc}.sf-reed-field footer>div button[aria-pressed=true]:after{content:"";position:absolute;left:12px;right:12px;bottom:5px;height:1px;background:#d7c590}.sf-reed-field button:focus-visible{outline:1px solid #eee3bc;outline-offset:-3px}.sf-reed-field button:disabled{cursor:default}.sf-reed-field .fallback{position:absolute;inset:40% 0;text-align:center;font:30px Georgia,serif}`}</style>
</section>
)
}
Where it works well
Add a small interactive natural surface to a botanical, landscape or material story.
Reduced motion
A single posed field replaces continuous wind and pointer motion. Wind controls select static poses.
Things to know
Canvas2D, capped at 480 stems and 2× display pixel ratio. Decorative illustration, not a physical vegetation simulation. Pause and document hiding stop the frame loop.