Subframe
Animation & motionInteractive dither effect

Tidal bitmap

Two saturated inks form a pixel field that bends around the pointer and switches into a fractured signal.

Move the pointer or arrow keys; change the field mode.

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"

export default function TidalBitmap({ active = true }: { active?: boolean }) {
  const canvas = useRef<HTMLCanvasElement>(null)
  const pointer = useRef({ x: 0.5, y: 0.5 })
  const clock = useRef(0)
  const repaint = useRef<() => void>(() => {})
  const [fault, setFault] = useState(false)
  const [running, setRunning] = useState(true)
  const [reduced, setReduced] = useState(true)
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(q.matches)
    update()
    q.addEventListener("change", update)
    return () => q.removeEventListener("change", update)
  }, [])
  useEffect(() => {
    const el = canvas.current
    const ctx = el?.getContext("2d")
    if (!el || !ctx) return
    let frame = 0,
      last = 0
    function draw() {
      if (!el || !ctx) return
      const w = el.width,
        h = el.height,
        data = ctx.createImageData(w, h)
      for (let y = 0; y < h; y++)
        for (let x = 0; x < w; x++) {
          const nx = x / w,
            ny = y / h,
            dist = Math.hypot(nx - pointer.current.x, ny - pointer.current.y),
            t = clock.current
          const field = fault
            ? Math.sin(Math.floor(nx * 17) * 1.71 + Math.floor(ny * 10) * 2.31 + t * 0.8) -
              (ny > pointer.current.y ? 0.6 : 0)
            : Math.sin(nx * 16 + ny * 7 + t) + Math.cos(ny * 24 - t * 0.7) - dist * 2.2
          const threshold = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5][(y % 4) * 4 + (x % 4)] / 16
          const on = fault ? field > -0.25 : Math.max(0, Math.min(1, (field + 1.3) / 2.6)) > threshold
          const i = (y * w + x) * 4
          data.data[i] = on ? 239 : 25
          data.data[i + 1] = on ? 49 : 37
          data.data[i + 2] = on ? 30 : 205
          data.data[i + 3] = 255
        }
      ctx.putImageData(data, 0, 0)
    }
    repaint.current = draw
    const resize = () => {
      if (!el) return
      el.width = Math.max(1, Math.min(180, Math.round(el.clientWidth / 3)))
      el.height = Math.max(1, Math.min(120, Math.round(el.clientHeight / 3)))
      draw()
    }
    const observer = new ResizeObserver(resize)
    observer.observe(el)
    resize()
    function tick(now: number) {
      if (last) clock.current += Math.min(now - last, 48) / 1000
      last = now
      draw()
      frame = requestAnimationFrame(tick)
    }
    if (active && running && !reduced) frame = requestAnimationFrame(tick)
    return () => {
      cancelAnimationFrame(frame)
      observer.disconnect()
      repaint.current = () => {}
    }
  }, [active, running, reduced, fault])
  function move(x: number, y: number) {
    pointer.current = { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }
    repaint.current()
  }
  return (
    <section className="sf-tidal-bitmap">
      <div
        className="sf-tb-field"
        role="img"
        aria-label="Interactive two-color tidal interference. Arrow keys move the wave center."
        tabIndex={0}
        onPointerMove={(e) => {
          const b = e.currentTarget.getBoundingClientRect()
          move((e.clientX - b.left) / b.width, (e.clientY - b.top) / b.height)
        }}
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId)
          const b = e.currentTarget.getBoundingClientRect()
          move((e.clientX - b.left) / b.width, (e.clientY - b.top) / b.height)
        }}
        onKeyDown={(e) => {
          const x = e.key === "ArrowLeft" ? -0.07 : e.key === "ArrowRight" ? 0.07 : 0,
            y = e.key === "ArrowUp" ? -0.07 : e.key === "ArrowDown" ? 0.07 : 0
          if (x || y) {
            e.preventDefault()
            move(pointer.current.x + x, pointer.current.y + y)
          }
        }}
      >
        <canvas ref={canvas} aria-hidden="true" />
      </div>
      <div className="sf-tb-bar">
        <strong>TIDAL</strong>
        <div>
          <button type="button" aria-pressed={fault} onClick={() => setFault(!fault)}>
            {fault ? "Fault" : "Flow"}
            <span>↗</span>
          </button>
          <button
            type="button"
            aria-label={running ? "Pause field" : "Play field"}
            aria-pressed={running}
            onClick={() => setRunning(!running)}
          >
            {running ? "Ⅱ" : "▶"}
          </button>
        </div>
      </div>
      <style>{`.sf-tidal-bitmap{width:100%;height:100%;min-height:280px;position:relative;background:#1925cd;overflow:hidden;font-family:Arial,sans-serif;color:#fff;box-sizing:border-box}.sf-tidal-bitmap *{box-sizing:border-box}.sf-tb-field{position:absolute;inset:0 0 58px;touch-action:none;outline:none}.sf-tb-field:focus-visible{outline:2px solid #fff;outline-offset:-8px}.sf-tb-field canvas{width:100%;height:100%;image-rendering:pixelated;display:block}.sf-tb-bar{position:absolute;bottom:0;left:0;right:0;height:58px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-top:1px solid #fff6;background:#1925cd}.sf-tb-bar strong{font-size:23px;letter-spacing:-1.2px;line-height:1}.sf-tb-bar>div{display:flex;gap:9px}.sf-tb-bar button{height:44px;min-width:44px;background:none;color:inherit;border:0;font:11px monospace;cursor:pointer;padding:0 9px}.sf-tb-bar button:first-child{min-width:76px;display:flex;gap:20px;align-items:center;border-inline:1px solid #ffffff40}.sf-tb-bar button:focus-visible{outline:2px solid #fff;outline-offset:-3px}.sf-tb-bar button:active{background:#ffffff20}`}</style>
    </section>
  )
}

Where it works well

Interactive binary texture for an expressive visual surface.

Reduced motion

The field freezes; pointer and mode controls update a single static frame.

Things to know

Canvas 2D ordered dithering, not WebGL or a physical wave simulation.

More examples