Subframe
Animation & motionCloth physics simulation

Tension fabric

Plum and green threads hang from a pinned edge and pull against their neighbors.

Drag a thread, pluck the cloth, adjust wind, or pause

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 { type PointerEvent, useEffect, useRef, useState } from "react"

function useReducedMotion() {
  const [reduced, setReduced] = useState(true)
  useEffect(() => {
    const query = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(query.matches)
    update()
    query.addEventListener("change", update)
    return () => query.removeEventListener("change", update)
  }, [])
  return reduced
}

type ThreadPoint = { x: number; y: number; px: number; py: number; homeX: number; homeY: number; pinned: boolean }
export default function TensionFabric({ active = true }: { active?: boolean }) {
  const reduced = useReducedMotion()
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const wind = useRef(30)
  const [windValue, setWindValue] = useState(30),
    [paused, setPaused] = useState(false)
  const action = useRef<(kind: "pluck" | "reset") => void>(() => {})
  const pointer = useRef<{ id: number; x: number; y: number; index: number } | null>(null)
  const pointsRef = useRef<ThreadPoint[]>([])
  const sizeRef = useRef({ width: 0, height: 0 })
  const clockRef = useRef(0)
  const refresh = useRef<() => void>(() => {})
  useEffect(() => {
    const canvas = canvasRef.current,
      ctx = canvas?.getContext("2d")
    if (!canvas || !ctx) return
    const cols = 29,
      rows = 17
    let width = 600,
      height = 360,
      raf = 0,
      last = 0,
      clock = 0,
      accumulator = 0
    let constraints: { a: number; b: number; length: number }[] = []
    const init = (reset = false) => {
      const points: ThreadPoint[] = []
      constraints = []
      const dx = (width * 0.84) / (cols - 1),
        dy = (height * 0.62) / (rows - 1)
      for (let y = 0; y < rows; y++)
        for (let x = 0; x < cols; x++) {
          const px = width * 0.08 + x * dx,
            py = height * 0.12 + y * dy
          const flutter = (y / rows) * Math.sin(x * 0.55) * 4
          points.push({ x: px + flutter, y: py, px: px, py, homeX: px, homeY: py, pinned: y === 0 })
          const index = y * cols + x
          if (x) constraints.push({ a: index - 1, b: index, length: dx })
          if (y) constraints.push({ a: index - cols, b: index, length: dy })
        }
      if (reset || sizeRef.current.width !== width || sizeRef.current.height !== height || !pointsRef.current.length)
        pointsRef.current = points
      sizeRef.current = { width, height }
    }
    const step = () => {
      const points = pointsRef.current
      clockRef.current += 1 / 60
      clock = clockRef.current
      for (let i = 0; i < points.length; i++) {
        const p = points[i]
        if (p.pinned) continue
        if (pointer.current?.index === i) {
          p.x = pointer.current.x
          p.y = pointer.current.y
          p.px = p.x
          p.py = p.y
          continue
        }
        const vx = Math.max(-12, Math.min(12, (p.x - p.px) * 0.965)),
          vy = Math.max(-12, Math.min(12, (p.y - p.py) * 0.965))
        p.px = p.x
        p.py = p.y
        p.x += vx + Math.sin(clock * 1.5 + p.homeY * 0.028) * wind.current * 0.002
        p.y += vy + 0.055
      }
      for (let pass = 0; pass < 6; pass++)
        for (const c of constraints) {
          const a = points[c.a],
            b = points[c.b],
            dx = b.x - a.x,
            dy = b.y - a.y,
            d = Math.hypot(dx, dy) || 0.001
          const af = a.pinned || pointer.current?.index === c.a,
            bf = b.pinned || pointer.current?.index === c.b
          const fraction = ((d - c.length) / d) * 0.9,
            weight = af || bf ? 1 : 0.5
          if (!af) {
            a.x += dx * fraction * weight
            a.y += dy * fraction * weight
          }
          if (!bf) {
            b.x -= dx * fraction * weight
            b.y -= dy * fraction * weight
          }
        }
      for (const p of points) {
        if (p.pinned) continue
        p.x = Math.max(8, Math.min(width - 8, p.x))
        p.y = Math.max(12, Math.min(height - 56, p.y))
      }
    }
    const draw = () => {
      const points = pointsRef.current
      ctx.fillStyle = "#291b37"
      ctx.fillRect(0, 0, width, height)
      const glow = ctx.createRadialGradient(width * 0.5, height * 0.42, 0, width * 0.5, height * 0.42, width * 0.6)
      glow.addColorStop(0, "#573453")
      glow.addColorStop(1, "#291b37")
      ctx.fillStyle = glow
      ctx.fillRect(0, 0, width, height)
      for (let row = 0; row < rows - 1; row++)
        for (let col = 0; col < cols - 1; col++) {
          const i = row * cols + col,
            a = points[i],
            b = points[i + 1],
            c = points[i + cols + 1],
            d = points[i + cols]
          const slope = (b.y - a.y) * 0.5
          ctx.beginPath()
          ctx.moveTo(a.x, a.y)
          ctx.lineTo(b.x, b.y)
          ctx.lineTo(c.x, c.y)
          ctx.lineTo(d.x, d.y)
          ctx.closePath()
          ctx.fillStyle = `hsl(${col % 4 < 2 ? 94 : 300} ${col % 4 < 2 ? 22 : 16}% ${Math.max(
            13,
            Math.min(46, 26 + slope),
          )}%)`
          ctx.fill()
        }
      // Each segment alternates above/below the cross thread to read as a weave.
      const line = (a: ThreadPoint, b: ThreadPoint, color: string, weight: number) => {
        ctx.beginPath()
        ctx.moveTo(a.x, a.y)
        ctx.lineTo(b.x, b.y)
        ctx.strokeStyle = color
        ctx.lineWidth = weight
        ctx.stroke()
      }
      for (let row = 0; row < rows - 1; row++)
        for (let col = 0; col < cols - 1; col++) {
          const i = row * cols + col,
            a = points[i],
            b = points[i + 1],
            d = points[i + cols],
            horizontalFirst = (row + col) % 2 === 0
          if (horizontalFirst) {
            line(a, b, "#201129a0", 4)
            line(a, d, col % 2 ? "#859363" : "#576545", 3)
          } else {
            line(a, d, "#172213aa", 4)
            line(a, b, row % 2 ? "#9b7095" : "#775473", 3)
          }
          line(a, b, "#c492b24d", 0.6)
          line(a, d, "#b2c18060", 0.6)
        }
      ctx.fillStyle = "#ccd2ac"
      for (let i = 0; i < cols; i += 4) {
        ctx.beginPath()
        ctx.arc(points[i].x, points[i].y, 1.4, 0, Math.PI * 2)
        ctx.fill()
      }
    }
    refresh.current = () => {
      if (reduced || paused || !active) for (let i = 0; i < 8; i++) step()
      draw()
    }
    const resize = () => {
      const layoutChanged = width !== canvas.clientWidth || height !== canvas.clientHeight || !pointsRef.current.length
      width = canvas.clientWidth
      height = canvas.clientHeight
      const dpr = Math.min(devicePixelRatio || 1, 2)
      const display = canvas.getBoundingClientRect()
      canvas.width = Math.max(1, Math.round(display.width * dpr))
      canvas.height = Math.max(1, Math.round(display.height * dpr))
      ctx.setTransform(canvas.width / Math.max(1, width), 0, 0, canvas.height / Math.max(1, height), 0, 0)
      if (layoutChanged) {
        pointer.current = null
        init()
      }
      draw()
    }
    action.current = (kind) => {
      if (kind === "reset") {
        init(true)
        draw()
        return
      }
      const points = pointsRef.current
      for (const p of points) {
        if (p.pinned) continue
        const d = Math.hypot((p.x - width * 0.54) / (width * 0.3), (p.y - height * 0.48) / (height * 0.3))
        const force = Math.max(0, 1 - d)
        p.x += force * width * 0.06
        p.y -= force * height * 0.08
        p.px = p.x
        p.py = p.y
      }
      if (reduced || !active || paused) for (let i = 0; i < 8; i++) step()
      draw()
    }
    const tick = (now: number) => {
      if (last) accumulator += Math.min((now - last) / 1000, 0.05)
      last = now
      while (accumulator >= 1 / 60) {
        step()
        accumulator -= 1 / 60
      }
      draw()
      raf = requestAnimationFrame(tick)
    }
    let resizeFrame = 0
    const scheduleResize = () => {
      cancelAnimationFrame(resizeFrame)
      // Ancestor transforms can settle after layout observers have run.
      resizeFrame = requestAnimationFrame(() => {
        resizeFrame = requestAnimationFrame(resize)
      })
    }
    const transforms = new MutationObserver(scheduleResize)
    for (let ancestor = canvas.parentElement; ancestor; ancestor = ancestor.parentElement) {
      transforms.observe(ancestor, { attributes: true, attributeFilter: ["style", "class"] })
    }
    window.addEventListener("resize", scheduleResize)
    const observer = new ResizeObserver(resize)
    observer.observe(canvas)
    resize()
    if (active && !paused && !reduced) raf = requestAnimationFrame(tick)
    return () => {
      observer.disconnect()
      transforms.disconnect()
      window.removeEventListener("resize", scheduleResize)
      cancelAnimationFrame(resizeFrame)
      cancelAnimationFrame(raf)
      pointer.current = null
    }
  }, [active, paused, reduced])
  const locate = (e: PointerEvent<HTMLCanvasElement>) => {
    const r = e.currentTarget.getBoundingClientRect()
    return {
      x: ((e.clientX - r.left) / r.width) * e.currentTarget.clientWidth,
      y: ((e.clientY - r.top) / r.height) * e.currentTarget.clientHeight,
    }
  }
  return (
    <section className="sf-tension">
      <canvas
        ref={canvasRef}
        role="img"
        aria-label="A woven plum and green fabric hangs from a pinned top edge. Drag the weave or use Pluck."
        onPointerDown={(e) => {
          const p = locate(e)
          let distance = Infinity,
            index = 0
          pointsRef.current.forEach((point, i) => {
            if (point.pinned) return
            const d = Math.hypot(point.x - p.x, point.y - p.y)
            if (d < distance) {
              distance = d
              index = i
            }
          })
          pointer.current = { id: e.pointerId, ...p, index }
          e.currentTarget.setPointerCapture(e.pointerId)
        }}
        onPointerMove={(e) => {
          if (pointer.current?.id !== e.pointerId) return
          const p = locate(e)
          pointer.current = { ...pointer.current, ...p }
          if (reduced || paused || !active) {
            const thread = pointsRef.current[pointer.current.index]
            thread.x = p.x
            thread.y = p.y
            thread.px = p.x
            thread.py = p.y
            refresh.current()
          }
        }}
        onPointerUp={(e) => {
          pointer.current = null
          if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
        }}
        onPointerCancel={() => {
          pointer.current = null
        }}
        onLostPointerCapture={() => {
          pointer.current = null
        }}
      />
      <div className="sf-tension-controls">
        <button type="button" onClick={() => action.current("pluck")}>
          Pluck
        </button>
        <button type="button" aria-pressed={paused} disabled={reduced} onClick={() => setPaused(!paused)}>
          {reduced ? "Still" : paused ? "Play" : "Pause"}
        </button>
        <label>
          Wind
          <input
            type="range"
            aria-label="Fabric wind"
            min="0"
            max="100"
            value={windValue}
            onChange={(e) => {
              wind.current = Number(e.target.value)
              setWindValue(wind.current)
            }}
          />
        </label>
        <button
          className="sf-tension-reset"
          type="button"
          aria-label="Reset fabric"
          onClick={() => action.current("reset")}
        >
          ↺
        </button>
      </div>
      <style>{`.sf-tension{position:relative;width:100%;height:100%;min-height:260px;overflow:hidden;background:#291b37;color:#dbe2c4;font:11px Arial,sans-serif}.sf-tension *{box-sizing:border-box}.sf-tension canvas{display:block;width:100%;height:100%;touch-action:none;cursor:grab}.sf-tension canvas:active{cursor:grabbing}.sf-tension-controls{position:absolute;inset:auto 15px 6px;display:flex;align-items:center;gap:5px}.sf-tension button{min-width:44px;min-height:44px;background:#291b37dd;border:1px solid #baa1b333;border-radius:3px;color:inherit;font:inherit;padding:0 9px;cursor:pointer}.sf-tension label{display:flex;align-items:center;gap:6px;margin-left:auto;padding-inline:5px}.sf-tension input{width:clamp(55px,13vw,105px);min-height:44px;accent-color:#aabc87}.sf-tension .sf-tension-reset{font:23px Arial}.sf-tension :focus-visible{outline:2px solid #e6edd3;outline-offset:2px}`}</style>
    </section>
  )
}

Where it works well

Interactive woven materials, cloth studies and responsive mesh surfaces.

Reduced motion

Continuous simulation stops. Pluck and direct manipulation produce static changes.

Things to know

A bounded 2D position-constraint solver; no 3D collisions, tearing, textile texture asset, or export.

More examples