Animation & motionGenerative scanline art
Etched terraces
Fine cyan scanlines bend over two stepped reliefs on a saturated red field.

Adjust relief and pause the moving engraving
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"
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
}
export default function EtchedTerraces({ active = true }: { active?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const time = useRef(0)
const reduced = useReducedMotion()
const [paused, setPaused] = useState(false)
const [relief, setRelief] = useState(65)
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext("2d")
if (!canvas || !ctx) return
let width = 600,
height = 360,
raf = 0,
previous = 0
const draw = () => {
ctx.fillStyle = "#e31b32"
ctx.fillRect(0, 0, width, height)
const field = (x: number, y: number) => {
let z = 0
for (let k = 0; k < 2; k++) {
const cx = width * (0.32 + k * 0.37),
cy = height * (0.51 - k * 0.1)
const angle = (k ? -0.27 : 0.16) + Math.sin(time.current * 0.16) * 0.09
const dx = x - cx,
dy = y - cy
const a = (dx * Math.cos(angle) - dy * Math.sin(angle)) / (width * 0.255)
const b = (dx * Math.sin(angle) + dy * Math.cos(angle)) / (height * 0.37)
const d = Math.max(Math.abs(a), Math.abs(b))
if (d < 1) {
const steps = (1 - d) * 8
const t = steps % 1
const ledge = Math.floor(steps) + (t < 0.2 ? ((t * t) / 0.04) * 0.13 : 0.13 + ((t - 0.2) * 0.87) / 0.8)
z += (ledge / 8) * relief * 0.9
}
}
return z
}
ctx.strokeStyle = "#5ce4ec"
ctx.lineWidth = 0.72
for (let y = height * 0.15; y < height - 65; y += 2.6) {
ctx.beginPath()
for (let x = width * 0.065; x <= width * 0.945; x += 3.5) {
const z = field(x, y)
const v = y - z + Math.sin(x * 0.07 + time.current * 0.7) * z * 0.045
if (x === width * 0.065) ctx.moveTo(x, v)
else ctx.lineTo(x, v)
}
ctx.stroke()
}
ctx.globalAlpha = 0.25
for (let x = width * 0.065; x < width * 0.945; x += 9) {
ctx.beginPath()
for (let y = height * 0.15; y < height - 65; y += 4) {
const z = field(x, y)
if (y === height * 0.15) ctx.moveTo(x, y - z)
else ctx.lineTo(x, y - z)
}
ctx.stroke()
}
ctx.globalAlpha = 1
}
const resize = () => {
width = canvas.clientWidth
height = canvas.clientHeight
if (!width || !height) return
const dpr = Math.min(devicePixelRatio || 1, 2),
bounds = canvas.getBoundingClientRect()
canvas.width = Math.round(bounds.width * dpr)
canvas.height = Math.round(bounds.height * dpr)
ctx.setTransform(canvas.width / width, 0, 0, canvas.height / height, 0, 0)
draw()
}
let resizeFrame = 0
const resizeViewport = () => {
cancelAnimationFrame(resizeFrame)
resizeFrame = requestAnimationFrame(resize)
}
const tick = (now: number) => {
if (previous) time.current += Math.min((now - previous) / 1000, 0.05)
previous = now
draw()
raf = requestAnimationFrame(tick)
}
const observer = new ResizeObserver(resize)
observer.observe(canvas)
// An ancestor transform changes raster density without resizing the layout box.
const transforms = new MutationObserver(resizeViewport)
for (let ancestor = canvas.parentElement; ancestor; ancestor = ancestor.parentElement)
transforms.observe(ancestor, { attributes: true, attributeFilter: ["style", "class"] })
resize()
window.addEventListener("resize", resizeViewport)
if (active && !paused && !reduced) raf = requestAnimationFrame(tick)
return () => {
observer.disconnect()
transforms.disconnect()
cancelAnimationFrame(raf)
cancelAnimationFrame(resizeFrame)
window.removeEventListener("resize", resizeViewport)
}
}, [active, paused, reduced, relief])
return (
<section className="sf-etched">
<canvas ref={canvasRef} aria-label="Two moving stepped reliefs woven from fine cyan lines" role="img" />
<div className="sf-etched-controls">
<button type="button" disabled={reduced} aria-pressed={paused} onClick={() => setPaused(!paused)}>
{reduced ? "Still" : paused ? "Play" : "Pause"}
</button>
<label>
Relief{" "}
<input
aria-label="Relief height"
type="range"
min="0"
max="100"
value={relief}
onChange={(e) => setRelief(Number(e.target.value))}
/>
<output>{relief}</output>
</label>
</div>
<style>{`.sf-etched{position:relative;width:100%;height:100%;min-height:260px;overflow:hidden;background:#e31b32;color:#b8fdff;font:12px Arial,sans-serif}.sf-etched *{box-sizing:border-box}.sf-etched canvas{display:block;width:100%;height:100%}.sf-etched-controls{position:absolute;inset:auto 22px 9px;display:flex;align-items:center;justify-content:space-between;gap:20px}.sf-etched button{color:inherit;background:none;border:0;border-bottom:1px solid #b8fdff80;min-height:44px;font:inherit;cursor:pointer;padding:0 8px}.sf-etched button:disabled{cursor:default}.sf-etched label{display:flex;align-items:center;gap:12px}.sf-etched input{width:100px;accent-color:#b8fdff;min-height:44px}.sf-etched output{font:11px ui-monospace,monospace;width:22px;text-align:right}.sf-etched :focus-visible{outline:2px solid #fff;outline-offset:3px}`}</style>
</section>
)
}
Where it works well
Generative architectural textures, art direction, and dimensional line artwork.
Reduced motion
The line field remains still; relief adjustment redraws immediately.
Things to know
Canvas 2D height-field illustration, not a volumetric mesh or physical surface.