Subframe
Animation & motionDraggable light ribbon

Light weave

Coral and white light travels through a suspended woven loop whose curvature changes under a direct drag.

Drag the loop or adjust Twist; pause to inspect the light.

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 Segment = { x: number; y: number; tx: number; ty: number; z: number; phase: number; lane: number }

export default function LightWeave({ active = true }: { active?: boolean }) {
  const canvas = useRef<HTMLCanvasElement>(null),
    time = useRef(0),
    value = useRef(0.58),
    paintNow = useRef<() => void>(() => {})
  const drag = useRef<{ id: number; x: number; before: number; width: number; target: HTMLCanvasElement } | null>(null)
  const [twist, setTwist] = useState(0.58),
    [playing, setPlaying] = useState(true),
    [reduced, setReduced] = useState(true)
  const running = useRef(false)
  running.current = active && playing && !reduced
  const change = (next: number) => {
    value.current = Math.max(0, Math.min(1, next))
    setTwist(value.current)
    paintNow.current()
  }
  useEffect(() => {
    const mq = matchMedia("(prefers-reduced-motion: reduce)"),
      sync = () => setReduced(mq.matches)
    sync()
    mq.addEventListener("change", sync)
    return () => mq.removeEventListener("change", sync)
  }, [])
  useEffect(() => {
    if (!active && drag.current) {
      const d = drag.current
      drag.current = null
      change(d.before)
      if (d.target.hasPointerCapture(d.id)) d.target.releasePointerCapture(d.id)
    }
    paintNow.current()
  }, [active, playing, reduced])
  useEffect(() => {
    const el = canvas.current,
      ctx = el?.getContext("2d")
    if (!el || !ctx) return
    let w = 1,
      h = 1,
      frame = 0,
      last = 0,
      previousBend = -1,
      segments: Segment[] = []
    const geometry = () => {
      const project = (a: number, lane: number) => {
        const u = (lane / 31 - 0.5) * 1.05,
          bend = value.current * Math.PI
        const radial = 1.32 + u * Math.cos(bend * Math.sin(a))
        let x = radial * Math.cos(a),
          y = radial * Math.sin(a),
          z = u * Math.sin(bend * Math.sin(a))
        const py = y * 0.47 - z * 0.883,
          pz = y * 0.883 + z * 0.47
        y = py
        z = pz
        const rx = x * 0.94 - y * 0.342,
          ry = x * 0.342 + y * 0.94
        x = rx
        y = ry
        const scale = (Math.min(w * 0.205, h * 0.49) * 4.8) / (4.8 - z)
        return { x: w * 0.5 + x * scale, y: h * 0.43 + y * scale, z }
      }
      segments = []
      for (let lane = 0; lane < 32; lane++)
        for (let j = 0; j < 144; j++) {
          const a = (j / 144) * Math.PI * 2,
            p = project(a, lane),
            q = project(((j + 1) / 144) * Math.PI * 2, lane)
          segments.push({ x: p.x, y: p.y, tx: q.x, ty: q.y, z: (p.z + q.z) / 2, phase: a, lane })
        }
      segments.sort((a, b) => a.z - b.z)
      previousBend = value.current
    }
    const paint = () => {
      if (previousBend !== value.current) geometry()
      ctx.fillStyle = "#140f13"
      ctx.fillRect(0, 0, w, h)
      const halo = ctx.createRadialGradient(w * 0.5, h * 0.53, 0, w * 0.5, h * 0.53, w * 0.38)
      halo.addColorStop(0, "#8c372016")
      halo.addColorStop(1, "#140f1300")
      ctx.fillStyle = halo
      ctx.fillRect(0, 0, w, h)
      ctx.lineCap = "round"
      for (const s of segments) {
        const pulse = Math.pow(Math.max(0, Math.cos(s.phase * 2 - time.current * 1.1 - s.lane * 0.105)), 18)
        const face = 0.3 + ((s.z + 1.6) / 3.2) * 0.7,
          intensity = pulse * face
        const hot = s.lane % 8 < 3
        ctx.beginPath()
        ctx.moveTo(s.x, s.y)
        ctx.lineTo(s.tx, s.ty)
        ctx.lineWidth = Math.max(0.45, (w / 600) * (0.65 + intensity * 0.85))
        ctx.strokeStyle = hot
          ? `rgba(255,${Math.round(119 + intensity * 119)},${Math.round(79 + intensity * 143)},${
              0.13 + intensity * 0.87
            })`
          : `rgba(244,239,221,${0.12 + intensity * 0.84})`
        ctx.shadowBlur = intensity > 0.55 ? 5 * intensity : 0
        ctx.shadowColor = hot ? "#ff6945" : "#ffedc6"
        ctx.stroke()
      }
      ctx.shadowBlur = 0
    }
    const tick = (now: number) => {
      frame = 0
      if (last) time.current += Math.min(40, now - last) / 1000
      last = now
      paint()
      if (running.current && !document.hidden) frame = requestAnimationFrame(tick)
    }
    const draw = () => {
      cancelAnimationFrame(frame)
      frame = 0
      last = 0
      paint()
      if (running.current && !document.hidden) frame = requestAnimationFrame(tick)
    }
    const resize = () => {
      const box = el.getBoundingClientRect(),
        dpr = Math.min(devicePixelRatio || 1, 2)
      w = el.clientWidth
      h = el.clientHeight
      if (!w || !h) return
      el.width = Math.max(1, Math.round(box.width * dpr))
      el.height = Math.max(1, Math.round(box.height * dpr))
      ctx.setTransform(el.width / w, 0, 0, el.height / h, 0, 0)
      previousBend = -1
      draw()
    }
    let resizeFrame = 0
    const queueResize = () => {
      cancelAnimationFrame(resizeFrame)
      resizeFrame = requestAnimationFrame(resize)
    }
    const size = new ResizeObserver(resize),
      transforms = new MutationObserver(queueResize)
    size.observe(el)
    for (let node: HTMLElement | null = el; node; node = node.parentElement)
      transforms.observe(node, { attributes: true, attributeFilter: ["style", "class"] })
    paintNow.current = draw
    resize()
    document.addEventListener("visibilitychange", draw)
    window.addEventListener("resize", queueResize)
    return () => {
      cancelAnimationFrame(frame)
      cancelAnimationFrame(resizeFrame)
      size.disconnect()
      transforms.disconnect()
      document.removeEventListener("visibilitychange", draw)
      window.removeEventListener("resize", queueResize)
      paintNow.current = () => {}
    }
  }, [])
  const cancel = () => {
    if (drag.current) {
      const d = drag.current
      drag.current = null
      change(d.before)
      if (d.target.hasPointerCapture(d.id)) d.target.releasePointerCapture(d.id)
    }
  }
  return (
    <section className="sf-light-weave">
      <canvas
        ref={canvas}
        role="img"
        tabIndex={0}
        aria-label="Woven light loop. Drag horizontally or use arrow keys to twist."
        onPointerDown={(e) => {
          if (e.button !== 0 || !active) return
          drag.current = {
            id: e.pointerId,
            x: e.clientX,
            before: value.current,
            width: e.currentTarget.getBoundingClientRect().width,
            target: e.currentTarget,
          }
          e.currentTarget.setPointerCapture(e.pointerId)
        }}
        onPointerMove={(e) => {
          const d = drag.current
          if (d?.id === e.pointerId) change(d.before + ((e.clientX - d.x) / d.width) * 1.5)
        }}
        onPointerUp={(e) => {
          if (drag.current?.id !== e.pointerId) return
          drag.current = null
          if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId)
        }}
        onPointerCancel={cancel}
        onLostPointerCapture={cancel}
        onKeyDown={(e) => {
          if (e.key === "Escape") {
            cancel()
            return
          }
          if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) {
            e.preventDefault()
            change(e.key === "Home" ? 0 : e.key === "End" ? 1 : value.current + (e.key === "ArrowRight" ? 0.05 : -0.05))
          }
        }}
      />
      <footer>
        <label>
          Twist{" "}
          <input
            type="range"
            aria-label="Ribbon twist"
            min="0"
            max="1"
            step=".01"
            value={twist}
            onChange={(e) => change(Number(e.target.value))}
          />
          <output>{Math.round(twist * 180)}°</output>
        </label>
        <button
          type="button"
          disabled={reduced || !active}
          aria-label={playing ? "Pause light" : "Play light"}
          aria-pressed={playing}
          onClick={() => setPlaying((p) => !p)}
        >
          {playing ? (
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <path d="M7 5v10M13 5v10" />
            </svg>
          ) : (
            <svg viewBox="0 0 20 20" aria-hidden="true">
              <path d="m7 4 9 6-9 6z" />
            </svg>
          )}
        </button>
      </footer>
      <style>{`.sf-light-weave{width:100%;height:100%;container-type:size;position:relative;overflow:hidden;background:#140f13;color:#dbbbae;font:11px Arial,sans-serif}.sf-light-weave canvas{display:block;width:100%;height:100%;cursor:grab;touch-action:pan-y;outline-offset:-6px}.sf-light-weave canvas:active{cursor:grabbing}.sf-light-weave footer{position:absolute;bottom:9px;left:26px;right:20px;display:flex;align-items:center;justify-content:space-between;pointer-events:none}.sf-light-weave label{display:flex;align-items:center;gap:14px;pointer-events:auto}.sf-light-weave input{width:clamp(70px,20cqw,130px);height:44px;accent-color:#ed9675;cursor:ew-resize}.sf-light-weave output{font:10px monospace;width:32px;color:#a58580}.sf-light-weave button{width:44px;height:44px;display:grid;place-items:center;border:1px solid #72504866;border-radius:50%;background:#140f1388;color:#edcab8;cursor:pointer;pointer-events:auto}.sf-light-weave svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:1.5}.sf-light-weave button:active{background:#553229}.sf-light-weave button:disabled{opacity:.35;cursor:default}.sf-light-weave :focus-visible{outline:2px solid #fab392;outline-offset:3px}.sf-light-weave canvas:focus-visible{outline-offset:-6px}`}</style>
    </section>
  )
}

Where it works well

Interactive light sculptures, expressive loading surfaces and procedural motion studies.

Reduced motion

Traveling light stops. Direct twist input still redraws the static geometry; host pause cancels a captured gesture and stops the clock.

Things to know

Depth-sorted Canvas 2D strands, not a physically simulated fabric or ray-traced light source. The closed band has a bounded twist and no external assets.

More examples